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:
- 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/andweb/) gives you all that creating needs: the stack (package.json:web;pyproject.tomlorrequirements.txt:python;go.modorCargo.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"). - 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").
- 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").
- 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; anshas one shared core. - One SSH connection, and plain checks: a file count, a port, an HTTP status. Never hide an error behind
| grepor; 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:wor:las 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 theaccess.terminalandaccess.boxaddresses), so pipe every answer through the printer: it shows the fields you need and nothing secret, says "no machines" for an empty list, and withloginprints 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.rsyncthen 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=.gitbelow), or push the branch and clone it on the machine. A git worktree's.gitis only a file pointing at the main checkout, so for a worktree, push the branch and clone, or copy without.gitand rungit initthere.
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:
.envfiles, 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/appabove). If several folders refer to each other by relative paths (../api/certs), copy each into~/workunder the same names they have here, side by side. - macOS's built-in
rsyncis old: stick to the flags above; newer ones like--infomake 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
rsyncagain. Run the install again only if a lockfile changed. Restart a service (end its tmux session and start it again) when its.envor 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 --waitin 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 -Hltnplists 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 installrather thannpm ci, which deletesnode_modulesfirst): it takes seconds when nothing changed.tmux lsshows 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'", thenssh -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.
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.
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
Commit and push the work to a branch (this is moving the work there, above).
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.gitinstead.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/appis-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/appis-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" # 0Ask 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.
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, thenshed help.