Skip to content

Commit 4a055db

Browse files
committed
feat(portal): browser terminal into 24/7 tmux Claude Code session
Self-hosted portal that streams a persistent tmux Claude Code session to the browser over Socket.io + node-pty, designed to run on a small VPS behind Tailscale. - PortalServer (Express + Socket.io + node-pty) attaching to tmux new-session -A so the agent survives browser/portal restarts - Token auth (auto-generated, 0600) gating both page + WS handshake - Embedded xterm.js UI (no build step, ships in dist) - stackmemory portal start|status|stop|token CLI - Hetzner cloud-init, setup.sh, and systemd unit for 24/7 operation - docs/guides/PORTAL.md walkthrough https://claude.ai/code/session_01Gk8DiqCeG9uMaWT9RprwP1
1 parent 6b45c80 commit 4a055db

12 files changed

Lines changed: 1252 additions & 0 deletions

File tree

docs/guides/PORTAL.md

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# StackMemory Portal — Run Claude Code 24/7
2+
3+
> A VPS, Claude Code in tmux, a Tailscale VPN, and a vibecoded web terminal.
4+
> Your agents run 24/7. You experience life.
5+
6+
The **portal** is a self-hosted, browser-based terminal into a persistent
7+
`tmux` session running Claude Code. Put it on a small VPS behind Tailscale and
8+
you get a private, always-on coding agent you can check on from your phone,
9+
laptop, or tablet — no exposed ports, no SaaS in the middle.
10+
11+
```
12+
┌── Hetzner CX22 (~€4.5/mo) ───────────────────────────┐
13+
│ │
14+
│ tmux session "claude" ──► claude (max plan) │
15+
│ ▲ │
16+
│ │ node-pty │
17+
│ stackmemory portal ──► :7799 (xterm.js + WS) │
18+
│ ▲ │
19+
└────────┼──────────────────────────────────────────────┘
20+
│ Tailscale (WireGuard, 100.x address)
21+
22+
Your browser → http://100.x.y.z:7799/?token=…
23+
```
24+
25+
**Why this shape?**
26+
27+
- **tmux** keeps the agent alive when you close the browser or the portal
28+
restarts. Reattach over SSH any time.
29+
- **Tailscale** gives you an encrypted private address with zero open ports —
30+
no nginx, no TLS certs, no firewall holes.
31+
- **node-pty + xterm.js** stream the real terminal, so Claude Code's TUI,
32+
permissions prompts, and colors all work exactly as they do locally.
33+
34+
---
35+
36+
## Quick start (Hetzner cloud-init)
37+
38+
The fastest path — the server provisions itself on first boot.
39+
40+
1. Create a Tailscale **auth key** at
41+
<https://login.tailscale.com/admin/settings/keys> (reusable, ephemeral off).
42+
2. In Hetzner Cloud, **Add Server** → Ubuntu 24.04 → type **CX22**.
43+
3. Expand **Cloud config** and paste
44+
[`scripts/portal/cloud-init.yaml`](../../scripts/portal/cloud-init.yaml).
45+
Set `TS_AUTHKEY=` to your key inside the pasted config.
46+
4. Create the server. After ~2 minutes it's on your tailnet.
47+
48+
Then finish the two interactive steps over SSH:
49+
50+
```bash
51+
ssh root@<hetzner-ip>
52+
53+
# Authenticate Claude Code (max plan) once — it caches credentials in ~/.claude
54+
tmux attach -t claude # log in, approve, then detach with: Ctrl-b d
55+
56+
# Grab your access URL + token
57+
journalctl -u stackmemory-portal --no-pager | grep -i token
58+
```
59+
60+
Open `http://100.x.y.z:7799/?token=…` (the `100.x` Tailscale address) from any
61+
device signed into your tailnet. You're now looking at your agent.
62+
63+
---
64+
65+
## Manual setup
66+
67+
Prefer to do it by hand, or installing on an existing box?
68+
69+
```bash
70+
# On the VPS (Debian/Ubuntu):
71+
curl -fsSL https://raw.githubusercontent.com/stackmemoryai/stackmemory/main/scripts/portal/setup.sh | bash
72+
73+
sudo tailscale up # join your tailnet (prints an auth URL)
74+
tmux new -s claude 'claude' # authenticate Claude, then Ctrl-b d to detach
75+
stackmemory portal start --cwd ~/work # start the portal (prints the URL + token)
76+
```
77+
78+
For 24/7 operation, install the service:
79+
80+
```bash
81+
sudo cp scripts/portal/stackmemory-portal.service /etc/systemd/system/
82+
sudo systemctl daemon-reload
83+
sudo systemctl enable --now stackmemory-portal
84+
journalctl -u stackmemory-portal -f # tail logs (the access URL is printed here)
85+
```
86+
87+
---
88+
89+
## The CLI
90+
91+
```bash
92+
stackmemory portal start # start the server (foreground; systemd runs this)
93+
stackmemory portal status # show status + the access URL for this machine
94+
stackmemory portal stop # stop a running portal
95+
stackmemory portal token # print the access token
96+
```
97+
98+
`start` options:
99+
100+
| Flag | Default | Description |
101+
|------|---------|-------------|
102+
| `--port <n>` | `7799` | Port to listen on |
103+
| `--host <h>` | `0.0.0.0` | Interface to bind (reachable over the tailnet) |
104+
| `--session <name>` | `claude` | tmux session name |
105+
| `--command <cmd>` | `claude` | Command tmux runs (`"claude --resume"`, a wrapper, etc.) |
106+
| `--cwd <dir>` | cwd | Working directory for the session |
107+
| `--no-auth` | off | Disable the token (rely on Tailscale alone) |
108+
109+
The portal runs `tmux new-session -A -s <session> <command>`: it **attaches** to
110+
the session if it already exists, otherwise creates it. Multiple browser tabs
111+
share the same live session. Closing a tab detaches but never kills the agent.
112+
113+
---
114+
115+
## Security model
116+
117+
- **Network:** binding to `0.0.0.0` is safe *because* the box only has a public
118+
IP plus its Tailscale address — keep the cloud firewall closed to `:7799` and
119+
reach it exclusively over the tailnet. (Hetzner's firewall: allow `22` from
120+
your IP, deny the rest.)
121+
- **Token:** a 48-char token is generated on first start and stored at
122+
`~/.stackmemory/portal/token` (`chmod 600`). It's required on both the page
123+
load (`?token=`) and the WebSocket handshake. Rotate it by deleting the file
124+
and restarting. `--no-auth` turns this off if you trust your tailnet ACLs.
125+
- **No inbound ports on the internet.** Tailscale is WireGuard point-to-point;
126+
there is nothing to port-scan.
127+
128+
> Treat the token like an SSH key — anyone with the URL gets a live shell as the
129+
> user running the portal.
130+
131+
---
132+
133+
## Troubleshooting
134+
135+
| Symptom | Fix |
136+
|---------|-----|
137+
| `tmux is not installed` | `sudo apt install tmux` |
138+
| Page loads but terminal is blank / "Cannot start session" | `node-pty` missing on the server: `npm install -g node-pty` (needs `build-essential` + `python3`) |
139+
| `401 Unauthorized` | Append `?token=<token>` to the URL (`stackmemory portal token`) |
140+
| Can't reach `100.x` address | `tailscale status` on both ends; make sure your client is logged into the same tailnet |
141+
| Claude asks to log in every time | Authenticate once inside the tmux session so credentials land in `~/.claude`; ensure systemd `HOME=` points at that user's home |
142+
| Agent died but portal is up | `tmux attach -t claude` to inspect; the portal recreates the session on next connect |
143+
144+
---
145+
146+
## Files
147+
148+
| Path | Purpose |
149+
|------|---------|
150+
| `src/features/portal/server.ts` | Express + Socket.io + node-pty bridge |
151+
| `src/features/portal/ui.ts` | Embedded xterm.js terminal UI |
152+
| `src/cli/commands/portal.ts` | `stackmemory portal` command |
153+
| `scripts/portal/setup.sh` | One-shot VPS installer |
154+
| `scripts/portal/cloud-init.yaml` | Hetzner first-boot provisioning |
155+
| `scripts/portal/stackmemory-portal.service` | systemd unit for 24/7 operation |

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"dist/src",
2525
"scripts/git-hooks",
2626
"scripts/hooks",
27+
"scripts/portal",
2728
"scripts/setup",
2829
"scripts/setup.sh",
2930
"scripts/install.sh",

