---
name: pitchstation-devbox
description: Open, size, sync and govern a PitchStation DEV INSTANCE — a cloud workbench sized for one repository with Claude Code / Kimi CLI / Codex pre-installed. Use when the user says "open a dev box for this repo", "start/stop my dev box", "sync my changes to the dev box", "the box ran out of memory", "which dev boxes do I have and what do they cost", or "terminate the dev box". Sizes from the checkout, shows the all-in monthly price, reserves in PitchStation BEFORE any cloud call, provisions from the operator machine under a scoped AWS profile, and moves code with git (never rsync --delete, never force). Requires the devbox.manage capability.
---

# pitchstation-devbox

A dev instance is **one person's cloud workbench for one repository**. PitchStation is the
registry and policy engine only: it never makes a cloud call and holds no login credential
for any box. Everything that touches AWS or the box runs **here, on the operator machine**,
through `devbox.py`, and is confirmed back to the registry.

```
~/.claude/skills/pitchstation-devbox/devbox.py                    # this CLI (stdlib Python)
~/.claude/skills/pitchstation-devbox/scan.py                      # sizing profile from the checkout
~/.claude/skills/pitchstation-devbox/provision-aws-instance.sh    # AWS phases (called by devbox.py)
```

Install all four files (the provisioner ships beside the CLI; `DEVBOX_PROVISIONER` overrides its path):

```bash
mkdir -p ~/.claude/skills/pitchstation-devbox && cd $_
for f in SKILL.md devbox.py scan.py provision-aws-instance.sh; do curl -fsSL -o "$f" "https://pitchstation.ai/downloads/pitchstation-devbox-$f"; done
chmod +x devbox.py scan.py provision-aws-instance.sh
```

From a pitchstation checkout, `npm run skills:install` (scripts/install-local-skills.sh) installs
or refreshes the same four files; `test/skills-distribution.test.js` fails when they drift.

The control-plane URL is taken only from `PITCHSTATION_URL` in your environment or the share-to-pitchstation skill's `.env`, must be https, and must be a pitchstation.ai host (or loopback). A repository's `.env` may hold a token for itself but can never redirect where tokens are sent.

Auth resolves from `PITCHSTATION_DEVBOX_TOKEN`, else `PITCHSTATION_TOKEN`, in the environment,
else the same names in `~/.claude/skills/share-to-pitchstation/.env` — never from a repository's `.env` (an untrusted checkout chooses neither the identity nor where it is sent). AWS uses profile `pitchstation-devbox`
(`generator/scripts/iam/README.md`); root credentials are refused.

## The five contract rules (never break these)

1. **Size before reserve, reserve before cloud.** Run `size` and show the class, the reasons and
   the **all-in monthly price** (compute + storage + public IPv4, running and stopped). Get **one
   confirmation**, then `provision` — which reserves in PitchStation first and only then launches.
2. **Never store or type a vendor login.** After provisioning, tell the user the exact login to run
   in their first SSH session (`claude auth login`, `kimi` → `/login`, `codex login`). API keys, if
   they prefer them, explicitly choose local `--env-file` or owner-only `--server-env`. Stored env
   remains encrypted; only the owner can reveal it. Values travel over SSH stdin, never user-data,
   argv, source sync or log values. Do not reveal stored keys without the user's explicit request.
