# shed for agents

shed gives you a cloud Linux machine of your own: Ubuntu 24.04 with sudo, Docker, Node 22, Python 3, git and gh, and
Claude Code and Codex installed. You drive it with `curl` and `ssh`. There is nothing to install.

This guide is for coding agents (Claude Code, Codex, Cursor and others) acting for a person. Every command below works
as written in bash or zsh, on macOS and Linux. Fetch it with `curl` (tools that summarize pages drop the commands) and
read all of it before your first command: most lost time comes from learning these things one failure at a time.

## The fast path

About a minute and a half for the machine, the project's own install, then a minute for the rest, in four commands:

1. **Glance, then start its machine at once.** One listing of the project's top folder (and one level down, when the
   code sits in folders like `api/` and `web/`) gives you all that creating needs: the stack (`package.json`: `web`;
   `pyproject.toml` or `requirements.txt`: `python`; `go.mod` or `Cargo.toml`: `systems`), the size and the
   project's name. Set up shed's key, then find the project's machine or create one, in one command and without
   waiting ("What you need", "Find or start the machine").
2. **Read the project while it boots** (about a minute and a half): its services and their ports, and what to copy
   ("Move a project onto it").
3. **Copy, run and open it, in one command:** wait for the machine, write its SSH settings, copy the project, start
   every service in tmux, wait for their ports, forward them here and check each answers ("One command to copy, run
   and open").
4. **Tell the person** the machine, the addresses and where the logs are.

The rules that save the most time:

- **Reuse the project's machine** when there is one: its dependencies are already installed.
- **Size by the heaviest step:** big installs want an `m`; an `s` has one shared core.
- **One SSH connection, and plain checks:** a file count, a port, an HTTP status. Never hide an error behind
  `| grep` or `; echo done`.
- **Give waiting commands time:** Claude Code stops a command after 2 minutes unless you give it a longer timeout (up
  to 10) or run it in the background. If one timed out, check the machine (`tmux ls`, the logs) before running it
  again: the first run may still be going.
- **zsh (macOS):** put braces round a variable that a colon follows (`${NAME}:work/`, `${PORT}:localhost`): zsh can
  read `:w` or `:l` as a modifier. A glob that matches nothing (`README*`) aborts the whole command.

## What you need

- **The address:** `https://shedix.dev`
- **A token** that starts with `shed_`. The person gives it to you; the "Copy prompt" buttons on the shed page put one
  in the prompt. A token can do anything the person can do with their machines. Never write it into files, commits,
  logs or output you show.
- **shed's SSH key**, and a small printer for shed's answers. Agents can't type passphrases, and the person's own
  keys aren't yours to change, so use the key kept for shed (the shed CLI uses the same one). A machine's JSON carries
  its secret (`access.password`, and inside the `access.terminal` and `access.box` addresses), so pipe every answer
  through the printer: it shows the fields you need and nothing secret, says "no machines" for an empty list, and
  with `login` prints only a ready machine's user and name for SSH. Set up both once (running it again does no harm):

```bash
mkdir -p ~/.config/shed/ssh
[ -f ~/.config/shed/id_ed25519 ] || ssh-keygen -q -t ed25519 -N "" -C shed -f ~/.config/shed/id_ed25519
cat > ~/.config/shed/show.py <<'EOF'
# Prints shed's answers without their secrets. With "login": only user@name of a ready machine (its IP if it has no
# name yet), and fails otherwise.
import json, sys
d = json.load(sys.stdin)
if "error" in d: sys.exit("error: " + d["error"])
ms = d.get("machines", [d])
if sys.argv[1:] == ["login"]:
    if not ms[0].get("ready"): sys.exit(f'{ms[0].get("name")} is not ready: {ms[0].get("status")}, {ms[0].get("setup")}')
    a = ms[0]["access"]; print(f'{a.get("user") or "dev"}@{a.get("host") or ms[0]["ip"]}'); sys.exit()
for m in ms:
    print("  ".join(f"{k}={m[k]}" for k in "name status setup ip size stack project label".split() if m.get(k) not in (None, "")) + ("  key added" if m.get("added") else ""))
if not ms: print("no machines")
if "flushed" in d: print("flushed:", d["flushed"])
EOF
```

Each command you run may start a fresh shell, so set what you need in the same command that uses it:

```bash
export SHED_URL=https://shedix.dev SHED_TOKEN=shed_...   # the token from the person
```

## Find or start the machine

Name the project so you (or the next agent) can find its machine again: the repository's name, `owner/repo`, or the
folder's name when there's no remote. For a one-off machine that isn't for a project, skip the lookup and just give it
a label. Up to 80 letters, digits, dots, dashes, underscores or slashes; shed stores it
lower-case.

**1. Look for this project's machine.** The printer shows one line per machine, or "no machines".

```bash
curl -sS "$SHED_URL/api/machines?project=acme/web" -H "Authorization: Bearer $SHED_TOKEN" | python3 ~/.config/shed/show.py
```

If one is `running`, use it, but still wait for it with `?wait=` (below): a running machine can still be booting,
and the call answers at once when it's ready. If it's `stopped`, start it: its files and installed dependencies are
still on its disk. If it's `starting`, wait for it with `?wait=`; if it's `stopping`, wait until it's `stopped`, then
start it. On a machine you didn't create just now, add shed's key too (harmless if it's already there):

```bash
curl -sS -X POST "$SHED_URL/api/machines/$NAME/start" -H "Authorization: Bearer $SHED_TOKEN" | python3 ~/.config/shed/show.py   # same name, new IP
curl -sS -X POST "$SHED_URL/api/machines/$NAME/ssh-keys" -H "Authorization: Bearer $SHED_TOKEN" \
  -H "Content-Type: application/json" -d "{\"key\": \"$(cat ~/.config/shed/id_ed25519.pub)\"}" | python3 ~/.config/shed/show.py
```

**2. Otherwise, create one, with the project's name and shed's key, without waiting.** The answer is the new machine,
usually `starting`; keep its `name`. Both steps in one command, creating only when there's none:

```bash
FOUND=$(curl -sS "$SHED_URL/api/machines?project=acme/web" -H "Authorization: Bearer $SHED_TOKEN" | python3 ~/.config/shed/show.py)
echo "$FOUND"
if [ "$FOUND" = "no machines" ]; then curl -sS -X POST "$SHED_URL/api/machines" \
  -H "Authorization: Bearer $SHED_TOKEN" -H "Content-Type: application/json" \
  -d "{\"size\": \"m\", \"stack\": \"web\", \"project\": \"acme/web\", \"label\": \"api and web\", \"sshKey\": \"$(cat ~/.config/shed/id_ed25519.pub)\"}" \
  | python3 ~/.config/shed/show.py; fi
```

Creating on its own:

```bash
curl -sS -X POST "$SHED_URL/api/machines" \
  -H "Authorization: Bearer $SHED_TOKEN" -H "Content-Type: application/json" \
  -d "{\"size\": \"m\", \"stack\": \"web\", \"project\": \"acme/web\", \"label\": \"api and web\", \"sshKey\": \"$(cat ~/.config/shed/id_ed25519.pub)\"}" \
  | python3 ~/.config/shed/show.py
```

Read the project while it boots, then wait for it. A new machine is ready in about a minute and a half; `?wait=N` (up
to 300) answers when it's ready or after N seconds:

```bash
curl -sS "$SHED_URL/api/machines/$NAME?wait=240" -H "Authorization: Bearer $SHED_TOKEN" | python3 ~/.config/shed/show.py
```

A machine's fields:

| Field | Meaning |
|---|---|
| `name` | The machine's name, like `arnav-3f2a1b` (the person's name and a random end; older machines: `shed-3f2a1b`). Use it in every later call. |
| `ready` | `true` once it's up. |
| `setup` | While it runs: `booting`, then `ready`. `copying` for a copy waiting for its snapshot. |
| `ip` | Its public IPv4 address. It changes every time the machine starts. |
| `access.host` | Its name on the internet, like `arnav-3f2a1b.shedix.dev`, once it points at this start's address. It follows the machine to every new IP, so SSH to this. |
| `access.user` | The user to log in as. |
| `status` | `starting`, `running`, `stopping`, `stopped` or `deleting`. |
| `size`, `stack` | Its size (`s`, `m`, …) and stack (`web`, `python`, `systems`, `plain`). |
| `image` | What it runs, for people: shed's image, or a Plain machine's OS (like "debian-12, plain"). |
| `project` | The project you named, or null. |
| `label` | The name the person (or you) gave it, or null. |
| `access.sshKeys` | The keys that can log in. |

Other answers: a list is `{"machines": [...]}`; an error is `{"error": "..."}`.

**Sizes**, billed per hour while running. Choose by the heaviest step:

| Size | Hardware | Price | For |
|---|---|---|---|
| `s` | 2 vCPU (one shared core), 4 GB RAM, 30 GB disk | $0.06/hr | Scripts, small projects, experiments |
| `m` | 4 vCPU, 8 GB RAM, 45 GB disk | $0.16/hr | Installing and running an app: big `node_modules`, a few services, Docker |
| `l` | 8 vCPU, 16 GB RAM, 60 GB disk | $0.32/hr | Several heavy installs or services at once, parallel agents |
| `xl` | 16 vCPU, 32 GB RAM, 75 GB disk | $0.64/hr | Heavy builds |

**Stacks:** `web` (Node, pnpm, Yarn, Bun, TypeScript, Playwright with Chromium), `python` (uv, JupyterLab, Ruff) and
`systems` (Go, Rust). Every stack has Docker, Node, Python, Claude Code and Codex.

Give a machine you create a name the person will recognize with `"label"` (up to 60 characters). To change it or the
project later: `PATCH $SHED_URL/api/machines/$NAME` with `{"label": "…"}` or `{"project": "acme/web"}`. A machine's
size can't be changed later, so choose it for the heaviest work it will do.

## Copy a machine

A machine that's set up (code, installed packages, Claude Code or Codex signed in) can be copied, to give several
agents the same starting point or to try something without touching the original. The copies start from its whole
disk as it is now, so nothing needs installing or signing in again: every sign-in kept on the machine comes along
(Claude Code, Codex, `gh`, Docker, npm, cloud CLIs, git credentials, SSH keys), root's too. They keep its size,
stack, project and keys, and get their own names, addresses, SSH host keys and secrets; programs that were running
don't carry over, so start them on each copy.

- **Sign in first.** If the copies are for agents, ask the person to sign in to Claude Code or Codex (and anything
  else the work needs) on the source before you copy it; every copy then starts signed in.
- **One identity per machine** (a Tailscale node, a named tunnel) is copied too, so the copies share it; sign it out on
  a copy, or give each its own, when that matters.
- **Size:** copies keep the source's size, and a size can't be changed later.

```bash
curl -sS -X POST "$SHED_URL/api/machines/$NAME/copy" -H "Authorization: Bearer $SHED_TOKEN" \
  -H "Content-Type: application/json" -d '{"count": 3, "label": "api worker"}' | python3 ~/.config/shed/show.py
```

The call answers within about 30 seconds with the new machines, up to 5 at a time. One copy is a clone of the disk,
ready in about a minute; several wait for a snapshot first (`setup=copying`) and are ready in three to six minutes.
Wait for each with `?wait=300`, then write its SSH settings. Before copying, shed has the source write everything in
memory to its disk, so files written up to that moment come along (`flushed: True`); an older machine can't
(`flushed: False`), so check that what matters arrived. Labels are numbered ("api worker", "api worker 2"…); without
one, copies are called after the source ("acme web copy"). If the call was cut off, list the project's machines
before trying again: the copies may exist already.

## Plain machines

`"stack": "plain"` gives you the operating system alone: Ubuntu 24.04 as Google ships it, reached over SSH as the
machine's user (`access.user`) with passwordless sudo. shed adds one small service, `shed-activity`, which reports when the machine was last used so it
goes to sleep after an hour unused; nothing else (no web terminal, VS Code, Files, Ports, agents, Docker, git, tmux,
rsync or `~/work`). Install what the rest of this guide uses before following it, for example
`sudo apt-get install -y rsync tmux git` on Ubuntu or Debian. For another free Linux from Google, add `"image": "project/family"`, like
`"debian-cloud/debian-12"` or `"rocky-linux-cloud/rocky-linux-9"` (projects: `ubuntu-os-cloud`, `debian-cloud`,
`rocky-linux-cloud`, `almalinux-cloud`, `centos-cloud`, `opensuse-cloud`). Use it when the person wants a clean OS;
for running their project, the other stacks save you installing everything.

## Connect

Write one SSH settings file per machine once it's ready. Every later `ssh`, `scp` and `rsync` uses it, in any shell:
it holds the key, the machine's name and one shared connection that stays open for 15 minutes, so only the first
command pays for connecting. The name follows the machine to each new IP, so the file keeps working after restarts.
To wait for a machine and keep its user and name in one go (it fails, saying why, if the machine isn't ready in time):

```bash
LOGIN=$(curl -sS "$SHED_URL/api/machines/$NAME?wait=240" -H "Authorization: Bearer $SHED_TOKEN" | python3 ~/.config/shed/show.py login)
```

Then:

```bash
NAME=arnav-3f2a1b LOGIN=arnav@arnav-3f2a1b.shedix.dev
cat > ~/.config/shed/ssh/$NAME.conf <<EOF
Host $NAME
  HostName ${LOGIN#*@}
  User ${LOGIN%@*}
  IdentityFile ~/.config/shed/id_ed25519
  IdentitiesOnly yes
  HostKeyAlias $NAME
  UserKnownHostsFile ~/.config/shed/known_hosts
  StrictHostKeyChecking accept-new
  ControlMaster auto
  ControlPath ~/.config/shed/cm-%C
  ControlPersist 15m
  ServerAliveInterval 30
  LogLevel ERROR
EOF
ssh -F ~/.config/shed/ssh/$NAME.conf $NAME 'echo connected; nproc; free -g | head -2'
```

From here: `ssh -F ~/.config/shed/ssh/$NAME.conf $NAME '<command>'`. The person can use the same line on this
computer to log in themselves; tell them it changes nothing on their own keys. To let them in with their own key
instead, add it with `POST …/ssh-keys`; then `ssh <user>@<name>` works for them anywhere. You log in as `access.user`,
who has passwordless `sudo` and is in the `docker` group, so `docker` needs no `sudo`.
Work in `~/work`; the shed page's Files panel shows that folder. Inside quotes, write `$HOME`, not `~`: a quoted `~`
isn't expanded, which matters for `rsync -e "…"`.

## Move a project onto it

**First decide why you're moving it.** That decides what to send.

- **To run it there while work continues here** (the person tests on the machine; you keep editing on this computer):
  copy the working tree without `.git`, start it, and copy again after each change. `rsync` then sends only what
  changed, and dev servers reload by themselves.
- **To move the work there** (you'll carry on on the machine, or the person wants to close the laptop): bring the git
  history too, so you can commit there. Copy with `.git` (leave out `--exclude=.git` below), or push the branch and
  clone it on the machine. A git worktree's `.git` is only a file pointing at the main checkout, so for a worktree,
  push the branch and clone, or copy without `.git` and run `git init` there.

When it's both (the person tests there and you carry on there), move the work: copy with `.git`.

**What to send:**

- **The code:** everything git tracks, plus new files not committed yet.
- **Files git ignores that the app needs to run:** `.env` files, local certificates, local config, small local data
  such as a SQLite file. They often hold secrets: tell the person which ones you sent. To read one setting without
  printing the rest, ask for that line alone: `grep '^PORT=' .env`.
- **Lockfiles** (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, `bun.lock`, `uv.lock`, `poetry.lock`,
  `requirements*.txt`, `Cargo.lock`, `go.sum`): they make the install on the machine exact.

**What not to send, and why:** installed dependencies and build output (`node_modules`, `.venv`, `venv`,
`__pycache__`, `target`, `dist`, `build`, `.next`, `.turbo`, `.cache`, `coverage`, logs). They're built for this
computer's operating system, so they won't run on Linux; they're usually most of the size, often gigabytes; and the
machine installs them over its fast connection quicker than this computer can upload them. Leave out large files the
app doesn't need to run, too (datasets, dumps, videos), unless the task needs them.

**How:** one `rsync`, compressed (`-z`), turning symlinks into the files they point to (`-L`). Worktrees and
monorepos often link `.env`, certificates or `node_modules` to another folder; without `-L` the links arrive pointing
at paths that exist only on this computer.

```bash
rsync -azL --exclude=node_modules --exclude=.venv --exclude=venv --exclude=__pycache__ --exclude=target \
  --exclude=dist --exclude=build --exclude=.next --exclude=.turbo --exclude=.cache --exclude=coverage \
  --exclude='*.log' --exclude=.git -e "ssh -F $HOME/.config/shed/ssh/$NAME.conf" ./ ${NAME}:work/app/
ssh -F ~/.config/shed/ssh/$NAME.conf $NAME 'find ~/work/app -type f | wc -l; ls -la ~/work/app | head'
```

- **Name the folder** on the machine after the project (`~/work/app` above). If several folders refer to each other
  by relative paths (`../api/certs`), copy each into `~/work` under the same names they have here, side by side.
- **macOS's built-in `rsync` is old:** stick to the flags above; newer ones like `--info` make it fail.
- **No `rsync`** (Git Bash on Windows): stream a tar instead, which follows symlinks with `-h`:
  `tar -czhf - --exclude=node_modules --exclude=.git . | ssh -F … $NAME 'mkdir -p ~/work/app && tar -xzf - -C ~/work/app'`
- **After you change files here,** run the same `rsync` again. Run the install again only if a lockfile changed.
  Restart a service (end its tmux session and start it again) when its `.env` or config changed: dev servers reload
  code, not environment files.

## Install and run

Start every service in its own tmux session, at once, each installing and then starting, with its output in a log.
Use the project's own install command (`npm ci`, `pnpm install --frozen-lockfile`, `yarn install --frozen-lockfile`,
`bun install`, `uv sync`, `uv venv && uv pip install -r requirements.txt` (the fast way on the `python` stack),
`go mod download`, `cargo build`). Installs get no input in tmux, so add `< /dev/null` to one that might ask a question:
it then fails at once instead of hanging the wait. Start a session only if it isn't already there, so running the
command twice does no harm:

```bash
ssh -F ~/.config/shed/ssh/$NAME.conf $NAME '
  tmux has-session -t api 2>/dev/null || tmux new -d -s api "cd ~/work/app/api && (npm ci && npm run dev) > ~/work/api.log 2>&1"
  tmux has-session -t web 2>/dev/null || tmux new -d -s web "cd ~/work/app/web && (npm ci && npm run dev) > ~/work/web.log 2>&1"
  tmux ls'
```

- **Docker Compose:** run `docker compose up -d --wait` in the project's folder directly, not in tmux; it returns once
  the containers are healthy. Then start the app's own services in tmux as above. Images download on the first run.
- **Don't know a service's port?** Start it and look: `ss -Hltnp` lists what's listening and which program opened it.
- **On a reused machine,** the dependencies are already there. Use the install command that keeps what's installed
  (`npm install` rather than `npm ci`, which deletes `node_modules` first): it takes seconds when nothing changed.
  `tmux ls` shows what's still running ("no server running" just means nothing is).
- **pnpm and Yarn versions** pinned in `package.json` (`packageManager`) are used automatically.
- **Python:** a tmux session doesn't activate the virtual environment, so start the app with `uv run …` or
  `.venv/bin/…` (`.venv/bin/uvicorn main:app --port 8000`), not the bare command from the README.
- **Only installing** (the person wants it set up, with nothing running): run the install over SSH directly, with a
  timeout long enough for it.

Then wait for every port in one command. List each session with its port (`api:4000`), so a service that dies stops
the wait at once with its log instead of running out the clock:

```bash
ssh -F ~/.config/shed/ssh/$NAME.conf $NAME 'end=$((SECONDS+600)); for sp in api:4000 web:3000; do s=${sp%%:*} p=${sp##*:}
  until ss -Hltn "sport = :$p" | grep -q .; do
    tmux has-session -t "$s" 2>/dev/null || { echo "$s stopped"; tail -n 40 ~/work/$s.log; exit 1; }
    [ $SECONDS -lt $end ] || { echo "$s: port $p never opened"; tail -n 40 ~/work/$s.log; exit 1; }; sleep 2; done; done
  echo "all ports open"'
```

A service run by a file watcher (`node --watch`, nodemon) keeps its session after a crash; if its port is slow to open,
read its log. To check the copy on a reused machine, `git status --short` there (when you copied `.git`) beats a file
count, which includes installed dependencies.

## Let the person open it

**Privately, from this computer:** open the forward yourself, in the background, and keep the same port numbers, so
the app's own links, logins and callbacks to `localhost:<port>` still work. Then check each address answers from here
(an open port isn't proof: some dev servers compile a page on the first request) and tell the person the addresses.

```bash
ssh -F ~/.config/shed/ssh/$NAME.conf -S none -f -N -o ExitOnForwardFailure=yes \
  -L 3000:localhost:3000 -L 4000:localhost:4000 $NAME
curl -sS -o /dev/null -m 60 -w "%{http_code}\n" http://localhost:3000/
```

`-S none` gives the forward its own connection. Without it the forward joins the shared one and closes with it, 15
minutes after your last command.

- If a port is already taken here (often by the same app still running locally), the forward fails at once. Ask the
  person to stop the local one, or forward to another local port (`-L 13000:localhost:3000`) and say so.
- An open forward counts as use, so the machine stays awake while the person is testing.
- Only SSH reaches the machine from the internet. Every other port (a database's too) is private to it, so the person
  reaches services only through your forward, or a public link if they ask for one. Forward only what they'll open.
- To close it: `pkill -f "ExitOnForwardFailure=yes .*$NAME"`.

**Publicly, as a link to share:** every machine runs a small server for this. Its address is `access.box` in the
machine's JSON. It carries the machine's secret: keep it in a variable, never print it.

```bash
BOX=$(curl -sS "$SHED_URL/api/machines/$NAME" -H "Authorization: Bearer $SHED_TOKEN" | python3 -c 'import json,sys; print(json.load(sys.stdin)["access"]["box"])')
curl -sS -X POST "$BOX/preview?port=3000"   # then, a few seconds later:
curl -sS "$BOX/ports"                        # the port's "preview" is now an https:// link
curl -sS -X DELETE "$BOX/preview?port=3000"  # stop sharing
```

Anyone with the link can open it, until it's stopped or the machine stops. Ask the person before you share one.

## One command to copy, run and open

Step 3 of the fast path, put together from the sections above. This one moves the work there (with its git history,
as shed's prompts ask) for a project with an `api` folder on port 4000 and a `web` folder on port 3000. Change the
machine, the folders, the install and start commands, the ports and the addresses checked at the end to fit the
project, and drop what it doesn't need. It stops at the first step that fails, showing why. Give it a timeout of 10
minutes (installs vary), or run it in the background.

```bash
(
set -e
export SHED_URL=https://shedix.dev SHED_TOKEN=shed_...   # the token from the person
NAME=arnav-3f2a1b                                  # the machine
if lsof -nP -iTCP:3000 -iTCP:4000 -sTCP:LISTEN; then echo "a port is taken here: forward to another (-L 13000:localhost:3000)"; exit 1; fi
LOGIN=$(curl -sS "$SHED_URL/api/machines/$NAME?wait=240" -H "Authorization: Bearer $SHED_TOKEN" | python3 ~/.config/shed/show.py login)
cat > ~/.config/shed/ssh/$NAME.conf <<EOF
Host $NAME
  HostName ${LOGIN#*@}
  User ${LOGIN%@*}
  IdentityFile ~/.config/shed/id_ed25519
  IdentitiesOnly yes
  HostKeyAlias $NAME
  UserKnownHostsFile ~/.config/shed/known_hosts
  StrictHostKeyChecking accept-new
  ControlMaster auto
  ControlPath ~/.config/shed/cm-%C
  ControlPersist 15m
  ServerAliveInterval 30
  LogLevel ERROR
EOF
rsync -azL --exclude=node_modules --exclude=.venv --exclude=venv --exclude=__pycache__ --exclude=target \
  --exclude=dist --exclude=build --exclude=.next --exclude=.turbo --exclude=.cache --exclude=coverage \
  --exclude='*.log' -e "ssh -F $HOME/.config/shed/ssh/$NAME.conf" ./ ${NAME}:work/acme-web/
ssh -F ~/.config/shed/ssh/$NAME.conf $NAME 'set -e
  # With Docker Compose, first: cd ~/work/acme-web && docker compose up -d --wait --quiet-pull
  tmux has-session -t api 2>/dev/null || tmux new -d -s api "cd ~/work/acme-web/api && (npm ci && npm run dev) > ~/work/api.log 2>&1"
  tmux has-session -t web 2>/dev/null || tmux new -d -s web "cd ~/work/acme-web/web && (npm ci && npm run dev) > ~/work/web.log 2>&1"
  end=$((SECONDS+480)); for sp in api:4000 web:3000; do s=${sp%%:*} p=${sp##*:}
    until ss -Hltn "sport = :$p" | grep -q .; do
      tmux has-session -t "$s" 2>/dev/null || { echo "$s stopped"; tail -n 40 ~/work/$s.log; exit 1; }
      [ $SECONDS -lt $end ] || { echo "$s: port $p never opened"; tail -n 40 ~/work/$s.log; exit 1; }; sleep 2; done; done
  echo "all ports open"'
ssh -F ~/.config/shed/ssh/$NAME.conf -S none -f -N -o ExitOnForwardFailure=yes -L 3000:localhost:3000 -L 4000:localhost:4000 $NAME
for u in http://localhost:3000/ http://localhost:4000/health; do curl -sS -o /dev/null -m 60 -w "$u %{http_code}\n" "$u" || true; done
)
```

Check addresses each service really serves, ideally one that uses its database (a health route), not only that
something answers. To run it there while you keep working here, add `--exclude=.git`. On a reused machine, use
`npm install` rather than `npm ci` (it keeps what's installed), and the copy and start take seconds.

## Run commands

```bash
ssh -F ~/.config/shed/ssh/$NAME.conf $NAME 'cd ~/work/app && npm test'
```

- For anything long, use tmux, so it survives a dropped connection:
  `ssh -F … $NAME "tmux new -d -s job 'cd ~/work/app && npm test > ~/work/job.log 2>&1; echo exit \$? >> ~/work/job.log'"`,
  then `ssh -F … $NAME 'tail -n 30 ~/work/job.log'`.
- A new machine isn't signed in to GitHub. To clone a private repository or push, connect it first ("Connect GitHub",
  next), or copy the working tree (above).

## Connect GitHub, so the machine can clone and push

Before work on the machine needs to clone a private repository, push or open a pull request, check whether it's signed
in to GitHub. It answers with the account's name when it is:

```bash
ssh -F ~/.config/shed/ssh/$NAME.conf $NAME 'gh api user --jq .login 2>/dev/null || echo "not connected"'
```

If it isn't, use one of two ways. Both are GitHub's own device sign-in: the person approves it on github.com with a
short code, the sign-in is made on the machine and stays there (shed never sees it), and afterwards `git` uses it for
`https://github.com`. Never ask for or handle the person's GitHub password or tokens.

1. **The person connects it on the shed page:** open the machine; under GitHub, "connect GitHub" shows a code and a
   link to github.com/login/device. Tell them that, and wait for them to say it's done.
2. **You start it and pass them the code**, when they'd rather not leave the conversation:

   ```bash
   ssh -F ~/.config/shed/ssh/$NAME.conf $NAME 'tmux has-session -t github 2>/dev/null || tmux new -d -s github "BROWSER=false gh auth login --hostname github.com --git-protocol https --web --scopes repo,workflow > ~/work/github.log 2>&1 && gh auth setup-git"; sleep 5; grep -o "one-time code: [A-Z0-9-]*" ~/work/github.log'
   ```

   Tell the person: "Open https://github.com/login/device and enter the code `XXXX-XXXX`" (it works from a phone, and
   expires after 15 minutes). Then check with the command above until it answers with the account's name.

Once connected, if the machine has no git identity yet (`git config --global user.email` prints nothing), set one so
commits show who made them: the person's own name and email if they've given them, or GitHub's private address,
`<id>+<login>@users.noreply.github.com` (`gh api user --jq '"\(.id)+\(.login)@users.noreply.github.com"'`). The shed
page's button does this by itself. Copies of a machine stay signed in, like everything else on its disk.

## Machines go to sleep after an hour unused

A machine counts as in use while an SSH session or port forward is connected, a terminal or VS Code tab is open,
Claude Code or Codex is working on it, or its CPU or network is busy (an install counts). After an hour with none of
that it stops by itself; its files and installed dependencies are kept. Only for long work nobody is watching (a job
in tmux with nobody connected and little CPU), keep it awake, and turn that off when you're done:

```bash
curl -sS -X PATCH "$SHED_URL/api/machines/$NAME" -H "Authorization: Bearer $SHED_TOKEN" \
  -H "Content-Type: application/json" -d '{"keepAwake": true}' | python3 ~/.config/shed/show.py
```

## Start, stop and delete

```bash
curl -sS -X POST "$SHED_URL/api/machines/$NAME/start?wait=240" -H "Authorization: Bearer $SHED_TOKEN" | python3 ~/.config/shed/show.py  # same name, new IP
curl -sS -X POST "$SHED_URL/api/machines/$NAME/stop" -H "Authorization: Bearer $SHED_TOKEN" | python3 ~/.config/shed/show.py            # files kept
curl -sS -X DELETE "$SHED_URL/api/machines/$NAME" -H "Authorization: Bearer $SHED_TOKEN" | python3 ~/.config/shed/show.py               # gone for good
```

A stopped machine costs nothing per hour; only its disk is kept, with everything installed, so prefer stopping a
project's machine to deleting it. Delete machines you started for a one-off job, unless the person asked to keep them.

## Hand off a Claude Code session, so the person can close the laptop

1. Commit and push the work to a branch (this is moving the work there, above).
2. Find or start the project's machine and clone the branch into `~/work/app`. Cloning a private repository, and
   pushing from the machine later, need it connected to GitHub ("Connect GitHub", above); without that, copy the
   working tree with `.git` instead.
3. Copy the conversation. Claude Code keeps each one at `~/.claude/projects/<folder>/<session id>.jsonl`, where
   `<folder>` is the project's path with every character that isn't a letter or a digit turned into `-` (so
   `/Users/sam/app` is `-Users-sam-app`; `pwd | sed 's/[^a-zA-Z0-9]/-/g'` prints it). On the machine the project is
   `~/work/app`, and its folder depends on the machine's user (`/home/arnav/work/app` is `-home-arnav-work-app`).

   **Don't copy the file as it is.** A conversation started in the Claude desktop app, or with Remote Control on,
   carries the ID of its claude.ai session (`bridgeSessionId`). A copy that keeps it takes over that link: the
   phone opens the laptop's conversation, and the machine's never shows up. Copy it without that field:

   ```bash
   DIR=$(ssh -F ~/.config/shed/ssh/$NAME.conf $NAME 'd=$(echo ~/work/app | sed "s/[^a-zA-Z0-9]/-/g"); mkdir -p ~/.claude/projects/$d; echo $d')
   python3 -c 'import json, sys; [print(json.dumps({k: v for k, v in json.loads(l).items() if k != "bridgeSessionId"}, ensure_ascii=False)) for l in sys.stdin if l.strip()]' \
     < ~/.claude/projects/<folder>/<session id>.jsonl \
     | ssh -F ~/.config/shed/ssh/$NAME.conf $NAME "cat > ~/.claude/projects/$DIR/<session id>.jsonl"
   ssh -F ~/.config/shed/ssh/$NAME.conf $NAME "grep -c bridgeSessionId ~/.claude/projects/$DIR/<session id>.jsonl"   # 0
   ```

4. Ask the person to sign in to Claude Code on the machine once: on the shed page, open the machine, then Claude
   Code's Terminal. It's Claude Code's own sign-in; shed never sees it, and you must not handle it.
5. Resume the conversation there, in tmux, with Remote Control on and a name the person will find in the Claude app:

   ```bash
   ssh -F ~/.config/shed/ssh/$NAME.conf $NAME "tmux new -d -s claude -c ~/work/app 'claude --resume <session id> --remote-control \"app (from laptop)\"'"
   ```

   On a machine where Claude Code hasn't run in this folder before, it first asks a few questions (a colour theme,
   whether to trust the folder). Have the person answer them in Claude Code's Terminal on the machine page, which
   attaches to this tmux session; don't answer the trust question yourself.

   Then tell the person where to find it, and that it's a new session in the Claude app, separate from the laptop's:
   in the app's list of sessions under the name above (not under the machine in Devices, which lists only sessions
   started from its phone access), or in Claude Code's Terminal on the machine page from any browser. Tell them to
   stop the laptop's copy, so the two don't drift apart.

## Everything else

- The whole API, with every field: `https://shedix.dev/docs/api` (the same calls the shed page makes).
- Errors come back as `{"error": "..."}` with 400 (bad request), 401 (bad or revoked token), 404, 409 (wrong state,
  like starting a machine that's running) or 502 (the cloud failed; try again).
- The person can see every call made with their token on their profile, and revoke it there.
- Optional, for people: a CLI with the same powers, `curl -fsSL https://shedix.dev/install.sh | sh`, then `shed help`.
