Back to list

Blog

Running Claude Code Remotely from Your Phone

How Tactic Remote turns your Mac into a reliably available AI coding server and your iPhone into a first-class control surface — with concrete architecture details.

Published Tags: engineering / architecture / guide / deep-dive
Blog

Follow product and engineering updates from this channel.

Browse category

Claude Code is a terminal-based AI coding assistant. It runs on your Mac, reads your codebase, writes code, runs tests, and commits changes. The problem: you have to sit at your Mac while it works.

Tactic Remote exists to break that constraint. Your Mac stays the execution engine. Your iPhone becomes the control surface. This article covers exactly how that works — the architecture, the protocol, the trade-offs, and the setup. No hand-waving.

TL;DR: A Node.js WebSocket server on your Mac manages Claude Code through tmux. Your iPhone connects over LAN or Cloudflare Tunnel, sends commands, and receives streamed terminal output. Push notifications alert you when Claude finishes or needs input.

The Architecture

Four components, three network paths:

┌──────────────────────────────────────────────────────────┐
│                       Your Mac                           │
│                                                          │
│  ┌─────────────┐    WebSocket     ┌──────────────────┐   │
│  │  Node.js    │◄───(ws://)──────►│   tmux session   │   │
│  │  server     │    send-keys     │                  │   │
│  │  (port 8765)│    capture-pane  │  $ claude        │   │
│  └──────┬──────┘                  │  > Working on... │   │
│         │                         └──────────────────┘   │
│         │ HTTP POST /hook                                │
│         │ (Claude Code hooks)                            │
└─────────┼────────────────────────────────────────────────┘
          │
          │ ws:// (LAN) or wss:// (Cloudflare Tunnel)
          │
┌─────────┼─────────────────────────────┐
│  Your iPhone                          │
│         │                             │
│  ┌──────▼──────┐   ┌──────────────┐   │
│  │  WebSocket  │──►│  ANSI Parser │   │
│  │  Client     │   │  + Terminal  │   │
│  │             │◄──│    Buffer    │   │
│  └─────────────┘   └──────────────┘   │
│                                       │
│  Push notification when Claude        │
│  completes or needs input             │
└───────────────────────────────────────┘

The Node.js server has exactly one runtime dependency: the ws npm package. Everything else — HTTP server, authentication, rate limiting, tmux management, file browsing — is built on Node.js built-ins. This is deliberate. Fewer dependencies means fewer things that break, fewer supply chain risks, and a smaller attack surface.

How the Server Controls Claude Code

Here's the part that surprises most people: there is no API between the server and Claude Code. The server controls Claude Code the same way you do — by typing into a terminal.

Claude Code runs inside a tmux session. The server sends keystrokes with tmux send-keys and reads output with tmux capture-pane. It's terminal automation, not an SDK integration.

Creating a Session

When you create a new session from your iPhone:

# 1. Create a detached tmux session in your project directory
tmux new-session -d -s my-project -c /Users/you/code/my-project
 
# 2. Set a large scrollback buffer (default is 2000, we use 50,000)
tmux set-option -t my-project history-limit 50000
 
# 3. Type "claude" into the session
tmux send-keys -t my-project -l 'claude'
 
# 4. Press Enter
tmux send-keys -t my-project C-m

The -l flag on send-keys is critical — it tells tmux to interpret the content literally, preventing special characters from being treated as tmux key names.

The 50,000-line scrollback buffer is 25x the tmux default. Claude Code sessions produce enormous output — compilation logs, file diffs, test suites. Without this, you'd lose context after a few minutes of heavy work.

Sending a Message to Claude

When you type a prompt on your iPhone and hit send:

# Clear any existing input
tmux send-keys -t my-project C-u
 
# Type the content literally
tmux send-keys -t my-project -l 'refactor the auth module to use JWT'
 
# Press Enter
tmux send-keys -t my-project C-m

The C-u at the start clears the input line. Without it, if Claude had left partial text in the prompt (from a previous interrupted input), your message would append to garbage.

Stopping Claude

Stopping is a multi-step sequence because Claude Code has two states that need different handling:

# Interrupt if Claude is processing
tmux send-keys -t my-project C-c
sleep 0.2
 
# Interrupt again (double Ctrl+C for stubborn processes)
tmux send-keys -t my-project C-c
sleep 0.2
 
# Send the quit command
tmux send-keys -t my-project -l '/exit'
tmux send-keys -t my-project C-m

If Claude is mid-execution, Ctrl+C interrupts it. If Claude is already at its prompt, /exit is Claude Code's quit command. Sending both covers both states. The 200ms sleeps give Claude time to process each signal before the next arrives.

Terminal Output Streaming

This is the hardest engineering problem in the entire system. Claude Code outputs text at hundreds of lines per second during compilation or test runs. That output needs to reach your iPhone in real time, without draining the battery or overwhelming the network.

The Polling Loop

The server captures terminal content using a 1-second polling interval:

tmux capture-pane -t my-project -p -S -2000

This grabs the last 2,000 lines of scrollback as plain text with ANSI escape codes preserved.

Why polling instead of event-driven? tmux doesn't offer change notifications. We evaluated filesystem watchers on tmux's internal socket — fragile across macOS versions. Polling at 1 second is a good balance: fast enough that output feels real-time, slow enough to minimize CPU usage when the session is idle.

Stability Detection

The output monitor doesn't just stream raw captures. It runs a stability detection algorithm:

  1. Capture terminal content every second
  2. Compare against the previous capture
  3. If different: send the update to the iPhone, reset the stability counter
  4. If identical for 5 consecutive captures (5 seconds stable): check if Claude is waiting for input
  5. If Claude is at its prompt: send a final update with final: true, then stop polling

This handles Claude's progressive rendering — during long outputs, the terminal changes rapidly. After Claude finishes, the screen stabilizes. Five seconds of stability is a reliable signal that Claude is done.

The monitor has a hard cap of 120 checks (2 minutes). If output is still changing after 2 minutes, the monitor stops and the client can request a manual refresh.

How We Detect Claude's State

The server screen-scrapes Claude Code's TUI to determine what it's doing. This is necessarily heuristic:

const claudeIndicators = [
  '> ', // Input prompt
  '│ >', // Input prompt inside border
  '? for shortcuts', // Help text visible at prompt
  '╭─', // Claude UI top border
  '╰─', // Claude UI bottom border
  '●', // Running indicator
]

We check the last few lines of terminal output for these patterns. If we see Goodbye! or Session ended, Claude has exited. If we see a shell prompt ($ at end of line) without Claude-specific indicators, the session has dropped back to the shell.

Is this fragile? Somewhat. If Claude Code changes its TUI layout, our heuristics break. But the alternative — building a custom IPC channel into Claude Code — would require maintaining a fork and tracking upstream changes. Screen-scraping the existing TUI is pragmatic: it works with every version of Claude Code without modification.

ANSI Color Preservation

A key design decision: the server preserves ANSI escape codes in the output. It strips problematic terminal sequences (cursor movement, OSC title sequences, private mode changes) but deliberately keeps color codes intact:

// These are stripped (cause rendering problems):
cleaned = cleaned
  .replace(/\x1b\].*?\x07/g, '') // OSC sequences
  .replace(/\x1b\[\?[0-9;]*[a-z]/g, '') // Private mode
  .replace(/\x1b\[[0-9;]*[ABCDEFGJKST]/g, '') // Cursor movement
 
// SGR color codes (\x1b[...m) are NOT stripped

The iOS app then parses these codes into native AttributedString styling. This gives you Claude Code's full color output — syntax highlighting, diff coloring, status indicators — faithfully rendered on your phone screen. The parser supports standard 16 colors, 256-color mode, and 24-bit true color.

The WebSocket Protocol

The server speaks two protocols simultaneously on the same WebSocket connection — a legacy flat protocol and a newer request/response/event protocol.

Three frame types:

// Client → Server: Request
{
  "type": "req",
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "method": "send_to_claude",
  "params": { "content": "fix the failing tests", "session": "my-project" }
}
 
// Server → Client: Response
{
  "type": "res",
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "ok": true,
  "payload": { "status": "sent" }
}
 
// Server → Client: Event (no request ID)
{
  "type": "event",
  "event": "hook_event",
  "payload": { "event": "Stop", "project": "my-project" }
}

The id field enables proper async request multiplexing. The client can have multiple in-flight requests and match responses correctly. The legacy protocol has no request IDs — responses are positional, and if you send two requests quickly, you can't tell which response belongs to which request.

Complete Method Catalog

MethodPurpose
list_sessionsGet all tmux sessions with names, paths, attach status
create_sessionCreate a new tmux session at a given path
attach_sessionSwitch to an existing session, get its current output
delete_sessionKill a tmux session immediately
kill_sessionGracefully stop Claude, then kill the session
start_claudeLaunch Claude Code in the current session
stop_claudeSend Ctrl+C + /exit sequence
send_to_claudeType a message and press Enter
send_keySend a single key (Enter, Escape, Tab, y, n, Ctrl+C)
get_outputGet current terminal content and Claude's state
get_full_outputGet up to 5,000 lines of scrollback
change_pathChange the working directory
list_directoryBrowse files at a path
create_folderCreate a new directory

The send_key method has an explicit allowlist of 18 keys. You can't send arbitrary key sequences — only keys that make sense for interacting with Claude Code's interface. The y and n keys are specifically included for answering Claude's permission prompts from your phone.

Network: LAN vs. Cloudflare Tunnel

Two connection modes solve two different problems.

Local Network (LAN)

Your iPhone connects directly to your Mac over WiFi:

iPhone ──── ws://192.168.1.50:8765 ──── Mac

The Mac companion app publishes a Bonjour service (_ws._tcp.) so your iPhone can auto-discover it on the network. No configuration needed — open the app, tap the banner, you're connected.

Latency: Sub-10ms round trip. Terminal streaming feels instantaneous.

Limitation: Only works on the same network. Step outside your house and you lose the connection.

Cloudflare Tunnel

For remote access, the Mac companion app can start a Cloudflare Tunnel:

iPhone ── wss://xxx.trycloudflare.com ── Cloudflare Edge ── Mac

The tunnel is outbound-only from your Mac. No ports are opened on your firewall. No router configuration. Cloudflare handles TLS termination, DDoS protection, and WebSocket proxying.

Quick tunnel setup generates a random *.trycloudflare.com subdomain and a random 32-character API key:

# Auto-generated from: openssl rand -hex 16
export CLAUDE_REMOTE_API_KEY="a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5"
 
cloudflared tunnel --url http://localhost:8765
# Your quick Tunnel has been created!
# https://abc123-random-words.trycloudflare.com

No Cloudflare account required. Free. Works in about 10 seconds.

Latency: 30–80ms typical, depending on your distance to the nearest Cloudflare edge.

Trade-off: The URL changes every time you restart the tunnel. For a persistent URL, you need a Cloudflare account and a named tunnel (still free).

QR Code Quick Connect

Setting up a connection manually means entering an IP address, port, and API key on a phone keyboard. This is error-prone and annoying.

The Mac companion app generates a QR code containing the connection details as JSON:

{
  "ip": "192.168.1.50",
  "port": 8765,
  "apiKey": "a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5",
  "mode": "lan",
  "version": "1.0"
}

Scan it from the iPhone app using the Vision framework's barcode detector. One scan sets up the entire connection — mode, address, authentication. Takes about 3 seconds.

Notification Pipeline

Push notifications are Tactic Remote's killer feature for "set it and forget it" workflows. Start a long task, walk away, get notified when Claude finishes or needs your input.

How It Works

Claude Code supports hooks — shell scripts that execute on lifecycle events. The Mac companion app auto-configures these hooks to POST to the local server:

#!/bin/bash
# ~/.claude/hooks/stop.sh (auto-configured)
curl -s -X POST http://localhost:8765/hook \
  -H "Content-Type: application/json" \
  -d "{\"event\": \"Stop\", \"project\": \"$CLAUDE_PROJECT\", \"tmux_session\": \"$TMUX_SESSION\"}"

When Claude finishes a task, this hook fires. The server receives the POST, broadcasts a notification event to all connected clients, and the iOS app fires a local notification via UNNotificationCenter.

Event Deduplication

Hook events can fire in rapid succession (e.g., Claude stops and restarts multiple subtasks). The iOS app deduplicates events using a 3-second window keyed by event|project|session|timestamp. If the same logical event arrives twice within 3 seconds, the duplicate is silently dropped.

Foreground vs. Background

When the app is in the foreground, notification events append a visual indicator to the terminal buffer:

📢 [Claude] Project completed

When the app is in the background, a system notification fires. Tap it to jump directly back into your session.

Security Model

Running AI-generated code remotely demands a serious security model. Here's ours.

Authentication

The server supports API key authentication via two mechanisms:

  • Authorization: Bearer <key> header (standard)
  • Sec-WebSocket-Protocol: <key> header (fallback for iOS WebSocket libraries that can't set custom headers on the upgrade request — a common mobile constraint)

Authentication is checked once at connection time. If it fails, the WebSocket is closed immediately with code 1008 (Policy Violation).

Command Injection Prevention

Every external command the server executes goes through execSafe(), which calls child_process.spawn() with shell: false. Arguments are passed as arrays rather than interpolated into shell command strings. This materially reduces shell injection risk.

Input validation adds defense in depth:

InputValidation
Session names/^[a-zA-Z0-9_-]{1,64}$/
Claude command/^claude(\s+--?[a-zA-Z0-9-]+)*$/
Key namesExplicit allowlist of 18 keys
File pathsResolved to absolute, checked with startsWith()

Path Traversal Protection

File browsing is restricted to the user's home directory tree. Sensitive directories are blocked regardless of other settings:

const SENSITIVE_DIRS = [
  '.ssh',
  '.gnupg',
  '.aws',
  '.kube',
  '.config/gcloud',
  'Library/Keychains',
  'Library/Cookies',
  'Library/Application Support/1Password',
]

The check uses segment-by-segment matching (not substring matching), so a directory named my-.ssh-backup won't trigger a false positive.

Rate Limiting

Failed authentication attempts are rate-limited to 30 per minute per IP using an in-memory sliding window. After exceeding the limit, the server returns 429 Too Many Requests with a retryAfter hint.

No Cloud Dependency

Tactic Remote is a control plane, not a vendor cloud data plane. In the default architecture, the WebSocket carries terminal text between your Mac and iPhone. Source files, git history, and build artifacts are not uploaded to Tactic Remote services.

When using Cloudflare Tunnel, the tunnel encrypts traffic end-to-end. Cloudflare sees encrypted WebSocket frames, not your code.

The iOS App: Not a Terminal Emulator

Early in development, we made a conscious decision: the iPhone app is not a dumb terminal emulator. Cramming a full 80x24 terminal onto a 4-inch phone screen is a terrible experience.

Instead, the app provides:

  • Conversation-style input: Type messages to Claude in a text field, not a terminal prompt
  • Terminal output view: Rendered text with ANSI color support, auto-scrolling, pinch-to-zoom font size (default 8pt)
  • Quick action buttons: Enter, Escape, Tab, Y, N, Ctrl+C — the keys you actually need when interacting with Claude Code
  • Session picker: Browse and switch between tmux sessions
  • Path browser: Navigate your Mac's filesystem to set the working directory
  • Slash commands: Auto-complete for /help, /clear, /compact, /cost, /model, /status

Reconnection Strategy

Mobile apps live in a hostile network environment. Your phone switches between WiFi and cellular, enters elevators, goes to sleep. The app's reconnection strategy handles all of this:

  1. Connection drops detected: WebSocket close event fires
  2. Exponential backoff: Retry after min(attempts * 3, 15) seconds, up to 5 attempts
  3. Background awareness: If the app is backgrounded, skip reconnection. Set a flag to reconnect when foregrounded
  4. Session restoration: On reconnect, list sessions → find the previously active session → reattach → poll for output at 0.2s, 0.7s, and 1.5s delays

The staggered output polling (0.2s, 0.7s, 1.5s) after reconnection handles the case where tmux needs a moment to produce a capture after reattachment. A single immediate poll would often return empty content.

Terminal Buffer

The TerminalBuffer is a circular buffer capped at 500,000 characters and 10,000 lines. When it overflows, content is truncated from the front, snapping to the nearest newline boundary to avoid showing a partial first line.

This cap exists because AttributedString rendering gets expensive at scale. 500K characters is roughly 30 minutes of heavy Claude Code output — enough for any reasonable session while keeping the UI responsive.

The Mac Companion App

The Mac companion app is a menu-bar application that manages the Node.js server, Cloudflare tunnel, and system settings.

Server Bundling

The entire Node.js server is bundled inside the Mac app. On first launch, it copies the server files to ~/Library/Application Support/ClaudeCodeRemote/Server/. On subsequent launches, it compares files byte-by-byte and only re-copies changed files. npm install only re-runs if package.json has changed.

This means most users do not need to touch the terminal to install or update the server.

Dependency Detection

The app checks for required tools (Node.js, tmux, Homebrew) by scanning known paths first (/opt/homebrew/bin/, /usr/local/bin/), then falling back to which. If dependencies are missing, a setup screen guides the user through installation.

Sleep Prevention

When the server is running, the app calls IOPMAssertionCreateWithName to prevent macOS from sleeping. This is essential — a sleeping Mac can't serve WebSocket connections.

Hook Auto-Configuration

The companion app writes shell scripts to ~/.claude/hooks/ that POST lifecycle events to the local server. This happens automatically on server start — no manual configuration needed.

What We Didn't Build

Some things we deliberately chose not to do:

No terminal multiplexing in the app. We use tmux on the server side, but the iPhone app shows one session at a time. Split panes on a phone screen would be unreadable.

No file editing. The app can browse files but not edit them. Claude Code handles file editing — you're the reviewer, not the editor. Adding a mobile code editor would be scope creep that degrades the core experience.

No offline mode. If your Mac is unreachable, the app shows you the last known state but can't do anything useful. We don't pretend otherwise.

No remote desktop fallback. Screen sharing and VNC exist, but they're 10x worse for this use case. Tactic Remote sends structured text, not pixels. This means lower bandwidth, lower latency, and a UI designed for the task rather than a shrunken desktop.

Quick Setup

Skip the details? Here's the minimum to get running.

On Your Mac

  1. Install the Mac companion app from tacticremote.com/download
  2. Launch it — it auto-installs dependencies and starts the server
  3. Note the QR code displayed in the app

On Your iPhone

  1. Install Tactic Remote from App Store
  2. Scan the QR code from the Mac app
  3. Create a session pointing to your project directory
  4. Start Claude and send your first prompt

For remote access (outside your home network), enable the Cloudflare Tunnel toggle in the Mac app's settings. The app generates a public URL automatically.

Configure Notifications

In the Mac app, hooks are configured automatically when the server starts. On the iPhone, grant notification permissions when prompted. That's it.

FAQ

Does Claude keep running if I close the iPhone app?

Yes. Claude runs in tmux on your Mac. The iPhone app is a window into that session. Close the app, put your phone in your pocket — Claude keeps working. Open the app again and you'll see everything that happened while you were away.

What if my network drops?

The iPhone app reconnects automatically with exponential backoff (up to 5 attempts over ~45 seconds). On reconnect, it reattaches to your previous session and fetches the accumulated output. No work is lost — tmux buffers everything on the Mac side.

How much battery does this use?

The iPhone app does not maintain a persistent background connection. When backgrounded, it disconnects cleanly. Battery impact is essentially zero when you're not actively using it.

Can I use this with other AI coding tools?

Tactic Remote is built around Claude Code's terminal interface, but the underlying architecture (tmux session management, terminal streaming) works with any terminal-based tool. If it runs in a terminal, the server can manage it. The state detection heuristics are Claude Code-specific, but the basic send-keys/capture-pane loop is universal.

Is my code safe?

Tactic Remote itself does not upload your source code to Tactic Remote-managed cloud services. It transmits terminal text over encrypted WebSocket connections. The server has no Tactic Remote telemetry pipeline by default. When using Cloudflare Tunnel, traffic is encrypted in transit and Cloudflare proxies transport frames rather than your local repository files.


If you want to dig deeper into specific subsystems, see Why We Chose tmux as Our Session Backbone, Building Real-Time Terminal Streaming, and the Security Model documentation.

Try Tactic Remote

Control your coding Agents from your phone

Connect to Claude Code, Codex, and other Agents on your Mac, Windows, or Linux computer. Check progress and send the next instruction from iPhone or iPad.