Skip to main content
Glama

claude-remote

ci

claude-remote lists Claude Code sessions on this Mac, including sessions started from the Code tab in the Claude desktop app. When send is turned on, it can resume one session through the claude CLI. claude-remote http serves the same methods on 127.0.0.1. Each request gets one JSON body. The listener does not use server-sent events.

claude-remote is an unofficial, community-built tool. It is not affiliated with, endorsed by, or supported by Anthropic. Claude and Claude Code are trademarks of Anthropic.

Requirements

  • Node.js 20 or newer

  • macOS, for the LaunchAgent installer

  • Homebrew, so install can add cloudflared when that binary is not already executable

  • The claude CLI on PATH, or CLAUDE_REMOTE_CLAUDE_BIN, because install registers the channel command and send or launch runs that CLI

  • tmux, only when a launch starts a terminal session

  • Tailscale, only for install --tunnel=funnel

npm test does not need claude, tmux, cloudflared, or tailscale.

Related MCP server: Rail Connector MCP

Install

Clone the repository and build it:

git clone https://github.com/itsthecsguy/claude-remote.git
cd claude-remote
npm ci
npm run build
node dist/index.js install

install builds, writes three LaunchAgents (the helper, the tunnel, and a watchdog), creates the bearer token, and registers claude-remote-channel at user scope when claude mcp get claude-remote-channel is not already registered. It prints the token path. It does not print the token. When the tunnel log contains a quick-tunnel URL, it prints that URL; with a fixed hostname it prints that.

node dist/index.js install --dry-run
node dist/index.js install --allow-send --allow-launch
node dist/index.js install --allow-send --allow-launch --port=47321
node dist/index.js install --allow-send --allow-launch --allow-approve
node dist/index.js install --no-hooks
node dist/index.js install --tunnel=named --tunnel-name=claude-remote --hostname=remote.example.com
node dist/index.js install --tunnel=funnel
node dist/index.js install --no-watchdog
node dist/index.js status
node dist/index.js status --json
node dist/index.js watchdog
node dist/index.js rotate-token
node dist/index.js http

--tunnel=quick|named|token|funnel|none picks how the helper is reached from outside (default quick); see Stable endpoint. --no-watchdog skips the watchdog agent and --watchdog-interval=<seconds> (30 to 3600, default 120) sets how often it runs; see Watchdog.

install resolves claude (or CLAUDE_REMOTE_CLAUDE_BIN) against the installing shell's PATH and writes the absolute path into the helper plist as CLAUDE_REMOTE_CLAUDE_BIN, because launchd starts the helper with a fixed PATH that usually lacks ~/.local/bin. --dry-run prints the plists and writes nothing. Running install again keeps the existing token and rewrites the plists. It always reloads the helper, but leaves a loaded tunnel running when the tunnel plist is unchanged, so a quick-tunnel URL survives a reinstall. --allow-send and --allow-launch default to off in the LaunchAgent. Passing them sets CLAUDE_REMOTE_ALLOW_SEND and CLAUDE_REMOTE_ALLOW_LAUNCH to 1 for the helper only. A foreground node dist/index.js http uses the environment of that process instead.

install also merges the claude-remote hooks into Claude Code's user settings (~/.claude/settings.json, or $CLAUDE_CONFIG_DIR/settings.json); see Hooks. --no-hooks skips that. --allow-approve sets CLAUDE_REMOTE_ALLOW_APPROVE=1 for the helper, and --approve-timeout=<seconds> (5 to 600, default 120) sets how long a permission or question hook waits for a remote answer.

The labels are com.claude-remote.http, com.claude-remote.tunnel, and com.claude-remote.watchdog. The plists are under ~/Library/LaunchAgents/ with those names, mode 0600. The helper and tunnel have RunAtLoad and KeepAlive; the watchdog has RunAtLoad and a StartInterval. --tunnel=funnel and --tunnel=none write no tunnel plist and remove one left by an earlier install.

Upgrading an existing install

  1. In the install directory, pull the new version and build it: git pull && npm ci && npm run build.

  2. Optional: give the helper a long-lived login. Run claude setup-token, then paste the token into (umask 077 && mkdir -p ~/.config/claude-remote && cat > ~/.config/claude-remote/oauth-token) and press Enter, then Ctrl-D. Skip this and you keep the periodic login prompt; see Long-lived login for headless claude children.

  3. Re-run install with the flags you used before, including --notify-url (and CLAUDE_REMOTE_NOTIFY_SECRET in the environment) if you use alerts. An install without --notify-url turns alerts off. This adds the watchdog agent and reloads the helper. A quick tunnel keeps its hostname, because its plist does not change.

  4. Optional: add --tunnel=named --tunnel-name=<name> --hostname=<host> or --tunnel=funnel for a URL that never changes; see Stable endpoint.

  5. Run node dist/index.js status and expect verdict=ok and watchdog=loaded.

  6. Update the connector URL in the client only if status prints a different one. The bearer token does not change.

