---
name: create-pitchstation-app
description: Prepare or deploy an application to PitchStation hosting, or manage an existing app's domains, preview access, expiry and environment through the Apps CLI/REST API. Use for "make this app deployable to PitchStation", "deploy this app", or "connect a domain/change publication settings" for a hosted app. Not for document sharing, static Website Creator publishing, or Devbox provisioning. Address/policy-only work must not trigger a content build or app restart.
---

# create-pitchstation-app

Make an application **deployable to PitchStation app hosting** from day one — or
retrofit one that wasn't. The platform runs any project with a
`docker-compose.yml` on a registered VPS: one POST uploads a source tarball,
the platform builds, migrates, health-checks, routes traffic behind nginx+TLS,
and retains release history for rollback. Managed publishing separates content
releases from domain/policy revisions. Full API: `/api/openapi-apps.json` · agent
summary: `/llms.txt` ("APP HOSTING") · human reference: `/api-docs.html#apps`.

**Auth**: `PITCHSTATION_TOKEN` (a `pst_…` PAT whose user holds the
`app.deploy` capability — explicit admin grant, never default). Resolve it
from the environment, else from the `.env` beside the `share-to-pitchstation`
skill if installed. Server: `PITCHSTATION_URL` (default
`https://www.pitchstation.ai`).

## Choose the operation before touching the project

- **Build/retrofit:** inspect the contract below. A request to prepare a project
  is not permission to deploy it, create cloud resources, or publish a document share.
- **Deploy content:** reuse the existing app ID when present. Package, inspect
  the dry-run plan, then apply only within the user's requested target/scope.
- **Change domains/access/expiry:** use `/apps.html`, domain/policy plans or
  the CLI below. Do not upload source, rebuild or restart an unchanged app.
- **Change env:** managed `PATCH env` preserves unrelated keys and restarts the
  current release; compatibility `PUT env` replaces all stored keys but does
  not apply them. Neither is a domain-only operation.

Apps have **no MCP tools** in the bundled server; use the CLI or REST. Discover
`GET /api/apps/publishing-capabilities`, owned `/targets`, and the app's
`GET /api/apps/:id/domain-config` instead of assuming availability from this
file. The production checkpoint on 2026-09-18 enabled a pilot for existing
target 4, not every target/account. Custom ownership proofs and first managed
route adoption were still pending. Missing inventory or `LIVE_DISABLED` is
an operator handoff, not permission to switch flags, bypass proof or use SSH.

## The contract (what the platform expects)

Design the app around these five facts — every one is load-bearing:

1. **Ports.** The platform allocates a permanent `hostPort` and writes
   `PORT=<containerPort>` and `HOST_PORT=<hostPort>` into the release's
   `.env`. Publish only on host loopback:
   `"127.0.0.1:${HOST_PORT:-<containerPort>}:<containerPort>"`.
   The app binds `0.0.0.0` inside the container — **never 127.0.0.1** (the
   health poll and nginx proxy arrive over the container network).
2. **Data lives on a named volume.** The compose *project* is per-app
   (`ps-<slug>`) and stable across releases; release *directories* change.
   Named volumes therefore survive every deploy — put the database there
   (e.g. `DATABASE_URL=sqlite:////app/data/app.db`, volume mounted at
   `/app/data`). Anything written to the image filesystem is lost on the next
   release.
3. **Health endpoint.** The pipeline polls `healthPath` for **HTTP 200**
   (60s) before routing traffic. A `/` that 302-redirects FAILS the check —
   give the app an unauthenticated `GET /api/health` → `{"ok":true}` and set
   `healthPath` to it.
4. **Migrate step = your idempotent hook.** `migrateCmd` runs via
   `docker compose run --rm <webService> <cmd>` BEFORE the web container
   is recreated; non-zero exit aborts the deploy. Shared data may already
   have changed, so failure does not imply rollback or uninterrupted service.
   Use it for schema migrations AND first-boot seeding — but it runs on
   *every* release, so it must be **idempotent** (an "ensure-admin" that
   no-ops when an admin exists, never a "create-admin" that errors on rerun).
5. **Env is stored server-side, encrypted.** Register secrets once
   (`env` on app creation, or `PUT /api/apps/:id/env`); the API returns key
   *names* only. The app reads plain environment variables (compose
   `env_file: .env` — the platform writes that file). Never ship a `.env` in
   the tarball or bake one into the image.

## Scaffold files

`docker-compose.yml` (the platform contract; also works locally):

```yaml
services:
  web:                                   # ← name this in webService
    build: .
    env_file: .env
    ports:
      - "127.0.0.1:${HOST_PORT:-8000}:8000" # ← right side = containerPort
    volumes:
      - data:/app/data                   # ← durable state lives here
    restart: unless-stopped
volumes:
  data:
```

