A machine with Docker runs Claude Code sessions unattended — any project, any prompt, several at a time. You dispatch a project's cloud workflow with a branch and a prompt. A self-hosted runner brings the project's docker-compose.yml up, runs the session in a container from this repository's image; the session pushes and opens pull requests itself — a stack of them with gh stack — and whatever it leaves unpushed, the job pushes as a draft pull request. Nothing runs in GitHub's cloud. The container holds one token, scoped to the project's repository (§3.5), and no sudo, no published port, no Docker socket.
The trigger is anything with gh. The session is a plain process on the checked-out branch: it starts the app's servers itself and checks its work in headless Chromium (chrome-devtools MCP). Watch, answer or stop it from the Claude app over Remote Control. The same branch queues behind itself; different branches run in parallel.
| File | Does |
|---|---|
Dockerfile |
the image: Node, pnpm, Chromium, pinned Claude Code, MCP, gh and gh stack, session |
session |
the run's one step in the container: adapt the setup, resume the branch's session or launch claude --bg, poll until it ends |
action.yml |
the composite action a project's workflow uses: sidecars up, the session's container, then teardown, commit, push, draft pull request |
workflow.yml |
the template a project copies to .github/workflows/cloud.yml: its env:, a checkout, uses: qrafttech/remote-agent@main |
.github/workflows/image.yml |
builds and tests the image on every push to main, pushes ghcr.io/qrafttech/agent |
test.sh [image] |
session against a stubbed claude; with image, builds and checks the pins |
AGENTS.md |
what a project brings, the rules of this repository, and the reference: every step, state, message |
skills/cloud/ |
the /cloud skill: push, dispatch, watch, report — from a Claude session on the client |
docs/ |
the two figures |
One host user, agent, uid 1000, in the docker group — the one you ssh as; vps below is your ssh alias for the host, the one the /cloud skill uses too. It owns:
/opt/agent/home/ the agent's $HOME in every container: the login, your ~/.claude at HEAD, its plugins/
~/runner-<repo>-<n>/ one Actions runner per concurrent run, per repository; a systemd service
Each job gets its own network, sidecars and checkout. Only home/ and the runners are shared. A run resumes the branch's session only from the runner whose checkout started it (AGENTS.md, "Anatomy of a run"): one runner per repository if every run is to be the previous one's next turn.
ssh root@vps 'curl -fsSL https://get.docker.com | sh && apt install -y git jq \
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \
&& apt update && apt install -y gh \
&& useradd -m -u 1000 -G docker agent && install -d -o agent -g agent -m 700 /opt/agent /opt/agent/home'Docker from Docker's script: Debian's docker.io lacks Compose ≥ 2.24, which the action needs. Give agent your SSH key and passwordless sudo.
One runner per concurrent run. The token comes from where gh is admin on the repository, valid one hour:
v=$(gh release view --repo actions/runner --json tagName -q '.tagName' | tr -d v)
for n in 1 2; do
token=$(gh api -X POST repos/owner/repo/actions/runners/registration-token -q .token)
ssh agent@vps "mkdir -p runner-repo-$n && cd runner-repo-$n \
&& curl -fsSL https://github.com/actions/runner/releases/download/v$v/actions-runner-linux-x64-$v.tar.gz | tar xz \
&& ./config.sh --unattended --url https://github.com/owner/repo --token $token --name vps-$n \
&& sudo ./svc.sh install agent && sudo ./svc.sh start"
doneThe runners appear under Settings → Actions → Runners. Make the ghcr.io/qrafttech/agent package public after the first push, or docker login ghcr.io on the host.
- Login, once per host, then every 30 days:
ssh -t agent@vps 'docker run --rm -it -v /opt/agent/home:/home/agent ghcr.io/qrafttech/agent claude auth login', the URL in a browser, the code back. Verify: the samedocker runwithout-it,claude auth status, must say"loggedIn": true. Credentials stay inhome/.claude/.credentials.json; never copy that file. The refresh grant is capped at 30 days from the login; past it the file is emptied and a job dies at once withnot logged in. KeepANTHROPIC_API_KEYunset and never useclaude setup-token: Remote Control needs the subscription login, and a setup-token cannot open one. - Your Claude setup travels with every
/clouddispatch (§5 of the skill): one rsync of~/.claudeas it is on the client — no commit needed — so the host runs what the client runs. The/cloudskill is part of it:ln -s ~/code/remote-agent/skills/cloud ~/.claude/skills/cloud(the link dangles on the host, where nothing dispatches). To ship a change without dispatching, the same rsync by hand:Only those paths travel; the host's login, sessions and projects are its own. Never edit the copy by hand; every session start adapts it (seersync -aR --delete --exclude .DS_Store --exclude /skills/synced/ ~/.claude/./{CLAUDE.md,settings.json,notify.sh,rules,commands,agents,agent-memory,skills,plugins} agent@vps:/opt/agent/home/.claude/AGENTS.md). - The project's workflow, once per project. Copy
workflow.ymlto.github/workflows/cloud.yml; its steps are a checkout and this repository's action,action.yml, so the file holds no shell. Fillenv:with the keys of the project's.env.examplefiles: compose services at their service name (postgres:5432, notlocalhost), the app's own servers atlocalhost. Dev values go in the file; anything sensitive is a repository secret (gh secret set NAME, read as${{ secrets.NAME }}). Dev credentials only. The workflow must be onmainand on the branch it runs. If this repository is private, Settings → Actions → General → Access, here, must allow the organization's repositories to use its actions, or the job fails atuses:. - Repository settings, once per project: allow Actions to create pull requests (Settings → Actions → General); protect
main. - The token, once per project: a fine-grained personal access token (Settings → Developer settings → Fine-grained tokens) on that one repository, permissions Contents, Pull requests and Workflows, each read and write, nothing else (Workflows because a push that touches
.github/workflows/— a session editingcloud.yml— is refused without it);gh secret set CLOUD_TOKEN, orpbpaste | gh secret set CLOUD_TOKEN --repo owner/repofrom the clipboard. The session pushes and opens pull requests with it, and so does the last step. It isGH_TOKENin the container's environment, so every process the session starts inherits it —pnpm installand its scripts, the API, the dev server, Chromium; the scope of the token and the protection ofmainare what bound that. Without the secret the workflow falls back togithub.token, which works the same with one difference: what it pushes or opens triggers no other workflow, so the project's CI never runs on the run's pull requests. Renew the token when it expires (a year at most); a run then fails at its first push.
gh workflow run cloud --ref feat/x -f prompt="Run the implement-loop skill against .claude/deliverable.md"
gh workflow run cloud --ref feat/x -f prompt="$(cat plan.md)" # a file as the prompt
gh workflow run cloud --ref feat/x -f fresh=false # resume the branch's session: "Continue where you left off." (without any -f, gh asks interactively)
gh workflow run cloud --ref feat/x -f fresh=true -f prompt="…" # a new session although one is named after the branchOr, from a Claude session in the project's checkout, /cloud <prompt> — the cloud skill of this repository (skills/cloud, linked into ~/.claude/skills/). It refuses a dirty tree (what is not pushed does not travel), checks the host's login, lists the sessions the host already holds for this branch, pushes, dispatches, watches the run and reports the pull request. On main it pushes HEAD to a new cloud/<date>-<slug> branch; on any other branch, the run continues that branch.
Sessions are kept. A run never removes its session: it stays listed on the host and in the Claude app, and the next run on the same branch resumes it — same ID, same context — with the new prompt as its next turn. /cloud fresh <prompt> (or -f fresh=true) starts a new one instead, leaving the old one listed; a resumed session re-reads its whole transcript first, so that is the choice for a long one. No prompt at all means Continue where you left off. To forget a session: ssh agent@vps 'docker run --rm -v /opt/agent/home:/home/agent ghcr.io/qrafttech/agent claude rm <id>', by hand, never by the tooling.
The session's prompt gets four preamble lines: it runs in its own container, the declared sidecars are already up and the rest of the stack (or the whole of it) is brought up by one subagent that hands back the addresses and the log paths, commit as you go and push and open pull requests when the task calls for it — GH_TOKEN is in its environment and gh stack on its path — or leave it to the workflow, and none of that for a subagent. Exact wording in session. The last line is there because the Workflow tool relays the session's prompt to every subagent it spawns, as the user's voice that wins over the task text: without it a capture worker told to "run the implement-loop skill" and "push" did exactly that, on its own, before any gate had run.
Watch, answer or stop the run in the Claude app: Code → <repo>/<branch>. gh run watch shows the job; gh run list --workflow cloud the queue.
Nothing to do on the client. The last step always runs, even after a cancel or timeout: it tears the containers down, commits what the session left as run: <branch> on the branch it left checked out (the dispatched one, or the top of a stack it cut), pushes that branch, and opens a draft pull request — or leaves the existing one alone, the session's own included. Nothing left unpushed → no push and no pull request: nothing left unpushed on <branch> in the log, which says nothing about what the session pushed itself: a stack's branches and pull requests are on GitHub, not in the log. The draft is for what the session did not push; a session that pushed its own commit and opened no pull request meant it — a skill that hands the branch to a review step owns that call. Then git pull (or git fetch origin for a stack), review, mark ready.
To cancel: stop the session in the Claude app (seen within 30 s), or gh run cancel <id> — the last step still runs, and the session stays listed for the next run to resume. A session blocked on an unanswered question holds its runner until the 23-hour timeout.
gh run list --workflow cloud # every run
gh run view <id> --log | tail -50 # what session printed
ssh agent@vps 'docker ps --format "{{.Names}}\t{{.Status}}"' # cloud-<run id> is the agent, cloud-<run id>-postgres-1 a sidecar
ssh agent@vps 'sudo systemctl status "actions.runner.*"' # the runnersThe session's own screen is in the Claude app. claude logs <id> runs as docker exec cloud-<run id> claude logs <id>.
action.yml: push tomain; every project on@mainruns it from its next run on, nothing to copy. A project pinned to a tag or a commit moves itsuses:when it chooses.- Claude Code, pnpm, the MCP,
gh,gh stack,session: bump theARGin theDockerfileor edit the script;bash test.shandbash test.sh image; push tomain. Then one probe on a scratch branch:gh workflow run cloud --ref probe -f prompt="Bring the stack up, open the web app in Chrome through the chrome-devtools MCP, report document.title, then stop everything you started". Only that run tests the network, Chromium, Remote Control and the plugins on the real host. - Docker, Compose,
git,jq,ghon the host:apt upgrade, as root. The runners update themselves. - Your Claude setup: the two lines of §3.2.
- The login: §3.1 again every 30 days, the refresh grant's cap;
claude auth logoutfirst after any doubt about the box.