---
name: opendeploy
description: Operate OpenDeploy end-to-end via the gateway API. Single Bearer-token auth model — the skill registers a client agent on cold start (anonymous, IP rate-limited), persists `od_a*` token to `.opendeploy/auth.json`, and uses Bearer mode for the entire deploy pipeline. After the deploy succeeds the skill prints a claim URL so the user can sign in via SSO and bind the agent (and its project) to their account; once bound, the same token continues to work as a PAT-equivalent. Covers the full first-deploy pipeline (analyze locally, create project, provision DB deps, upload + bind source, deploy, bind *.dev.opendeploy.run subdomain) and ongoing operations - redeploy, env-var rotation, resource resizing, adding a DB, subdomain rename, log triage. Use whenever the user mentions OpenDeploy, deploying to dev.opendeploy.run, "deploy this for me", "spin this up somewhere I can show people", "one-click deploy to opendeploy", "redeploy", "rotate secrets", "resize service", "add a database", or "rename subdomain".
homepage: https://opendeploy.dev
metadata: {"category":"deploy","api_base":"https://dashboard.dev.opendeploy.dev/api"}
user-invokable: true
---

# OpenDeploy

End-to-end OpenDeploy: local source -> live URL. **One** auth model — Bearer token in `.opendeploy/auth.json` — and two states the skill handles automatically:

- **`api_key` already exists** in `.opendeploy/auth.json`. Token is either a personal access key (PAT, `od_k*`) the user created via dashboard, or a bound agent token (`od_a*`) from a prior deploy. The skill deploys directly and returns only the project URL.
- **`api_key` missing** (or file absent). The skill calls `POST /v1/client-agents/register` (anonymous, IP rate-limited), persists the returned `od_a*` token to `.opendeploy/auth.json` (mode 0600), and uses Bearer mode for the rest of the deploy. After success, it prints a one-time **claim URL** so the user can sign in and adopt the deployment.

There is **no** HMAC signing, **no** `claim_token`, **no** `redeem_code`, **no** `ClaimSig` header. If you read older OpenDeploy docs that mention any of those, they describe the previous flow that has been removed. Every gateway request is `Authorization: Bearer od_*`. Period.

Every call goes through the gateway. Never hit downstream services (`project-service`, `deployment-service`, `build-service`, etc.) directly.

## Skill files

All files are served from `https://opendeploy.dev/skills/...`. A friendly alias also redirects to the SKILL.md:

- `https://opendeploy.dev/start` -> `https://opendeploy.dev/skills/SKILL.md`

| File | Canonical URL |
|------|---------------|
| SKILL.md (this file) | `https://opendeploy.dev/skills/SKILL.md` |
| skill.json (state metadata) | `https://opendeploy.dev/skills/skill.json` |
| references/api-schemas.md | `https://opendeploy.dev/skills/references/api-schemas.md` |
| references/analyze-local.md | `https://opendeploy.dev/skills/references/analyze-local.md` |
| references/failure-playbook.md | `https://opendeploy.dev/skills/references/failure-playbook.md` |

---

## Step 0 - Bootstrap install (run first, every time)

When the user says *"Read https://opendeploy.dev/start and deploy this project with OpenDeploy"*, your first action - before any auth or source touch - is to install the skill into Claude's local skills directory. Future invocations resolve locally; the install pass also keeps you up-to-date.

`-z` (`If-Modified-Since`) makes the update check almost free: the server returns 304 if nothing changed, and locally hand-edited files are protected because their mtime is ahead of the remote.

```bash
SKILLS_DIR="${CLAUDE_SKILLS_DIR:-$HOME/.claude/skills}"
mkdir -p "$SKILLS_DIR/opendeploy/references" && chmod 700 "$SKILLS_DIR/opendeploy"
BASE="https://opendeploy.dev/skills"

fetch() {
  local src="$1" dst="$2"
  if [ -n "$OD_FORCE_REINSTALL" ]; then
    curl -fsSL "$src" -o "$dst"
  else
    curl -fsSL -z "$dst" "$src" -o "$dst"   # 304 if unchanged or local edited
  fi
}

fetch "$BASE/SKILL.md"                          "$SKILLS_DIR/opendeploy/SKILL.md"
fetch "$BASE/skill.json"                        "$SKILLS_DIR/opendeploy/skill.json"
fetch "$BASE/references/api-schemas.md"         "$SKILLS_DIR/opendeploy/references/api-schemas.md"
fetch "$BASE/references/analyze-local.md"       "$SKILLS_DIR/opendeploy/references/analyze-local.md"
fetch "$BASE/references/failure-playbook.md"    "$SKILLS_DIR/opendeploy/references/failure-playbook.md"

echo "ready: opendeploy -> $SKILLS_DIR/opendeploy"
```

The web URL base is `https://opendeploy.dev/skills/`; the local install lives at `~/.claude/skills/opendeploy/` so Claude resolves the skill by its name (`opendeploy`, or `/opendeploy` in agents that expose slash-command skill invocation).

---

## URL convention