`Dockerfile` — non-root, with the **volume-ownership fix**:

```dockerfile
FROM python:3.12-slim                    # or node:22-slim, etc.
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# /app/data must EXIST in the image, owned by the app user: a named volume
# copies the image directory's ownership on first mount. Without this the
# mountpoint is created root-owned and a non-root app cannot write its DB
# ("unable to open database file" on the very first deploy).
RUN useradd -m app && mkdir -p /app/data && chown -R app:app /app
USER app
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]
```

`.dockerignore` (defense in depth — these also stay out of the tarball):

```
.env
*.db
*.db-shm
*.db-wal
.venv
node_modules
__pycache__
*.pyc
.git
```

## App-code checklist

- **Static files by allowlist only.** If the app serves its own directory,
  whitelist the exact files/dirs; never serve dotfiles, `*.py`/source, config,
  or data files. (`SimpleHTTPRequestHandler`-style catch-alls will serve
  `/.env` to the internet.)
- **Behind the proxy**: honor `X-Forwarded-For` / `X-Forwarded-Proto` only
  behind a trusted proxy flag (e.g. `TRUST_PROXY=1`); set cookies
  `Secure` in production (`COOKIE_SECURE=1`) — the platform terminates TLS
  and redirects HTTP→HTTPS.
- **Seeding creds via env**: first admin from `ADMIN_USERNAME` /
  `ADMIN_PASSWORD` env in the idempotent migrate hook — never hardcoded.
- WebSockets work through the vhost (Upgrade headers are proxied). Long
  requests: proxy read timeout is 300s.

## Local smoke test

Use a new, uniquely named fixture project/volume, synthetic credentials and
loopback-only published ports. Never delete a pre-existing volume to make
the test pass. Build; run the migrate/seed hook twice before starting the web
service; then verify health 200, secret/source paths 404, and the app's auth
and one real feature. Clean up only fixture resources created in this run.
If Docker is unavailable, report that the runtime smoke test was not run.

## Official client or REST

The client lives in the PitchStation repository at
`generator/scripts/pitchstation-apps.cjs`; run from `generator/` with its
dependencies installed and Node 22. It imports repo modules: downloading
`SKILL.md` alone does **not** install a standalone client. Without that
checkout, use the same REST contract, not an invented CLI download or MCP tool.
Supply `PITCHSTATION_TOKEN` through the environment, never a command-line
token/password flag. Treat the following IDs and filenames as examples.

```bash
node scripts/pitchstation-apps.cjs help
node scripts/pitchstation-apps.cjs list
node scripts/pitchstation-apps.cjs show --app 7
node scripts/pitchstation-apps.cjs package --source /absolute/project
node scripts/pitchstation-apps.cjs deploy --app 7 --source /absolute/project
# Only when the user authorized this deployment; retain the key for retries:
node scripts/pitchstation-apps.cjs deploy --app 7 --source /absolute/project --apply --key release-20260918-01 --watch
node scripts/pitchstation-apps.cjs operation --app 7 --operation OPERATION_UUID --watch
```

For a new app, first choose an owned container target with capacity via
`GET /api/apps/targets`, then `POST /api/apps` with slug, name, targetId,
webService, containerPort, healthPath and optional migrateCmd/env. Creating
the app is a write, not a preview; promote/provision targets only if authorized.
For Vite + React + npm lockfile projects, the packager generates runtime files
in the archive without rewriting the project; existing Compose is preserved.
Inspect the manifest: secret exclusions are not a complete secret scanner.
Server intake limits are **80 MiB compressed, 256 MiB expanded, 20,000 entries**;
links, traversal, devices and duplicate paths are rejected. Normal REST uploads
use `tarballBase64`; server-local `sourceDir` requires an approved `APP_SOURCE_ROOTS`.

### Domains and publication policy — no content deploy

1. Read the current configuration and ETag; preserve every existing host,
   alias mode, password and expiry unless the requested change includes them.
2. Plan with `PUT /api/apps/:id/domain-config` and
   `{ "domains": { "primary":"example.com", "aliases":[{"hostname":"www.example.com","mode":"serve"}], "platform":"retain" } }`,
   or `PATCH /api/apps/:id/publication-policy` with `{ "policy": { … } }`.
   Both require `If-Match`; dry-run defaults true. Planning can save a plan
   and reserve claims, but does not write target routes or provider DNS.
3. Copy the **per-host** `_pitchstation.<hostname>` TXT proof from the returned
   plan or configuration. Correct existing A/CNAME routing need not be replaced.
   A working URL/certificate is not ownership proof. Leave provider DNS to the
   user unless separately authorized. Verify with
   `POST /api/apps/:id/domain-config/verify {"planId":"PLAN_UUID"}`.
