A remote CLI agent orchestrator that lets you manage and stream Claude Code, Cursor Agent, Codex, Grok, Pi, Kimi Code, opencode, and shell sessions through a web dashboard. Terminal streaming climbs a transport ladder — local loopback, then WebRTC P2P, then Centrifugo — so output stays on your machine or network whenever possible, and automatically upgrades back to a direct connection once it can. Recurring agent work runs as scheduled loops: cron or interval triggers that spawn full sessions with guardrails, instead of a hand-rolled polling script.
demo-desktop.mp4
demo-mobile.mp4
┌─────────────┐
┌────┤ Centrifugo ├────┐
│ │ (pub/sub) │ │
WebSocket └─────────────┘ WebSocket
(control · signaling · rung 3: cloud fallback)
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Browser │◄─────►│ Bridge │
│ (Next.js) │ │ (node-pty) │
└─────────────┘ └─────────────┘
rung 1: loopback WS — ws://127.0.0.1 (same machine)
rung 2: WebRTC DataChannel — direct P2P (localhost / LAN)
self-healing: browser keeps retrying rung 1 → 2 while on rung 3
- UI (
/ui) — Next.js 15 web dashboard with real-time terminal streaming, climbing the transport ladder (local → P2P → Centrifugo) to reach the bridge - Bridge (
/bridge) — CLI tool that runs on remote machines, spawns processes via PTY, serves a loopback WebSocket for same-machine access, and relays I/O directly to the browser over WebRTC (or through Centrifugo when neither direct rung is possible) - Centrifugo (
/centrifugo) — WebSocket messaging server handling sessions, presence, and signaling, and serving as the last-resort (rung 3) data plane for terminal I/O
- Every session tries three rungs in order, falling through automatically: Local (loopback WebSocket, same machine, plain TCP so it survives VPNs/endpoint filters like Cloudflare WARP that block UDP/WebRTC) → P2P (WebRTC DataChannel over LAN/localhost, STUN only, no TURN) → Cloud (Centrifugo relay, the reliable fallback used only when both direct rungs fail)
- Live Local / P2P / Cloud badges in the terminal UI, each with a hoverable diagnostic tooltip explaining exactly why you're on that rung (e.g. "P2P unavailable — connection blocked (VPN or firewall may be interfering)")
- Self-healing auto-upgrade: while on Cloud, the browser keeps retrying the ladder in the background (backoff 15s → 30s → 60s → 120s with jitter, plus immediate retry on network-online / tab-visible events) and seamlessly flips a live session from Cloud to Local/P2P with a full screen resync — no reload
- Local-first, private transport: terminal output never touches the cloud while you're on the same machine or network; Centrifugo relays only while a remote viewer is actively watching
- Loopback WS is gated by a per-process random nonce plus Origin check; presence adverts are scoped to the owning user's JWT
- First-class scheduled recurring agent runs — interval or cron+timezone — each firing a normal session with guardrails (preflight/postflight, max runtime, overlap/retention policy); see Loops below
- Seven coding-agent CLIs plus raw shells as full interactive sessions
- Parent/child agent trees, durable cross-session mail, and session reparenting
- Native resume support for Claude, Cursor, Codex, and Pi; workdir-based continuation for Kimi Code
- Live per-session token/model usage for harnesses with structured native session logs
- Hook forwarding to the dashboard (Claude/Codex settings, Cursor hooks, and ftown's bundled Pi extension)
- Native Pi tools for session discovery/control, durable ftown mail, token usage, terminal inspection, archives, and loop management
- Multiple concurrent sessions with session management
- Multi-bridge support (connect multiple machines)
- Mobile-optimized responsive UI
- Authentication via NextAuth with rate-limited login
- Auto-refreshing bridge tokens (30-day refresh tokens)
- Connection diagnostics overlay for troubleshooting
Loops replace fragile "keep an agent awake and polling" patterns with a durable scheduled primitive — cron for agents, with the full session UX (watch live, scroll back, take over) on every run.
Each loop is a schedule + harness + task prompt owned by the bridge; every fire spawns a normal ftown session, grouped under the loop in the dashboard.
ftown-sessions loop create \
--cron "0 9 * * 1-5" --tz America/New_York \
--harness claude --workdir ~/projects/ftown \
--preflight "git fetch && git log origin/main..HEAD --oneline" \
"Review overnight commits on main: {{preflight}}. Flag anything that needs attention."- Schedules — interval (
--every 30s|5m|2h|1d) or cron with timezone (--cron "0 9 * * 1-5" --tz America/New_York); create via the dashboard's loop modal or the CLI - Harness choice — claude, cursor, codex, grok, pi, kimi-code, opencode, or plain shell, each with its own configurable workdir and model
- Guardrails —
--preflight <cmd>(a non-zero exit skips the run; its stdout is injected into the prompt via{{preflight}}),--postflight <cmd>(receives run status, session id, and output),--max-runtimeto force-stop a run - Overlap & retention — overlapping runs are skipped by default (
--allow-overlapto permit them); retention keeps only the newest N runs - Lifecycle — pause/resume, fire a one-shot run manually, edit a loop live, and see run history with status dots (running/done/error/skipped/paused) plus next-due time in the dashboard
- Node.js 22+
- PostgreSQL database (e.g., Neon)
- Centrifugo v5 server
- On bridge machines: install and authenticate whichever agent CLIs you plan to run. For Pi:
npm install -g @mariozechner/pi-coding-agent, then runpiand/login(or provide a supported provider API key).
docker run -d --name centrifugo \
-p 8000:8000 \
-v $(pwd)/centrifugo/config.json:/centrifugo/config.json \
centrifugo/centrifugo:v5cd ui
cp .env.local.example .env.local
# Edit .env.local with your Centrifugo secret, database URL, etc.
npm install
npm run devCreate the database tables:
-- Run against your PostgreSQL database
\i schema.sqlcd bridge
npm install
npm run buildStart a bridge with a bootstrap token. In the dashboard, click Generate
bridge token (it fetches a fresh, short-lived ~10 min token from the
session-gated POST /api/auth/bridge/bootstrap, audience ftown:bridge-bootstrap,
and copies it to your clipboard):
npx ftown-bridge --token <bootstrap-jwt> --api-url http://localhost:3000The bootstrap token is used once to onboard the bridge; it is then exchanged
for a 30-day rotating refresh token stored under the bridge data dir
(~/.ftown/data/refresh-token, 0600). Restarts resume from that token, so the
bootstrap token does not need to stay valid — generate a fresh one each time you
onboard a new bridge.
On start, the bridge auto-deploys a local harness CLI:
~/.ftown/bin/ftown-harness— list/tail/grep sessions, resolve workspace viahere~/.ftown/harness-agent.md— agent cheat sheet (regenerated each start)~/.ftown/bridge.json— port + token while bridge is running
~/.ftown/bin/ftown-harness status
~/.ftown/bin/ftown-harness here -n 20
~/.ftown/bin/ftown-harness ls| Variable | Description |
|---|---|
CENTRIFUGO_TOKEN_SECRET |
Shared HMAC secret (must match Centrifugo config) |
NEXT_PUBLIC_CENTRIFUGO_URL |
WebSocket URL for Centrifugo |
AUTH_SECRET |
NextAuth.js secret (npx auth secret) |
DATABASE_URL |
PostgreSQL connection string |
Development config is at centrifugo/config.json. For production, create a config.prod.json (gitignored) with real secrets and TLS settings.
Run all three components on a single machine accessible to your LAN.
1. Generate secrets:
# Generate a strong HMAC secret for Centrifugo + UI
openssl rand -base64 64
# Generate an auth secret for NextAuth
npx auth secret2. Start PostgreSQL (or use a hosted service like Neon):
docker run -d --name ftown-db \
-p 5432:5432 \
-e POSTGRES_USER=ftown \
-e POSTGRES_PASSWORD=changeme \
-e POSTGRES_DB=ftown \
-v ftown-pgdata:/var/lib/postgresql/data \
postgres:16
# Create tables
psql postgresql://ftown:changeme@localhost:5432/ftown -f ui/schema.sql3. Start Centrifugo:
Edit centrifugo/config.json — replace token_hmac_secret_key and api_key with your generated secrets. Update allowed_origins to include your LAN IP:
"allowed_origins": ["http://192.168.1.100:3000"]docker run -d --name centrifugo \
-p 8000:8000 \
-v $(pwd)/centrifugo/config.json:/centrifugo/config.json \
centrifugo/centrifugo:v54. Start the UI:
cd ui
cp .env.local.example .env.localEdit .env.local:
CENTRIFUGO_TOKEN_SECRET=<same secret as centrifugo config>
NEXT_PUBLIC_CENTRIFUGO_URL=ws://192.168.1.100:8000/connection/websocket
AUTH_SECRET=<your nextauth secret>
DATABASE_URL=postgresql://ftown:changeme@localhost:5432/ftownnpm install
npm run build
npm start # Runs on port 30005. Connect a bridge from any machine on the LAN:
npx ftown-bridge --token <jwt> --api-url http://192.168.1.100:3000Get the token via Generate bridge token in the dashboard at
http://192.168.1.100:3000 (see Quick Start above).
The target production layout uses Fly.io for Centrifugo, Vercel for the UI, and Neon for the database. See the hardened, rollback-safe Fly.io rollout runbook.
The EC2 instructions below are retained for rollback and self-hosting. New hosted deployments should use the Fly.io runbook above.
1. Launch an EC2 instance (t3.micro is sufficient):
- Amazon Linux 2023 or Ubuntu 22.04
- Security group: allow inbound TCP 80, 443 from
0.0.0.0/0 - Attach an Elastic IP for a stable address
2. Install Docker and start Centrifugo:
sudo yum install -y docker # Amazon Linux
sudo systemctl enable --now docker
# Create config directory
sudo mkdir -p /opt/centrifugo3. Create production config at /opt/centrifugo/config.json:
{
"token_hmac_secret_key": "<your-secret>",
"api_key": "<your-api-key>",
"admin": false,
"token_audience": "ftown:centrifugo",
"allowed_origins": ["https://your-domain.vercel.app"],
"allow_anonymous_connect_without_token": false,
"allow_user_limited_channels": true,
"allow_publish_for_subscriber": false,
"allow_subscribe_for_client": false,
"allow_history_for_subscriber": true,
"presence": true,
"join_leave": true,
"force_push_join_leave": true,
"allow_presence_for_subscriber": true,
"history_size": 500,
"history_ttl": "24h",
"force_recovery": true,
"namespaces": [
{
"name": "terminal",
"allow_publish_for_subscriber": true,
"allow_subscribe_for_client": true,
"allow_user_limited_channels": true,
"allow_history_for_subscriber": true,
"history_size": 10000,
"history_ttl": "24h",
"force_recovery": true
},
{
"name": "sessions",
"allow_publish_for_subscriber": true,
"allow_subscribe_for_client": true,
"allow_user_limited_channels": true,
"history_size": 0,
"history_ttl": "0s",
"force_recovery": false
},
{
"name": "terminal-input",
"allow_publish_for_subscriber": true,
"allow_subscribe_for_client": true,
"allow_user_limited_channels": true
},
{
"name": "events",
"allow_publish_for_subscriber": true,
"allow_subscribe_for_client": true,
"allow_user_limited_channels": true,
"history_size": 100,
"history_ttl": "1h"
},
{
"name": "commands",
"allow_publish_for_subscriber": true,
"allow_subscribe_for_client": true,
"allow_user_limited_channels": true
},
{
"name": "bridges",
"allow_publish_for_subscriber": true,
"allow_subscribe_for_client": true,
"allow_user_limited_channels": true,
"presence": true,
"join_leave": true,
"force_push_join_leave": true,
"allow_presence_for_subscriber": true
}
],
"tls_autocert": true,
"tls_autocert_host_whitelist": "centrifugo.yourdomain.com",
"tls_autocert_cache_dir": "/centrifugo/autocert",
"tls_autocert_http": true,
"tls_autocert_http_addr": ":80",
"address": "0.0.0.0",
"port": 443,
"internal_port": 9000,
"client_channel_limit": 256,
"ping_interval": "10s",
"pong_timeout": "5s",
"log_level": "info"
}4. Run Centrifugo with TLS:
sudo docker run -d --name centrifugo \
--restart unless-stopped \
-p 80:80 -p 443:443 \
-v /opt/centrifugo/config.json:/centrifugo/config.json \
-v /opt/centrifugo/autocert:/centrifugo/autocert \
centrifugo/centrifugo:v55. Point DNS — Create an A record for centrifugo.yourdomain.com pointing to the Elastic IP. Centrifugo handles Let's Encrypt TLS automatically via tls_autocert.
1. Push to GitHub and import the repo in Vercel.
2. Set root directory to ui in project settings.
3. Add environment variables in Vercel dashboard:
| Variable | Value |
|---|---|
CENTRIFUGO_TOKEN_SECRET |
Same secret as Centrifugo config |
NEXT_PUBLIC_CENTRIFUGO_URL |
wss://centrifugo.yourdomain.com/connection/websocket |
AUTH_SECRET |
Your NextAuth secret |
DATABASE_URL |
Your Neon connection string |
4. Deploy. Vercel auto-deploys on push to main.
- Create a project at neon.tech
- Run
ui/schema.sqlagainst the database once for a fresh install (use the Neon SQL editor orpsql) - Copy the connection string to Vercel env vars
Incremental schema changes live in ui/migrations/*.sql. They are applied
automatically by the .github/workflows/migrate.yml GitHub Actions workflow
on every push to main (and via manual workflow_dispatch) — no more manual
"apply the SQL to Neon before deploy" step.
One-time setup: add a repo secret MIGRATION_DATABASE_URL = a Neon direct
postgresql:// connection string (the standard TCP host, not the
@neondatabase/serverless HTTP host the app uses). The runner
(ui/scripts/migrate.mjs) tracks applied files in a schema_migrations table,
runs each pending migration in a single transaction, and is idempotent (safe to
re-run, and safe even if a migration was already applied by hand). The workflow
runs concurrently with Vercel's deploy on the same push; migrations are kept
additive so that window is safe.
On any machine where you want to run Claude or Cursor Agent sessions:
npx ftown-bridge --token <jwt> --api-url https://your-domain.vercel.app# Terminal 1: Centrifugo
docker run -p 8000:8000 -v $(pwd)/centrifugo/config.json:/centrifugo/config.json centrifugo/centrifugo:v5
# Terminal 2: UI
cd ui && npm run dev
# Terminal 3: Bridge
cd bridge && npm run dev -- --token <jwt> --api-url http://localhost:3000