Default port

The default port is 47321. The listener binds to 127.0.0.1 and rejects any other address. node dist/index.js http --port=47321 and node dist/index.js install --port=47321 select another port. CLAUDE_REMOTE_HTTP_PORT does the same when the command line does not pass --port.

Auth token

install creates ~/.claude-remote/http/token if that file is missing. node dist/index.js rotate-token replaces it. Both commands print the path and not the token.

The token file is mode 0600. ~/.claude-remote and ~/.claude-remote/http are mode 0700. The listener reads the file on every request, so rotation takes effect without a restart. A missing or wrong token is HTTP 401. The check hashes both values and then uses a constant-time compare.

Send the token only in the Authorization header:

Authorization: Bearer <token>

There is one space after Bearer. <token> is the single line in ~/.claude-remote/http/token.

Status

status checks each part on its own and says which one is down. It does not print the bearer token or the setup-token.

verdict=public-not-answering
helper=running pid=1380 answers=yes
tunnel=running mode=quick pid=9688 runs=2 lastExit=0
tunnelConnected=no lastRegistered=2026-10-24T21:50:11Z lastRetry=2026-10-26T19:14:20Z retries10m=9 logBytes=14336831
url=https://mood-precise-long-gregory.trycloudflare.com/mcp
public=no detail=tunnel-error:http-530
tokenPath=/Users/nick/.claude-remote/http/token
tokenFile=present
port=47321
oauthTokenPath=/Users/nick/.config/claude-remote/oauth-token
oauthTokenFile=present mode=0600 ageDays=12
watchdog=loaded lastRun=2026-10-27T22:58:03.120Z lastVerdict=tunnel-failing helperRestarts=0 tunnelRestarts=1
checkedAt=2026-10-27T22:59:41.004Z

