# Set up Opslane Verify for this repo

> **Fetch this file with curl and read all of it** (`curl -fsSL https://verify.opslane.com/INSTALL.md`). A summary drops required steps.

You are a coding agent. The user asked you to set up Opslane Verify for the repository you are working in. Opslane Verify boots the app in the cloud for every pull request and checks the change in a real browser. To do that it needs a **setup**: an `opslane.yml` that says how to build, boot and seed the app, plus any helper files under `.opslane/`. Your job is to write the setup and get it passing a cloud check. Opslane stores the setup itself: **nothing is committed to the repo, and there is no PR to merge.**

## The checklist

Show the user this list when you start, and begin every update with the step you're on (**"Step 2 of 3: …"**). If your tool has a task list or plan that stays on screen (Claude Code's todo list, Codex's plan), put these three steps in it now and mark each one done as you go: it's how the user sees where things stand without scrolling.

1. **Sign in** to Opslane (a browser link).
2. **Connect the repo** (a GitHub link and code; installing the Opslane GitHub App if needed).
3. **Get your app running on Opslane.** Opslane works out how to build, start and fill a copy of your app with test data, then runs it in the cloud (usually 15–25 minutes). You'll hear from us only if something needs you; otherwise you'll hear when your copy of the app works and Opslane is ready to review your pull requests.

Show step 3 to the user in those words. How it works is yours, not theirs: you write the setup (`opslane.yml` and `.opslane/`), then Opslane checks it in the cloud, and you fix and resubmit until it passes. Don't list those as separate steps for the user, and keep step 3 one item in your task list too: track its parts yourself.

When step 3 passes, Opslane verifies the repo's pull requests from then on. **Before your closing message, [send feedback](#before-you-finish-send-feedback)** on how it went, whether it passed or you stopped.

**What the user does:** steps 1 and 2 only (two browser links, a few minutes). Step 3 needs nothing from them, unless you hit something you can't decide. Tell them that up front.

## Rules

- **Links first, then stop.** Right after you start `login` or `register-repo`, read its log, put the link and code in a message, and **end your turn there**. Don't read the repo or run anything else in the foreground while the user is in the browser: everything you print scrolls the link off their screen. Carry on when the user says they're done, or when the background command finishes. If you send another message while a link is still waiting on the user, repeat the link and code at the end of it. Codes expire in 5 to 15 minutes.
- **Decide, don't ask.** Make the setup decisions yourself: which login recipe, which services, what to leave out. Ask the user at most one question, and only when something blocks you (see [Stop and ask](#stop-and-ask)). When you ask, give your recommendation first.
- **No approval needed to submit.** Submitting doesn't touch the repo: Opslane stores the files and checks them in its cloud. Say in one line what you're submitting, and submit.
- **Every update says what the user should do.** One or two lines: the step, what's running and roughly how long it takes, then what the user should do: "nothing, you can step away", "open this link", or a question. When you need the user, make that the **last line, in bold**, so it's the one thing they see.
- **Never print, log or store tokens.** The commands keep them out of stdout. Don't read `~/.config/opslane/verify.json`.
- **Only `opslane.yml` and files under `.opslane/`.** Never edit or add the app's own files, its Dockerfile included. If no Dockerfile in the repo builds the product, write one in `.opslane/build/` ([Several Dockerfiles, one image](#your-own-dockerfile-opslanebuild)). If the app itself needs a change to build or boot, stop and tell the user which change and why: it's theirs to make, in their own PR, before you submit.
- **Secrets:** generate throwaway values (`openssl rand -hex 32`). They're stored at Opslane, not committed. Never use the user's real secrets.

### Stop and ask

