xa
Manage Claude Code sessions across projects and machines.
Claude Code's built-in /resume picker is per-project, per-machine — sessions
on your laptop, your Mac, your server, and your phone live in separate silos.
xa treats sessions as first-class records you can list, search, spawn,
resume, and kill across every machine you use, from one CLI or one HTTPS
endpoint.
Works great on a headless server (tmux-hosted claude, phone URL via
claude.ai/code/… remote control). Works fine on a laptop too.
What xa is for, now that Remote Control exists
Claude Code's own Remote Control
does the "start a session and pick it up on my phone" job natively — server
mode serves up to 32 sessions from one process, the claude.ai session list
shows them across machines, and mobile push tells you when one needs you.
Use it. Set "remoteControlAtStartup": true and xa gets out of the way.
xa is for the four things it doesn't do:
- Launch in an arbitrary directory, from your phone. A
claude remote-controlserver is rooted at one directory (--spawn worktreemeans worktrees of that same repo).xa spawn <any-cwd>— and the web UI's folder picker — is how you start a session in any one of a few hundred projects without being at the machine. - See sessions that were never remote-controlled. claude.ai lists only
live Remote Control sessions.
xa listreads the whole transcript tree, andxa history --searchgreps every prompt you have ever typed, across projects and machines. - Find out what happened to a session that died.
xa archive forensicsgives a death reason and the last tool call. Nothing upstream keeps one. - Reconnect a session whose Remote Control dropped. After a network
change Claude Code retries, gives up, and leaves the session running but
unreachable.
xa revivefinds those and reconnects them — carefully; seexa revive --help.
Easiest path — let Claude Code set it up
xa ships with an agent skill that teaches any Claude Code instance how to
install, configure, and secure-by-default-set-up the package on your machine.
After pip install xa, a Claude Code session running on that machine can
guide you through the rest with zero docs-reading.
pip install 'xa[service]' # service extra bundles the HTTP server
Then, in a Claude Code session on the target machine:
"Set up xa on this server so I can manage Claude Code sessions from my phone."
The xa-install skill will:
- verify system prerequisites (
tmux,claude,rsync/sshif needed); - generate a strong password + captcha key (via
xa gen-secret); - write a secure
~/.config/xa/config.tomland a systemd unit; - refuse to expose the HTTP service publicly without auth + TLS guidance;
- run a health-check and confirm everything is wired up.
Two companion skills live in the repo:
xa-install— the consumer-facing setup skill, shipped inside the wheel so everypip install xagets it.xa-dev— for collaborators hacking on the codebase (architecture, test conventions, known pitfalls).
Running xa as a phone-accessible server
This is the highest-leverage use case. A headless server runs
xa serve, you open https://your-server/api/xa/sessions/{id} (or a small
web UI) on your phone, and you can list / spawn / resume / kill Claude Code
sessions from anywhere. Each session exposes a claude.ai/code/… URL that
resumes the agent right in your phone's browser.
⚠️ Security first — this is a code-execution endpoint
A live xa session runs claude, which can invoke arbitrary shell
commands via its Bash tool. Anyone who reaches your xa server with
valid credentials can execute code as the server user. Treat the server
like you would an SSH endpoint — only more carefully, because the
attack surface is a web request.
xa is secure by default. When running on a non-loopback interface,
it refuses to start without credentials. You have to pass
--i-know-its-insecure to bypass the check.
The recommended recipe
# 1. Install (on the server).
pip install 'xa[service]'
# 2. Generate secrets. Don't copy passwords from a tutorial — use these.
export XA_PASSWORD=$(xa gen-secret)
export XA_CAPTCHA_KEY=$(xa gen-secret)
# 3. Start xa, bound locally, captcha-gated.
xa serve \
--host 127.0.0.1 --port 8010 \
--username $(whoami) --password-env XA_PASSWORD \
--captcha
Then put a TLS-terminating reverse proxy in front of port 8010. Caddy, nginx, Traefik, Coolify — any of them. Without TLS, HTTP Basic sends your password in plaintext on every request.
A minimal Caddy block:
xa.example.com {
reverse_proxy 127.0.0.1:8010
}
Defense in depth — things to add
-
mTLS client certs. Issue one per device (phone, laptop). Caddy's
client_auth { mode require_and_verify }rejects any connection without a valid cert. Removes the "shared password" problem entirely. Sketch:xa.example.com { tls { client_auth { mode require_and_verify trusted_ca_cert_file /etc/caddy/xa_ca.pem } } reverse_proxy 127.0.0.1:8010 }Generate a CA with
step-caor plainopenssl; export per-device.p12bundles to install on each device. Once you have this, you can runxa serve --i-know-its-insecure(Basic auth becomes redundant — the proxy already authenticated the client) or keep Basic as a second factor. -
Obscure the hostname. Don't call it
xa.example.com. Random subdomain + private DNS is cheap friction for bots. -
Systemd hardening. Run as a non-privileged user with
ProtectSystem=strict,NoNewPrivileges=true, and a tightReadWritePaths=list. -
Firewall. Bind only to loopback; let the reverse proxy be the only thing on a public interface.
-
Rotate credentials.
xa gen-secretis a one-liner; rotate monthly and restart the service.
Systemd unit
# /etc/systemd/system/xa.service
[Unit]
Description=xa Claude Code session server
After=network.target
[Service]
Type=simple
User=xa
WorkingDirectory=/home/xa
Environment="XA_PASSWORD=<paste the output of `xa gen-secret`>"
Environment="XA_CAPTCHA_KEY=<paste a second `xa gen-secret`>"
ExecStart=/path/to/venv/bin/xa serve \
--host 127.0.0.1 --port 8010 \
--username xa --password-env XA_PASSWORD \
--captcha
Restart=on-failure
# Hardening — uncomment and tune if your setup allows:
# ProtectSystem=strict
# ProtectHome=true
# NoNewPrivileges=true
# PrivateTmp=true
[Install]
WantedBy=multi-user.target
sudo systemctl enable --now xa
curl -u xa:$XA_PASSWORD http://127.0.0.1:8010/health
What happens if you try to skip the guardrails
$ xa serve --host 0.0.0.0 --port 8010
error: refusing to bind to a non-loopback interface ('0.0.0.0') without
--username/--password.
Anyone who can reach this port could spawn arbitrary shell
commands via Claude Code sessions on this host.
Fix (recommended): add credentials + captcha.
xa gen-secret # use for password + XA_CAPTCHA_KEY
…
Bypass (only when an outer layer already gates access,
e.g. mTLS in the reverse proxy): --i-know-its-insecure
The flag exists, but it's named so the error is honest.
Reviving dropped Remote Control sessions
Remote Control gives up on its own. A network change — moving between
networks, toggling a VPN — produces a transient 403, Claude Code retries for
a few minutes and then stops, and the session keeps running locally while
becoming unreachable from claude.ai. xa revive finds those and reconnects
them.
$ xa revive
reconnectable work:@1.%1 would send /remote-control [remote control disconnected]
held_elsewhere phone:@1.%1 not actionable [ended or archived from another device]
connected build:@2.%1 [/rc]
(dry run — re-run with --apply to send)
xa revive --apply # send /remote-control to the dropped ones
xa revive --include-held-elsewhere # …also take back ones held on another device
xa revive --server-mode # …also restart died `claude remote-control` servers
xa revive --json-out # machine-readable, one record per pane
It only reaches tmux-hosted sessions. /remote-control is a built-in
slash command with no CLI equivalent, so reconnecting means typing into the
pane — a session running in a plain terminal tab has to be reconnected by
hand.
Four refusals are deliberate, and each cost something to learn:
| It will not | Because |
|---|---|
| reconnect a session held on another device | it would take the session back from your phone mid-sentence |
| type into a pane with unsent text in its prompt | send-keys appends, so it would submit your half-typed instruction with /remote-control glued to the end |
| touch a busy or API-stalled session | the fix there is resending a prompt, which costs money and can duplicate work |
| touch the same pane twice in ten minutes | so a timer, a hook and an impatient human cannot add up to a keystroke storm |
Detection reads the footer's /rc pill, which is present while Remote
Control is connected and gone when it is not, and cross-checks it
against bridgeSessionId in the session's own state file. Both were
verified against a live session rather than inferred — getting the pill
backwards would send /remote-control at healthy sessions, which opens a
modal menu inside them.
The rest of the CLI
Already have some Claude Code sessions on this machine? List them:
$ xa list
ID HOST STATE MOD TURNS CWD URL / FIRST_MSG
-------- ----- --------------- ------- ----- ----------------------- ---------------------------------------------------
7b875f44 local live 2s ago 494 /root https://claude.ai/code/session_01ALoSc...
b3366c60 local live 20h ago 725 /root/py/proj https://claude.ai/code/session_012W14x...
4e738fb5 local transcript_only 18h ago 15 /root my apps.thorwhalen.com has an admin level app…
Dig into one:
xa info 7b875f44 # full record + forensics
xa archive forensics 7b875f44 # what was it doing when it last ran a tool?
xa history --search "bridge URL" # cross-project prompt grep
Or walk through them interactively:
xa pick
Commands
| Command | Purpose |
|---|---|
xa list |
Table of sessions across configured hosts |
xa info <id> |
One session — metadata + forensics |
xa history [--search X] |
Grep ~/.claude/history.jsonl |
xa pick |
Interactive list → number → action |
xa spawn <cwd> |
Start claude in tmux, print phone URL |
xa resume <id> |
claude --resume in a new tmux pane |
xa kill <id> |
End a live session |
xa archive list |
Postmortem of every session xa spawned |
xa archive log <id> |
Raw pane log |
xa archive forensics <id> |
Death reason + last tool use |
xa sync [--host H] |
Refresh SSH-host caches |
xa serve … |
Run the HTTP service (secure-by-default) |
xa gen-secret |
Cryptographically strong random hex string |
Every command supports --help; every listing supports --json-out.
Configuration
Config lives at ~/.config/xa/config.toml (respects $XDG_CONFIG_HOME;
override with $XA_CONFIG). Missing file → implicit LocalHost.
[settings]
cache_dir = "~/.cache/xa/remotes"
stale_threshold_sec = 3600
claude_bin = "claude"
tmux_bin = "tmux"
[hosts.local]
kind = "local"
# Another machine via SSH. Uses your ~/.ssh/config.
[hosts.devbox]
kind = "ssh"
host = "devbox"
user = "deploy"
remote_claude_home = "~/.claude"
# Another xa server over HTTPS.
[hosts.phone_server]
kind = "http"
base_url = "https://xa.example.com"
auth = "basic"
username = "me"
password_env = "XA_PHONE_PW" # secret stays in env, not TOML
After editing, xa sync pulls SSH hosts into the local cache.
Python API
Everything the CLI does is available as a library:
import xa
# List all sessions on this machine.
for s in xa.list_sessions(limit=10):
print(s.id[:8], s.state, s.cwd, s.url)
# Multi-host.
hosts = xa.load_hosts()
for s in xa.list_sessions(hosts=list(hosts.values())):
print(s.host, s.id[:8], s.url)
# Spawn / resume / kill.
local = xa.LocalHost()
result = local.spawn("my-session", cwd="/tmp")
print(result.url)
# Read the archive.
events = xa.default_events_store()
panes = xa.default_pane_store()
for rec in xa.records(events, panes):
print(rec.id, rec.gone_reason, rec.url)
For embedding the HTTP service in a larger app:
from xa.service import build_api, make_basic_auth, Captcha
import os
api = build_api(
auth=make_basic_auth("me", os.environ["XA_PASSWORD"]),
captcha=Captcha(key=os.environ["XA_CAPTCHA_KEY"]),
)
outer_app.mount("/api/xa", api)
HTTP API surface
| Method | Path | Purpose |
|---|---|---|
GET |
/sessions |
List (query: project, state, include_forks, limit) |
POST |
/sessions |
Create a tmux+claude session; up to 120 s for the bridge URL |
DELETE |
/sessions/{name} |
Kill (captcha-gated when --captcha) |
GET |
/sessions/{id}/info |
Metadata + forensics + pane tail |
POST |
/sessions/{id}/resume |
claude --resume in a new pane |
GET |
/archive |
Postmortem list |
GET |
/archive/{id}/forensics |
Rich forensics |
GET |
/archive/{id}/log |
Pane log (?tail_kb=) |
GET |
/captcha |
4-letter challenge (only with --captcha) |
GET |
/health |
Liveness |
How it compares
| Feature | xa |
cc-sessions |
claude --resume |
claude-history-viewer |
|---|---|---|---|---|
| Cross-project discovery | ✓ | ✓ | per-project | ✓ |
| Cross-machine discovery | ✓ (SSH + HTTP) | ✓ (SSH) | ✗ | ✗ |
| Live tmux / spawn / kill | ✓ | ✗ | ✗ | ✗ |
| Bridge URL resolution (phone) | ✓ | ✗ | N/A | ✗ |
| Postmortem / death reason | ✓ | ✗ | ✗ | ✗ |
| HTTP API surface | ✓ | ✗ | ✗ | ✗ |
| Python library | ✓ | ✗ | — | ✗ |
| Language | Python | Rust | — | TypeScript (VS Code) |
Short version: cc-sessions pioneered cross-machine listing; the VS Code
extension pioneered transcript search with a UI; xa unifies those with
live CRUD + bridge URL resolution + postmortem, and exposes all of it as
both a CLI and a Python library you can embed.
How session data flows
┌──────────── local machine ─────────────┐ ┌────────── remote machine ─────────┐
│ │ │ │
│ ~/.claude/projects/<slug>/*.jsonl │ │ ~/.claude/projects/<slug>/*.jsonl│
│ ~/.claude/sessions/<pid>.json │ │ ~/.claude/sessions/<pid>.json │
│ │ │ │ │ │ │
│ ▼ ▼ │ │ ▼ │
│ xa.claude_fs xa.tmux ─────┐ │ │ rsync (SSHHost) ─┐ │
│ │ │ │ │ │ or HTTP (HTTPHost)│ │
│ └────┬────┘ │ │ │ │ │
│ ▼ │ │ │ ▼ │
│ Session dataclass ◄─────┘ │ │ cached ~/.claude/ │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ xa.list_sessions ◄───────────────┼───┤ remote Sessions │
│ │ │ │ │
│ ▼ │ └───────────────────────────────────┘
│ CLI / API / phone URL │
└────────────────────────────────────────┘
Development
Layered architecture in misc/docs/design.md;
phased plan in misc/docs/roadmap.md; research
on Claude Code internals in misc/docs/research.md.
git clone https://github.com/thorwhalen/xa.git
cd xa
pip install -e '.[dev,service]'
pytest -q # ~100 tests, ~8 seconds
XA_RUN_INTEGRATION=1 pytest -q # also runs the real-claude spawn test
The xa-dev skill is picked up
automatically when you're working on this repo (architecture overview,
testing conventions, known pitfalls).
License
MIT
Metadata
Release files for xa 0.1.10
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| xa-0.1.10.tar.gz | 146.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| xa-0.1.10-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 249.7 kB
Release files / xa-0.1.10.tar.gz
| Download URL | xa-0.1.10.tar.gz |
|---|---|
| Size | 146.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c36cc12e3d0223fb7a52e8061f4adc3d917d1d1b1e3dfcf6f0196bd71774cc9a
|
|
BLAKE2b-256 checksum How to use checksums |
98f61b5f05e91263ec97c057894685f743415a6eb942fc5d14868fc88ec7909e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.9 {"installer":{"name":"uv","version":"0.12.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / xa-0.1.10-py3-none-any.whl
| Download URL | xa-0.1.10-py3-none-any.whl |
|---|---|
| Size | 103.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d99944391baaf7c827731b88d5b9571c8dbeaba313f8992c027aa5f1516da40d
|
|
BLAKE2b-256 checksum How to use checksums |
bd1bf7ddd86c8b107b6b9457fb281550f33e8da37676c5a9560dd23f18719d17
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.9 {"installer":{"name":"uv","version":"0.12.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|