A remote CLI agent orchestrator that lets you manage and stream Claude Code and Cursor Agent 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
- Claude Code and Cursor Agent (
agent) interactive sessions with resume support - Hook forwarding to the dashboard (Claude
~/.claude/settings.json, Cursor~/.cursor/hooks.json) - 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, 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: Claude Code and/or Cursor CLI (
curl https://cursor.com/install -fsS | bash, thenagent login)
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).
This setup uses an EC2 instance for Centrifugo, Vercel for the UI, and Neon for the database. Adjust to your preferred stack.
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