Only for these: the app needs something this runbook says is [not supported](#not-supported); the user can't push to the repo (see step 2); or 5 checks have failed. Then [send feedback](#before-you-finish-send-feedback), summarize in a few lines and ask, with your recommendation.

## The CLI

Every step uses the Opslane Verify CLI through `npx` (Node 20 or newer):

```sh
npx -y @opslane/verify-cli@latest <command> [options]
```

- Each command prints **one JSON line on stdout**: `{"ok": true, ...}` on success, `{"ok": false, "error": "...", "code": "..."}` on failure (exit code 1). Messages for the user go to stderr.
- The CLI tells Opslane, anonymously, which setup step it reached and whether it worked (never a token or a file's contents). If the user asks to turn that off, set `OPSLANE_TELEMETRY=0`.
- `login` and `register-repo` wait for the user to finish in the browser, and print the link and code while they wait. **Run them in the background with output going to a fresh log file**, read the log after a few seconds, give the user the link and code, and end your turn. To be woken when it finishes, start a background wait on the log (for example `until grep -q '^{"ok"' "$log"; do sleep 5; done; tail -1 "$log"`) if your tool runs commands in the background; otherwise check the log when the user says they're done:

```sh
log=$(mktemp) && echo "log: $log" && (npx -y @opslane/verify-cli@latest login > "$log" 2>&1 &)
sleep 6; cat "$log"
```

Your shell may not keep variables between commands: note the path it printed, and use that path in later `cat` commands.

## Step 1 of 3: Sign in

```sh
npx -y @opslane/verify-cli@latest whoami
```

If that says `"ok": true`, go to step 2. Otherwise run `login` in the background (above). The log shows a link on `https://spectacular-cactus-02.authkit.app/device` and a code: give both to the user and end your turn. Wait until the log ends with `{"ok":true,...}`. If it ends with `"code": "login_required"` or an expired code, run `login` again.

## Step 2 of 3: Connect the repo

The repo is the `owner/name` of `git remote get-url origin`. Start `register-repo` in the background, then give the user the link and code from its log:

```sh
log=$(mktemp) && echo "log: $log" && (npx -y @opslane/verify-cli@latest register-repo --repo owner/name > "$log" 2>&1 &)
sleep 6; cat "$log"
```

- The log shows `https://github.com/login/device` and a code. The user opens the link, enters the code and authorizes "Opslane". This proves they can push to the repo; the GitHub token is used once and never saved.
- If the Opslane GitHub App can't see the repo yet, the log then shows `https://github.com/apps/opslane/installations/new`. Give the user that link: they install the App on the repo's owner (or add the repo to the existing installation). The command keeps checking for 10 minutes. If it gives up with `"code": "app_not_installed"`, run `register-repo` again once the user says it's installed.
- Each of these links: show it, end your turn, and go on when the user is done or the log shows the next line. Start step 3 once the log ends with `{"ok":true,...}`.
- **Hand off as soon as step 2 passes,** before you start reading the repo: one message telling the user their part is done, what you'll do next, and how long it takes. For example: "Step 2 of 3 done: the repo is connected. **You're done for now.** Next, Opslane works out how to run a copy of your app and starts it in the cloud (usually 15–25 minutes). Nothing needs you for now: you can step away. If something blocks me, my last line will be a question for you; otherwise I'll tell you when your copy of the app works and Opslane is ready to review your pull requests."
- On success the JSON has `repo_id`, `config_verified` and `next`. If `managed_setup` is set and `config_verified` is true, the repo is already set up: tell the user, and only continue if they want to replace the setup.
- **`no_push_access`, or the repo isn't visible:** the user can't push to `origin`, for example because it's a public upstream they forked. Say so in one line and ask for the `owner/name` of the repo they can push to (their fork). Don't ask before trying.
- **`repo_owned_by_another_org`** or **`installation_owned_by_another_org`**: the repo is connected to a different Opslane account. Stop and tell the user.

## Step 3 of 3: Get your app running on Opslane

Two parts, both yours: write the setup, then check it in the cloud. To the user they're one step (**"Step 3 of 3: …"**): describe what's happening in their terms (working out how to run their app, building it, starting it), not as files you're writing.

### Write the setup

Read the repo: Dockerfiles, `docker-compose*.yml`, `.env.example`, the package manifest and scripts, the README, and the CI workflows (they often show how the app is built and started for tests). Then write `opslane.yml` at the repo root, and any helpers under `.opslane/`.

These are working files, never part of the repo: Opslane stores the setup, and you delete them once the check passes ([below](#after-it-passes)). Before writing them, keep them out of git locally: `printf 'opslane.yml\n.opslane/\n' >> "$(git rev-parse --git-path info/exclude)"` (`.git/info/exclude` is never committed). Don't `git add -f` them. If `git ls-files opslane.yml .opslane` lists files, an older setup committed them: leave those alone and tell the user, in your final update, they're no longer used and can go in their own PR.

### How the cloud check uses it

On a fresh `linux/amd64` VM, at the head of the repo's default branch, with the setup files you submitted:

1. **build**: `docker build -f <dockerfile> [--target <build_target>] [--build-arg K=V ...] .` with the **repo root** as the context. The Dockerfile owns installing, building and the start command.
2. **services**: one container per declared service, on a private docker network.
3. **migrate** (optional): `sh -lc "<migrate>"` as a one-shot container from the built image, with the app's env and network, and `.opslane/` mounted read-only at `/opslane`.
4. **seed** (optional): the same kind of one-shot, after migrate. It must leave a login-ready account.
5. **boot**: runs the image with its own entrypoint/CMD, the app's env, and `/opslane` mounted the same way. Then each of `processes` (below), from the same image.
6. **health**: polls `http://127.0.0.1:<port><health_path>` until it answers 2xx or 3xx (5 minute budget). The container and every process must keep running (a process that exited and was restarted fails it too). With `ready` ([below](#when-the-app-is-ready)), it then runs that until it passes.
7. **session** (with `auth.session_command` only): runs it once in the app container. It must exit 0 and print cookies (see [The seeded login](#the-seeded-login)).
8. **signed_in** (with `auth.session_command` and a target): opens `verify_setup.landing` in a browser, and calls `verify_setup.api_landing`, as the seeded user, then signed out (see [Prove the signed-in page](#prove-the-signed-in-page)). Without a target the check passes with a note (`setup_target`).
9. **reset** (with `ready` and a `postgres`, `dynamodb` or `kafka` service only): takes the snapshot hunts take, and runs one reset as hunts get it. The app usually keeps running. When it has to restart (several apps, or the reset couldn't keep it running), the reset must finish, with the app ready again, within 2 minutes. A reset that works but takes more than 90 seconds still passes, with a note: hunts on the repo run slower.

One-shots run inside the built image with nothing from the host except `/opslane`: if a tool isn't in the image, migrate and seed can't use it.

### Opslane's own files: `.opslane/`

Put everything Opslane needs that the app doesn't (seed scripts, fixtures, small helpers) in a `.opslane/` directory at the repo root. Opslane stores these files with `opslane.yml` and the runner mounts them **read-only at `/opslane`** in the app, migrate and seed containers, so `opslane.yml` can run them directly:

```yaml
dockerfile: Dockerfile
port: 3000
seed: node /opslane/seed.cjs
```

- Regular files only (no symlinks); with `opslane.yml`, at most 50 files and 1 MB in total. Executable bits are kept, so `seed: /opslane/seed.sh` works if the file is executable (`sh /opslane/seed.sh` always does).
- It is read-only: write temporary files to `/tmp`, not `/opslane`.
- The working directory is the image's `WORKDIR`, not `/opslane`. Node resolves `require()` from the script's own directory, so a script in `/opslane` doesn't see the app's `node_modules`: run it as `NODE_PATH=/app/node_modules node /opslane/seed.cjs` (with the image's app directory), or use `createRequire` from that directory.
- The image is built from the repo. `dockerfile` is normally one the repo already has; when no single one fits, write your own under `.opslane/build/` ([below](#your-own-dockerfile-opslanebuild)).

### Fields

| field | required | meaning |
|---|---|---|
| `dockerfile` | yes | repo-relative path to the Dockerfile; it must build with the repo root as context |
| `port` | yes | the single HTTP port the app serves; also passed as `PORT` |
| `build_target` | no | `--target` stage of a multi-stage Dockerfile |
| `build_args` | no | map of `--build-arg` values (public, build-time only) |
| `services` | no | from the catalog below: `postgres`, `redis`, `s3`, `smtp`, `dynamodb`, `llm`, `sqs`, `kafka`, or `{type, version?, database?, bucket?, extensions?}` |
| `processes` | no | more processes from the same image, next to the app: `[{name, command, env?}]` (at most 10), see [Several processes](#several-processes-one-image) |
| `env` | no | runtime env for the app and the one-shots; overrides the service defaults |
| `migrate` | no | shell command run in the image before seed; omit it if the entrypoint migrates an empty DB itself |
| `seed` | no | shell command run in the image after migrate; must create a user that can log in |
| `health_path` | no | readiness path, default `/`; must not need auth and must answer 2xx/3xx on a fresh DB |
| `ready` | no | `{command, timeout_seconds?}`: when the app is ready to test, beyond answering `health_path`. Needed when workers, queues or Kafka sit behind the web server; see [When the app is ready](#when-the-app-is-ready) |
| `verify_setup.instructions` | effectively yes | prose for the verifier: the seeded login (email / password), what data exists, where you land after login |
| `auth.session_command` | no | command run in the app container that prints the seeded login's cookies as a Playwright storage state; the config check runs it once (see [The seeded login](#the-seeded-login)) |
| `notes` | no | documentation for humans; never executed |

The schema is strict: unknown keys are rejected. `version` is a full image reference (`postgres:16`, `pgvector/pgvector:pg16`, `redis:7-alpine`), not a bare number.

### Service catalog

The app reaches each service by its network alias, never `localhost` (inside the container, `localhost` is the app itself).

| service | alias:port | credentials | env set automatically |
|---|---|---|---|
| postgres (default image `pgvector/pgvector:pg15`) | `pg:5432` | user `app`, password `app`, db `app` (or `database:`) | `DATABASE_URL`, `PG_DATABASE_URL` = `postgres://app:app@pg:5432/app`, `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE` |
| redis (`redis:7-alpine`) | `redis:6379` | none | `REDIS_URL=redis://redis:6379`, `REDIS_HOST`, `REDIS_PORT` |
| s3 (MinIO) | `minio:9000` | key `app`, secret `appsecret`, bucket `app` (or `bucket:`) | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION=us-east-1`, `S3_ENDPOINT`, `AWS_ENDPOINT_URL=http://minio:9000`, `S3_BUCKET`, `S3_FORCE_PATH_STYLE=true` |
| smtp (Inbucket) | `smtp:2500`, no TLS, no auth | none | `SMTP_HOST=smtp`, `SMTP_PORT=2500`, `SMTP_URL` |
| dynamodb (DynamoDB Local) | `dynamodb:8000` | key `app`, secret `appsecret`, any region | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION=us-east-1`, `AWS_ENDPOINT_URL_DYNAMODB` and `DYNAMODB_ENDPOINT` = `http://dynamodb:8000` |
| sqs (ElasticMQ) | `sqs:9324` | key `app`, secret `appsecret`, any region | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION=us-east-1`, `SQS_ENDPOINT` and `AWS_ENDPOINT_URL_SQS` = `http://sqs:9324`, `SQS_QUEUE_URL_PREFIX=http://sqs:9324/000000000000/` |
| kafka (Apache Kafka, one broker) | `kafka:9092`, no TLS, no auth | none | `KAFKA_BROKERS`, `KAFKA_BOOTSTRAP_SERVERS`, `KAFKA_URL` = `kafka:9092` |
| llm (the LLM provider twin) | `llm-twin:8080` | any key | `LLM_TWIN_URL=http://llm-twin:8080`, `OPENAI_BASE_URL=http://llm-twin:8080/v1`, `ANTHROPIC_BASE_URL=http://llm-twin:8080`; `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENROUTER_API_KEY`, `GEMINI_API_KEY`, `GOOGLE_API_KEY` = `opslane-test-only` (forced, even over `env`) |

The app also always gets `HOST=0.0.0.0`, `HOSTNAME=0.0.0.0`, `BIND_ADDRESS=0.0.0.0` and `PORT`, and **must listen on 0.0.0.0**. If the app reads differently named variables (`NEXT_PRIVATE_DATABASE_URL`, `STORAGE_ENDPOINT`, ...), map them in `env` using the values above. Postgres `extensions` are created before migrate.

**The browser's address.** Checks and hunts always open the app at `http://localhost:<port>` (with `apps:`, the entry's `port`), never another hostname. Every absolute URL that points at the app itself must have that origin: ones baked in at build time (`VITE_*` or `NEXT_PUBLIC_*` API and login URLs, set with `build_args`) and ones read at runtime (`APP_URL`, `NEXTAUTH_URL`). With another origin, sign-in redirects and cookies go to a different site, and every check starts signed out.

**`AWS_*` settings.** Bug hunts refuse every `AWS_*` variable except:
- the catalog's dummy keys and the settings it sets itself (`AWS_REGION`, `AWS_ENDPOINT_URL`, …);
- the app's own storage settings: bucket, endpoint, region and addressing, like `AWS_S3_BUCKET_NAME`, `AWS_S3_ENDPOINT_URL` and `AWS_STORAGE_BUCKET_NAME`.

So an app that reads its storage that way can point it at the `s3` service, e.g. `AWS_S3_ENDPOINT_URL: http://minio:9000` and `AWS_S3_BUCKET_NAME: app`. Storage settings hold plain values only: letters, digits and `. _ : / -`, so no query strings, `%` escapes or `@`. A URL with a username or password in it is refused in any of these settings. A real AWS access key (`AKIA…`, `ASIA…`) is refused under any name; AWS's documentation examples (`AKIAIOSFODNN7EXAMPLE`) aren't keys, and for fakes use the catalog's dummies (`app` / `appsecret`).

**DynamoDB.** Tables start empty: create them in `migrate` (the app's own create-tables script). Don't set other `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` values in `env`: bug hunts refuse any AWS key but the dummies above. AWS SDK v3 picks up `AWS_ENDPOINT_URL_DYNAMODB` by itself; an AWS SDK v2 app (`require("aws-sdk")`) has no endpoint variable, so point it at the service with a preload in `.opslane/` and `NODE_OPTIONS: --require /opslane/aws-local.cjs` in `env`:

```js
// .opslane/aws-local.cjs
const AWS = require(require.resolve("aws-sdk", { paths: [process.cwd()] }));
AWS.config.update({ dynamodb: { endpoint: process.env.DYNAMODB_ENDPOINT } });
```

Setup checks can't query DynamoDB with SQL: verify seeded items with a `script` that reads them through the app's client and prints what it found. A preload runs in every script too, so it must log to stderr (`console.error`), never stdout, or its lines end up in what the verify compares.

**Queues (`sqs`) and topics (`kafka`).** Queues start empty: create them in `migrate`. A queue's URL is `SQS_QUEUE_URL_PREFIX` plus its name (`http://sqs:9324/000000000000/jobs.fifo`), so map the app's queue URL variables in `env` with those values. One way to create them, with only curl in the image (ElasticMQ takes unsigned calls):

```sh
for q in jobs.fifo events; do
  attrs='{}'; case $q in *.fifo) attrs='{"FifoQueue":"true","ContentBasedDeduplication":"true"}';; esac
  curl -fsS -X POST "$SQS_ENDPOINT/" -H 'content-type: application/x-amz-json-1.0' \
    -H 'x-amz-target: AmazonSQS.CreateQueue' -d "{\"QueueName\":\"$q\",\"Attributes\":$attrs}"
done
```

Kafka creates a topic the first time it's used; create it in `migrate` if the app needs particular partitions or settings. Every reset between checks empties the queues (keeping the queues and their settings) and puts Kafka back exactly as it was after `seed`: its topics, messages and consumer group offsets. Both run Opslane's pinned images: `version` isn't accepted for them.

**Model providers (`llm`).** The app's calls to OpenAI, OpenRouter, Anthropic and Gemini go to a twin that answers like the provider (plain and streaming) and records every call, so checks can assert on exactly what the app sent the model. SDKs that read `OPENAI_BASE_URL` / `ANTHROPIC_BASE_URL` need nothing more. A client with a hard-coded host (OpenRouter's or Gemini's URL in the code) needs a preload in `.opslane/` that sends those hosts to `LLM_TWIN_URL`, loaded with `NODE_OPTIONS: --require /opslane/llm-twin.cjs`. Patch exactly the clients the app uses, and throw if one can't be patched, so a call never quietly goes to the real provider:

```js
// .opslane/llm-twin.cjs
const twin = process.env.LLM_TWIN_URL;
if (!twin) throw new Error("llm-twin.cjs: LLM_TWIN_URL is unset (is `llm` in services?)");
const HOSTS = /^https:\/\/(openrouter\.ai|api\.openai\.com|generativelanguage\.googleapis\.com)(?=[/?]|$)/;
const realFetch = globalThis.fetch;
globalThis.fetch = (input, init) => {
  const url = typeof input === "string" || input instanceof URL ? String(input) : input.url;
  if (!HOSTS.test(url)) return realFetch(input, init);
  const to = url.replace(HOSTS, twin);
  return realFetch(typeof input === "string" || input instanceof URL ? to : new Request(to, input), init);
};
```

Prefer this over `verify_setup.responder_env`: the responder answers nothing until a bug hunt scripts it, so the app's model calls fail during checks.

### External services

List every outside service the app talks to in `notes`, one line each: payments, email, chat, CRMs, sign-in providers, analytics, storage, model providers, other companies' APIs. Give what it's used for, the env names or routes it uses, and whether the setup covers it (a catalog service) or leaves it out. Opslane reads the list and adds stand-ins for the ones it can test.

```yaml
dockerfile: Dockerfile
port: 3000
services: [postgres, llm]
notes: |
  External services:
  - OpenAI: chat answers (OPENAI_API_KEY); covered by llm
  - Stripe: subscriptions and checkout (STRIPE_SECRET_KEY, webhook /api/stripe/webhook); left out
  - Resend: sends email (RESEND_API_KEY); left out
  - Slack: posts alerts to a channel (SLACK_BOT_TOKEN); left out
```

An outside service is never a reason to stop. Leave out the ones the catalog doesn't cover, turn them off by env if the app allows that, and set the setup up without them. Name the ones you left out in your final update.

### Several processes, one image

Many apps run one image as several processes: the API, background workers, a scheduler, a dashboard. The image's own CMD is the app; list the others under `processes`, each with the command that starts it (run with `sh -lc` in the image's working directory, like `migrate`):

```yaml
dockerfile: docker/Dockerfile   # its CMD runs the API on :8080
port: 3000                      # the dashboard, a process below, serves the published port
processes:
  - name: dashboard
    command: cd /app/vite && bun run dev --host 0.0.0.0 --port 3000
  - name: workers
    command: bun workers
  - name: scheduler
    command: bun cron
    env:
      CRON_ENABLED: "true"
```

- Every process runs from the built image with the app's env (plus its own `env`), `/opslane` mounted, and **the app container's network**: they reach each other and the app on `localhost`, as on one machine. Any of them can serve `port`.
- Every process gets the app's `PORT` too, and they share one network: a process that serves HTTP on `$PORT` collides with the app. Give it its own port in its `env` (a process's own `PORT` overrides the app's), or have it read another variable, and let only the one meant to serve `port` bind it.
- They start right after the app and restart with it. Each must be long-running (not a one-shot) and must come up cleanly: one that exits while the app boots fails the check, with its log, even if it was restarted.
- After boot, a process that exits is restarted, as an orchestrator (ECS, Kubernetes) would, so a worker that exits on a dependency's trouble for its task to be replaced behaves as in prod. Each exit is still shown: in the run, at the next reset, and in its logs. One that exits during a scripted check makes that check an error, and one that keeps exiting stops the hunts.
- Prefer this to a Dockerfile of your own with a start script: the app's own Dockerfile stays the one that's built.
- **Add `ready` ([below](#when-the-app-is-ready)) whenever you add `processes`,** from the first submission: without it the check passes with a warning, and you'll have to submit again.

### Several images: monorepos (`apps:`)

When the product is built from several Dockerfiles (a web app and an API, each with its own), list them under `apps:`, the way `docker-compose.yml` lists its services. Read the repo's compose file and translate it:

- **Each service with `build:` becomes an app:** `build.dockerfile` → `dockerfile` (repo-relative), `build.context` → `context` (repo-relative; the repo root when absent), `build.target` → `build_target`, `build.args` → `build_args`, `command` → `command`, `environment` → `env`, the port it listens on → `port`.
- **Infra (`image: postgres`, redis, minio, mailhog, rabbitmq) becomes catalog `services`.** RabbitMQ isn't in the catalog: point Celery at Redis instead (for example `AMQP_URL` / `CELERY_BROKER_URL` = `redis://redis:6379/1`).
- **The service the browser opens is the entry:** often a proxy (nginx, Caddy), or the web app when it proxies `/api` itself. Mark it `entry: true`; it needs a `port`. Health, `ready`, the seeded login and every check go through it.
- **Services that run another command from the same image** (a worker, a scheduler) become `processes` of that app. A one-shot (a `migrator` service) becomes `migrate: { app: api, command: ... }`.
- **Apps reach each other by name**, as in compose (`http://api:8000`). Each app has its own network, so ports may repeat across apps. A non-entry app with `health_path` is waited on before the entry.
- **`command`:** a string runs with `sh -lc` and skips the image's ENTRYPOINT (put whatever the entrypoint does into the command); a list keeps the ENTRYPOINT and is its arguments, as compose does. Leave it out to run the image as built.
- **Limits and rules:** at most 8 apps; at most 10 processes across all apps. App names can't be `app` or one of Opslane's own service names (`pg`, `redis`, `minio`, `smtp`, `kafka`, `sqs`, `dynamodb`, `webhooks`, `llm-twin`, `scripted`, or a catalog type). With `apps:`, `dockerfile`, `port`, `health_path`, `build_*` and `processes` go on the apps, not at the top level. Top-level `env` is shared by every app.
- **What's not supported yet:** checks and `verify_setup.commands` run in the entry; a server outside the catalog (OpenSearch, Elasticsearch, a model server you'd rather not run) stays out. Turn it off by env if the app allows that, and list it in `notes` (see [External services](#external-services)).

Plane (Caddy proxy in front; the web apps, the API with its workers, and the live server):

```yaml
apps:
  - name: proxy
    dockerfile: apps/proxy/Dockerfile.ce
    entry: true
    port: 80
  - name: web
    dockerfile: apps/web/Dockerfile.web
    port: 3000
  - name: api
    dockerfile: apps/api/Dockerfile.api
    command: ./bin/docker-entrypoint-api.sh
    port: 8000
    health_path: /
    processes:
      - name: worker
        command: ./bin/docker-entrypoint-worker.sh
      - name: beat
        command: ./bin/docker-entrypoint-beat.sh
  - name: live
    dockerfile: apps/live/Dockerfile.live
    port: 3000
services: [postgres, redis, s3]
env:
  AMQP_URL: redis://redis:6379/1
migrate: { app: api, command: ./bin/docker-entrypoint-migrator.sh }
seed: { app: api, command: python /opslane/seed.py }
ready: { command: "wget -qO- http://api:8000/ >/dev/null" }
```

Read the compose file for the real service names, ports, commands and env; the proxy's upstream names must match the app names.

Onyx (the web app proxies `/api` itself, so there's no nginx; the background workers run under the API's image; no vector DB):

```yaml
apps:
  - name: web
    dockerfile: web/Dockerfile
    context: web
    entry: true
    port: 3000
    env:
      INTERNAL_URL: http://api:8080
      OVERRIDE_API_PRODUCTION: "true"
  - name: api
    dockerfile: backend/Dockerfile
    context: backend
    build_target: runtime
    command: uvicorn onyx.main:app --host 0.0.0.0 --port 8080
    port: 8080
    health_path: /health
    processes:
      - name: background
        command: /app/scripts/supervisord_entrypoint.sh
services: [postgres, redis, llm]
env:
  POSTGRES_HOST: pg
  REDIS_HOST: redis
  DISABLE_VECTOR_DB: "true"
migrate: { app: api, command: alembic upgrade head }
```

### Your own Dockerfile (`.opslane/build/`)

**For monorepos with a Dockerfile per service, use `apps:` above, not this.** Write a Dockerfile of your own only when the repo has no Dockerfile that builds what you need: say its only image pulls published images instead of building the repo's source, or a service has no Dockerfile at all. Build it **from the repo's source**, put it under `.opslane/build/`, and point `dockerfile` (or one app's `dockerfile`) at it. The repo's own files still aren't changed: Opslane stores the setup and writes it into the build.

```yaml
dockerfile: .opslane/build/Dockerfile   # multi-stage: builds the app from the repo's source
port: 3000
```

- **Put it in `.opslane/build/`, with everything it copies from `.opslane/`** (a Caddyfile, a start script). That directory, and nothing else from `.opslane/`, is in the build context, so the build can't see a file elsewhere in `.opslane/`, and editing your seed doesn't rebuild the image. Repo files are copied by their repo path as usual (the context is the repo root).
- If the repo's `.dockerignore` leaves out `.opslane/` (or all dotfiles) or something your build needs, add `.opslane/build/Dockerfile.dockerignore`: the build uses it instead of the repo's.
- Mirror the repo's own Dockerfiles stage by stage (same base images and build commands), so the image matches what ships.
- Serve every route on `port` (a reverse proxy in front when there are several servers), and start the other servers as `processes`, as above.
### When the app is ready

`health_path` says the server answers. For many apps that's long before anything works: a dashboard answers `/` at once while the workers behind it are still connecting, or a Kafka consumer is still waiting to own its partition. Checks and hunts that start then test a broken app.

`ready` is a command that passes (exit 0) only when a core flow works end to end:

```yaml
dockerfile: docker/Dockerfile
port: 3000
services: [postgres, kafka]
processes:
  - name: balance-worker
    command: bun balance-workers
ready:
  command: bun /opslane/ready.ts
  timeout_seconds: 90   # optional: default 90, at most 110
```

- **Where it runs:** in the app container (`sh -lc`, from the image's working directory), so it reaches the app and its processes on `localhost`.
- **When:** at boot, after health, and again wherever the app starts again: after every hunt reset, after a restart, and before a check whose worker had to be started again. It's run every 2 seconds until it passes. One attempt gets at most 30 seconds, and the image's `timeout` stops an attempt that runs longer.
- **What it should check:** a result you can observe, not a status code. Many apps fail open: a read that falls back to the database, or a write that's queued for later, answers 200 or 202 while the worker is down. Make it a call that has to go through the worker and come back with its effect: a write that's applied before the answer, then its new value.
- **What it may write:** only its own records (a customer or account of its own), so hunts don't see its writes. Create anything it needs in `seed`, which runs before the app and before the snapshot. A key or token made at runtime is gone after a reset; a fixed one created in the seed isn't.
- **What it proves:** the setup check also runs one hunt reset (the **reset** stage above) and fails if the app isn't ready after it, or if the reset doesn't finish within 2 minutes (one over 90 seconds passes, with a note). Hunts get at most 120 seconds per reset. In a run, an app that isn't ready after a reset stops the hunts rather than letting them test it.

- **Fast restarts:** with `ready` and one app, a reset usually keeps the app running. With several apps, or after a fault, the reset restarts it, so a fast restart makes those hunts faster. A slow reset doesn't fail the check, but it gets a note, and a reset over 2 minutes ends that hunt early. Many start scripts repeat one-time work on every boot: database migrations (which `migrate` already ran), rewriting build assets, warming caches. Turn that off in `env`, usually with a flag the start script already reads (for example `SKIP_DB_MIGRATIONS: "true"`). If the app's first boot took more than about 45 seconds to answer health, look for this before you submit.

Setups whose app has `processes`, `kafka` or `sqs` and no `ready` pass with a warning (`setup_warning`), so write `ready` for them from the start.

### The seeded login

The verifier signs in as a seeded account on every PR, so the seed must leave one. Pick the first recipe that fits; don't ask the user.

Start from how the repo already signs in for tests: the end-to-end setup (Playwright or Cypress global setup, auth helpers under `tests/` or `e2e/`) and the local dev docs. A seeded user, a test token helper or a test login mode that works in the built image tells you the recipe. Unit tests that replace the auth check with a mock don't help: a running app can't use them.

**1. Email and password.** The seed creates a user that can sign in with a password, with its email verified (or verification turned off by env). In order of preference: the repo's own seed tooling, if it's **in the built image**; the app's own signup API, called from a seed script in `.opslane/` (start the server in the background, wait for it, POST to signup, stop it); or a minimal upsert with the ORM client the image ships (precompute password hashes on the host if the hashing library can't be required). Put the credentials in `verify_setup.instructions`.

**2. OAuth-only or magic-link apps** (Google/Microsoft/GitHub sign-in, email links): sign in with a **seeded session** instead.

- The seed inserts the user, its linked account (provider and account id), anything the app needs before its first page (onboarding finished), and a **session row with a fixed token** and a far-future expiry.
- `auth.session_command` prints that session as a Playwright storage state. Checks and hunts send its cookies with HTTP requests.
- `verify_setup.instructions` gives the browser the same cookie: the browser doesn't get the session command's cookies, so the verifier sets it from the instructions before opening the app. Run `session.cjs` once locally with the same secret, and write the exact value it prints into the instructions (the example below shows where).
- How the cookie is made depends on the auth library:
  - **better-auth**: cookie `better-auth.session_token`, value `encodeURIComponent(token + "." + base64(HMAC-SHA256(secret, token)))`, with the app's auth secret (`BETTER_AUTH_SECRET` or what the app reads).
  - **NextAuth / Auth.js with database sessions**: cookie `next-auth.session-token` (v4) or `authjs.session-token` (v5), value the session row's `sessionToken`, unsigned.
  - Anything else: read how the library signs or looks up its session cookie, and do the same.
- If Docker is available locally, prove the cookie before submitting: boot the image with the setup, run the seed, and load a signed-in page with the cookie.

```js
// .opslane/session.cjs: the seeded session as a Playwright storage state (better-auth)
const { createHmac } = require("node:crypto");
const token = "opslane-seeded-session-0001"; // the session row's token, from .opslane/seed.cjs
const sig = createHmac("sha256", process.env.AUTH_SECRET).update(token).digest("base64");
const value = encodeURIComponent(`${token}.${sig}`);
process.stdout.write(JSON.stringify({ cookies: [{ name: "better-auth.session_token", value, domain: "localhost", path: "/" }] }));
```

```yaml
dockerfile: docker/Dockerfile.prod
port: 3000
health_path: /api/health
services: [postgres, redis, llm]
env:
  AUTH_SECRET: 0b5f0c7b1e2d4a6f8c9e1a3b5d7f9e2c4a6b8d0f1e3c5a7b9d2f4e6a8c0b1d3f
seed: NODE_PATH=/app/node_modules node /opslane/seed.cjs
auth:
  session_command: node /opslane/session.cjs
verify_setup:
  instructions: |
    The app has only Google sign-in. Sign in with the seeded session instead: open the app, set the cookie
    better-auth.session_token=opslane-seeded-session-0001.<signature> (write the exact value session.cjs
    prints here) on path /, and reload. You are opslane@example.com (onboarding finished) and land on the inbox.
```

**3. A hosted login provider** (PropelAuth, Auth0, Clerk, WorkOS, ...): the app sends users to the provider's pages, and its API checks the provider's tokens (JWTs) with a public key. A run can't reach the provider, and the setup never holds a real provider key. Run a **stand-in provider** on the app's own origin instead, and point the app at it. The app's code doesn't change, and the API still checks every token.

- **A throwaway key pair, made for this setup.** Generate it once (`openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048`, then `openssl pkey -pubout`). Never reuse a key from anywhere else. The key only works in the run: the real app trusts the provider's key, not this one.
  - The public half goes in the shared `env`, in the variable the app verifies with (`PROPELAUTH_VERIFIER_KEY`, or what the app reads).
  - The private half goes only in the stand-in process's own `env`, so the app, the one-shots and the PR's code never see it.
- **The stand-in** is a small server in `.opslane/` that answers the provider calls the app makes. On each call it signs a new token for the user its session cookie names, with the claims the provider puts in its tokens. It stores nothing.
  - Read the app's provider libraries to find every call. The example answers what PropelAuth's browser library calls: the hosted `/login` page, `/api/v1/refresh_token` and `/api/v1/logout`.
  - Every call the app makes to the provider URL now reaches the stand-in. One it doesn't answer goes to the app and gets a 404. Backend libraries often call the provider too, for example to fetch the verification key at startup when it isn't set in `env`.
- **Put it in front of the app:** run it as a process that serves `port` and passes everything else, websockets included, to the app on another port. Set the provider URL to `http://localhost:<port>`, both the server's (`PROPELAUTH_AUTH_URL`) and the front end's build-time copy (`build_args`).
- **Users come from one file.** `.opslane/users.json` maps a session name to each seeded user's `user_id` and `email`. The seed inserts the same users into the app's own tables, with their roles there. Seed more than one role (an admin and a member) so checks can cover permissions.
- **Sign-in:**
  - The stand-in's login page sets the session cookie and redirects to the app, so `verify_setup.instructions` tells the browser to open it.
  - `auth.session_command` prints the cookie and a token, as `Authorization: Bearer`, for HTTP checks: an API that checks provider tokens doesn't read the cookie. It gets the token from the stand-in itself, so the config check's session stage also proves the stand-in signs tokens.
  - Before you submit, prove it locally: load a signed-in page in the browser, and call one API route with the session command's header.
- **Shared paths:** a provider path can also be one of the app's routes. A front end often has its own `/login` page, and the provider library sends signed-out users to the provider's `/login`, which is now the same address. The stand-in answers `/login` only when there's no session cookie, and passes it to the app after that. As a result, opening `/login` while signed out signs in as the first user. A check of what a signed-out user sees must open the page itself, not `/login`.
- **Say what isn't covered** in `verify_setup.instructions`: sign-in runs against the stand-in, so a PR that changes how the app talks to the provider isn't tested against the real one.

```js
// .opslane/idp.mjs: a stand-in for PropelAuth on the app's origin (test only). Serves PORT; the rest goes to the app.
import http from "node:http";
import net from "node:net";
import { createSign } from "node:crypto";
import { readFileSync } from "node:fs";

// {"admin": {"user_id": "opslane-admin", "email": "admin@example.com"}, "member": {...}}; the first is the default.
const USERS = JSON.parse(readFileSync("/opslane/users.json", "utf8"));
const APP = { host: "127.0.0.1", port: Number(process.env.OPSLANE_APP_PORT) };
const COOKIE = "opslane_idp_session";
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");

function who(req) {
  const name = new RegExp(`(?:^|;\\s*)${COOKIE}=([^;]+)`).exec(req.headers.cookie ?? "")?.[1];
  return name && Object.hasOwn(USERS, name) ? USERS[name] : undefined;
}

function token(user) {
  const now = Math.floor(Date.now() / 1000);
  const exp = now + 24 * 3600; // longer than a run: the session command's token is reused across resets
  const claims = { sub: user.user_id, user_id: user.user_id, email: user.email, org_id_to_org_member_info: {}, iss: process.env.PROPELAUTH_AUTH_URL, iat: now, exp };
  const body = `${b64({ alg: "RS256", typ: "JWT" })}.${b64(claims)}`;
  const sig = createSign("RSA-SHA256").update(body).sign(process.env.OPSLANE_IDP_PRIVATE_KEY, "base64url");
  return { access_token: `${body}.${sig}`, expires_at_seconds: exp, org_id_to_org_member_info: {}, user };
}

const server = http.createServer((req, res) => {
  const path = new URL(req.url, "http://localhost").pathname;
  const user = who(req);
  if (path === "/api/v1/refresh_token") {
    res.writeHead(user ? 200 : 401, { "content-type": "application/json" });
    return res.end(JSON.stringify(user ? token(user) : {}));
  }
  if (path === "/api/v1/logout") {
    res.writeHead(200, { "content-type": "application/json", "set-cookie": `${COOKIE}=; Path=/; Max-Age=0` });
    return res.end("{}");
  }
  if (path === "/login" && !user) {
    res.writeHead(302, { location: "/", "set-cookie": `${COOKIE}=${Object.keys(USERS)[0]}; Path=/; SameSite=Lax` });
    return res.end();
  }
  const up = http.request({ ...APP, method: req.method, path: req.url, headers: req.headers }, (r) => {
    res.writeHead(r.statusCode ?? 502, r.headers);
    r.pipe(res);
    r.on("error", () => res.destroy());
  });
  up.on("error", () => (res.headersSent ? res.destroy() : res.writeHead(502).end()));
  res.on("close", () => up.destroy());
  req.pipe(up);
});

// Websockets: pass the raw connection through to the app.
server.on("upgrade", (req, socket, head) => {
  const up = net.connect(APP.port, APP.host, () => {
    up.write(`${req.method} ${req.url} HTTP/1.1\r\n${Object.entries(req.headers).map(([k, v]) => `${k}: ${v}`).join("\r\n")}\r\n\r\n`);
    up.write(head);
    socket.pipe(up).pipe(socket);
  });
  up.on("error", () => socket.destroy());
  socket.on("error", () => up.destroy());
});

server.listen(Number(process.env.PORT), "0.0.0.0");
```

```js
// .opslane/idp-session.cjs: the session for HTTP checks: the stand-in's cookie, and a token from the stand-in.
(async () => {
  const cookie = "opslane_idp_session=admin";
  const res = await fetch(`http://127.0.0.1:${process.env.OPSLANE_IDP_PORT}/api/v1/refresh_token`, { headers: { cookie } });
  if (!res.ok) throw new Error(`stand-in answered ${res.status}`);
  const { access_token } = await res.json();
  process.stdout.write(JSON.stringify({
    cookies: [{ name: "opslane_idp_session", value: "admin", domain: "localhost", path: "/" }],
    headers: { authorization: `Bearer ${access_token}` },
  }));
})().catch((err) => { console.error(err); process.exit(1); });
```

```yaml
dockerfile: Dockerfile
port: 3000                      # the stand-in serves the published port
services: [postgres]
build_args:
  VITE_PROPELAUTH_URL: http://localhost:3000
env:
  PORT: "3001"                  # the app itself, behind the stand-in
  OPSLANE_IDP_PORT: "3000"
  PROPELAUTH_AUTH_URL: http://localhost:3000
  PROPELAUTH_VERIFIER_KEY: |
    -----BEGIN PUBLIC KEY-----
    (the public half of the key pair you generated)
    -----END PUBLIC KEY-----
processes:
  - name: idp
    command: node /opslane/idp.mjs
    env:
      PORT: "3000"
      OPSLANE_APP_PORT: "3001"
      OPSLANE_IDP_PRIVATE_KEY: |
        -----BEGIN PRIVATE KEY-----
        (the private half of the key pair you generated)
        -----END PRIVATE KEY-----
seed: NODE_PATH=/app/node_modules node /opslane/seed.cjs   # inserts the users in users.json, with their roles
ready:
  command: |
    node -e "fetch('http://127.0.0.1:3001/').then((r) => process.exit(r.status < 500 ? 0 : 1), () => process.exit(1))"
auth:
  session_command: node /opslane/idp-session.cjs
verify_setup:
  instructions: |
    Sign-in uses a local stand-in for PropelAuth. Open /login: you are admin@example.com (admin).
    To be member@example.com instead, set the cookie opslane_idp_session=member on path / and reload.
    Opening /login while signed out signs you in, so open other pages to see what a signed-out user sees.
    A PR that changes how the app talks to PropelAuth isn't tested against the real PropelAuth.
```

The `ready` command above only shows the app answers. Write a real one, as [When the app is ready](#when-the-app-is-ready) says.

**4. Logged out only:** the last resort, when none of these works. Say so in `verify_setup.instructions`, and tell the user in your final update that signed-in pages aren't covered.

### Prove the signed-in page

An HTTP 200 doesn't mean a user can work: a signed-in user can still land on a setup wizard, a "missing permissions" screen or a blank page, and every PR check then runs against that. With `auth.session_command`, declare where the seeded user lands and text only they see:

```yaml
dockerfile: Dockerfile
port: 3000
auth:
  session_command: node /opslane/session.cjs
verify_setup:
  instructions: Signed in as verify@verify.opslane.com (seeded session); lands on the websites list.
  landing:
    path: /               # opened with the session's cookies
    expect: /websites     # where it must end up (`*` is one segment, `**` several; may include #hash)
    text: Add website     # visible only to a signed-in user, on that page
  api_landing:            # for an app without a UI, or an API that signs in with a token
    path: /api/me
    text: verify@verify.opslane.com   # in the signed-in answer
```

The check opens `landing.path` in a browser with the session's cookies and fails when the page is blank, is a sign-in page or shows a password field, ends up somewhere `expect` doesn't match, or doesn't show `text`. It then opens the same path signed out: if that shows the same page, the target proves nothing and the check fails. `api_landing` is a `GET` with the session's cookie and headers: it must answer 2xx with `text` in the body, and must not do the same signed out.

- **Read the look.** Pass or fail, `steps.signed_in.message` says where the browser landed, the page title, whether a login form showed, and the start of the page's text. Before you resubmit, check it's the page a user would see. A wizard or a consent screen means the seed hasn't finished what a real user did once (onboarding, connected accounts, permissions).
- Pick `text` from the signed-in page itself (a heading, a nav item, the seeded org's name), not from the layout every page has.
- The browser gets the session command's cookies. A token-only session (headers, no cookies) can't sign the browser in: use `api_landing`.

### Not supported

A server outside the catalog that the app can't start without (turn it off by env if the app allows that), or more than 8 apps. Say so to the user instead of approximating. An outside service the app calls (Stripe, an email API) isn't one of these: list it and carry on ([External services](#external-services)).

### Examples

A small app:

```yaml
dockerfile: Dockerfile
port: 3000
health_path: /api/health
services:
  - postgres
env:
  SESSION_SECRET: 3f1c0d8e5b7a49e2a6c4d0b9e8f7a6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9
  APP_URL: http://localhost:3000
migrate: npx prisma migrate deploy
seed: NODE_PATH=/app/node_modules node /opslane/seed.cjs
verify_setup:
  instructions: |
    Seeded login: admin@example.local / OpslaneVerify123! (email verified, role admin).
    Sign in at /login. After sign-in you land on /dashboard, which is empty: no projects are seeded.
```

A monorepo with a multi-stage Dockerfile and several services (Formbricks):

```yaml
dockerfile: apps/web/Dockerfile
port: 3000
health_path: /health
services:
  - type: postgres
    extensions: [vector]
  - redis
env:
  # DATABASE_URL and REDIS_URL come from the services.
  WEBAPP_URL: http://localhost:3000
  NEXTAUTH_URL: http://localhost:3000
  NEXTAUTH_SECRET: 41fb976f1ebd4e7a71c75038b033f6628ce7c6f92a690c30e4dd4a07f9de3a4e
  ENCRYPTION_KEY: 41c94f448e06638630eb6f376d19b94cb143f7adcecc43d94975f7101e8f9adc
  EMAIL_VERIFICATION_DISABLED: "1"
  TELEMETRY_DISABLED: "1"
migrate: node packages/database/dist/scripts/apply-migrations.js
seed: cd /home/nextjs && NODE_PATH=/home/nextjs/node_modules node /opslane/seed.cjs
verify_setup:
  instructions: |
    Seeded login: admin@formbricks.local / OpslaneVerify123!, owner of organization "Opslane Org"
    with one workspace. Sign in at /auth/login; you land on the workspace's (empty) surveys page.
notes: |
  The image's start script migrates on boot; migrate runs the same script first because the seed
  needs the schema (one-shots bypass the entrypoint). The seed script is .opslane/seed.cjs, mounted
  at /opslane; apps/web/Dockerfile is unchanged.
```

With a build target, a pinned Postgres, and names the app expects (Twenty):

```yaml
dockerfile: packages/twenty-docker/twenty/Dockerfile
build_target: twenty
port: 3000
health_path: /healthz
services:
  - type: postgres
    version: postgres:16
    extensions: [uuid-ossp, unaccent]
  - redis
env:
  PG_DATABASE_URL: postgres://app:app@pg:5432/app
  SERVER_URL: http://localhost:3000
  APP_SECRET: 50ed8f9d0ee3cd163f9b6f2f19232e2597d3e10f5deff615963cf1b767877570
migrate: yarn database:init:prod
seed: yarn command:prod workspace:seed:dev --light
verify_setup:
  instructions: |
    Seeded login: tim@apple.dev / tim@apple.dev (Admin of the "Apple" workspace). Sign in at /welcome.
```

If Docker is available locally, `docker build -f <dockerfile> .` catches most build mistakes faster than a cloud attempt. It's optional: only the cloud check counts.

### Check it in the cloud

Tell the user their app is now being built and started in the cloud: this part usually takes **10–20 minutes, mostly the Docker build**, and needs nothing from them. Then submit `opslane.yml` and **every file under `.opslane/`** (paths relative to the repo root):

```sh
npx -y @opslane/verify-cli@latest submit-config --repo owner/name opslane.yml .opslane/seed.cjs .opslane/session.cjs
```

- On success you get a `run_id` and the setup's `version`. Tell the user the check is running, about how long it has left, and that it needs nothing from them. Then **wait in rounds, so the user keeps hearing from you** while it runs:

```sh
npx -y @opslane/verify-cli@latest status --repo owner/name --wait --until-change
```

  Run it in the background if your tool wakes you when a background command ends (otherwise in the foreground). It returns when the check moves to another stage, after 5 minutes with no change, or when the check ends. Each time it returns with `run.done` false, send the user one line and start it again:
  - **The step changed:** what finished and what's running now. For example: "Step 3 of 3: your app is built (12 minutes); now starting it in the cloud."
  - **5 quiet minutes:** that it's still going and that's normal, using `elapsed_minutes`. For example: "Step 3 of 3: still building your app, 9 minutes in. This part usually takes 10–20 minutes. Nothing needed from you."

  When `run.done` is true, read the result (below).
- **`reverify_github`** (403): Opslane re-checks GitHub push access on every submit and has no record of it for this user yet. Run `register-repo` again (step 2: give the user the link first), then submit again.
- **`invalid_files`**: `opslane.yml` didn't validate, or a file was refused. Fix it and submit again. **`outside_opslane_paths`**: you sent something that isn't `opslane.yml` or under `.opslane/`: drop it (see [Rules](#rules)). At most 50 files and 1 MB in total; binary files are fine; executable bits are taken from your local files.
- **`check_in_progress`**: the previous check is still running. Wait for it, then submit. **`too_many_checks`**: 20 checks a day is the limit; stop and tell the user.

### Reading the result

- **`"config_verified": true`:** it passed, and the repo is live. End with a short summary for the user: Opslane now verifies their pull requests with this setup and there's nothing to merge; how the verifier signs in (the seeded account); anything you left out (workers, signed-in pages); and what happens next: on their next pull request, Opslane posts a review comment.
  - If `steps` has a **`setup_warning`** (status `note`), its `message` says what to add (usually `ready`): fix it and submit once more. The current setup stays active until the new check passes.
  - If `steps` has a **`setup_target`** (status `note`), the setup has no `verify_setup.landing` or `api_landing`: add one ([Prove the signed-in page](#prove-the-signed-in-page)) and submit once more.
- **`run.status` is `failed`:** `failure.cause` says whose failure it is: `setup` (below), `opslane` or `unknown` (further down). For `setup`, read `failure.step`, `failure.error` and `failure.log_tail` (the end of that step's log); `steps` also carries each failed step's `message`. Fix the cause and submit again. Common causes:
  - **build**: the Dockerfile doesn't build from the repo root on amd64, or needs a build arg.
  - **migrate/seed**: a tool isn't in the image, or the database URL isn't mapped to `pg:5432`.
  - **health**: the app listens on 127.0.0.1 instead of 0.0.0.0, the port is wrong, the health path needs auth, or the app crashed at start (the log tail shows why).
  - **ready**: the core flow doesn't work yet when `ready` runs, or `ready` itself is wrong.
  - **setup_reset**, "not healthy after a restart": the app didn't come back within 5 minutes of a restart. Read the log for why; if it's only slow, see [Fast restarts](#when-the-app-is-ready). A `setup_reset` **note** (the check passed) is optional: the same advice makes hunts faster.
  - **session**: `auth.session_command` exited non-zero, timed out, or printed no cookies.
  - **signed_in**: the target opened as the seeded user isn't a signed-in page. `failure.log_tail` (and `steps.signed_in.message`) shows where it landed and what it showed: fix the seed or the session command, or `verify_setup.landing` if it points at the wrong page.
- **`failure.cause` is `opslane`** (no step failed; Opslane's worker stopped while holding the check): not your setup. Tell the user in one line and submit the same files again. It doesn't count toward the 5 failed checks below.
- **`failure.cause` is `unknown`** (no step failed, another error): read `failure.error`. If it isn't about your files, submit the same files again.
- A passing local build can still fail in the cloud (arm64 laptop vs amd64 VM, no host files, no `.env`). Trust the cloud result.
- After **5 failed checks** (not counting `opslane` ones), stop: summarize what you tried and the last error, and ask the user how to proceed.

### After it passes

The setup lives at Opslane, and that's the only copy that matters. **Delete your local `opslane.yml` and `.opslane/`** (unless git tracks them, see [Write the setup](#write-the-setup)), so a stale copy can't be edited and resubmitted over someone else's change later. Say so in your closing summary: "the setup is stored at Opslane; to change it, ask me (or run `pull-config`)".

Also tell the user in one line how reviews are paid for: each PR review uses one Opslane credit, a new organization has 10 free, and an owner can subscribe to the monthly plan (150 credits a month) and, on it, buy more or turn on auto top-up by asking you (`credits`, `subscribe`, `buy-credits`, `auto-top-up` below). If the user has an Opslane YC deal code (`YC-…`, emailed after redeeming our deal on Bookface), redeem it: `redeem-code YC-…`.

### Changing the setup later

1. `npx -y @opslane/verify-cli@latest pull-config --repo owner/name` writes the live setup's `opslane.yml` and `.opslane/` into the repo root and prints its `version`. It won't overwrite existing files (`--force` does). Add them to `.git/info/exclude` as in [Write the setup](#write-the-setup).
2. Edit the files.
3. Submit **every** file with the version you pulled: `submit-config --repo owner/name --base-version <version> opslane.yml .opslane/...`. A `409 setup_changed` means someone else's setup went live since you pulled: pull again, redo your edit on top, and submit.
4. Wait for the check as in step 3 ("Check it in the cloud"), then delete the local files again.

## Before you finish: send feedback

Once, when the check passes or when you stop (blocked, gave up after 5 checks, or the user stopped you), tell Opslane how onboarding went, **before** your closing message. It's how this runbook gets better; the user doesn't need to do anything. Write a JSON file outside the repo and send it:

```sh
npx -y @opslane/verify-cli@latest feedback --repo owner/name --file /tmp/opslane-feedback.json
```

```json
{
  "outcome": "passed",
  "rating": 4,
  "unclear": ["'Several processes, one image' didn't say whether a worker needs its own health check"],
  "guesses": ["guessed the auth secret env name from apps/web/env.ts"],
  "stuck": ["waited 14 min on build.head; no progress line between minutes 5 and 12"],
  "suggestions": ["a recipe for NextAuth's encrypted session cookie"],
  "notes": "first check failed on migrate: prisma wasn't in the runtime image",
  "agent": { "name": "Claude Code", "model": "claude-opus-5-5" },
  "run_ids": [612, 614]
}
```

- `outcome`: `passed`, `blocked`, `gave_up` or `in_progress`; `rating`: 1 (painful) to 5 (smooth). Every other field is optional; lists hold at most 20 items of 2,000 characters.
- **Be specific and candid:** quote the instruction that confused you, name what you had to guess, and say where you got stuck or waited and for how long. "Fine" helps nobody.
- **Never include tokens, cookies, passwords or secret env values,** not even the throwaway ones you generated.
- With the MCP server connected, the `send_feedback` tool takes the same fields.

## Optional: the Opslane MCP server

Offer to add the Opslane Verify MCP server, so future sessions can check runs and resubmit the setup with native tools (`submit_config`, `get_status`, `get_run`, `list_repos`, `send_feedback`). Connecting a repo stays a CLI command.

- Claude Code: `claude mcp add --transport http opslane-verify https://verify.opslane.com/mcp`, then in `/mcp` enable it and choose Authenticate.
- Codex: `codex mcp add opslane-verify --url https://verify.opslane.com/mcp`, then `codex mcp login opslane-verify`.

## Command reference

| command | what it does |
|---|---|
| `whoami` | who is signed in |
| `login` / `logout` | sign in (browser) / forget the sign-in |
| `register-repo --repo owner/name` | GitHub authorization + App install check; connects the repo |
| `sync --id <repo_id>` | refresh a connected repo from GitHub (renamed, default branch, App access) |
| `submit-config --repo owner/name [--base-version N] FILE...` | Opslane stores the setup and starts a config check; it replaces the current setup once it passes. `--base-version`: the version `pull-config` gave; refused (409 `setup_changed`) if another went live since |
| `pull-config --repo owner/name [--dir D] [--force]` | writes the live setup (`opslane.yml` and `.opslane/`) into `D` (default: the repo root) to change it; refuses to overwrite files unless `--force` |
| `feedback --repo owner/name --file F` | tells Opslane how onboarding went: a JSON report from `F` (or stdin) with `outcome`, `rating` and what was unclear, guessed or slow |
| `credits` | credits left (one PR review uses one), auto top-up, a redeemed YC deal, the packs on offer |
| `subscribe` | a checkout page (`payment_url`) for the monthly plan, 150 credits a month (unused ones carry over one month): give it to the user to pay. Owners only, and only when the user asked |
| `buy-credits --credits 25\|100\|500` | on the plan: a checkout page (`payment_url`) for an extra pack. Owners only, and only when the user asked |
| `auto-top-up --on --threshold N --credits 25\|100\|500` / `--off` | buy a pack whenever credits drop below N (at most 10 a month); give the user `setup_url` to save a card |
| `redeem-code YC-XXXX-XXXX-XXXX` | redeem a YC deal code from the Opslane email |
| `billing-portal` | a link to invoices and the saved card |
| `status --repo owner/name [--wait]` or `status --run <id>` | the latest check: `run.status`, `steps` (with messages for failures and notes), `config_verified`, `failure`, `next`; `--wait` waits until the run ends |