scripts/portal/cloud-init.yaml

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
#cloud-config
2+
# StackMemory Portal — Hetzner Cloud-Init
3+
#
4+
# Paste this into the Hetzner Cloud "Cloud config" box when creating a server
5+
# (Ubuntu 24.04, a CX22 is plenty: 2 vCPU / 4 GB, ~€4.5/mo). On first boot it
6+
# installs tmux, Node, Claude Code, StackMemory, and Tailscale, then starts the
7+
# portal as a systemd service.
8+
#
9+
# Set TS_AUTHKEY to a Tailscale auth key (https://login.tailscale.com/admin/settings/keys)
10+
# to auto-join your tailnet. Otherwise SSH in afterwards and run `tailscale up`.
11+
#
12+
# After boot, find the access URL + token with:
13+
# journalctl -u stackmemory-portal --no-pager | grep -i token
14+
15+
package_update: true
16+
packages:
17+
- tmux
18+
- git
19+
- curl
20+
- ca-certificates
21+
- build-essential
22+
- python3
23+
24+
write_files:
25+
- path: /etc/stackmemory-portal.env
26+
permissions: "0600"
27+
content: |
28+
# Set this to a Tailscale auth key to auto-join the tailnet on boot.
29+
TS_AUTHKEY=
30+
- path: /etc/systemd/system/stackmemory-portal.service
31+
permissions: "0644"
32+
content: |
33+
[Unit]
34+
Description=StackMemory Portal (browser terminal for Claude Code)
35+
After=network-online.target tailscaled.service
36+
Wants=network-online.target
37+
38+
[Service]
39+
Type=simple
40+
User=root
41+
WorkingDirectory=/root/work
42+
Environment=HOME=/root
43+
Environment=NODE_ENV=production
44+
ExecStart=/usr/bin/env stackmemory portal start --port 7799 --session claude --cwd /root/work
45+
Restart=always
46+
RestartSec=3
47+
48+
[Install]
49+
WantedBy=multi-user.target
50+
51+
runcmd:
52+
# Node.js 20.x
53+
- curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
54+
- apt-get install -y nodejs
55+
# Claude Code + StackMemory + node-pty (browser terminal backend)
56+
- npm install -g @anthropic-ai/claude-code @stackmemoryai/stackmemory node-pty
57+
# Tailscale
58+
- curl -fsSL https://tailscale.com/install.sh | sh
59+
- bash -c 'set -a; . /etc/stackmemory-portal.env; set +a; [ -n "$TS_AUTHKEY" ] && tailscale up --authkey "$TS_AUTHKEY" --ssh || true'
60+
# Working dir + start the portal
61+
- mkdir -p /root/work
62+
- systemctl daemon-reload
63+
- systemctl enable --now stackmemory-portal
64+
65+
final_message: |
66+
StackMemory Portal is up after $UPTIME seconds.
67+
1. If you didn't set TS_AUTHKEY: run `tailscale up` over SSH.
68+
2. Authenticate Claude (max plan): `tmux attach -t claude` then log in. Detach with Ctrl-b d.
69+
3. Get the URL + token: `journalctl -u stackmemory-portal --no-pager | grep -i token`

