Running Versely in CI without a browser login
Browser-approval login cannot run on a build runner. Set up a scoped key, inject it as a secret, and keep the job fully non-interactive.
versely auth login cannot run on a GitHub-hosted runner. It opens a browser, completes an authorization-code flow with PKCE over a localhost redirect, and waits for a human to click Allow. There is no display on the runner, no loopback the user can reach, and no one sitting there to approve the consent screen. A job that calls it will hang until the platform kills the step.
Headless Versely is a scoped API key in the environment and HTTP. Nothing interactive is stored on the box.
Why the browser flow dies on a runner
The CLI's login is built for a laptop. It mints a long-lived vsk_ key and writes it to ~/.versely/config.json with user-only permissions. That is the right shape for a developer machine. It is the wrong shape for CI, for three independent reasons:
- The flow needs a browser. GitHub Actions, GitLab runners, and Buildkite agents do not have one. Even
versely auth login --keyonly helps after a key exists. You cannot mint the first key from the runner. - Do not mint keys in CI.
POST /api/v1/auth/api-keysauthenticates as a user: a session JWT, an OAuth access token, or an existingvsk_key./auth/api-keysis not owned by a catalog scope, so a stolen CI key can mint a child. Rotate on a machine where a person can sign in, and never print a raw key in a build log. - The runner's filesystem is not a secret store. Writing
~/.versely/config.jsonon a ephemeral VM means the key sat on a disk you do not control, in a workspace that may be cached, logged, or copied into an artifact. Environment secrets exist so you never do that.
OAuth against the MCP server has the same problem. The connector sign-in is a consent screen. Access tokens expire. Refresh still assumes a client that can complete the dance. None of that belongs in a build. MCP is a conversation surface. CI is a function call.
The CLI remains useful on the laptop that prepares CI: it is how you mint the key without pasting a secret into a random shell. It is not how the runner authenticates.
Mint a scoped key on a machine that has a browser
Do this once, locally, while signed in.
POST /api/v1/auth/api-keys
Authorization: Bearer <session JWT>
Content-Type: application/json
{
"name": "ci-generate",
"scopes": ["generate", "read"],
"rate_limit_rpm": 8,
"expires_at": "2026-12-01T00:00:00Z"
}
The response includes the raw key once. It is vsk_ plus 40 hex characters (160 bits). The server stores a SHA-256 hash and a key_prefix for identification. If you close the tab without saving the raw value, that key is gone. Revoke it and mint another.
What each field is doing:
name. An audit label.ci-generateis better thanprod. You will grep this later.scopes. Required, non-empty. Empty arrays are rejected.generatecovers/generate/*,/ltx-batch/*,/suno/*,/movie/*, and the rest of the generation prefixes.readcovers/user, which is how the job checks a balance viaGET /api/v1/user/me. Do not putposton a CI key unless the job is actually publishing. Do not use the"all"sentinel for CI; it expands to the full list at creation time and silently widens the blast radius.rate_limit_rpm. Default 60, clamped to 1–1000. A looping test that forgot to stub/generateshould stall after a handful of calls, not drain the account. Eight is a reasonable CI fuse. This is a request fuse, not a spend cap. Every accepted generate still costs credits.expires_at. Optional, ISO 8601. Put one on CI keys. A forgotten GitHub secret that still works in 2028 is a defect. Expired keys return401withAPI key has expired.
Live catalog: GET /api/v1/auth/api-keys/scopes. Matching is longest-prefix. Soft-revoke is DELETE /api/v1/auth/api-keys/:keyId, which sets revoked_at. There is no PATCH. Changing RPM or scopes is a rotation: mint a new key, swap the secret, revoke the old row.
If you prefer the CLI for the local half:
npm install -g @versely/cli
versely auth login
versely auth whoami
That mints and stores a laptop key. It is not the CI key. Create the CI key as its own row so you can revoke it without logging yourself out of Cursor.
Store it as a secret, never as a file
In GitHub Actions the key is a repository (or environment) secret named VERSELY_API_KEY. The job reads it into the environment. It never writes ~/.versely/config.json. It never echoes the value. It never passes it as a command-line argument, because process lists leak.
name: generate-preview
on:
workflow_dispatch:
jobs:
generate:
runs-on: ubuntu-latest
env:
VERSELY_API_KEY: ${{ secrets.VERSELY_API_KEY }}
VERSELY_API_URL: https://api.versely.studio
steps:
- name: Check balance
run: |
curl -sS "$VERSELY_API_URL/api/v1/user/me" \
-H "Authorization: Bearer $VERSELY_API_KEY" \
| jq '.user.credits'
- name: Submit image job
run: |
curl -sS -X POST "$VERSELY_API_URL/api/v1/generate/image" \
-H "Authorization: Bearer $VERSELY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"Imagen 4","prompt":"CI smoke: ceramic mug, window light","aspect_ratio":"1:1"}'
Do not run versely auth login in that file. Do not run versely install. There is no agent on the runner to wire, and the login will block. If a step needs the CLI (for versely status or whoami), inject the key with versely auth login --key "$VERSELY_API_KEY" and treat the home directory as disposable. Prefer plain curl. The first terminal generation is the same HTTP, written for a laptop.
Never send user_id in a body. The key resolves the account server-side. A body id is ignored at best and a 403 at worst.
There is no sandbox, no test mode, and no free API allowance. The cheapest model in the catalog still costs credits. A CI job that fires on every push will spend on every push. Gate live generates behind workflow_dispatch, a label, or a nightly schedule. Pull-request checks that only need to prove your client parses a 401 should not call /generate.
The job itself is HTTP
Generation is asynchronous on every surface. Submit, keep the request id, poll GET /api/v1/status/:requestId until completed or failed. Do not wait for a webhook. Versely does not take a webhook_url from you. Batch rendering from a shell script is the polling loop you want, just running as a CI step instead of a local script.
Handle the codes the runner will actually see:
| Status | Meaning | What the job should do |
|---|---|---|
401 |
Invalid, revoked, or expired key | Fail the build. Do not retry. Rotate locally. |
403 |
Missing scope, or balance already at zero on entry | Fail. Add the scope, or top up. |
402 |
Credit deduction failed | Fail. There is nothing to retry without credits. |
429 |
Rate limit | Sleep retryAfter from the body. Do not re-POST /generate as a wait; there is no idempotency key, so a retry that lands is a second charge. |
200 with a request id |
Accepted | Persist the id, then poll status. |
GET /api/v1/user/me before the first generate is cheap insurance. The credit check is the same idea in the agent. In CI it is a jq of .user.credits and a comparison against the number of jobs you are about to fire.
The developer page is the entry point for the three connection paths. The API pricing page states the billing shape: no API-only plan, no per-seat fee, no separate API wallet, no free API tier. Same credit balance as the app, topped up by the same subscriptions and credit packs. Plan catalog is where those live.
When the job is done, the runner goes away. That is the point of not writing a config file. The secret stays in GitHub. The key's last_used_at updates. If a build is compromised, revoke that one row. Your laptop key, and any production poster key, keep working.
FAQ
Can I run versely auth login with a fake display in CI?
Do not. Even if you get a browser up with Xvfb, you still need a human to complete consent, and you still write a key onto the runner's disk. VERSELY_API_KEY in the environment is the supported headless path. versely auth login --key is the same idea if you specifically need the CLI binary.
Can the pipeline mint its own keys?
Do not. Key CRUD sits behind authenticateUser, which accepts a session JWT or a vsk_ key, and /auth/api-keys is not in the scope catalog. A stolen CI key can mint a child. Sign in locally, POST /api/v1/auth/api-keys, put the new value in the secret store, wait for in-flight jobs to finish, then DELETE the old id.
What scopes should a smoke-test job have?
generate and read, and nothing else, for a job that only submits images or video and checks a balance. Add slideshow or ugc only if the test hits those prefixes. Leave post and manage_accounts off so a broken test cannot publish. Longest-prefix matching means a generate key will still 403 on /social/posts.
What if the secret leaks in a log?
Revoke immediately (DELETE /api/v1/auth/api-keys/:keyId). Mint a replacement with a short expires_at. Treat any job that printed the raw vsk_ value as compromised, including workflow dumps and set -x traces. Prefixes (vsk_ plus the first eight hex chars) are identifiers, not credentials; the raw 40 hex is the secret.