3. **Never sync secrets implicitly.** `.env*`, databases, keys are git-ignored on both sides and
   excluded from assets. If `scan` reports `secretsSeen`, say so. Committed templates (`.env.example`,
   `*.key.sample`) are not secrets and never block a push. The two explicit exceptions are the user's
   own choices, by name: `--pitchstation-env` / `env-sync` (their PitchStation token and app records,
   streamed over SSH stdin, 0700/0600 on the box) and `--github` / `github` (a key generated ON the
   box, registered through this machine's `gh`; the box never receives a GitHub login or token).
4. **Never overwrite.** `push` refuses when the box has commits you have not pulled or its worktree
   is dirty; `pull` refuses non-fast-forward. When both refuse, say the two sides diverged and offer
   `pull NAME --branch <branch>` (fetches into a side branch for review). Do not "fix" it with force.
5. **Terminate needs the instance id and a fresh check.** `terminate NAME --instance i-…` runs
   `git status` on the box first and refuses if anything is unreconciled; `--discard` is the user's
   explicit choice, never yours.

## What the user says → what you run

| User says | You run | Then say |
|---|---|---|
| "open a dev box for this repo" / "for ~/prj/x" | `devbox.py size PATH` → show class + price + reasons → confirm → `devbox.py provision PATH --name NAME [--agents claude,kimi] [--size L] [--concurrency 4]` | `ssh NAME`, the per-agent login line, the cost, and that it stops itself after 60 idle minutes |
| "… with kimi" / "… with codex" | add `--agents kimi` / `--agents codex` (Node is installed for codex, uv+Python for kimi) | same |
| "… and let it push to GitHub" / "set up GitHub on the box" | add `--github` to provision, or `devbox.py github NAME` later (`--read-only` for a read-only key; `--remote OWNER/REPO` when the checkout's origin is not the repo). Needs `gh auth login` on THIS machine | the box's `origin` (SSH, per-repo alias), "authenticated", which branch tracks which; the deploy key opens that one repository and `terminate` revokes it |
| "… with my PitchStation setup" / "put my token / Apps client / skills on the box" | add `--pitchstation-env` to provision, or `devbox.py env-sync NAME` later; `env-sync NAME --remove` deletes it | what was copied, by name only (never a value): `~/.pitchstation`, the publishing and Apps skills with their `.env` |
| "install the latest Claude Code / Codex" / "the agent on the box is old" | `devbox.py upgrade-agents NAME --latest` (resolves the current releases here, installs those exact versions). Plain `upgrade-agents NAME` re-applies the catalog pins, which can be OLDER than what the box runs — say so | the installed versions, and the login line if an agent was first installed |
| "are the catalog versions current?" (maintainers) | `devbox.py catalog-check` | which pins are behind, the exact lines to bump in `backend/config/devbox-agents.js`, and that a deploy follows |
| "raise my cap" / `402 COST_CAP` (admins) | `devbox.py cap USERNAME USD` (or `unlimited`) — an admin acting on a user, through the admin API | the new cap; then re-run the refused provision/resize |
| "start NAME" / "stop NAME" | `devbox.py start NAME` / `devbox.py stop NAME` | start waits for SSH/bootstrap readiness; EIP retained unless explicitly released (then a new address/alias) |
| "sync my changes" / "push" | `devbox.py push NAME [--repo PATH]` | what moved; if refused, why and the way out |
| "pull the agent's work back" | `devbox.py pull NAME` | fast-forwarded, or diverged → side branch |
| "what's the state of the box's repo" | `devbox.py status NAME` | fresh cloud confirmation; when running, fresh operator/box/origin graphs and box dirtiness |
| "the box ran out of memory" / "typecheck OOMs" | `devbox.py list` (see last report) → `devbox.py resize NAME <next class>` (`--confirm-xl` for XL, after the user says yes) | new class, new price; resize is stop → modify → start |
| "disk is full" | `devbox.py grow NAME <GB>` | grows the volume AND the filesystem; verified with `df` |
| "which dev boxes do I have / what do they cost" | `devbox.py list` | per box: state, class, $/month, cost to date; committed total |
| "rename the box" | `devbox.py rename OLD NEW` | tag, registry and ssh alias updated |
| "build and deploy the app from the box" / "deploy app 7 from the dev box" | `devbox.py app-deploy NAME --app 7` (dry run: shows the plan) → confirm → `--apply` (`--box-path` if the repo is not `~/work/NAME`; `--platform` only if the host's architecture is unknown) | the box built the images for the app host's CPU and deployed them as an image archive — nothing was built on the app host; the Apps token went over the SSH session's input, never a command line. With `--apply` it waits up to 5 minutes and its last line is the deploy operation's final state (still running after that: the Apps client's `operation --app 7 --operation ID --watch`); a refused or failed deploy exits non-zero. Needs Docker on the box and an Apps token on this machine: `PITCHSTATION_APPS_TOKEN` in the environment or in `~/.pitchstation/.env`, where the Apps skill's Setup saves it |
| "I can't ssh in" (IP changed) / "update my allowlist" | `devbox.py allow-ip` (every box of yours; `allow-ip NAME` updates only that box's region) | the current address can SSH to every box of yours behind `ps-devbox-sg`; relay the addresses it revoked, any box it names as not covered (an adopted box keeps its own group), and any group it skipped (another AWS account) or failed. In an AWS account several operators share, allow-ip also revokes their addresses; each of them runs it again |
| "terminate / delete the box" | `devbox.py terminate NAME --instance i-…` | what the fresh check found; EIP released; row kept for audit |
| "something is stuck / an instance is orphaned" | `devbox.py reconcile` | tagged instances/EIPs/volumes/SGs/key pairs; ownership, dependencies, conflicts and repair/cleanup choices |

Name rule: lowercase letters, digits, hyphens (2–40). Suggest `<repo>-dev`.

## Sizing, in one paragraph you can repeat

The scanner finds workspaces (TypeScript or not), JVM, compose, Flutter, Rust, Python, and the
size of the tree with and without dependency trees. The engine adds the **two largest** RAM
allowances (one build or typecheck plus one agent-driven run; `--concurrency 4` if the user runs
all typechecks in parallel) to a 1 GB base and picks the smallest class whose RAM covers that
with 20 % headroom. Swap is never counted. Disk is 6 GB + 2× repo + 1.2× deps + caches, never
below 20 GB. After the first real install + typecheck on the box, the measured peak replaces the
estimate. Classes: S 2 GB · M 4 GB · L 8 GB · XL 16 GB. `--size` overrides; both the proposal and
the choice are stored. `--type` must map to the catalog class/architecture; `--disk` must cover
the chosen class minimum and stay ≤ 2000 GB. Never choose XS for a repo.

## Warm-up, reassignment and explicit recovery

- `provision PATH --name NAME --warm` or `warm NAME --repo PATH` measures install/typecheck
  using GNU time and persists HEAD-bound peak RSS/disk after success. pnpm/Yarn recipes require
  packageManager pins in package.json; unknown toolchains need installed tools and explicit
  `--warm-command`. Failed commands or HEAD changes save no new measurement. `size PATH --name NAME`
  prefers matching stored measurements; sampled/sequential peaks do not prove every concurrent load.
- Default sync is operator transport, with no origin credential on the box. Explicit
  `--sync-mode origin` validates a clean operator checkout equal to freshly fetched origin before
  reservation, then clones with separately configured box credentials. Never copy operator origin
  credentials implicitly. Origin-mode termination freshly fetches origin and refuses unpushed work.
- `repo NAME PATH` freshly reconciles old work, scans the new repo and asks the API to recompute
  class AND disk before assignment/sync. A 409 returns the full sizing proposal; resize first,
  then retry. New work defaults to a separate directory; old work is never discarded.
- If no AGENTS.md/CLAUDE.md exists, provisioning offers instructions. Explicit `--instructions`
  or `instructions NAME --repo PATH --write [--deploy-command COMMAND]` creates one exclusively;
  review/commit before the next push. Existing files are preserved; never guess a deploy command.
- Policy auto executes owned, non-stale, next-class proposals on relevant CLI invocations only;
  dry runs and explicit stop/terminate/rename/resize do not trigger unrelated mutations. XL always
  requires explicit confirmation. AWS execution stays in the operator provisioner, never the server.
- `reconcile --repair NAME` resumes exact pending resize/provisioning intent or repairs confirmation,
  Name tag and alias. Discovery errors, foreign scopes and missing already-bound identities fail
  closed without replacement launches. Never bypass a conflict with root credentials.
  After EIP release, start/resize/disk repair recover the exact owned/tagged address before SSH;
  ambiguous addresses and conflicting attachments are refused. `/resized` confirms the optional
  `publicIp`/`eipAllocId` pair atomically with the approved intent (both or neither). Failed recovery
  or confirmation may leave a running, billable instance: inspect and retry the same repair, never
  bypass it with `/started`. Update the SSH alias only after successful confirmation.
- Orphan cleanup needs `reconcile --cleanup ID --confirm ID`. Only a verified owned, unattached,
  unregistered resource qualifies; shared/foreign/unknown resources are protected. Instance/volume
  deletion also needs the user's explicit `--discard`; data is not recoverable here. Apply the
  updated scoped IAM policy before using cleanup. Never infer discard from a request to inspect.
- List offers EIP release after 30 stopped days. `release-eip NAME --confirm eipalloc-ID` requires
  observed stopped state and exact identity. Explain that the stable address is lost and the next
  start obtains a new one; never release automatically. Commitment still counts future IPv4 costs.

## Post-provision message template

```
Dev instance NAME is ready (class L, t4g.large, $57.5/month running, $8.5 stopped).

  ssh NAME
  claude auth login        ← run this once in that session (Claude Code 2.1.289 is installed)
  codex login              ← same, for Codex (0.160.0)

Your repo is at ~/work/NAME on the box (git; the box is remote "devbox" of your checkout).
  GitHub: origin → git@gh-OWNER-REPO:OWNER/REPO.git, authenticated, main tracks origin/main   (only with --github)
  PitchStation: ~/.pitchstation and the publishing skill are on the box                      (only with --pitchstation-env)
  devbox.py push NAME      send your commits          devbox.py pull NAME   bring the agent's commits back
  devbox.py status NAME    dirty / ahead / behind     devbox.py stop NAME   stop now (it also stops itself after 60 idle min)
```

Say what the summary says: the agent versions actually installed (`agents:` line), and, when the
repo has a `CLAUDE.md` but no `AGENTS.md` and Codex is on the box, that Codex reads only `AGENTS.md`
(offer to commit a short pointer file). `node`, `codex` and `claude` resolve in non-interactive
`ssh NAME 'command'` sessions too (the bootstrap sources `/etc/profile.d/devbox-env.sh` first).

## Failure modes

| Symptom | Meaning | Do |
|---|---|---|
| `402 COST_CAP` at reserve or resize | the user's monthly cap (admin-set, default $60) would be exceeded; the numbers are in the message | tell the user the committed / requested / cap figures; an admin raises it with `devbox.py cap USERNAME USD` (or records a one-off override) |
| "secret-like files exist in this repository's HISTORY" | a real `.env`, key or database was committed at some point (templates ending in `.example` / `.sample` / `.template` / `.dist` never count) | the user decides: rewrite history, or `--allow-tracked-secrets` after they confirm those files hold no secrets |
| On the box, Codex (or Claude Code's sandbox) fails every command with `bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted` | Ubuntu 24.04's AppArmor confines unprivileged user namespaces; the agents' sandbox is bubblewrap. Boxes bootstrapped from the 2026-10-06 catalog on carry the fix | on an older box: `ssh NAME "echo 'kernel.apparmor_restrict_unprivileged_userns = 0' \| sudo tee /etc/sysctl.d/60-devbox-userns.conf && sudo sysctl -p /etc/sysctl.d/60-devbox-userns.conf"`; the running session can simply retry |
| Codex says commit or push is blocked: "`.git` is read-only" / no network | not a fault: Codex's default workspace sandbox keeps `.git` read-only and the network off, and asks for approval to step outside it | the user approves the prompts, or runs the session the way they choose (`codex --yolo …` is full access with no prompts — their decision, never yours to set) |
| `gh` not logged in during `--github` / `github` | the deploy key is registered through this machine's GitHub CLI, never through anything on the box | `gh auth login` here, then re-run; nothing was registered |
| `no AWS profile 'pitchstation-devbox'` | this machine has the scoped user under another profile name | the provisioner accepts the default profile only when its identity IS `pitchstation-devbox`; otherwise `aws configure --profile pitchstation-devbox` or `DEVBOX_AWS_PROFILE=<profile>` |
| `409 NAME_TAKEN` | a live box with that name exists | pick another name or `list` |
| provisioner dies mid-run (VPN drop) | the reservation still exists; resources are tagged | re-run `provision-aws-instance.sh --kind devbox --reservation ID` — it converges; the token is reissued |
| `409 STATE_CONFLICT` on a lifecycle call | the registry moved on (a report or another confirmation landed first) | `devbox.py list`, then act on the current state |
| box shows `failed` + needsReconcile | provisioning lease expired (30 min) | `devbox.py reconcile`, then explicit `reconcile --repair NAME`; inspect identity conflicts before further action |
| box shows `stopped (unconfirmed)` | it announced it would stop and then went silent (final report lost) | `status NAME` / `reconcile` observes AWS and confirms; pending resize must be repaired before separate start/stop |
| `push` refused: box ahead | the agent committed on the box | `pull` first |
| `push` refused: dirty worktree on the box | uncommitted edits on the box | ssh in and commit/stash, or `pull` after committing there |
| `pull` refused: non-fast-forward | both sides moved | `pull NAME --branch <branch>` into a side branch, merge by hand |
| ssh times out | the operator's IP changed (SG is operator-/32 by default) | `devbox.py allow-ip` (all your boxes) |
| refuses to run: root credentials | ARCH-5 | create the scoped IAM user (`generator/scripts/iam/README.md`) |
| `NOT_IMPLEMENTED` from `/size` | server predates M2 | pass `--size CLASS` explicitly |

## What this skill never does

- Never calls AWS from anything but `provision-aws-instance.sh` under the scoped profile.
- Never puts a token, key or password into user-data, a command-line argument, or a commit.
- Never `git push --force`, never `rsync --delete`, never `reset --hard` toward the box.
- Never terminates without the instance id and a fresh check, and never passes `--discard` on its own.
