Skip to content

jagnani73/claude-controller

Repository files navigation

Claude Controller

Claude Controller

Run Claude Code from your phone — over your own private, encrypted tunnel.

No cloud · no database · no API billing · your Claude credits, your machine.


Claude Controller spawns the real Claude Code CLI on your machine and relays it to a custom mobile PWA over a Tailscale tunnel. From your phone you drive sessions, approve tool requests with one tap, switch model/mode mid-run, and send slash commands — all on your own Max-plan credits (it never calls the paid API).

Claude Code's built-in remote is a chat relay. This is a full control plane:

  • Switch permission modes mid-session (default / acceptEdits / plan / auto / bypassPermissions)
  • One-tap tool approvals — pending requests, approve or deny instantly
  • Spawn / resume sessions and browse recent chats per project
  • Switch model + effort mid-session (Opus / Sonnet / Haiku)
  • Send slash commands and structured AskUserQuestion answers
  • Live status — model, tokens, cost, context, duration
  • Installable PWA — fullscreen, home-screen launch

Everything runs on your laptop; the phone is just an encrypted window into it. See the Roadmap for what's next.

Quick start

Prerequisites

  • Node + pnpm, and the Claude Code CLI installed and logged in (the controller drives your local CLI).
  • Tailscale on the laptop and phone, signed into the same account. In the tailnet admin (login.tailscale.com/admin/dns) enable MagicDNS and HTTPS Certificates.
  • Caddy ≥ 2.5 on PATH (Windows: winget install CaddyServer.Caddy; macOS: brew install caddy).

1. Install & build

pnpm install
pnpm build

2. Configure

Two .env files (both gitignored) — copy the examples and fill them in:

cp packages/backend/.env.example packages/backend/.env   # backend vars
cp .env.example .env                                     # Caddy vars (root)

In packages/backend/.env, set WORK_DIR to the folder your projects live in — sessions spawn there and recent chats are browsed from it. It's required; the backend won't start without it.

Find this machine's tailscale values and fill them in:

tailscale ip -4           # -> TAILSCALE_IP (e.g. 100.x.x.x)
tailscale status --json   # Self.DNSName -> CONTROLLER_HOST (drop the trailing dot)

At minimum set CONTROLLER_HOST + TAILSCALE_IP (root .env) and WORK_DIR + NODE_ENV=production + ALLOWED_ORIGINS=https://<your-host>.ts.net (backend .env). Full reference below.

3. Run

pnpm start

Launches the backend + Caddy together (Ctrl+C stops both). On your phone (Tailscale on), open https://<your-host>.ts.net — green padlock, the PWA loads. Use the browser's Install / Add to Home screen for the fullscreen app.

Updating: git pullpnpm build → restart pnpm start. (Caddy serves the frontend dist live, so a frontend-only change just needs pnpm build:frontend.)

Configuration

Backendpackages/backend/.env (loaded by the backend via dotenv; template: packages/backend/.env.example):

Var Required Default Description
WORK_DIR yes The folder your projects live in — sessions spawn here and recent chats are browsed from it. The backend won't start without it.
NODE_ENV for remote dev Set to production to fail closed: the WS/CORS origin check rejects any origin not in ALLOWED_ORIGINS (and any request with no Origin). Leave unset for local dev (localhost auto-allowed).
ALLOWED_ORIGINS in prod Comma-separated allowed browser origins, e.g. https://laptop.tailnet.ts.net.
HOST no 127.0.0.1 Bind address. Loopback only — Caddy is the sole process facing the tailnet; never 0.0.0.0.
PORT no 4577 Backend port.
HOOKS_PORT no 0 (auto) Loopback hooks-listener port. 0 lets the OS pick a free port (collision-proof); set a number to pin it.
LOG_LEVEL no info,error Comma-separated levels to emit (debug,info,warn,error). Unset emits info + error only; set explicitly to include debug/warn.
DUMP_DIR no ./dump Dump dir. Holds per-session statusline payloads (always — the backend reads these) and, in dev only, debug PTY .raw captures (skipped when NODE_ENV=production; nothing reads them).
PTY_COLS no 120 PTY width for the spawned CLI.
PTY_ROWS no 40 PTY height for the spawned CLI.

Caddy — root .env (loaded by scripts/start.ts, passed to Caddy; template: .env.example):

Var Required Default Description
CONTROLLER_HOST yes Your tailnet hostname (*.ts.net). Caddy's site address and the name it fetches the TLS cert for.
TAILSCALE_IP yes This machine's Tailscale IP. Caddy binds only here, so the PWA never appears on the LAN or public internet.
BACKEND_ADDR no 127.0.0.1:4577 Where Caddy proxies /ws + /health.
FRONTEND_DIST no ./packages/frontend/dist Path to the built PWA that Caddy serves.

How it works