scripts/portal/setup.sh

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
#!/usr/bin/env bash
2+
#
3+
# StackMemory Portal — VPS setup script.
4+
#
5+
# Provisions a fresh Debian/Ubuntu box (e.g. a Hetzner CX22, ~€4.5/mo) to run
6+
# Claude Code 24/7 inside tmux, reachable from a browser over Tailscale.
7+
#
8+
# curl -fsSL https://raw.githubusercontent.com/stackmemoryai/stackmemory/main/scripts/portal/setup.sh | bash
9+
#
10+
# Idempotent: safe to re-run. Re-run after editing PORTAL_* env vars below.
11+
set -euo pipefail
12+
13+
PORTAL_USER="${PORTAL_USER:-$(whoami)}"
14+
PORTAL_PORT="${PORTAL_PORT:-7799}"
15+
PORTAL_SESSION="${PORTAL_SESSION:-claude}"
16+
PORTAL_WORKDIR="${PORTAL_WORKDIR:-$HOME/work}"
17+
NODE_MAJOR="${NODE_MAJOR:-20}"
18+
19+
log() { printf '\033[36m[portal-setup]\033[0m %s\n' "$*"; }
20+
have() { command -v "$1" >/dev/null 2>&1; }
21+
22+
SUDO=""
23+
if [ "$(id -u)" -ne 0 ]; then
24+
if have sudo; then SUDO="sudo"; fi
25+
fi
26+
27+
log "1/6 Installing base packages (tmux, git, curl, build tools)…"
28+
if have apt-get; then
29+
$SUDO apt-get update -y
30+
$SUDO apt-get install -y tmux git curl ca-certificates build-essential python3
31+
fi
32+
33+
log "2/6 Installing Node.js ${NODE_MAJOR}.x…"
34+
if ! have node || [ "$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)" -lt "$NODE_MAJOR" ]; then
35+
curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | $SUDO -E bash -
36+
$SUDO apt-get install -y nodejs
37+
fi
38+
log " node $(node -v) / npm $(npm -v)"
39+
40+
log "3/6 Installing Claude Code + StackMemory…"
41+
have claude || $SUDO npm install -g @anthropic-ai/claude-code
42+
$SUDO npm install -g @stackmemoryai/stackmemory
43+
# node-pty powers the browser terminal; build tools above let it compile.
44+
$SUDO npm install -g node-pty || log " (node-pty global install failed — install it in the portal's working dir)"
45+
46+
log "4/6 Installing Tailscale…"
47+
if ! have tailscale; then
48+
curl -fsSL https://tailscale.com/install.sh | $SUDO sh
49+
fi
50+
log " Run 'sudo tailscale up' to join your tailnet (prints an auth URL)."
51+
52+
log "5/6 Preparing working directory at ${PORTAL_WORKDIR}"
53+
mkdir -p "$PORTAL_WORKDIR"
54+
55+
log "6/6 Next steps:"
56+
cat <<EOF
57+
58+
StackMemory Portal is installed. To finish:
59+
60+
1. Join Tailscale: sudo tailscale up
61+
2. Authenticate Claude: tmux new -s ${PORTAL_SESSION} 'claude' # log in (max plan), then detach: Ctrl-b d
62+
3. Start the portal: stackmemory portal start --port ${PORTAL_PORT} --session ${PORTAL_SESSION} --cwd ${PORTAL_WORKDIR}
63+
4. Open the printed http://100.x.y.z:${PORTAL_PORT}/?token=... URL from any device on your tailnet.
64+
65+
For 24/7 operation, install the systemd service:
66+
sudo cp scripts/portal/stackmemory-portal.service /etc/systemd/system/
67+
sudo systemctl enable --now stackmemory-portal
68+
69+
EOF
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# StackMemory Portal — systemd unit
2+
#
3+
# Runs the browser terminal portal 24/7. The Claude Code agent itself lives in
4+
# a detached tmux session, so it survives portal restarts and browser closes.
5+
#
6+
# Install:
7+
# sudo cp scripts/portal/stackmemory-portal.service /etc/systemd/system/
8+
# sudo systemctl daemon-reload
9+
# sudo systemctl enable --now stackmemory-portal
10+
# journalctl -u stackmemory-portal -f # logs (incl. the access URL/token)
11+
#
12+
# EDIT BEFORE USE: set User=, WorkingDirectory=, and HOME to match your box.
13+
# The defaults below assume the common Hetzner "run as root" layout.
14+
15+
[Unit]
16+
Description=StackMemory Portal (browser terminal for Claude Code)
17+
After=network-online.target tailscaled.service
18+
Wants=network-online.target
19+
20+
[Service]
21+
Type=simple
22+
User=root
23+
WorkingDirectory=/root/work
24+
# HOME must be set so the portal finds ~/.stackmemory and Claude's credentials.
25+
Environment=HOME=/root
26+
Environment=NODE_ENV=production
27+
# Bind to 0.0.0.0 so the Tailscale (100.x) address is reachable; Tailscale
28+
# itself restricts who can connect, and the portal also requires its token.
29+
ExecStart=/usr/bin/env stackmemory portal start --port 7799 --session claude --cwd /root/work
30+
Restart=always
31+
RestartSec=3
32+
33+
[Install]
34+
WantedBy=multi-user.target

0 commit comments

Comments
 (0)