`OD_GATEWAY` **includes** the `/api` prefix. All curls use `$OD_GATEWAY/v1/...`. Example: `OD_GATEWAY=https://dashboard.dev.opendeploy.dev/api` -> `$OD_GATEWAY/v1/regions/` resolves to `https://dashboard.dev.opendeploy.dev/api/v1/regions/`.

**Trailing slash on collection endpoints** (`/projects/`, `/services/`, `/deployments/`, `/regions/`). The gateway 301-redirects the slashless form, and curl drops POST bodies across a 301 unless you also pass `--post301`.

## Resource model

```
User / Org
  +-- Project              (one deployable unit; has region, source, env)
        +-- Environment    (staging | production - separate config plane)
        +-- Service        (web / worker / static; has cpu/mem, env, port)
        |     +-- Deployment       (point-in-time release; has build + runtime logs)
        +-- Dependency     (managed DB: postgres / mysql / mongo / redis / clickhouse)
        +-- ServiceDomain  (auto: <random>.dev.opendeploy.run; or user-chosen prefix)
```

A `Project` may also carry an `agent_id` linking it back to the client agent that created it. Once the user binds the agent, ownership transfers to the user atomically (created_by + tenant_id + state flip in one transaction); the agent_id stays for audit.

## Token shapes (READ THIS BEFORE THE FIRST CURL)

Every accepted Bearer token starts with `od_` and a single kind byte at position 3:

| Plaintext form | Kind | Backing table | Authority |
|---|---|---|---|
| `od_k<43 chars>` | PAT  | `user_api_keys`   | Full user (one active per user) |
| `od_a<43 chars>` (pending) | Agent | `client_agents` | Guest tenant, resource-capped |
| `od_a<43 chars>` (bound)   | Agent | `client_agents` | Same as PAT for the bound user |

Pending agents authenticate but the gateway sets `X-Tenant-Type: guest` and routes their workloads to the `guest-claims` K8s namespace. Bound agents transparently lift to PAT-equivalent — no client change required, the same plaintext token continues to work.

## Common quick read operations

```bash
curl -fsSL -H "$AUTH" "$OD_GATEWAY/v1/regions/"                         # auth sanity + regions
curl -fsSL -H "$AUTH" "$OD_GATEWAY/v1/projects/"                        # list projects
curl -fsSL -H "$AUTH" "$OD_GATEWAY/v1/projects/$PID"                    # project detail
curl -fsSL -H "$AUTH" "$OD_GATEWAY/v1/projects/$PID/services/"          # list services
curl -fsSL -H "$AUTH" "$OD_GATEWAY/v1/deployments/$DID"                 # deployment status
curl -fsSL -H "$AUTH" "$OD_GATEWAY/v1/deployments/$DID/logs?tail=200"   # one-shot logs
curl -fsSL -H "$AUTH" "$OD_GATEWAY/v1/service-domains?service_id=$SID"  # domains for svc
```

In **pending-agent mode** the same routes work, but a few are gated by tier (custom production domain, billing/subscription endpoints) — you'll get `403 bind_required`. Resource limits (cpu/mem/services) are enforced at create time as `403 agent_quota_exceeded`.

## References (load on demand)

| file | when to load |
|---|---|
| [references/api-schemas.md](references/api-schemas.md) | Before making any API call - full request/response schemas with required/optional fields. |
| [references/analyze-local.md](references/analyze-local.md) | At Step 2 - local analysis rules: what files to read, port/framework/DB detection heuristics, fixed output schema. |
| [references/failure-playbook.md](references/failure-playbook.md) | On any non-2xx or unexpected state - symptom -> action mapping and log-source routing. |

## Inputs (ask once, then proceed)

1. **Source** - local folder path, ZIP, or `GIT_URL` (+ optional `GIT_BRANCH`, `GIT_TOKEN`).
2. **`PROJECT_NAME`** - lowercase, DNS-safe.
3. **`OD_GATEWAY`** - default `https://dashboard.dev.opendeploy.dev/api` (must include `/api`).
4. **Auth** - read from `.opendeploy/auth.json` (see Step 1). The skill writes the file itself if it doesn't exist. Override with `OPDEPLOY_AUTH_FILE` only when the user explicitly wants a different credential file.
5. **`OD_REGION_ID`** - optional; auto-pick first `active` region if unset.
6. **`SUBDOMAIN`** - optional `*.dev.opendeploy.run` prefix. Allowed for both pending and bound agents (server enforces uniqueness).

Fail fast if source or gateway is missing.

---

## Step 1 - Auth (the skill writes auth.json itself)

Single source of truth for the gateway URL is the frontmatter `metadata.api_base` above. The shell here mirrors it; if you change one, change both.

### 1.a Auth file shape: `.opendeploy/auth.json`

```json
{
  "version": 1,
  "api_key": "od_axxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "gateway": "https://dashboard.dev.opendeploy.dev/api",
  "agent_id": "8f3e2b14-ad7c-4f0c-9b1d-aaaaaaaaaaaa",
  "bind_sig": "0123456789abcdef",
  "claim_url": "https://dashboard.dev.opendeploy.dev/claim/8f3e2b14-ad7c-4f0c-9b1d-aaaaaaaaaaaa?h=0123456789abcdef"
}
```

- `version` (int, currently `1`) - schema marker. Unrecognized -> abort with a clear message; do not auto-rewrite.
- `api_key` (string, required) - the user's `od_*` token. Treat as a secret. Same field for PAT and agent — kind byte at position 3 tells the gateway which table to look up.
- `gateway` (string, optional) - overrides the default API base URL.
- `agent_id` (string, optional) - present only for agent tokens; the skill uses it to construct the claim URL on first deploy.
- `bind_sig` (string, optional) - HMAC seal the dashboard verifies on bind. Present only for agent tokens.
- `claim_url` (string, optional) - server-provided dashboard handoff URL. If absent, construct from `agent_id` + `bind_sig`.

File permissions MUST be `0600` (owner read/write only). The skill sets this when it writes the file. If pre-existing perms are looser, the skill warns and tightens — never deletes.

### 1.b Resolve / mint flow

```bash
OD_GATEWAY="${OD_GATEWAY:-https://dashboard.dev.opendeploy.dev/api}"
AUTH_FILE="${OPDEPLOY_AUTH_FILE:-$PWD/.opendeploy/auth.json}"
mkdir -p "$(dirname "$AUTH_FILE")" && chmod 700 "$(dirname "$AUTH_FILE")"

OD_API_KEY=""
AGENT_ID=""
BIND_SIG=""
CLAIM_URL=""
IS_FRESH_AGENT=0

if [ -f "$AUTH_FILE" ] && [ -s "$AUTH_FILE" ]; then
  PERM=$(stat -f '%Lp' "$AUTH_FILE" 2>/dev/null || stat -c '%a' "$AUTH_FILE" 2>/dev/null)
  case "$PERM" in 600|400) ;; *) chmod 600 "$AUTH_FILE" ;; esac

  OD_API_KEY=$(jq -r '.api_key // empty' "$AUTH_FILE")
  AGENT_ID=$(jq -r '.agent_id // empty' "$AUTH_FILE")
  BIND_SIG=$(jq -r '.bind_sig // empty' "$AUTH_FILE")
  CLAIM_URL=$(jq -r '.claim_url // empty' "$AUTH_FILE")
  GATEWAY_FROM_FILE=$(jq -r '.gateway // empty' "$AUTH_FILE")
  [ -n "$GATEWAY_FROM_FILE" ] && OD_GATEWAY="$GATEWAY_FROM_FILE"
fi

if [ -z "$OD_API_KEY" ]; then
  RESP=$(curl -fsSL -X POST "$OD_GATEWAY/v1/client-agents/register" \
    -H "Content-Type: application/json" \
    -d "{\"source_hint\":\"claude-code/$(uname -s)\"}")
  OD_API_KEY=$(echo "$RESP" | jq -r '.api_key // empty')
  AGENT_ID=$(echo "$RESP"  | jq -r '.agent_id // empty')
  BIND_SIG=$(echo "$RESP"  | jq -r '.bind_sig // empty')
  CLAIM_URL=$(echo "$RESP" | jq -r '.claim_url // empty')
  GW=$(echo "$RESP"        | jq -r '.gateway // empty')
  [ -n "$GW" ] && OD_GATEWAY="$GW"

  if [ -z "$OD_API_KEY" ]; then
    echo "OpenDeploy returned an existing pending agent but did not return api_key." >&2
    echo "The plaintext key is only shown once. Restore the previous $AUTH_FILE or wait 24h and retry." >&2
    exit 1
  fi
  if [ -z "$AGENT_ID" ] || [ -z "$BIND_SIG" ]; then
    echo "OpenDeploy agent registration response was missing agent_id or bind_sig." >&2
    exit 1
  fi
  # Fallback if the server didn't return a claim_url. Derive the host from
  # OD_GATEWAY (strip /api): the agent only exists in this gateway's DB, so
  # the dashboard handoff page must live on the same host. The marketing
  # site at opendeploy.dev does not have a /claim/:id route — only the
  # dashboard does (one per environment).
  CLAIM_HOST="${OD_GATEWAY%/api}"
  [ -n "$CLAIM_URL" ] || CLAIM_URL="$CLAIM_HOST/claim/$AGENT_ID?h=$BIND_SIG"

  umask 0077
  jq -n --arg k "$OD_API_KEY" --arg gw "$OD_GATEWAY" \
        --arg aid "$AGENT_ID" --arg sig "$BIND_SIG" --arg cu "$CLAIM_URL" \
    '{version:1, api_key:$k, gateway:$gw, agent_id:$aid, bind_sig:$sig, claim_url:$cu}' > "$AUTH_FILE"
  chmod 600 "$AUTH_FILE"
  IS_FRESH_AGENT=1
fi

if [ -z "$CLAIM_URL" ] && [ -n "$AGENT_ID" ] && [ -n "$BIND_SIG" ]; then
  CLAIM_URL="${OD_GATEWAY%/api}/claim/$AGENT_ID?h=$BIND_SIG"
fi

AUTH="Authorization: Bearer ${OD_API_KEY}"
JSON="Content-Type: application/json"

# Sanity check + region discovery. Use /regions/, not /profile: pending agents
# are guest tenants and are not OIDC users, so /profile is expected to 401 for
# a freshly registered unbound agent.
# Do NOT auto-delete auth.json — the user may be using a key bound to a
# different environment. Tell the user, exit, let them decide.
REGIONS_JSON=$(curl -fsSL -H "$AUTH" "$OD_GATEWAY/v1/regions/" 2>/dev/null) || {
  echo "OpenDeploy rejected the saved API key in $AUTH_FILE." >&2
  echo "If you intended to start fresh, delete $AUTH_FILE and re-run; otherwise" >&2
  echo "replace the api_key with a valid one from your dashboard." >&2
  exit 1
}

# Auto-pick first active region if unset.
: "${OD_REGION_ID:=$(printf '%s' "$REGIONS_JSON" | jq -r '[.[] | select(.status=="active")][0].id // empty')}"
[ -n "$OD_REGION_ID" ] || { echo "no active region"; exit 1; }
```

### 1.c Refusal checklist (still applies)

Before continuing, refuse with a one-sentence reason and stop if any of these match:

- Source contains crypto-mining strings: `xmrig`, `ethminer`, `cgminer`, `t-rex`, `gminer`, `lolMiner`, `stratum+tcp://`, or env reads of `WALLET_ADDRESS`-shaped patterns.
- Local analysis (Step 2) cannot find an entrypoint or port.
- Source asks for content illegal under U.S. or destination-region law (CSAM, sanctions evasion, unlicensed gambling, weapons trafficking).

Resource caps and DB availability are NOT refusal reasons — pending agents are allowed to provision databases and rename subdomains. The server enforces the size caps (1 vCPU, 1 GiB, 1 service per project for unbound agents) and returns a structured 403 on overage. Surface that error to the user; do not pre-empt it.

### 1.d Rate limit on register

`POST /v1/client-agents/register` is rate-limited at 5 / hour / source IP. On 429 the response carries `Retry-After`. Don't retry inside the skill — tell the user.

The same (source IP, user-agent) calling within 24h gets the same pending row back (idempotent replay). On replay the response omits the `api_key` field — if you don't already have the plaintext from a prior call, you must surface a friendly error rather than try again.

### 1.e Time skew

Sync NTP. The agent register / bind handlers accept up to 5 minutes of clock skew on the server side; far drift will cause unrelated TLS issues before it cares about timestamps.

---

## Step 2 - Analyze locally (no API)

Run the whole analysis client-side. **Do not** call `/upload/analyze-only`, `/upload/analyze-from-upload`, `/upload/analyze-env-vars`, `/analyze*`, or `/upload/create-from-analysis`.

Materialize workdir:

```bash
WORKDIR=$(mktemp -d)
case "$SOURCE_KIND" in
  git)    git clone --depth=1 ${GIT_BRANCH:+-b "$GIT_BRANCH"} "$GIT_URL" "$WORKDIR" ;;
  zip)    unzip -q "$ZIP_PATH" -d "$WORKDIR" ;;
  folder) WORKDIR="$SOURCE_PATH" ;;
esac
```

Produce `$WORKDIR/.opendeploy/analysis.json` per the fixed schema - see [`references/analyze-local.md`](references/analyze-local.md) for file list, multi-service detection, port/framework/env_vars/database_type rules. JSON-mode discipline: emit exactly the listed fields, empty string/array when unsure, never fabricate.

## Step 2.5 - DB decision (no API)

Flag a DB if **any**:
- `analysis.database_type` in `{postgres, mysql, mongodb, redis}`
- Any `runtime_vars[].name` ~ `DATABASE_URL | MYSQL_* | POSTGRES_* | PG_* | REDIS_URL | MONGO* | CLICKHOUSE_*`
- A compose DB image was found in Step 2

No DB flagged -> skip Step 3.2:
```bash
DEPENDENCY_IDS_JSON='[]'; echo '{}' > db_env.json
```

Pending agents may provision a DB (D6 contract). The 1-service-per-project cap still applies, so the DB lives alongside the single web service in the same project.

---

## Step 3 - Project / DB deps / Services

Order is fixed: project first, then DB deps (so `env_vars` are ready), then services (pre-merged env baked in). Every curl carries `-H "$AUTH"` — there is no other auth shape.

### 3.1 Create project -> `POST /projects/`

```bash
PROJ=$(curl -fsSL -X POST "$OD_GATEWAY/v1/projects/" \
  -H "$AUTH" -H "$JSON" \
  -d "$(jq -n --arg name "$PROJECT_NAME" \
                --arg repo "${GIT_URL:-file://upload}" \
                --arg branch "${GIT_BRANCH:-main}" \
                --arg token "${GIT_TOKEN:-}" \
                --arg region "$OD_REGION_ID" \
     '{name:$name, repo_url:$repo, branch:$branch, token:$token, region_id:$region,
       skip_validation: ($repo|startswith("file://"))}')")
PROJECT_ID=$(echo "$PROJ" | jq -r .id)
```

The gateway automatically links the new project to the calling agent (writes `projects.agent_id`) when the auth is a pending agent token. No additional client-side work needed.

For pending agents: a second `POST /projects/` from the same agent while a prior project still exists returns **409** with the existing `project_id` — agents are capped at one live project at a time. If you need a second project, wait for the first to be GC'd (6 h) or have the user bind and use a PAT.

Schema: `references/api-schemas.md` -> Step 3.1. Handles 400 Git validation errors with `error_code` / `available_branches` - see `references/failure-playbook.md`.

### 3.2 Create DB dependencies (skip if Step 2.5 didn't flag)

```bash
DEP=$(curl -fsSL -X POST "$OD_GATEWAY/v1/dependencies/create" \
  -H "$AUTH" -H "$JSON" \
  -d "$(jq -n --arg pid "$PROJECT_ID" --arg did "$DEPENDENCY_ID" --arg env "staging" \
     '{project_id:$pid, dependency_id:$did, environment:$env}')")
DEP_ID=$(echo "$DEP"  | jq -r .id)
DEP_ENV=$(echo "$DEP" | jq -c .env_vars)
```

Collect all `DEP_ID`s into `DEPENDENCY_IDS_JSON`. Merge every `DEP_ENV` into `db_env.json`. Optionally poll `GET /dependencies/status/:project_id` until `running` before Step 7. Schema: `references/api-schemas.md` -> Step 3.2.

### 3.3 Create each service -> `POST /projects/:id/services/`

Pre-merge runtime vars (later sources override earlier):

```bash
RUNTIME_VARS=$(jq -s '
  (.[0] | map({(.name): (.default // "")}) | add // {}) * (.[1] // {}) * (.[2] // {})
' analyzer_runtime.json db_env.json user_overrides.json)
```

**Pending-agent caps** (gateway middleware enforces these BEFORE the proxy reaches project-service, returns `403 agent_quota_exceeded` with offending `field` / `requested` / `limit` keys):
- `cpu_limit` <= `1` vCPU (1000 millicores)
- `memory_limit` <= `1Gi` (1 GiB)
- `replicas` <= 1
- 1 service per project

For pending agents, send these explicitly:

```bash
SVC=$(curl -fsSL -X POST "$OD_GATEWAY/v1/projects/$PROJECT_ID/services/" \
  -H "$AUTH" -H "$JSON" \
  -d "$(jq -n --arg name "$SVC_NAME" --arg type "web" --arg env "staging" \
                --arg lang "$SVC_LANG" --arg fw "$SVC_FW" \
                --argjson port "$SVC_PORT" \
                --argjson build_vars "$BUILD_VARS" \
                --argjson runtime_vars "$RUNTIME_VARS" \
     '{name:$name, type:$type, environment:$env, language:$lang, framework:$fw, port:$port,
       build_variables:$build_vars, runtime_variables:$runtime_vars,
       cpu_request:"500m", cpu_limit:"1", memory_request:"512Mi", memory_limit:"1Gi", replicas:1}')")
SERVICE_ID=$(echo "$SVC" | jq -r '.service_id // .id')
```

For PATs and bound agents, use the same shape but raise the limits to whatever the user's subscription allows (typical default is `cpu_limit:"2"`, `memory_limit:"4Gi"`).

Prompt user **before** the POST for any `required:true` var with empty default (LLM/API keys, third-party secrets). Never auto-generate secrets. Schema: `references/api-schemas.md` -> Step 3.3.

---

## Step 4 - Park source -> `POST /upload/upload-only`

Parks the archive in a shared tmpdir and returns a `temp_file_path`. **Does NOT bind the upload to any project, does NOT extract the ZIP, does NOT set `project.source_path`.** Step 4.5 does all of that.

Pick 4.a or 4.b per service.

**4.a ZIP** (local folder / existing ZIP / monorepo subfolder). **Must be ZIP** - the backend only imports `archive/zip`, no `.tar.gz`.

```bash
SRC_ZIP="$WORKDIR/.opendeploy/$SVC_NAME.zip"
mkdir -p "$(dirname "$SRC_ZIP")"

# zip from inside the service subfolder so paths are flat
(cd "$WORKDIR/$SVC_SOURCE_PATH" && \
  zip -qr "$SRC_ZIP" . \
    -x '*.git/*' 'node_modules/*' 'dist/*' 'build/*' \
       'target/*' '.venv/*' '__pycache__/*' '*.pyc' \
       '.opendeploy/*')

UP=$(curl -fsSL -X POST "$OD_GATEWAY/v1/upload/upload-only" \
  -H "$AUTH" \
  -F "project_name=$PROJECT_NAME" -F "region_id=$OD_REGION_ID" \
  -F "project_file=@$SRC_ZIP")
```

**4.b Git URL** (whole-repo deploy):
```bash
UP=$(curl -fsSL -X POST "$OD_GATEWAY/v1/upload/upload-only" \
  -H "$AUTH" \
  -F "project_name=$PROJECT_NAME" -F "region_id=$OD_REGION_ID" \
  -F "git_url=$GIT_URL" \
  ${GIT_BRANCH:+-F "branch=$GIT_BRANCH"} \
  ${GIT_TOKEN:+-F "git_token=$GIT_TOKEN"})
```

```bash
TEMP_FILE_PATH=$(echo "$UP" | jq -r .temp_file_path)
```

Schema: `references/api-schemas.md` -> Step 4.

---

## Step 4.5 - Bind upload to project -> `POST /upload/update-source` (REQUIRED)

`/upload/upload-only` alone only parks the archive in a shared tmpdir. The deployment handler writes `temp_file_path` into the Deployment row, but **the Temporal workflow input uses `SourcePath`, not `TempFilePath`**. Without Step 4.5, `SourcePath == ""` and every build fails inside 1 second with the generic `"Service failed to deploy"` - see `references/failure-playbook.md` -> *Deployment fails at progress=10 within seconds*.

`/upload/update-source` copies the temp file into `/var/lib/minions/projects/<PROJECT_ID>/<uuid>/`, extracts the ZIP, sets `project.source_path` to the extracted directory, and (if `analysis` is provided) writes `project.analyze_config` JSON so the build activity can skip its own LLM analysis.

```bash
UPDATE=$(curl -fsSL -X POST "$OD_GATEWAY/v1/upload/update-source" \
  -H "$AUTH" -H "$JSON" \
  -d "$(jq -n --arg pid "$PROJECT_ID" --arg tmp "$TEMP_FILE_PATH" \
              --slurpfile analysis "$WORKDIR/.opendeploy/analysis.json" \
     '{project_id:$pid, temp_file_path:$tmp, analysis:$analysis[0]}')")

SOURCE_PATH=$(echo "$UPDATE" | jq -r .source_path)
```

If your local `analysis.json` doesn't conform to the backend `ProjectAnalysisResult` shape, omit the `analysis` key - Step 4.5 will still bind + extract; the build activity falls back to Railpack auto-detect.

Schema: `references/api-schemas.md` -> Step 4.5. Not optional.

---

## Step 5 - Env override (optional) -> `PUT /projects/:id/services/:sid/env`

Skip unless user supplied late secrets or is rotating. PUT is a **full replace** - your payload becomes the entire variable set.

```bash
curl -fsSL -X PUT "$OD_GATEWAY/v1/projects/$PROJECT_ID/services/$SERVICE_ID/env" \
  -H "$AUTH" -H "$JSON" \
  -d "$(jq -n --argjson vars "$OVERRIDES" '{variables:$vars}')"
```

Schema: `references/api-schemas.md` -> Step 5.

---

## Step 6 - Resources (no separate call)

Resources are set **only at Step 3.3** (service creation body: `cpu_request/cpu_limit/memory_request/memory_limit` as K8s strings). The deployment handler reads them off the Service row.

> **Do NOT pass `resources:{...}` in the Step 7 body.** `deployment-service` defines `ResourceLimits.cpu_limit` as `float64`, so sending K8s strings (`"2"`, `"500m"`, `"1Gi"`) returns `400 json: cannot unmarshal string into Go struct field ResourceLimits.resources.cpu_limit of type float64`. The skill previously re-asserted this block and every first deploy 400'd until it was dropped. If you need to override at Step 7, switch to numeric cores/GiB - but the service-row values are already the source of truth.

> The `PUT /api/v1/projects/:id/resources` handler is **not** proxied by the gateway - it 404s. Don't call it. To change resources on a running service without redeploying, use `PUT /v1/services/:id` with the same four K8s-style fields.

---

## Step 7 - Build + deploy -> `POST /deployments/`

One call per service. Requires Step 4.5 to have run.

```bash
SOURCE=${GIT_URL:+git}; SOURCE=${SOURCE:-zip}
DEP=$(curl -fsSL -X POST "$OD_GATEWAY/v1/deployments/" \
  -H "$AUTH" -H "$JSON" \
  -d "$(jq -n --arg pid "$PROJECT_ID" --arg sid "$SERVICE_ID" \
                --arg env "staging" --arg src "$SOURCE" \
                --arg tmp "$TEMP_FILE_PATH" --arg branch "${GIT_BRANCH:-main}" \
                --argjson deps "$DEPENDENCY_IDS_JSON" \
     '{project_id:$pid, service_id:$sid, environment:$env,
       source:$src, temp_file_path:$tmp, branch:$branch, dependencies:$deps}')")
DEPLOYMENT_ID=$(echo "$DEP" | jq -r .id)
```

`temp_file_path` is stored on the deployment row for dependency-detection hints, but the actual build reads `project.source_path` populated by Step 4.5. Do **not** add a `resources` block here - see Step 6 note.

### Watch until terminal

The `/status` suffix is documented in `Backend/API.md` as an alias but the gateway does **not** register it - calls return 404. Use the resource GET instead:

```bash
while :; do
  S=$(curl -fsSL -H "$AUTH" "$OD_GATEWAY/v1/deployments/$DEPLOYMENT_ID" | jq -r .status)
  case "$S" in success|failed|cancelled|rolled_back) break ;; esac
  sleep 5
done
```

- Build logs (WS, ClickHouse): `GET /deployments/:id/build-logs/stream`
- Deploy logs (SSE): `GET /deployments/:id/logs/stream`
- One-shot: `GET /deployments/:id/logs?tail=N`

On `failed`: dump `logs?tail=300` + last 200 build_log lines; consult `references/failure-playbook.md`. **Don't retry silently** - "Task polling timeout" is a frontend 5-min timeout; ClickHouse build_logs is authoritative. If the deployment failed in <2 s at `progress=10` with `error_msg:"Service failed"`, Step 4.5 was skipped or silently errored - re-run it and retry Step 7.

Schema: `references/api-schemas.md` -> Step 7.

---

## Step 8 - Subdomain -> `PUT /service-domains/:id/subdomain`

Allowed for both pending and bound agents. Server enforces uniqueness across the namespace.

```bash
# 8.1 check availability
curl -fsSL -H "$AUTH" "$OD_GATEWAY/v1/service-domains/check-subdomain/$SUBDOMAIN" | jq .

# 8.2 find the auto staging row (poll up to 30s after Step 7)
AUTO_ID=$(curl -fsSL -H "$AUTH" \
  "$OD_GATEWAY/v1/service-domains?service_id=$SERVICE_ID&environment=staging&type=auto" \
  | jq -r '.[0].id')

# 8.3 rename
curl -fsSL -X PUT "$OD_GATEWAY/v1/service-domains/$AUTO_ID/subdomain" \
  -H "$AUTH" -H "$JSON" \
  -d "$(jq -n --arg s "$SUBDOMAIN" '{subdomain:$s}')"
```

On 409 -> append a 4-char suffix and retry 8.1 once. Verify:
```bash
curl -fsSL -o /dev/null -w "%{http_code}\n" "https://$SUBDOMAIN.dev.opendeploy.run${HEALTH_PATH:-/}"
```

Schema: `references/api-schemas.md` -> Step 8. Subdomain reserved list and edge cases in there too.

---

## Step 9 - Final report

Resolve the live URL:

```bash
APP_URL=$(curl -fsSL -H "$AUTH" \
  "$OD_GATEWAY/v1/service-domains?service_id=$SERVICE_ID&environment=staging&type=auto" \
  | jq -r '.[0].domain // .[0].url // empty')
[ -n "$APP_URL" ] && case "$APP_URL" in https://*) ;; *) APP_URL="https://$APP_URL" ;; esac
```

Print:

```text
project_id:    <uuid>
deployment_id: <uuid>
service:       <name>  <APP_URL>
status:        success
```

If `IS_FRESH_AGENT == 1`, also print the claim URL. Include the project URL in the claim URL so the landing/dashboard handoff can show the user what they are claiming:

```bash
CLAIM_URL="${CLAIM_URL:-${OD_GATEWAY%/api}/claim/$AGENT_ID?h=$BIND_SIG}"
APP_URL_Q=$(printf '%s' "$APP_URL" | jq -sRr @uri)
SEP="?"; case "$CLAIM_URL" in *\?*) SEP="&" ;; esac
CLAIM_URL_WITH_APP="$CLAIM_URL"
[ -n "$APP_URL_Q" ] && CLAIM_URL_WITH_APP="${CLAIM_URL}${SEP}url=${APP_URL_Q}"
```

```text
claim:         <CLAIM_URL_WITH_APP>
expires:       6 hours from now (the deployment is GC'd if you don't sign in)
status:        waiting for you to sign in
```

If `IS_FRESH_AGENT == 0` (the user already had `auth.json`), DO NOT print the claim URL — the project belongs to them already and the URL would be misleading.

Never echo `BIND_SIG` standalone; it is meaningful only as part of the claim URL. Never log `OD_API_KEY`. Do not shorten the claim URL through a third-party service - the redemption host must remain `opendeploy.dev`.

---

## Operations beyond first deploy (PAT or bound-agent only)

| Intent | Pattern |
|---|---|
| **Redeploy current source** | `POST /v1/deployments/` with the existing `service_id` (Step 7). Skip Steps 2-4.5 if source hasn't changed. |
| **Redeploy with new source** | Steps 4 -> 4.5 -> 7. Step 4.5 is required - it re-extracts the ZIP and updates `project.source_path`. |
| **Rotate env vars / secrets** | `PUT /v1/projects/$PID/services/$SID/env` (Step 5, full replace) -> Step 7. |
| **Resize a running service** | `PUT /v1/services/$SID` with K8s strings (`cpu_request`, `cpu_limit`, `memory_request`, `memory_limit`). No redeploy needed - the K8s deployment rolls. **Don't** call `PUT /api/v1/projects/:id/resources` (not proxied, 404s). |
| **Add a DB to an existing service** | Step 3.2 (create dependency) -> merge `env_vars` via Step 5 -> Step 7. |
| **Rename subdomain** | Steps 8.1 -> 8.2 -> 8.3 against the existing `ServiceDomain` row. No redeploy. |
| **Cancel a running deployment** | `POST /v1/deployments/$DID/cancel`. Confirm with user first - drops the build. |
| **Roll back** | Find a previous successful `deployment_id` via `GET /v1/projects/$PID/deployments?status=success` -> `POST /v1/deployments/$DID/rollback`. |
| **Triage a failed deploy** | `GET /v1/deployments/$DID/logs?tail=300` -> ClickHouse build logs `GET /v1/deployments/$DID/build-logs/stream` -> map symptom via `references/failure-playbook.md`. |

When the request spans two areas ("rotate the DATABASE_URL and redeploy"), do them in one chain - don't ask the user to invoke each step separately.

Pending-agent callers will hit `403 bind_required` on:
- billing / subscription routes (`/v1/billing/...`)
- custom production domains (CNAME on a user-owned hostname)

The skill cannot bind on the user's behalf. Surface the error with a clear pointer to the claim URL.

## User-only actions (never execute directly)

Show the command, explain the side effect, wait for the user.

| Action | Why user-only |
|---|---|
| Create / rotate a PAT via dashboard or `POST /v1/user/api-key` | Requires session login; key shown once. The skill never replaces a PAT silently. |
| Project deletion (`DELETE /v1/projects/:id`) | Irreversible; drops services, deployments, DBs |
| `PUT /env` with full secret replacement | Full-replace semantics; easy to wipe a needed var |
| Custom production domain | Out of scope; user binds from dashboard |
| Bind / revoke an agent | The user's browser holds the OIDC session. The skill never tries to call `/v1/client-agents/:id/bind` itself. |

## Execution rules

1. **Gateway only.** All calls go through `$OD_GATEWAY/v1/...`. Never reach `project-service:8081`, `deployment-service:8082`, `build-service:8083`, etc.
2. **Analysis is local.** Never call `/upload/analyze-only`, `/upload/analyze-from-upload`, `/upload/analyze-env-vars`, `/analyze*`, or `/upload/create-from-analysis`.
3. **Use `--fail` (`-f`) on curl** so non-2xx surfaces immediately. Use `jq` for parsing - never grep JSON.
4. **Resolve context before mutation.** Know which project, environment, service you're acting on. Pass IDs explicitly.
5. **Read-back after mutation.** Verify with a GET before reporting success.
6. **Destructive actions confirm first.** Project delete, deployment cancel, `PUT /env` (full replace) require explicit user intent.
7. **Two upload endpoints to remember:** `/upload/upload-only` only **parks** the archive. `/upload/update-source` (Step 4.5) is what **binds** it - skipping it is the #1 cause of a sub-2s `"Service failed to deploy"`.
8. **Never auto-delete `auth.json`** on a 401. Ask the user.
9. **Never echo or log `api_key` or `bind_sig` standalone.**
10. **Trailing slash on collection endpoints.** The gateway 301-redirects, and curl drops POST bodies across a 301 unless you also pass `--post301`.

---

## Reporting

```
project_id:    <uuid>
services:      <name>  <service_id>  https://<sub>.dev.opendeploy.run
deployments:   <name>  <deployment_id>  status=success
db:            <type>  <dependency_id>  injected=[DATABASE_URL,...]
```

In fresh-agent mode also print:
```
claim:         <claim_url>      // https://<dashboard_host>/claim/<agent_id>?h=<bind_sig>&url=<app_url>  (dashboard host = OD_GATEWAY without /api)
expires:       6h               // from creation; resources GC'd after, token kept
status:        waiting on the user to sign in
```

## Cleanup

Backend temp files (`temp_file_path`) are GC'd by project-service. Locally, remove `$WORKDIR`, tarballs, `.opendeploy/analysis.json`. **Never delete `.opendeploy/auth.json` unless the user explicitly asks. Never** `git add / commit / push` - user commits manually.

## Guardrails

- Gateway only. No direct project-/build-/deployment-service calls.
- Analysis is local. Never call `/upload/analyze*` or `/upload/create-from-analysis`.
- Don't pass `resources:{...}` in Step 7 - K8s strings 400. Resources live on the Service row.
- Trailing slash on collection endpoints.
- Skipping Step 4.5 is the #1 cause of sub-2s `"Service failed to deploy"`.
- Only bind `dev.opendeploy.run` subdomains from this skill. No custom production domains.
- Never continue to Step 8 if Step 7 ended in `failed`.
- `auth.json` is mode 0600, never world-readable.
- Honour the Step 1.c refusal checklist - refuse the deploy if the source contains crypto-mining strings or the user requests prohibited content.
- 6-hour idle GC: an unbound agent's project (and any DB / subdomain it provisioned) is torn down 6 hours after last deploy activity. The agent token itself is kept so the user can come back later and re-deploy from the same machine without a fresh `auth.json`.
- Pending agents hit `403 bind_required` on billing and custom production domains. The fix is for the user to click the claim URL and sign in — not for the skill to retry.