A terminal relay, not an API client. The controller spawns the CLI in a PTY and consumes two structured signals the CLI already emits — it never scrapes the terminal and never calls Claude's API:

  • Blocking HTTP hooks (loopback) for tool approvals + AskUserQuestion and compaction. A blocked hook holds the CLI until your phone responds — that's how one-tap approval works.
  • The transcript JSONL the CLI writes per session, tailed for all content (assistant text, tool calls/results, prompts).

Both normalize into a per-session SessionBus, forwarded as typed messages over WebSocket. PTY stdin carries your input; transport (encryption, TLS, device auth) is delegated to Tailscale + Caddy.

graph LR
    Phone["📱 Phone<br/>(PWA)"]
    subgraph Laptop["💻 Your laptop"]
        Caddy["Caddy<br/>TLS · binds tailnet IP"]
        Backend["Controller backend<br/>binds 127.0.0.1"]
        CLI["Claude Code CLI"]
        Caddy -->|"reverse proxy (loopback)"| Backend
        Backend <-->|"PTY stdin · hooks · JSONL tail"| CLI
    end
    Phone <==>|"WSS over Tailscale<br/>(WireGuard, encrypted)"| Caddy
Loading

Full internals — the hook contract, JSONL shape, the SessionBus, the AskUserQuestion keystroke relay, input confirmation, session persistence, and the design decisions — live in docs/architecture.md.

Architecture

A pnpm monorepo, three packages:

  • packages/backend — Node runtime. Spawns the CLI via node-pty, runs the loopback hooks listener, tails the transcript, normalizes everything into a SessionBus, and serves the typed WebSocket protocol.
  • packages/frontend — React 19 + Vite + Tailwind 4. Mobile-first PWA that renders structured message cards (assistant text, tool calls, approval/question cards).
  • packages/common — shared TypeScript types for the WS protocol.

Deep dive: docs/architecture.md.

Security

Defense in depth — each layer reused from a battle-tested tool:

  • Device auth (Tailscale). Only your approved devices join the tailnet; WireGuard (ChaCha20-Poly1305) end-to-end encryption, plus 2FA / tailnet lock / device approval.
  • Transport (Caddy). Terminates TLS with a real Let's Encrypt cert for your *.ts.net host, bound to the Tailscale IP only — never the LAN or public internet.
  • Loopback backend. The controller binds 127.0.0.1; it's reachable only through Caddy. Nothing listens on 0.0.0.0 (verified by socket inspection).
  • Fail-closed origin check. In production the WebSocket/CORS layer rejects any origin not in ALLOWED_ORIGINS (and any request with no Origin).
  • Read-only ingestion. The controller never auto-acts — approvals/answers require explicit input from the phone; the hooks listener is loopback-only behind Claude Code's SSRF guard.

SSH-grade reach without exposing anything to the internet. Threat model, hardening checklist, and "risks that exist regardless" → docs/architecture.md#security.

Roadmap

What works today: session control, the hardened Tailscale + Caddy transport, one-tap tool approvals, the AskUserQuestion relay, slash-command sending, model/effort/mode switching, and the installable PWA. What's next:

Interactive features

  • Plan approval (ExitPlanMode) — render the plan on the phone and choose approve / approve + auto-accept edits / keep planning. Reuses the AskUserQuestion picker-driving + confirm machinery.
  • Slash-command discovery — a / autocomplete menu listing built-in + project + plugin commands with their args. (Sending already works; discovery is the missing half.)
  • Subagents (agents) view — nested, collapsible rendering of Task-spawned subagents so a fanned-out run stays legible on a phone.
  • File-diff rendering — mobile-friendly diffs for Edit/Write in the tool cards (currently raw tool I/O).
  • Image / attachment send — snap or paste a screenshot straight into a session.
  • File browser / viewer — open and read actual project files from the phone, beyond the diffs shown in tool cards.
  • Port tunneling — reach a dev server a session starts (e.g. localhost:3000) through the tunnel and open it on your phone.
  • Internal: a shared "drive-an-ink-picker + confirm" helper to de-risk the timing-fragile keystroke flows that plan approval and the slash menu inherit.

Multi-session

  • Attention routing — fast session switching plus a badge when a backgrounded session needs input.
  • Push notifications — alert the phone when a session needs input (a pending approval/question) or finishes a turn, so you don't have to keep the app open.

Reliability

  • Run as a durable service — wrap pnpm start so the backend + Caddy start on boot and survive sleep (Windows service via NSSM, a logon Scheduled Task, or pm2).
  • Session-registry persistence — state is in-memory, so a backend restart loses the session list (transcripts persist on disk). A small persisted registry removes the "restarted and my sessions vanished" cliff.
  • Adaptive history replay — the reconnect replays only the recent transcript tail; long sessions need a bigger/adaptive window.
  • Test suite — to be built from scratch.

Hardening (optional)

  • Tailnet lock + device approval in the Tailscale admin (2FA + HTTPS certs already enabled).
  • PWA idle timeout — auto-disconnect after inactivity.

License

MIT © Yashvardhan Jagnani


Special thanks

Special thanks to Ratanshi Puri for the idea and security analysis.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors