shed

Docs

shed for agents

What a coding agent reads to use shed. The same text, as Markdown, is at /agents.md.

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):
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:

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".

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):

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:

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:

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:

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.
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):

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

Then:

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.

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:

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:

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.

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.

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.

(
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

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:

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:

    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:

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

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:

    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:

    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.