verdict is the first thing that is wrong, in this order: helper-not-loaded (launchd does not have the helper), helper-not-answering (launchd has it, but GET http://127.0.0.1:<port>/mcp did not get the helper's 401 challenge), tunnel-not-loaded (no cloudflared job, for the quick, named, and token modes), no-public-url (a quick tunnel that has not printed a hostname yet), public-not-answering (the public URL did not get the helper's challenge; detail says what answered instead), or ok. ok-no-tunnel is --tunnel=none.

The probes send no credentials. The helper answers an unauthenticated GET /mcp with 401 and WWW-Authenticate: Bearer realm="claude-remote", and that exact reply is what counts as "answers". detail values: tunnel-error:http-530 means Cloudflare's edge answered for a tunnel that is not connected, host-rejected means the request reached the helper with a Host header it does not accept (see CLAUDE_REMOTE_PUBLIC_HOST), unreachable:ENOTFOUND means DNS failed on this Mac, and unreachable:timeout means nothing answered.

The tunnelConnected line comes from the last megabyte of ~/.claude-remote/tunnel/cloudflared.log: when cloudflared last registered a connection, when it last scheduled a retry, and how many retries fell in the last ten minutes. url for a quick tunnel is the newest hostname in that tail. Earlier versions read the first megabyte, which named a hostname that had been dead for days once the log grew past that.

status --no-probe skips both HTTP probes, as does CLAUDE_REMOTE_PROBE=0. status --json prints the same fields as one object.

Cloudflare tunnel

The listener stays on this Mac. A tunnel only forwards HTTPS to 127.0.0.1. The bearer token is still required.

The listener allows only localhost names in Host and Origin. A public hostname in either header is HTTP 403. A missing Origin is allowed. cloudflared has to set the Host header to 127.0.0.1:47321 (or the port you chose).

Quick tunnel

install starts a Cloudflare quick tunnel:

cloudflared tunnel --url http://127.0.0.1:47321 --http-host-header 127.0.0.1:47321 --no-autoupdate

install searches PATH, /opt/homebrew/bin, and /usr/local/bin for cloudflared. CLAUDE_REMOTE_CLOUDFLARED_BIN overrides that search. When none of those is executable, install runs brew install cloudflared.

Quick tunnels need no Cloudflare account. The tunnel is outbound only. The hostname changes every time the process starts, the URL dies with the process, and there is no uptime guarantee. The URL shape is:

https://<your-tunnel>.trycloudflare.com/mcp

After a restart, run node dist/index.js status and put the new URL in the client. Quick tunnels do not support server-sent events, which is why this listener answers with one JSON body.

There is a worse failure than a restart. When the Mac sleeps long enough for Cloudflare's edge to forget the quick tunnel, the running cloudflared never gets it back: every reconnect fails with control stream encountered a failure while serving, it retries every 64 seconds, and because the process never exits, launchd's KeepAlive never restarts it. One such episode lasted 44 hours and 9,543 retries, most of them while the Mac was awake and online. A named tunnel does not have this problem, because its registration survives a disconnect. The watchdog covers the quick tunnel by restarting it, at the cost of a new hostname.

Stable endpoint

--tunnel=named, --tunnel=token, and --tunnel=funnel give the helper a hostname that survives restarts, sleep, and network changes, so the client is set up once. install records the choice in ~/.claude-remote/tunnel/settings.json (mode 0600) and status and the watchdog read it from there. Switching modes is another install with other flags.

With a fixed hostname, install also sets CLAUDE_REMOTE_PUBLIC_HOST=<hostname> for the helper, and the listener accepts that name in Host and Origin in addition to the localhost names. A foreground node dist/index.js http can set the variable itself (a comma-separated list is allowed).

Named tunnel (Cloudflare account, hostname you own)

Create the tunnel once with your own cloudflared, then let install run it:

cloudflared tunnel login
cloudflared tunnel create claude-remote
cloudflared tunnel route dns claude-remote remote.example.com
node dist/index.js install --allow-send --allow-launch --tunnel=named --tunnel-name=claude-remote --hostname=remote.example.com

install writes ~/.claude-remote/tunnel/named.yml with the ingress below and starts cloudflared tunnel --no-autoupdate --logfile <log> --config <that file> run claude-remote from the tunnel LaunchAgent. cloudflared finds the tunnel credentials through ~/.cloudflared/cert.pem; pass --tunnel-credentials=~/.cloudflared/<tunnel-id>.json to name the file instead.

tunnel: "claude-remote"
ingress:
  - hostname: "remote.example.com"
    service: http://127.0.0.1:47321
    originRequest:
      httpHostHeader: "127.0.0.1:47321"
  - service: http_status:404

The public URL is https://remote.example.com/mcp. The header is still Authorization: Bearer <token>. Put the hostname behind Cloudflare Access if you want a second factor in front of the bearer token.

Token tunnel (Zero Trust dashboard)

A tunnel created in the Cloudflare Zero Trust dashboard runs from a token. Put the token in a file only you can read, and never on the command line:

(umask 077 && mkdir -p ~/.config/claude-remote && cat > ~/.config/claude-remote/tunnel-token)
node dist/index.js install --tunnel=token --hostname=remote.example.com --tunnel-token-file=~/.config/claude-remote/tunnel-token

The LaunchAgent runs cloudflared tunnel --no-autoupdate --logfile <log> run --token-file <that file> (needs cloudflared 2025.4.0 or newer). The dashboard's public hostname must route to http://127.0.0.1:47321, and either its HTTP Host header setting is 127.0.0.1:47321 or the helper accepts the public name through CLAUDE_REMOTE_PUBLIC_HOST, which install sets from --hostname.

Tailscale Funnel

Funnel publishes the helper on this node's MagicDNS name, over Tailscale's relay, with no Cloudflare account. Funnel has to be enabled for the node in the Tailscale admin console first.

node dist/index.js install --allow-send --allow-launch --tunnel=funnel

install finds the tailscale CLI on PATH or in /Applications/Tailscale.app/Contents/MacOS (CLAUDE_REMOTE_TAILSCALE_BIN overrides that), reads the node name from tailscale status --json unless --hostname=<node>.<tailnet>.ts.net is given, and runs:

tailscale funnel --bg --https=443 http://127.0.0.1:47321

tailscaled keeps that configuration across reboots, so there is no tunnel LaunchAgent in this mode, and the watchdog only reports a Funnel it cannot reach. The public URL is https://<node>.<tailnet>.ts.net/mcp. uninstall runs the same command with off.

No tunnel

--tunnel=none manages no tunnel at all, for a reverse proxy you run yourself. Add --hostname=<name> so the helper accepts that name in Host. status then reports ok-no-tunnel and the watchdog only watches the helper.

Watchdog

com.claude-remote.watchdog runs node dist/index.js watchdog every 120 seconds (--watchdog-interval) and after every wake, because launchd fires a missed StartInterval once when the Mac wakes. Each run:

  1. Probes http://127.0.0.1:<port>/mcp. If the helper does not answer with its 401 challenge, it runs launchctl kickstart -k gui/<uid>/com.claude-remote.http (or bootstrap when the job is not loaded) and stops there for this run.

  2. Works out the public URL: the fixed hostname, or the newest trycloudflare.com name in the tunnel log.

  3. Probes that URL the same way. One failure is counted; two in a row, while the helper answers, restart the tunnel job the same way. A DNS failure on this Mac (ENOTFOUND) counts as offline and never restarts anything, and restarts are at least ten minutes apart, so a dead internet connection does not burn through hostnames.

  4. After a quick-tunnel restart, waits up to 15 seconds for the new hostname, and POSTs {"type":"claude-remote.tunnel","event":"url-changed","url":...,"previousUrl":...} to the alert URL from --notify-url, signed like the pending alerts, so a phone can pick up the new URL. A named or token tunnel sends tunnel-restarted with the same URL.

  5. Trims cloudflared.log, helper.log, and its own watchdog.log when one passes 8 MB: the last 512 KB go to <name>.1 and the file is truncated in place. cloudflared --logfile never rotates on its own, and the quick-tunnel retry loop wrote 14 MB in two days.

Its state is ~/.claude-remote/watchdog/state.json (last run, last verdict, consecutive public failures, restart counts) and status shows it. Each run logs one line to ~/.claude-remote/watchdog/watchdog.log, with the probe outcomes and never a token. node dist/index.js watchdog runs one pass by hand and prints the same report (--json for the object).

Sleep and network changes

Nothing here can keep a tunnel up through sleep: a MacBook on battery with the lid closed drops its network, and cloudflared cannot reconnect until the Mac wakes. What the watchdog guarantees is recovery: within one interval of the Mac being awake and online, the helper and the tunnel are either answering or restarted, and a quick tunnel that the edge has forgotten gets a new hostname instead of retrying forever. Short sleeps are fine without a restart, because cloudflared reconnects on its own as long as the edge still has the tunnel; the logs show that taking one to four seconds after a wake.

To keep the Mac awake on power, sudo pmset -c sleep 0 and a closed lid on power with an external display, or caffeinate -s, are the usual choices. Those are Nick's call, not something install sets.

Long-lived login for headless claude children

The helper runs from launchd. Its headless children (claude -p for new_session with a prompt, send_followup when it resumes a closed session, deliver_message, and claude agents --json) use the login that /login saved. That login has a lifetime: the Claude Code docs say that once the stored login expires and cannot be refreshed, each request fails with Login expired ยท Please run /login, and the terminal warns three days ahead. For a process nobody is watching, that means a sign-in at the keyboard every time it runs out.

The documented fix for unattended use is claude setup-token: it prints a one-year OAuth token that the CLI reads from CLAUDE_CODE_OAUTH_TOKEN (precedence 5, above the /login credential). The helper reads that token from a file at every spawn and puts it in the child's environment only. It is never written to a plist, never logged, and never printed by status.

Create the file once. The token goes in through stdin so it stays out of shell history and the process list:

claude setup-token
# approve in the browser, copy the printed token, then:
(umask 077 && mkdir -p ~/.config/claude-remote && cat > ~/.config/claude-remote/oauth-token)
# paste the token, press Enter, then Ctrl-D
node dist/index.js status | grep oauthToken

The file must be a regular file, mode 0600, holding the token on its first line. Anything else is ignored and status says why: oauthTokenFile=bad-mode (run chmod 600), symlink, empty, or bad-shape. oauthTokenFile=missing means the children keep using the interactive login. ageDays counts from the file's last write; at 330 days status adds a warning, because the token lasts one year. Replace it by running claude setup-token again and overwriting the file; no restart is needed, the helper reads the file at each spawn. The helper log records {"tool":"oauth-token","code":"file-used"} once per process when the file is in use, or ignored-<state> when it is not.

CLAUDE_REMOTE_OAUTH_TOKEN_FILE moves the file for a foreground http. A CLAUDE_CODE_OAUTH_TOKEN already in the helper's own environment wins over the file. The setup-token login makes model requests only; the docs note it cannot establish Remote Control sessions or fetch claude.ai connectors, which the helper does not need. Interactive sessions started in tmux or Terminal keep using your normal login.

MCP client

In any MCP client that supports a remote server URL with a custom header, add a custom connector named claude-remote.

  • URL: https://<host>/mcp

  • Header name: Authorization

  • Header value: Bearer <token>

<host> is <your-tunnel>.trycloudflare.com for a quick tunnel, or <your-hostname> for a named tunnel. <token> is the line in ~/.claude-remote/http/token.

Tools

These are the method names.

Method

When it is registered

list_sessions

always

get_session

always

continue_command

always. Returns a shell command and does not launch.

rename_session

always. Reports that no documented route sets the title. It does not edit a transcript.

cleanup_desk

always. Removes stale channel sockets and expired attachment directories. It does not delete key files.

send_followup

CLAUDE_REMOTE_ALLOW_SEND=1. The call still has to set confirm to true.

queue_status

CLAUDE_REMOTE_ALLOW_SEND=1

new_session

CLAUDE_REMOTE_ALLOW_LAUNCH=1

continue_session

CLAUDE_REMOTE_ALLOW_LAUNCH=1

stop_session

CLAUDE_REMOTE_ALLOW_LAUNCH=1

deliver_message

CLAUDE_REMOTE_ALLOW_DELIVER=1. Experimental. The call still has to set confirm to true.

list_pending

always. Empty unless the hooks are installed and the HTTP helper is the process they report to.

answer_permission

CLAUDE_REMOTE_ALLOW_APPROVE=1. The call still has to set confirm to true.

answer_question

CLAUDE_REMOTE_ALLOW_APPROVE=1. The call still has to set confirm to true.

install --allow-send registers send_followup and queue_status. install --allow-launch registers new_session, continue_session, and stop_session. install --allow-approve registers answer_permission and answer_question. With both flags the registered methods are list_sessions, get_session, continue_command, rename_session, cleanup_desk, send_followup, queue_status, new_session, continue_session, stop_session, and list_pending.

deliver_message is not part of either install flag. install never sets CLAUDE_REMOTE_ALLOW_DELIVER, so it is only registered when you export it yourself for a foreground http.

send_followup does nothing unless confirm is true. continue_session refuses a live owner unless confirmLiveOwner is true, and it still refuses while that pid is alive. new_session and continue_session send Enter on the development-channels prompt only when acceptDevChannelPrompt is true and the highlighted line is the accept option. Trust, a new MCP server prompt, and the Chrome extension prompt are not answered. If one of those panes is up and no session id has been written yet, the result is code=blocked-by-dialog with dialog set to workspace-trust, mcp-server, or chrome-extension.

Session status

list_sessions and get_session read status from claude agents --json --all, which the Claude Code docs name as the supported way to read session state from outside Claude Code. Each card has a status object:

claude agents --json

status.value

status: busy

working

status: waiting

needs-you, with waitingFor (permission prompt, input needed, sandbox request, worker request, dialog open, or other)

status: idle

idle

background state: working

working

background state: blocked

needs-you

background state: done

idle

background state: failed or stopped

failed or stopped

not listed, transcript closed

not-running

A background session uses state. status.source is agents-json when the value came from that command. Only kind, pid, sessionId, id, status, state, and waitingFor are kept from its output; cwd and name are dropped. When the command is missing, fails, times out, or prints something that is not a JSON array (an older CLI), the server falls back: a live session uses the status token in ~/.claude/sessions/<pid>.json mapped the same way (source: sessions-file), and anything else uses the run state (source: fallback). CLAUDE_REMOTE_AGENTS_JSON=0 turns the command off. The stuck line in get_session follows status when it came from agents-json, and keeps the transcript-tail heuristic otherwise. The observedStatus field on a handed send uses the same values.

While a process is live, process carries what claude agents --json does not: entrypoint (desktop, terminal, other), tmux, bridgeSessionId, and messagingSocketPath, read from ~/.claude/sessions/<pid>.json.

Sub-sessions

Each card lists subagent transcripts under ~/.claude/projects/<project>/<sessionId>/subagents/agent-<id>.jsonl as subSessions, newest first. A row has agentId, agentType, isFork, spawnDepth, finished, parentAgentId, worktree, sizeBytes, and lastActivity. They come from the file's stat and its agent-<id>.meta.json sidecar. The subagent transcript itself is never opened, and the meta description is not returned. list_sessions shows up to 20 rows per card, and subSessionCount is the total. get_session returns every row. Symlinked files or folders are skipped.

Launch options

new_session takes these optional fields. Each one is checked before anything runs, and every value is passed as its own argv entry, never through a shell.

Field

What it runs

worktree

true adds --worktree. A name (letters, digits, ., -, _, up to 64, starting with a letter or digit) adds --worktree <name>.

fastMode

true adds --settings '{"fastMode":true}'. Off unless set, because fast mode bills extra usage. That exact value is the only --settings value the server will pass.

prompt

Starts a headless claude -p --output-format stream-json --verbose session with the prompt on stdin, reads the system/init event, and returns the session id and fastMode.state (fast_mode_state: on, off, or cooldown) with fastMode.disabledReason. The process keeps running detached; its output is dropped. stop_session can end it.

cloud

A task description. Runs claude --cloud "<description>" in tmux, because that form needs a terminal, and returns cloudSessionId and cloudUrl once the CLI prints them. A description that starts with -, or looks like session_..., cse_..., or a URL, is refused, since that form sends into an existing cloud session.

environment

A ccpool_ id. Needs cloud. Runs claude -p "<description>" --environment <id> --output-format json and returns the session id from {ok, session_id, url}.

An interactive launch has no init event, so fastMode.state is null there and fastMode.source is no-init-event. cloud does not combine with worktree, fastMode, or prompt.

claude --worktree refuses to run in a folder whose workspace trust has not been accepted yet. An interactive launch then fails with session-unobserved and a hint. Headless (prompt) launches skip the trust dialog, as claude -p always does, and do create the worktree. To fix it, open claude in that folder once and accept the trust dialog. The launcher never accepts trust for you. In headless runs the init event reports fast_mode_state: "off" with fast_mode_disabled_reason: "sdk_opt_in_required" unless fastMode: true is set.

Hooks: pending questions and remote answers

claude agents --json only says that a session needs you and why (waitingFor). The hooks add what it is waiting on, and let an authenticated client answer a permission prompt or an AskUserQuestion question remotely.

install adds one command handler per event below to the user settings.json. Each runs /bin/sh ~/.claude-remote/http/hook.sh --from=claude-remote in exec form; the script is described below the table. Before it edits the file, install copies it to settings.json.bak-claude-remote-<UTC timestamp> (mode 0600). The merge is done in code: every other key and every other hook stays as it was, a second install changes nothing, and uninstall removes exactly the handlers that carry --from=claude-remote, again after a backup. A symlinked settings.json is edited at its target. A file that is not valid JSON is left alone, and the error does not quote it. The file can hold secrets in env, so it is never printed.

Event

Matcher

Mode

What the helper does

Notification

all types

blocking, 5 s timeout

Records message, title, and notification_type (permission_prompt, idle_prompt, elicitation_dialog, ...) as a pending notification.

PermissionRequest

all tools

blocking, approve timeout + 15 s

Records the tool name and a summary of tool_input. With approve on, the hook waits for answer_permission.

PreToolUse

AskUserQuestion

blocking, approve timeout + 15 s

Records the questions, headers, and options. With approve on, the hook waits for answer_question.

PostToolUse, PostToolUseFailure, PermissionDenied

all tools

async

Clears the matching permission or question (same tool_use_id, or same tool and input).

UserPromptSubmit, Stop, StopFailure, SessionEnd

-

async

Clears everything pending for the session.

install writes ~/.claude-remote/http/hook.sh (mode 0700), a POSIX sh script that forwards each event with the system curl, so an event costs a shell and a curl rather than a Node start. It behaves like claude-remote hook: the same endpoints (in a plain-text variant, ?format=text), the same 1.5-second registration timeout, the same long-poll for permission requests and questions, and the same fail-open exit 0 with no output when the token is missing, the helper is down, or anything else goes wrong. The helper builds the decision JSON for it with the code the Node hook uses, so both print the same output. The script reads the token with the shell's read and gives it to curl as a config file on a pipe (-K /dev/fd/3), so the token never appears in a process argument list, and it keeps the hook JSON in memory rather than on disk. It reads stdin to the end before doing anything else; a stdin that never closes is ended by the hook timeout in settings.json. If curl is not on PATH, the script runs node <install>/dist/index.js hook instead. uninstall deletes the script.

claude-remote hook reads the hook JSON on stdin and POSTs it to http://127.0.0.1:<port>/hook/event with the bearer token from ~/.claude-remote/http/token. It fails open: if the token is missing, the helper is down or does not answer within 1.5 seconds, or anything else goes wrong, it exits 0 with no output, which Claude Code treats as no decision. A watchdog ends the process after 2.5 seconds unless it is waiting for an answer. The hook loads only Node built-ins, so it starts quickly. The /hook/* endpoints need the same token and refuse any request that carries Cloudflare or proxy forwarding headers, so they cannot be reached through the tunnel.

Pending entries live in the HTTP helper's memory and in ~/.claude-remote/http/pending.json, so a helper restart keeps them. The file is mode 0600, written to a temp file and renamed, and saved a moment after each change. It holds the same text list_pending returns (questions, notification messages, a summary of tool input), so it is never logged or printed, and uninstall deletes it. On start the helper drops saved entries whose session is no longer live in ~/.claude/sessions, permission requests and questions older than the approve timeout, and anything older than two hours. Nothing restored from the file is answerable: the hook that was waiting gave up when the helper stopped, and the prompt fell back to the app or terminal. They show up as pending on each list_sessions and get_session card and in list_pending, with a requestId, kind (permission, question, or notification), the text, and answerable. An entry goes away on the next prompt, a matching tool result, a stop, or when the session stops running; notifications also go when the session is working again.

Remote answers

With CLAUDE_REMOTE_ALLOW_APPROVE=1, a PermissionRequest or AskUserQuestion hook long-polls the helper in slices of up to 25 seconds, until the approve timeout (CLAUDE_REMOTE_APPROVE_TIMEOUT_S, default 120). If no answer comes, it exits with no decision and the normal prompt in the app or terminal takes over. A prompt answered locally first releases the waiting hook through the clearing events.

  • answer_permission takes sessionId, requestId, decision (allow or deny), an optional message for a deny, and confirm: true. The hook prints only {"behavior":"allow"} or {"behavior":"deny","message":...}. It never returns updatedPermissions, so a remote answer cannot add an always-allow rule or change the permission mode (including bypassPermissions), and it never returns updatedInput, so the tool runs with the input Claude asked for. Deny and ask rules in settings still apply after an allow.

  • answer_question takes sessionId, requestId, answers (each question's exact text mapped to the chosen label, labels joined with commas for multi-select, or free text), and confirm: true. Every question must be answered. The hook returns permissionDecision: "allow" with updatedInput set to the original questions plus answers, which is the documented way to answer AskUserQuestion from a hook.

Per the Claude Code hook docs, the terminal runs the PermissionRequest hook before it shows its own dialog, so with approve on the local prompt is expected to appear only after the remote window closes (this ordering has been checked with claude -p, not in an interactive terminal). In the Desktop app, which hosts Claude Code through the SDK permission callback, the hook runs alongside the app's own prompt and whichever answers first wins. Keep the timeout short if you also answer locally. Without approve the hooks only report, and return at once. In claude -p runs there is no local prompt: if nobody answers in time, Claude Code denies the call, and AskUserQuestion is not offered at all.

A question can also be answered by sending a normal message (send_followup or deliver_message) once the question has timed out to the normal prompt.

Push alerts

The helper can POST a small JSON event to a URL of your choice when a new pending entry appears, so a phone or a webhook-triggered routine hears about a waiting prompt without polling list_pending. It is off unless configured.

CLAUDE_REMOTE_NOTIFY_SECRET='<16+ characters>' node dist/index.js install --allow-approve --notify-url=https://example.com/hooks/claude
node dist/index.js install --notify-url=https://example.com/hooks/claude --notify-include-text --notify-cleared

install writes the URL and secret to ~/.claude-remote/http/notify.json (mode 0600); neither goes in the plist. The secret can also be passed as --notify-secret=<value>, but the environment keeps it out of the process list and shell history. Running install without --notify-url turns alerts off and deletes the file, and uninstall deletes it too. A foreground node dist/index.js http also reads CLAUDE_REMOTE_NOTIFY_URL, CLAUDE_REMOTE_NOTIFY_SECRET, CLAUDE_REMOTE_NOTIFY_AUTH, CLAUDE_REMOTE_NOTIFY_INCLUDE_TEXT, and CLAUDE_REMOTE_NOTIFY_CLEARED, which take precedence over the file. The URL must be https, or http to 127.0.0.1 or localhost.

Each new permission request, question, or notification produces exactly one pending event. The permission_prompt notification Claude Code sends a few seconds after a permission request is not sent again. With --notify-cleared the endpoint also gets one cleared event when an alerted entry goes away. The body is:

{"type":"claude-remote.pending","event":"pending","eventId":"<requestId>.pending","requestId":"...","sessionId":"...","projectName":"my-app","kind":"permission","toolName":"Bash","notificationType":null,"createdAt":"...","sentAt":"...","answerable":true}

The question, message, or tool-input text is left out unless --notify-include-text (CLAUDE_REMOTE_NOTIFY_INCLUDE_TEXT=1) adds text, title, and questions. Pass requestId and sessionId to get_session, list_pending, or answer_permission.

With a secret, each request carries X-Claude-Remote-Timestamp (Unix seconds) and X-Claude-Remote-Signature: sha256=<hex HMAC-SHA256 of "<timestamp>.<raw body>" keyed by the secret>. Check it with a constant-time compare and reject old timestamps. --notify-auth=bearer sends Authorization: Bearer <secret> instead, for receivers that can only match a static header. X-Claude-Remote-Delivery repeats eventId for deduplication.

Delivery is fire-and-forget: a 3-second timeout, two retries (after 0.5 and 2 seconds) on a network error, 429, or 5xx, no redirects, and at most 100 queued events. A hook never waits for it. Logs record only sent, failed-<status>, or dropped, never the URL, the secret, or any text.

Deliver a message to a live session (experimental)

deliver_message puts text in the cross-session inbox of a live session, including a session open in the Desktop app, without resuming it or closing the app. It uses the documented route: a short claude -p worker whose only tool is SendMessage (--tools SendMessage --strict-mcp-config --no-session-persistence) is asked to send the text to uds:<messagingSocketPath> from the target's ~/.claude/sessions/<pid>.json. The text goes to the worker on stdin. The server never writes to the socket itself, because that format is not documented.

The result is delivered only when the worker made exactly one SendMessage call, to that address, and the call reported success. verbatim says whether the sent text matched. The receiving session still applies its own inbound rules. A session that prompts for permissions takes the message; a session that bypasses permission prompts holds it, and a Desktop or -p session cannot show the approval dialog, so a held message expires after five minutes by default. delivered means the message reached the inbox, not that Claude has read it. The worker is a model call: the text is fenced and the worker is told not to follow instructions inside it, and the result checks the target, but a worker run costs a small amount of usage.

Safety

CLAUDE_REMOTE_ALLOW_SEND, CLAUDE_REMOTE_ALLOW_LAUNCH, CLAUDE_REMOTE_ALLOW_APPROVE, and CLAUDE_REMOTE_ALLOW_DELIVER are opt-in. A default install leaves them off. send_followup, deliver_message, answer_permission, and answer_question also need confirm: true on every call.

The server does not open ~/.claude/sessions/*.key, ~/.claude/.credentials.json, ~/.claude.json, or anything under ~/.config/anthropic. A session id that is not a UUID is rejected before any path is joined. A symlink whose target leaves the transcript directory is refused.

Logs may include a UTC timestamp, a tool name, a session id, a ticket id, a duration, an exit code, and a short code such as unauthorized, host-rejected, origin-rejected, start, end, or error. They do not include the bearer token, the setup-token, a header value, a message, transcript text, file text, or any hook payload: a hook event is logged as its event name and recorded or wait, and an answer as its decision. The watchdog log holds probe outcomes and tunnel hostnames.

The server does not pass permission-bypass flags. That includes --dangerously-skip-permissions, --permission-mode, and --allow-dangerously-skip-permissions. --settings is refused unless its value is exactly {"fastMode":true}.

list_sessions does not stat or resolve a working directory under Desktop, Documents, Downloads, Library/Mobile Documents, or Library/CloudStorage. Those are the macOS protected folders, including iCloud.

Each tools/call has a deadline. The default is 30 seconds. CLAUDE_REMOTE_CALL_TIMEOUT_MS can set it from 200 to 60000. On expiry the listener returns one JSON body, JSON-RPC code -32000, message deadline exceeded.

Uninstall

node dist/index.js uninstall --dry-run
node dist/index.js uninstall
node dist/index.js uninstall --purge

uninstall runs launchctl bootout for all three LaunchAgents, deletes the plists, the token, the port file, the tunnel settings and named config, the watchdog state, and the logs, runs claude mcp remove --scope user claude-remote-channel, and removes the claude-remote hook handlers from the user settings.json after backing it up. In Funnel mode it also runs tailscale funnel ... off. It leaves attachments, the queue, the inbox, session files, and ~/.config/claude-remote/oauth-token in place. uninstall --purge also removes the claude-remote directories under ~/.claude-remote. Neither command opens or deletes a .key file.

You can also stop the jobs directly:

launchctl bootout gui/$(id -u)/com.claude-remote.http
launchctl bootout gui/$(id -u)/com.claude-remote.tunnel
launchctl bootout gui/$(id -u)/com.claude-remote.watchdog

Develop

npm test

npm test typechecks and runs the unit tests, including a clean-machine check that starts the HTTP server with an empty HOME and expects initialize plus list_sessions to return an empty list.

CI (.github/workflows/ci.yml) runs npm ci, npm run build, npm test, and npm run test:portable on macOS and Ubuntu with Node 20, 22, and 24, on every push and pull request. It has read-only repository access and uses no secrets.

License

MIT. See LICENSE.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables MCP clients to spawn and control Codex CLI and Claude Code sessions on the host machine, with session management and filesystem access.
    4
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables a local MCP client such as Codex or another Claude Code session to join and message already-running, allowlisted Claude Code sessions on the same Mac, using a local stdio server and daemon with kernel-verified peer identity and an append-only event ledger.
    Apache 2.0
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables voice assistants to start, monitor, message, and approve local Claude Code sessions on your computer, including sessions already open in a terminal. It exposes session management, recaps, approvals, and history search as MCP tools.
    7
    GPL 3.0