4. Apply the reviewed plan with `dryRun:false`, `planId`, its exact `If-Match`
   and a stable `Idempotency-Key`. Plans expire after 15 minutes. A stale plan
   needs a new review, not a forced overwrite. Poll the returned operation.

CLI equivalents:

```bash
node scripts/pitchstation-apps.cjs domains plan --app 7 --file domains.json
node scripts/pitchstation-apps.cjs domains verify --app 7 --plan PLAN_UUID
node scripts/pitchstation-apps.cjs domains apply --app 7 --plan PLAN_UUID --etag '"app-7-v0"' --key domains-20260918-01
node scripts/pitchstation-apps.cjs policy plan --app 7 --file policy.json
node scripts/pitchstation-apps.cjs policy apply --app 7 --plan PLAN_UUID --etag '"app-7-v1"' --key policy-20260918-01
node scripts/pitchstation-apps.cjs readiness --app 7
node scripts/pitchstation-apps.cjs receipt --app 7
```

Private policy uses an owner-supplied, retained password of 12–72 UTF-8 bytes;
put it in an owner-only input file, not argv/logs/receipts. Omit `password` to
retain an existing gate; do not send an empty password. Explicit `access:public`
removes only the **platform** gate. Omit `expiresAt` to preserve it, use an
explicit timezone-qualified value to change it, or `null` only to remove it.
Document-share audience/password/30-day defaults do not automatically apply
to a hosted app. Its own login/expiry is a separate restriction.
`POST /preview` only returns a one-time credential; it does not activate a gate.
For a new app that must be private from its first deployment, use the REST
deploy request with `previewPassword` in its protected JSON body. The current
CLI deploy command has no password/policy input option; do not deploy publicly
first and promise to add the gate afterward.

### Operations, env and verification

- Managed deploy/rollback: `Prefer: respond-async` plus `Idempotency-Key`
  opts into HTTP 202. Acceptance is not success; poll the operation ID. A
  disconnected client does not cancel it. Retry identical intent with the same
  key, not a new release. Without `Prefer`, compatibility returns final 200/502.
- Env: `PATCH /api/apps/:id/env {set:{…},unset:[…]}` previews by default;
  apply needs `dryRun:false`, `If-Match` and `Idempotency-Key`, and restarts
  the current release. `PUT env` remains replace-all/stored-only. Reserved
  PORT/HOST_PORT/COMPOSE_*/DOCKER_* keys and multiline/NUL values are rejected.
- Rollback preserves managed hosts/policy and last-applied env; it does **not**
  undo database migrations. Container updates may affect traffic before route
  activation. `reconciliation_required` needs operator inspection; no blind retry.
- Verify every primary/alias/platform hostname: TLS, anonymous denial or
  explicit public access, expiry, identity and required assets. HTTP readiness
  is not evidence of working video, language switching or 3D; run matching
  browser checks before claiming those. Report pending checks separately.
- Certificate renewal is automated on an onboarded target, but alert delivery
  is separate. `APP_ACME_EMAIL` does not enable expiry notices; Let’s Encrypt
  ended those emails in 2025. Do not enable monitoring/email flags implicitly.
- Stop is `POST /api/apps/:id/stop` (dry-run default; volumes kept). Deletion
  removes registry data without stopping the host or freeing live host claims.

## Failure modes seen in the field

| Symptom | Cause → fix |
|---|---|
| First deploy fails at `migrate`, "unable to open database file" | Volume mountpoint root-owned; `mkdir` + `chown` the volume dir in the Dockerfile **before** `USER` |
| Second deploy fails at `migrate` | Seeding command not idempotent — make it check-then-create |
| `health` step times out but app runs | App bound to 127.0.0.1 (bind 0.0.0.0), or `healthPath` returns 302/401 (use an unauthenticated 200 endpoint) |
| Deploy 413 / rejected | Tarball over budget — check for bundled originals, node_modules, `.git` |
| `OWNERSHIP_UNVERIFIED` | Add the exact app-scoped TXT proof for each custom hostname, then verify; do not replace correct A/CNAME records |
| `TLS_VALIDATION_FAILED` / `TLS_RATE_LIMITED` | Hand the typed failure to the operator; check HTTP-01/CAA and issuer retry timing, without bypassing TLS or repeatedly issuing certificates |
| `CONFIG_REVIEW_REQUIRED` / `LIVE_DISABLED` | Reviewed inventory or approved target onboarding is missing; do not self-enable the pilot |
| Secrets visible at `/<something>` | Catch-all static serving — switch to an allowlist |
| Data gone after a release | State was written outside the named volume |
| 403 CAPABILITY_REQUIRED | Token's user lacks `app.deploy` — ask the server admin |
