@oxy-hq/cli 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,10 +3,12 @@
3
3
  `oxyc` — a `gh api`-shaped client for the Oxy HTTP API, plus the tooling that
4
4
  manages customer workspace repos.
5
5
 
6
- - **API client** — `api`, `routes`, `schema`, `openapi`, `login`, `whoami`, `assume`, `oltp`
6
+ - **API client** — `api`, `routes`, `schema`, `openapi`, `login`, `whoami`, `token`, `tokens`, `assume`, `oltp`
7
7
  - **Customer workspaces** — `list`, `new`, `import`, `doctor`, `update`, `adopt`, `launch`
8
- - **Custom apps** — `publish`, `init-ci`, `proxy`
8
+ - **Custom apps** — `publish`, `init-ci`, `proxy`, `apps`
9
9
  - **Checks** — `checks run`
10
+ - **Sandboxes** — `env`, `fn call`, `invocations`, `logs`
11
+ - **Workspace previews** — `preview`
10
12
  - **Development** — `validate`, `mcp`, `guide`, `skills`
11
13
 
12
14
  ## Install
@@ -48,8 +50,9 @@ and `cache`.
48
50
  | --- | --- | --- |
49
51
  | `--env <name\|url>` | `production` | deployment to target |
50
52
  | `--target <url>` | — | explicit base URL; overrides `--env` |
51
- | `--token-env <VAR>` | `OXY_TOKEN` | env var holding the bearer token |
52
- | `--api-key-env <VAR>` | `OXY_API_KEY` | env var holding the key for `/external/api` |
53
+ | `--token-env <VAR>` | `OXY_TOKEN` | env var holding the bearer: any credential. **Named, it is the only source** — an unset variable is exit `4`, never a fallback to your login |
54
+ | `--api-key-env <VAR>` | `OXY_API_KEY` | env var holding the legacy API key or API token for `/external/api` |
55
+ | `--service-account <id>` | `OXY_SERVICE_ACCOUNT` | in GitHub Actions: the **ID** of the service account the OIDC exchange acts as |
53
56
  | `--org <slug>` | — | value for the `{org}` placeholder |
54
57
  | `--workspace <id>` | — | value for the `{workspace}` placeholder |
55
58
  | `--project <id>` | — | value for the `{project}` placeholder |
@@ -77,7 +80,10 @@ Credentials are stored **per deployment**, and the default is production. A
77
80
  oxyc api <path> [flags]
78
81
  ```
79
82
 
80
- `<path>` is relative to `/api`; a leading `/` or `api/` is accepted.
83
+ `<path>` is relative to `/api`; a leading `/` or `api/` is accepted. One
84
+ exception: `customer-apps/…` is sent as written, because `/customer-apps/<org>/<app>/…`
85
+ is where an app's bundle is served. The app registry API is `api/customer-apps/…`
86
+ — spell the `api/` out for it (a 404 on the bare form says so).
81
87
 
82
88
  | Flag | Meaning |
83
89
  | --- | --- |
@@ -107,13 +113,18 @@ to `--env`, or from `--org` / `--workspace` / `--project`. An unresolvable
107
113
  placeholder is an error naming the flag that fills it — never a literal sent to
108
114
  the server.
109
115
 
110
- **Surfaces.** The path selects the credential: `/api/**` sends the bearer from
111
- `oxyc login`; `/external/api/**` sends `X-API-Key` from `$OXY_API_KEY`.
116
+ **Surfaces.** The path selects how the credential is sent. `/api/**` sends it
117
+ as a bearer — whichever one [resolves](#authentication). `/external/api/**`
118
+ sends `X-API-Key` from `$OXY_API_KEY`, which holds a legacy API key (`oxy_…`) or
119
+ an API token (`oxy_pat_…`, `oxy_sat_…`, `oxy_ci_…`). With none set, an
120
+ `OXY_TOKEN` holding either of those stands in for it, so one `OXY_TOKEN` covers
121
+ both surfaces. A session token never does.
112
122
 
113
123
  **`--paginate` is a heuristic.** Oxy sends `Link: rel="next"` on some endpoints
114
- and nothing on others; where there is no header, `oxyc` reads
115
- `pagination.has_next`, then `has_more`, then `page < total_pages`. Nothing
116
- recognised means one page, and it warns when the server said nothing at all.
124
+ and nothing on others; where there is no header, `oxyc` reads `next_offset`
125
+ (and asks again with `?offset=`), then `pagination.has_next`, then `has_more`,
126
+ then `page < total_pages` (asking again with `?page=`). Nothing recognised
127
+ means one page, and it warns when the server said nothing at all.
117
128
 
118
129
  ```bash
119
130
  oxyc api {org}/workspaces --md
@@ -139,13 +150,130 @@ route's surface and fleet role.
139
150
 
140
151
  ```bash
141
152
  oxyc login [--env <e>] [--login-env <e...>] [--assume <org> -r <why>]
142
- oxyc whoami [--json]
153
+ oxyc whoami [--json] # who the credential is, and what it can reach
143
154
  oxyc token # print the bearer, for a raw curl
144
- oxyc logout
155
+ oxyc logout # revoke the cached token, then forget it
156
+ oxyc tokens list [--json] # your personal access tokens
157
+ oxyc tokens revoke <id>
158
+ oxyc tokens revoke --current # end the token in OXY_TOKEN
159
+ oxyc tokens create # opens Account → Personal access tokens
160
+ oxyc tokens create --sandbox-agent --app <org>/<app> [--app …] [--hours 8] [--name …]
145
161
  ```
146
162
 
147
- `--login-env` is repeatable and comma-separated (`--login-env dev,staging`);
148
- the browser opens once per environment, in sequence.
163
+ **Every command resolves its credential the same way, first match wins:**
164
+
165
+ 1. **`OXY_TOKEN`** (or the variable `--token-env` names). It holds any
166
+ credential, and all are sent as a bearer: an API token — a personal access
167
+ token (`oxy_pat_…`), a service account token (`oxy_sat_…`), a CI token
168
+ (`oxy_ci_…`) or a sandbox agent token (`oxy_sbx_…`) — a legacy API key
169
+ (`oxy_…`), a publish token, or a session token. A blank value counts as
170
+ unset.
171
+ 2. **The login cache** — what `oxyc login` stored for this deployment.
172
+ 3. **GitHub OIDC**, in a GitHub Actions job granted `id-token: write`: the job's
173
+ OIDC token is exchanged, once per process, for a fifteen-minute token acting
174
+ as a service account, and that token is revoked when the command ends. See
175
+ [CI without a stored secret](#ci-without-a-stored-secret).
176
+
177
+ **Naming the variable stops the list at 1.** With `--token-env <VAR>`, and
178
+ always in `oxyc mcp`, an unset or empty variable is an auth error (exit `4`):
179
+ the login cache and the OIDC exchange are not tried. The flag says which
180
+ credential runs the command — an agent's scoped token, typically — so a typo in
181
+ the name must not quietly run it as whoever last ran `oxyc login` on the
182
+ machine. A person who wants `oxyc mcp` on their own login starts it with
183
+ `--login`. `OXY_API_KEY` is unaffected: a command that can use one on its own
184
+ (`checks run`, the sandbox verbs) still does.
185
+
186
+ `OXY_API_KEY` holds a legacy API key or an API token, and is honoured where it
187
+ always was — the `/external/api` surface, and `checks run`.
188
+
189
+ **Legacy API keys and API tokens are separate things.** A legacy API key is the
190
+ older `oxy_…` key: it reaches everything its owner can and can't be limited to
191
+ workspaces. It keeps working wherever it works today, but it is not a token —
192
+ `oxyc tokens` never lists or revokes one, and `oxyc whoami` calls it a
193
+ `Legacy API key`. They are extended and revoked in the web app, under
194
+ Settings → Workspace → Legacy API keys. Use an API token for anything new.
195
+
196
+ **`login`** runs a browser loopback flow. Against a current deployment it
197
+ receives a one-time code and exchanges it (PKCE) for a **personal access
198
+ token** named `oxyc on <hostname>`: revocable, ninety days, and replacing that
199
+ machine's previous one. No credential travels in a URL. Against a deployment
200
+ that predates the exchange it stores the session token the page hands back,
201
+ exactly as before, and says so — nothing needs upgrading first, in either
202
+ direction. `--login-env` is repeatable and comma-separated
203
+ (`--login-env dev,staging`); the browser opens once per environment, in
204
+ sequence.
205
+
206
+ **`logout`** revokes the cached token on the server, best-effort, then removes
207
+ it from this machine whatever the server said. It never touches `OXY_TOKEN`.
208
+
209
+ **`whoami`** makes a live call, so an expired or revoked credential fails here
210
+ rather than reading as fine from the cache. For a token it also prints what the
211
+ token is and what it can reach — `all access`, or one line per grant with the
212
+ role it is capped at, and any organization whose policy blocks it. For a legacy
213
+ API key it prints `Legacy API key`, and that it reaches everything its owner
214
+ can.
215
+
216
+ **`token`** prints the bearer on stdout and nothing else. Inside a GitHub
217
+ Actions job with nothing stored it prints the token the job's OIDC identity
218
+ exchanges for — like `gh auth token` — and that one is **not** revoked on exit,
219
+ since the output is the token. It still expires in fifteen minutes.
220
+
221
+ **`tokens`** manages personal access tokens, and the server allows that from a
222
+ **browser session only**: a token that could list, mint or revoke tokens could
223
+ outlive its own revocation. Since `login` now stores a token, `tokens list` and
224
+ `tokens revoke` usually answer with a pointer to the web app (Account →
225
+ Personal access tokens, `<deployment>/?settings=account.tokens`); they work
226
+ from a login that stored a session token. `tokens create` always opens that
227
+ page — a new token's secret is shown once, and a terminal is the wrong place.
228
+
229
+ **`tokens create --sandbox-agent`** is the exception, and the one command an
230
+ agent runs to get its own credential. It mints a **sandbox agent token**
231
+ (`oxy_sbx_…`): the [sandbox loop](#sandboxes) on one to five named apps, in
232
+ sandboxes the token created, for 8 hours by default (`--hours`, 1 to 168) —
233
+ and nothing else on the deployment.
234
+
235
+ ```bash
236
+ eval "$(oxyc tokens create --sandbox-agent --app acme/store --env dev)"
237
+ ```
238
+
239
+ It opens the same browser loopback as `login`, with the apps and the lifetime
240
+ on the page; the agent's operator approves once, under their own session.
241
+ Stdout is the single line `export OXY_TOKEN=oxy_sbx_…`, printed once, and
242
+ everything else is on stderr. It holds no credential while it runs, never
243
+ writes the credentials file, and revokes no other token. `--app` is
244
+ `<org>/<app>` and repeats; arguments are checked before the browser opens
245
+ (exit `2`). Only staff who can open the app's sandboxes can approve.
246
+
247
+ What comes back is checked before anything is printed: it must be an
248
+ `oxy_sbx_`, described as `sandbox_agent` and not all-access, for exactly the
249
+ apps named, expiring no later than the hours asked for. Anything the check
250
+ cannot read — an app entry with no slugs, an expiry that is missing or not a
251
+ time — is refused rather than skipped. A deployment that predates sandbox
252
+ agent tokens would otherwise hand back an ordinary login token with the
253
+ approver's whole reach. On any mismatch the command prints nothing, revokes
254
+ what was minted, and exits `8`.
255
+
256
+ With that token in `OXY_TOKEN`, `oxyc` behaves differently in five ways:
257
+
258
+ - **An app resolves against the token's own list** (`GET /api/auth/token`),
259
+ never `/api/admin/apps`. An app it was not minted for is exit `5`.
260
+ - **Read-back uses `/api/customer-apps/{id}/…`** — `invocations list`,
261
+ `invocations held` and `checks run` — since the token never reaches `/admin`.
262
+ - **Refused before any request, exit `2`:** an `--app-env` that is not
263
+ `dev-<handle>`; `publish` without `--app-env`, or with `--promote`;
264
+ `fn call`, `logs`, `invocations list` and `checks run` without `--app-env`;
265
+ `env show` of production or staging; `oxyc apps`, `tokens list` and
266
+ `tokens revoke <id>`; and `oxyc api` against any path.
267
+ - **`whoami`** prints the token's description — `kind`, `expires_at`, the
268
+ `apps` it reaches and its `minter` — and `--json` is that document as the
269
+ server sent it.
270
+ - **`tokens revoke --current`** ends it (`DELETE /api/auth/token`). Exit `0`
271
+ when it is revoked or was already dead. It works for any API token in the
272
+ variable; a cached login is ended with `logout` instead.
273
+
274
+ A refusal from the server carries a hint an agent can act on — never "try
275
+ `oxyc login` again". Exit `4` under this token means it expired, was revoked,
276
+ or its minter lost access: stop and report.
149
277
 
150
278
  Credentials live in the OS config directory under **`oxy`** — the same file the
151
279
  Rust `oxy login` wrote before it was removed, so an existing login still works:
@@ -153,8 +281,95 @@ Rust `oxy login` wrote before it was removed, so an existing login still works:
153
281
  - macOS: `~/Library/Application Support/oxy/credentials.json`
154
282
  - Linux: `$XDG_CONFIG_HOME/oxy/credentials.json`
155
283
 
156
- `OXY_CREDENTIALS_PATH` overrides the path. Caches are separate, under `oxyc`
157
- (`~/.cache/oxyc`). In CI set `OXY_TOKEN` instead of logging in.
284
+ An entry may now also carry `token_id` and `expires_at`; both are optional, and
285
+ a file written before they existed loads unchanged. `OXY_CREDENTIALS_PATH`
286
+ overrides the path. Caches are separate, under `oxyc` (`~/.cache/oxyc`).
287
+
288
+ ### CI without a stored secret
289
+
290
+ In a GitHub Actions job granted `id-token: write`, with no `OXY_TOKEN` set,
291
+ **any** `oxyc` command signs itself in — **as the service account the workflow
292
+ names, by its ID,** in `OXY_SERVICE_ACCOUNT` (or `--service-account`). It asks
293
+ GitHub for an OIDC token whose audience is `oxy:<the deployment's host>`, posts
294
+ it with the account's ID to `/api/auth/oidc/exchange`, and uses the
295
+ fifteen-minute `oxy_ci_…` token that comes back, revoking it on exit.
296
+
297
+ The audience is **worked out from the URL `oxyc` is pointed at** — the host of
298
+ the resolved `--env` / `--target`, lowercased, with a port only when it is not
299
+ the scheme's default: `oxy:app.oxygen-hq.com`, `oxy:aip.staging.oxy.tech`. It is
300
+ never asked of the deployment, and nothing a server says can change it. So the
301
+ token a job gives to a host is good at the deployment that calls itself by that
302
+ host and at no other: one handed to staging cannot be replayed at production,
303
+ and a server cannot talk the job into minting a token for somewhere else.
304
+ There is no shared fallback, and plain `oxy` is never requested.
305
+
306
+ A deployment that answers to a different address than the one `oxyc` was
307
+ pointed at — reached through an alias or a port-forward — refuses with
308
+ `wrong_audience` and names the address it does answer to: point `--target` at
309
+ that. One with no public URL configured (`OXY_API_URL`) takes no GitHub
310
+ sign-in at all; use `OXY_TOKEN` there.
311
+
312
+ What makes the deployment honour the job is a
313
+ **trust policy** on that service account, naming the repository, the workflow
314
+ file and the job's `environment:` — registered by `oxyc init-ci`, or in the web
315
+ app under Organization settings → API access → Service accounts → *the
316
+ account* → Trusted access.
317
+
318
+ **The account is always named, never guessed.** Anyone can register a trust
319
+ policy that names a repository they do not own, so the deployment matches a
320
+ run against the policies of the one account its workflow asked for and no
321
+ other. With no account named, `oxyc` attempts no exchange at all: `publish` and
322
+ `checks run` go straight to the app's registered publisher (below), and every
323
+ other command fails with "not authenticated", saying `OXY_SERVICE_ACCOUNT` is
324
+ what is missing.
325
+
326
+ **By ID, never by `<org>/<name>`.** An org's slug can be changed, and once the
327
+ org renames or is deleted the slug is free for anyone — who could create the
328
+ same account name under it and register a policy on your repository's public
329
+ ids. A workflow that said `acme/deployer` would then be naming *their*
330
+ account. An ID never changes hands, so it is the only thing the deployment
331
+ takes: anything else answers `400 service_account_required`, and `oxyc` does
332
+ not fall back from that. The ID is shown in the web app (Organization settings
333
+ → API access → Service accounts → *the account*), and `oxyc init-ci` writes it
334
+ for you. `oxyc` passes the value through untouched; the answer still says
335
+ `<org>/<name>`, so the log reads as it did.
336
+
337
+ ```yaml
338
+ permissions: { id-token: write, contents: read }
339
+ jobs:
340
+ deploy:
341
+ environment: production # the trust policy requires it
342
+ runs-on: ubuntu-latest
343
+ steps:
344
+ - uses: actions/checkout@v4
345
+ - uses: actions/setup-node@v4
346
+ with: { node-version: 20 }
347
+ # or any oxyc command the grants allow
348
+ - run: npx --yes @oxy-hq/cli publish --promote
349
+ env:
350
+ OXY_SERVICE_ACCOUNT: 3f2504e0-4f89-41d3-9a0c-0305e82c3301 # acme/deployer
351
+ ```
352
+
353
+ Each command exchanges for itself, which is all a job running one command
354
+ needs. Several policies of the named account matching one run is not an error:
355
+ the oldest is used. The exchange is rate-limited per client address (60 a
356
+ minute); a `429` is waited out once, for its `Retry-After` (at most 60 s), and
357
+ retried before it fails.
358
+
359
+ The `setup-oxyc` action (`sdk/setup-oxyc`) does the exchange once for a whole
360
+ job: it installs `oxyc`, exports `OXY_TOKEN` for the later steps and revokes
361
+ the token in a post step. **It is not published yet** — `uses:
362
+ oxy-hq/setup-oxyc@v1` does not resolve, and a job naming it fails at "Set up
363
+ job" — so nothing here depends on it.
364
+
365
+ A refused exchange says what to fix: `no_matching_policy` prints the
366
+ repository, workflow, ref and event the run presented and where to register a
367
+ policy on the named account; `missing_environment`, `self_hosted_runner` and
368
+ `pull_request_target` name the rule that was not met. A deployment with no
369
+ exchange route (404) reads as "not authenticated", with that reason — except
370
+ in `publish` and `checks run`, which fall back to the app's registered
371
+ publisher, as they also do on `no_matching_policy` and when no account is
372
+ named.
158
373
 
159
374
  ## Acting as an organization
160
375
 
@@ -181,6 +396,8 @@ Staff can see and request an org's transactional Postgres — the store behind
181
396
  ```bash
182
397
  oxyc oltp status [--org <slug|uuid|url>] [--json]
183
398
  oxyc oltp provision --org <slug|uuid|url> --writer app:<slug> [--writer pipeline:<source> …] [--yes] [--json]
399
+ oxyc oltp provision --org <slug|uuid|url> --branch staging [--yes] [--json]
400
+ oxyc oltp reset --org <slug|uuid|url> --branch staging [--yes] [--json]
184
401
  ```
185
402
 
186
403
  `status` lists every org, with a database or without; with `--org` it shows
@@ -194,6 +411,15 @@ derived the way the platform derives `ctx.oltp`'s schema (`app:store-ops` →
194
411
  `raw_<source>`. A 404 can be an org outside your staff grant's scope, and an
195
412
  active `assume` session closes `/admin`.
196
413
 
414
+ `--branch staging` also cuts the org's **staging branch**: one copy of the whole
415
+ database per org, which every app's staging environment writes to. It is made
416
+ only by this flag — never by a publish — and re-running it mints whatever a
417
+ writer added since the cut needs, without resetting anyone's data. `status`
418
+ shows its age and calls it stale past 30 days; nothing resets it on a timer.
419
+ `reset --branch staging` re-copies it from production, discarding every app's
420
+ staging data in the org, so it lists those apps and asks first (`--yes`
421
+ without a terminal).
422
+
197
423
  ## Customer workspaces
198
424
 
199
425
  A customer **is** a GitHub repo carrying the `oxy-customer` topic. Tagging the
@@ -225,8 +451,8 @@ the current directory while granting access to the customer's repo.
225
451
  ## Custom apps
226
452
 
227
453
  ```bash
228
- oxyc publish [--env <e>] [--dir <path>] [--promote] [--build-only | --prebuilt] [--json] [--allow-function-lint]
229
- oxyc init-ci [--app <org>/<app>] [--environment <name>] [--force]
454
+ oxyc publish [--env <e>] [--dir <path>] [--promote] [--build-only | --prebuilt] [--json] [--allow-function-lint] [--app-env <dev-handle>]
455
+ oxyc init-ci [--app <org>/<app>] [--environment <name>] [--promote] [--force] [--no-register] [--setup-action]
230
456
  oxyc proxy [--port <n>] [--allow-writes] [--allow-events] [--yes]
231
457
  ```
232
458
 
@@ -245,11 +471,16 @@ channel unless `--promote`. `--env` defaults to **production**; name it.
245
471
  **`--prebuilt`** (with `--dir`) skips esbuild and refuses if a declared
246
472
  function's `functions/<name>.js` is missing. The pair splits CI so the job that
247
473
  runs package scripts never holds the credential.
248
- - **Auth**: the `--token-env` variable (`OXY_TOKEN`), then the login cache — or,
249
- in a GitHub Actions job with `id-token: write` and neither set, **trusted
250
- publishing**: the job's OIDC token is exchanged for a credential scoped to this
251
- one app. That needs the org **slug** and a publisher registered for the
252
- workflow (see `init-ci`).
474
+ - **Auth**: `OXY_TOKEN`, then the login cache — or, in a GitHub Actions job
475
+ with `id-token: write` and neither set, the job's OIDC token. (A variable
476
+ named with `--token-env` is the only source: unset, the publish stops.) Two exchanges are tried, in order, and the exchange happens last, after
477
+ the build. First the general one (`/api/auth/oidc/exchange`, audience `oxy:<the deployment's host>`):
478
+ a **trust policy** on a service account (see `init-ci`). Then, only when the
479
+ deployment has no such route (404) or answers `no_matching_policy`, the app's
480
+ own **registered publisher** (`/api/customer-apps/publish/oidc-exchange`,
481
+ audience `oxy-publish`) — the registration every workflow written before trust
482
+ policies relies on; it needs the org **slug**. Any other refusal is final: it
483
+ names something wrong with the run, which the older path would only hide.
253
484
  - **Provenance**: the checkout's `origin`, `HEAD` and branch (else `GITHUB_SHA` /
254
485
  `GITHUB_REF_NAME`) are recorded; a missing half or a dirty app directory is a
255
486
  warning, never a failure. The build id defaults to
@@ -270,12 +501,147 @@ channel unless `--promote`. `--env` defaults to **production**; name it.
270
501
  skipped. **`--allow-function-lint`** is the way past a false positive: every
271
502
  finding becomes a warning that names its rule — please open an issue with it.
272
503
 
273
- **`init-ci`** writes `.github/workflows/oxy-publish.yml` at the repo root: a
274
- `build` job (no id-token) that runs `publish --build-only`, and an
275
- environment-gated `publish` job whose only work is `publish --prebuilt` with
276
- OIDC. `--promote` makes that publish go live and adds a `checks run` step after
277
- it, when the manifest declares a check. It prints the `oxyc api …/publishers`
278
- call that registers the workflow.
504
+ **`init-ci`** does two things: writes the workflow, and registers the trust that
505
+ lets it publish.
506
+
507
+ *The workflow* is `.github/workflows/oxy-publish.yml` at the repo root: a
508
+ `build` job (no id-token) that runs `publish --build-only`, and a `publish` job
509
+ bound to a GitHub `environment:` (`--environment`, default `oxy-publish`) whose
510
+ only work is `publish --prebuilt`. That job uses no third-party action: each
511
+ step runs `npx @oxy-hq/cli@<this version>` and `oxyc` exchanges the job's OIDC
512
+ token for itself, acting as the service account `--service-account <org>/<name>`
513
+ names — by default `<app's org>/deployer`. **Here, and only here, the account is
514
+ given by name:** `init-ci` is run by a person, looks the account's ID up as that
515
+ person, and writes the ID into the workflow with the name beside it as a
516
+ comment (`OXY_SERVICE_ACCOUNT: 3f25…3301 # acme/deployer`). When it cannot
517
+ look the ID up — nobody logged in, no such account yet — it writes a
518
+ `<service-account-id>` placeholder and says so; it never writes the name as
519
+ the value. `--promote` makes the publish go live. **No `checks run` step is
520
+ written**, even when the manifest declares a check: the job's service-account
521
+ token is answered `403` on the check routes, which sit behind platform gates a
522
+ service account never passes, so the step could not succeed. The workflow
523
+ carries a one-line comment where it would be, and `init-ci` says so when it
524
+ writes one. Run the checks with a staff credential until `ci` tokens may.
525
+ `--setup-action` writes the same workflow around `uses: oxy-hq/setup-oxyc@v1`
526
+ instead, which exchanges once for the whole job. That action is not published
527
+ yet, so a workflow written with the flag cannot start until it is; `init-ci`
528
+ says so when it writes one.
529
+
530
+ *The registration* is a trust policy on that service account — this repository,
531
+ the workflow file, the environment — granted publishing this one app and
532
+ nothing else. `init-ci` creates the `deployer` account if none was named and it
533
+ does not exist, then the policy, and is idempotent: a second run finds both. A
534
+ named account is never created, and must be one of the app's own org — an
535
+ account's grants never reach another org, so `init-ci` refuses one. A service
536
+ account's name is lowercase words joined by single hyphens, starting with a
537
+ letter, 2–40 characters.
538
+
539
+ It can only register **when the caller can**: an org admin, logged in with a
540
+ browser-session credential — the server refuses those two creations to a token,
541
+ which is what a fresh `oxyc login` stores. When it cannot (not an admin, a token
542
+ login, offline, a deployment without trusted access, `--no-register`), the
543
+ workflow is still written and the command prints the exact steps instead: where
544
+ in the web app, the `oxyc api` calls with every id it managed to learn filled
545
+ in, and the older `…/publishers` registration for a deployment without trust
546
+ policies. Registration never fails the command.
547
+
548
+ **`--app-env <dev-handle>`** moves one sandbox's build pointer instead of the
549
+ draft/live channel — see [Sandboxes](#sandboxes). It needs a staff credential
550
+ (never a publish token or OIDC) and is refused together with `--promote`: a
551
+ sandbox build is never promoted, so the loop is publish to the sandbox,
552
+ iterate, then publish the same tree to staging and promote that.
553
+
554
+ ## Apps
555
+
556
+ Read-only: every request these make is a GET, against `/api/customer-apps/**`
557
+ (the app-admin role). `<app>` is `<org-slug>/<app-slug>` or an app UUID.
558
+
559
+ ```bash
560
+ oxyc apps list [--org <slug>] [--published | --draft] [--builds] [--json]
561
+ oxyc apps show <app> [--json]
562
+ oxyc apps builds <app> [--json]
563
+ oxyc apps health [<app>] [--needs-attention] [--json]
564
+ oxyc apps usage <app> [--json]
565
+ oxyc apps drift [<app>] [--org <slug>] [--dir <path>] [--refresh] [--json]
566
+ ```
567
+
568
+ - **`list`** — every app you can see, across organizations, walking the paged
569
+ registry to its end. `STATE` is `live` when the app has a published build and
570
+ `draft` when it has none. `SOURCE` is the repository the app is *registered*
571
+ against. `--builds` adds the live build's id, the repository and commit it was
572
+ built from, who published it and when — one extra request per app, six at a
573
+ time.
574
+ - **`show`** — one app on one screen: the registry row, the live build, the
575
+ draft build when it is newer than the live one, deployment integrity,
576
+ availability, and the 7-day usage summary. `last promote` is when the promote
577
+ or rollback action was last used; `oxyc publish --promote` does not update it,
578
+ so it can be older than the live build.
579
+ - **`builds`** — the build history, newest first. `CHANNEL` marks the build each
580
+ channel points at: `live`, `draft`, or both for one build.
581
+ - **`health`** — with no argument, the fleet table (published apps only):
582
+ `down`, `degraded`, `not_measured`, `quiet` or `operational` per app, with the
583
+ totals on stderr. `--needs-attention` keeps the first three. With `<app>`: the
584
+ deployment-integrity checks, the availability windows, and the browser errors
585
+ of the last 24 hours.
586
+ - **`usage`** — the 7-day summary, the visitors, and the tracked events by name.
587
+ - **`drift`** — see below.
588
+
589
+ Times are UTC. `--json` prints one document on stdout; a table goes to stdout and
590
+ the count line to stderr.
591
+
592
+ **An empty result and a failed request are different answers.** No apps, no
593
+ builds, no errors recorded: the command says so and exits `0`. A request that
594
+ failed exits with the code its status maps to (`4`, `5`, `7`, …). Where a
595
+ command makes several independent requests (`show`, `health <app>`,
596
+ `list --builds`, `drift`), it prints what it did read, marks what it did not as
597
+ `NOT READ` (`"failed": […]` / `live_build_error` in `--json`), and then exits
598
+ non-zero. These commands report state: a failing health check, a `down` app or
599
+ a drifted app is an answer and exits `0` — read it from the output.
600
+
601
+ The health route answers **503 with its report** when a check fails; `oxyc`
602
+ prints that as a failing report, not as an outage.
603
+
604
+ ### `apps drift`
605
+
606
+ Compares what is live against source on **this machine**. For each app it takes
607
+ the live build's recorded repository and commit, finds a local checkout of that
608
+ repository (the ones `oxyc repos` and `oxyc path` report, or `--dir`), finds the
609
+ app's directory by the `oxy-app.json` whose `slug` and `orgSlug` match — at
610
+ `<app>/oxy-app.json` or `<app>/public/oxy-app.json`, never by folder name — and
611
+ counts the commits on the checkout's **current branch** that touch that
612
+ directory after the published commit.
613
+
614
+ | Result | Meaning |
615
+ | --- | --- |
616
+ | `in sync` | no commit on the current branch touches the app directory after the published commit |
617
+ | `N commits ahead` | that many do; they are listed |
618
+ | `unknown — <reason>` | the comparison could not be made |
619
+
620
+ `unknown` is never reported as `in sync`. Its reasons (`reason` in `--json`):
621
+
622
+ | `reason` | |
623
+ | --- | --- |
624
+ | `not_published` | nothing is live |
625
+ | `source_unrecorded` | the live build records no repository or no commit |
626
+ | `repo_not_checked_out` | the repository is not on this machine — clone it, or pass `--dir` |
627
+ | `commit_not_in_checkout` | the checkout does not have the published commit — `git fetch` there |
628
+ | `app_dir_not_found` | no tracked `oxy-app.json` declares this `slug` and `orgSlug` |
629
+ | `app_dir_ambiguous` | more than one does |
630
+ | `commit_not_on_branch` | the published commit is not an ancestor of the checked-out branch |
631
+ | `working_tree_dirty` | the app directory has uncommitted or untracked files |
632
+ | `git_failed` | `git log` or `git status` failed in the checkout, so the comparison could not be read |
633
+ | `request_failed` | the builds request failed (the command then exits non-zero) |
634
+
635
+ It runs read-only git commands and nothing else: it does not fetch, check out,
636
+ or refresh the index. The answer is therefore about the checkout as it is on
637
+ disk — a branch that is behind its remote reports fewer commits than the remote
638
+ has. With no `<app>`, every published app is compared, and `--dir` applies to
639
+ the apps whose source repository is that checkout's `origin`; with `<app>`,
640
+ `--dir` is used whatever its remote is.
641
+
642
+ An app published to two organizations from one directory (a staging org and a
643
+ production org) matches the manifest for only one of them; the other reports
644
+ `app_dir_not_found` and names the manifest it found with the other `orgSlug`.
279
645
 
280
646
  ## Checks
281
647
 
@@ -305,13 +671,21 @@ difference: `checks run` talks to `/api/admin/**`, which is not the
305
671
  `oxyc login` (or `--token-env`) always wins when one is present.
306
672
 
307
673
  **In CI, nothing is stored.** With neither a token nor an API key set, a job
308
- holding `id-token: write` exchanges its GitHub OIDC token for the same
309
- short-lived, app-scoped publish credential `oxyc publish` uses, and drives
310
- `/api/customer-apps/**` instead — the same three handlers, mounted where a
311
- publish token may reach them. The exchange returns the app id, so no lookup is
312
- needed, and `<app>` must be `<org-slug>/<app-slug>` in this mode (a UUID names
313
- an app the exchange cannot verify a publisher for). A publish token set in
314
- `OXY_TOKEN` takes the same surface without minting.
674
+ holding `id-token: write` signs itself in from its GitHub OIDC token — the same
675
+ two exchanges `oxyc publish` tries, in the same order: a service account's
676
+ trust policy first, the app's registered publisher second — and drives
677
+ `/api/customer-apps/**` instead: the same three handlers, mounted where a
678
+ machine credential may reach them. A service account's token (`oxy_sat_…`,
679
+ `oxy_ci_…`) or a publish token set in `OXY_TOKEN` takes that surface too,
680
+ without minting; a personal token, a legacy API key or a session takes the admin
681
+ one. `OXY_API_KEY` is read **before** OIDC here: a job that set one has said
682
+ which credential it means.
683
+
684
+ The app id comes from the credential where it can. The publisher exchange
685
+ returns it, so `<app>` must be `<org-slug>/<app-slug>` on that path (a UUID
686
+ names an app it cannot verify a publisher for). A service account's token names
687
+ the apps it may publish, so a slug resolves against its own grants — and when
688
+ it cannot, the publisher exchange is tried before asking for a UUID.
315
689
 
316
690
  What that credential may do is deliberately small, and enforced server-side
317
691
  rather than by the client. An app-scoped token is **confined to its app**: it
@@ -329,23 +703,164 @@ it admitted any function.
329
703
  **A check runs against the app's live build** — the server resolves
330
704
  `published_build_id`, else `draft_build_id`, for the pre-flight lookup and for
331
705
  the execution alike. So `checks run` after a plain `oxyc publish` verifies the
332
- *previously promoted* code, not the draft just uploaded. `oxyc init-ci` writes
333
- the step only with `--promote`, where the build just published is the one the
334
- check runs, and only when the manifest declares a check.
335
-
336
- With a publish token, `<app>` must be the **UUID** — resolving a slug means
337
- listing every app, which such a token may not do. The OIDC path needs no id at
338
- all (the exchange returns it) but does need `<org-slug>/<app-slug>`, since the
339
- exchange is registered by slug.
706
+ *previously promoted* code, not the draft just uploaded. (`oxyc init-ci` writes
707
+ no checks step at all today: a service account's `oxy_ci_` token is answered
708
+ `403` on these routes — the token's scope admits them for its own app, and the
709
+ routes' platform gates then refuse a caller with no platform standing.) **With
710
+ `--app-env`**
711
+ it runs against that environment's own build instead — a sandbox's own
712
+ publish, or staging's — and the report gains `environment` and each check's
713
+ `invocationId`; the publish-token and OIDC paths are unchanged and work only
714
+ against production, since every non-production operation needs a staff
715
+ credential (see [Sandboxes](#sandboxes)).
716
+
717
+ With a publish token (`oxypublish_…`), `<app>` must be the **UUID** — resolving
718
+ a slug means listing every app, which such a token may not do. A service
719
+ account's token takes either: the UUID, or a slug it can match to one of its
720
+ own app-publish grants. The publisher exchange needs no id at all (it returns
721
+ it) but does need `<org-slug>/<app-slug>`, since it is registered by slug.
340
722
 
341
723
  **Exit codes:** `0` every check passed; `9` (`CHECK_FAILED`) at least one check
342
724
  failed or timed out; `1` the app declares no checks; `5` the app was not
343
725
  found; `4` no credential resolved, or the API rejected it (401/403 — an
344
- expired 90-day API key reads this way, not as a missing one); `2` a bad
726
+ expired legacy API key or API token reads this way, not as a missing one); `2` a bad
345
727
  `--timeout`, or a slug where the credential needs a UUID; `7` (`UNAVAILABLE`)
346
728
  the deployment answered the OIDC exchange without an `app_id`, meaning it
347
729
  predates trusted checks.
348
730
 
731
+ ## Sandboxes
732
+
733
+ ```bash
734
+ oxyc env create <app> <name> # name is dev-<handle>, 1-12 chars a-z0-9-
735
+ oxyc env list <app>
736
+ oxyc env show <app> <name>
737
+ oxyc env delete <app> <name> [--yes] [--wait [seconds]]
738
+
739
+ oxyc publish --app-env <name> [--env <e>] # see "Custom apps" above
740
+ oxyc fn call <app> <function> [--app-env <name>] [--data <json|@file|->]
741
+ oxyc checks run <app> --app-env <name> # see "Checks" above
742
+
743
+ oxyc invocations list <app> [--app-env] [--build] [--function] [--limit]
744
+ oxyc invocations held <app> <invocation-id>
745
+ oxyc logs <app> [--app-env] [--invocation] [--request] [--hours] [--limit]
746
+
747
+ oxyc env secret list <app> --app-env <name> # keys and flags, never a value
748
+ oxyc env secret set <app> <key> --app-env <name> (--value <text> | --value-env <VAR>)
749
+ oxyc env secret delete <app> <key> --app-env <name>
750
+ ```
751
+
752
+ A **sandbox** is a named, short-lived `dev-<handle>` environment of one custom
753
+ app: its own build pointer, storage silo, secrets, Airhouse sibling and — in
754
+ an org with an OLTP staging branch — its own copy of the app's database, with
755
+ reads hitting production and most writes either isolated or *held* (recorded,
756
+ not performed — see `invocations held`). It starts with no build, is deleted
757
+ explicitly or after 7 idle days, and an app has at most 20. Full contract,
758
+ including what is **not** isolated (`ctx.warehouse`) and the other gaps:
759
+ `internal-docs/custom-app-sandboxes.md`.
760
+
761
+ `ctx.oltp` in a sandbox runs in a schema of its own (`app_<app>__dev_<handle>`)
762
+ inside the org's staging branch. `publish --app-env` queues it: a copy of
763
+ staging's tables (rows up to a size cap), then the build's OLTP migrations.
764
+ `env show` reports it as `oltp_schema` — `status` is `seeding`, `ready`,
765
+ `failed` (with `error`) or `stale` (the branch was reset; publish again), and
766
+ `structure_only` lists the tables copied empty. Until it is `ready` a
767
+ `ctx.oltp` call in the sandbox is refused with a message that says which.
768
+
769
+ The loop: `env create` → `publish --app-env` → `fn call` / `checks run
770
+ --app-env` → `invocations list` / `held` to read back what ran → iterate from
771
+ `publish` → `env delete --yes --wait` when done. `oxyc guide` has the same six
772
+ lines — between the two an agent adds to mint and revoke its own token — meant
773
+ to sit in an agent's context.
774
+
775
+ **`--app-env <environment>`** (`production`, `staging` or `dev-<handle>`,
776
+ validated client-side — exit `2` on a malformed name) is a *different axis*
777
+ from `--env`, which is the deployment: both can appear on the same command
778
+ (`oxyc fn call acme/store f --env dev --app-env dev-a1`). It is not a global
779
+ flag — only `publish`, `fn call`, `checks run`, `invocations list` and `logs`
780
+ take it. A publish-token credential is refused before any request for
781
+ `--app-env` other than `production` (exit `2`); `env`, `invocations` and
782
+ `logs` refuse one outright, for any environment, because sandbox management
783
+ is a staff console surface.
784
+
785
+ **An unattended agent drives this loop with a sandbox agent token**
786
+ (`oxy_sbx_…`), not a staff credential: `oxyc tokens create --sandbox-agent
787
+ --app <org>/<app>` mints one after a browser approval — see
788
+ [Authentication](#authentication) for what `oxyc` then refuses, and
789
+ `internal-docs/custom-app-sandboxes.md` §2 for the agent's procedure. With
790
+ it, `--app-env dev-<handle>` is required on every verb above that takes one.
791
+
792
+ **`env secret`** reads and writes one sandbox's own secrets, for any
793
+ credential that reaches the sandbox, and only a `dev-<handle>` environment —
794
+ never staging's or production's. `list` prints each key with `is_set`,
795
+ `required` and `inherits_staging`; no verb returns a value. `set` takes the
796
+ value from `--value`, or from an environment variable with `--value-env` so it
797
+ stays off the command line. A sandbox with no value of its own reads
798
+ staging's.
799
+
800
+ **`env delete`** without `--yes` asks on a terminal and exits `8` (refused)
801
+ off one, same as `oltp reset`. `--wait [seconds]` (default 120) polls until
802
+ the teardown finishes and prints `{"name","status":"deleted"}`; past the
803
+ deadline it exits `7` (retryable) rather than hanging.
804
+
805
+ **`fn call`** POSTs directly to `{target}/customer-apps/{org}/{app}/fn/{name}`
806
+ (no `/api`) — the response is Server-Sent Events, not JSON, so `oxyc` parses
807
+ the `log` / `data` / `done` / `error` frames itself. It exits `0` only when
808
+ the stream ends `done` with a 2xx status; a function that threw, or answered a
809
+ non-2xx, is a function-level failure (exit `1`, `ok: false` in the JSON), kept
810
+ distinct from a non-2xx on the POST itself (exit per the usual status mapping
811
+ — e.g. `EnvironmentHasNoBuild` before the stream starts). The
812
+ `x-oxy-invocation-id` response header becomes `invocationId` in the result.
813
+
814
+ **`invocations held`** is what the non-production write policy did *not* do —
815
+ production's would-be effect, recorded rather than performed. It is `[]` for a
816
+ production invocation, since nothing is held there.
817
+
818
+ ## Workspace previews
819
+
820
+ ```bash
821
+ oxyc preview create <branch> [--wait [seconds]] [--json]
822
+ oxyc preview list [--json]
823
+ oxyc preview show <branch> [--json]
824
+ oxyc preview delete <branch> [--yes] [--json]
825
+ oxyc preview checks <branch> [--json]
826
+
827
+ oxyc preview run <branch> <kind> <ref> [--variables <json|@file|->] [--read-live-only]
828
+ [--window-from <iso>] [--window-to <iso>] [--resource <name>...]
829
+ [--wait [seconds]] [--json]
830
+ oxyc preview runs list <branch> [--json]
831
+ oxyc preview runs show <run-id> [--wait [seconds]] [--json]
832
+ ```
833
+
834
+ A **preview** opens a workspace branch on the real product, against real data,
835
+ without the branch being live — staff only. `create` compiles the branch's
836
+ head into a staging revision (or reuses a ready one for that commit) and is
837
+ idempotent; `--wait [seconds]` (default 120) blocks until the compile is
838
+ `ready` or `failed` instead of returning the `compiling` item the request
839
+ itself answers with, and exits `7` (retryable) past the deadline. There is no
840
+ single-preview route: `show` fetches `list` and filters it client-side, so its
841
+ `5` (not found) is synthesized here, not a literal 404.
842
+
843
+ The loop: `preview create --wait` → `preview checks` → `preview run` →
844
+ `preview runs show --wait` → iterate → `preview delete --yes` when done.
845
+
846
+ **`<kind>`** is `procedure` or `airway_sample` — the only two `POST
847
+ /previews/runs` accepts (exit `2` client-side on anything else, naming the
848
+ read-back verb). `transform_build` and `compare` runs are queued by the
849
+ server's own Airway change check, never started here; `preview runs list`
850
+ surfaces them alongside the kinds you *can* start, and `preview runs show
851
+ <run-id> --wait` reads any of the four back — a procedure's held steps, a
852
+ transform build's or compare's outcome, or a sample's ask and result.
853
+ `--variables` / `--read-live-only` are `procedure`-only; `--window-from` /
854
+ `--window-to` / `--resource` (repeatable) are `airway_sample`-only.
855
+
856
+ Every verb needs `{workspace}` resolved — `--workspace <id>`, exactly the way
857
+ `oxyc api {workspace}/...` resolves it; there is no second workspace
858
+ resolver. A deployment with `OXY_PREVIEW_RUNS` off answers every runs route
859
+ `404 preview_runs_disabled`, surfaced the same way any other server refusal
860
+ is: the server's own `code` rides along on the thrown error, and the exit
861
+ class follows the usual HTTP-status mapping (`400`/`409` → `6`, `404` → `5`).
862
+ Full contract: `internal-docs/workspace-previews.md`.
863
+
349
864
  ## Development commands
350
865
 
351
866
  ```bash
@@ -424,14 +939,46 @@ production target (any host under `oxygen-hq.com`) is refused without `--yes`.
424
939
  Each Oxy Function call carries a W3C trace — the SDK's, or one minted here — and
425
940
  prints `↳ <status> fn <name> request_id=… trace_id=…`.
426
941
 
427
- **`mcp`** serves the API over stdio as four tools — `oxy_routes`, `oxy_schema`,
428
- `oxy_request`, `oxy_whoami` — rather than one per endpoint, so the tool schemas
429
- cost ~2 KB per turn and reach endpoints added after this package shipped.
942
+ **`mcp`** serves the API over stdio as four generic tools — `oxy_routes`,
943
+ `oxy_schema`, `oxy_request`, `oxy_whoami` — rather than one per endpoint, so
944
+ those schemas cost ~2 KB per turn and reach endpoints added after this
945
+ package shipped. Two named exceptions trade that minimalism for validation an
946
+ agent needs to drive a loop unsupervised: the **sandbox loop**
947
+ (`oxy_env_create`/`list`/`show`/`delete`, `oxy_publish_sandbox`,
948
+ `oxy_fn_call`, `oxy_checks_run`, `oxy_invocations_list`/`held`, `oxy_logs`)
949
+ and **workspace previews** (`oxy_preview_create`/`list`/`show`/`delete`/
950
+ `checks`/`run`/`runs_list`/`run_show`). Each tool calls its CLI verb's own
951
+ request-building function, so a tool's result is the same JSON document that
952
+ verb's `--json` prints, and a failure carries the server's own error code
953
+ plus the exit-code class (`[exit 5 NOT_FOUND]`) the way `oxyc`'s exit code
954
+ would. `oxy_env_delete` / `oxy_preview_delete` need `confirm: true` (there is
955
+ no terminal to ask on inside an MCP server), and `oxy_publish_sandbox`
956
+ requires a `dev-<handle>` `appEnv` and refuses production, staging or a
957
+ promote client-side — it can never reach the live channel.
958
+
959
+ **Its credential is the token variable and nothing else.** An agent runtime
960
+ starts `oxyc mcp` with the agent's own token in `OXY_TOKEN` (or the
961
+ `--token-env` variable); with the variable unset the server exits `4` before
962
+ serving a tool, rather than running on the `oxyc login` of whoever owns the
963
+ machine. To serve on your own login, start it with `--login`.
430
964
 
431
965
  ```bash
432
- claude mcp add oxyc -- npx -y @oxy-hq/cli mcp --env production
966
+ # an agent, on its own token
967
+ claude mcp add oxyc -e OXY_TOKEN=oxy_sbx_… -- npx -y @oxy-hq/cli mcp --env dev
968
+ # you, on your login
969
+ claude mcp add oxyc -- npx -y @oxy-hq/cli mcp --login --env production
433
970
  ```
434
971
 
972
+ **A sandbox agent token (`oxy_sbx_…`) is served a different, smaller list**:
973
+ the ten sandbox-loop tools, `oxy_whoami` (the token's `kind`, `expires_at`,
974
+ `apps` and `minter`), and four that exist only for it — `oxy_env_secret_list`,
975
+ `oxy_env_secret_set` and `oxy_env_secret_delete`, which accept only a
976
+ `dev-<handle>` `appEnv`, and `oxy_token_revoke`, which needs `confirm: true`.
977
+ `oxy_request`, `oxy_routes`, `oxy_schema` and the preview tools are dropped,
978
+ and refused if called by name: the server answers that token `404` on all of
979
+ them, and a tool that can only fail still costs its schema every turn. A dead
980
+ token ends the server at startup with exit `4`.
981
+
435
982
  **`guide`** prints a page to paste into `AGENTS.md` / `CLAUDE.md`.
436
983
  **`skills install`** symlinks the six bundled Claude skills into
437
984
  `~/.claude/skills`; it refuses to run from an `npx` cache, whose symlinks dangle
@@ -457,8 +1004,10 @@ once npm reclaims it.
457
1004
 
458
1005
  | Variable | Default | Meaning |
459
1006
  | --- | --- | --- |
460
- | `OXY_TOKEN` | — | bearer token, overriding the login cache (the CI path) |
461
- | `OXY_API_KEY` | — | key for the `/external/api` surface |
1007
+ | `OXY_TOKEN` | — | any credential, sent as a bearer; overrides the login cache and GitHub OIDC |
1008
+ | `OXY_API_KEY` | — | a legacy API key or an API token, for the `/external/api` surface and for `checks run` |
1009
+ | `OXY_SERVICE_ACCOUNT` | — | the **ID** of the service account a GitHub OIDC exchange acts as (`--service-account`); never `<org>/<name>` |
1010
+ | `OXY_AGENT` | detected | the name of the agent driving `oxyc`, sent in the user agent — see below |
462
1011
  | `OXY_CREDENTIALS_PATH` | OS config dir | the shared `credentials.json` |
463
1012
  | `OXYC_ORG` | `oxy-hq` | GitHub org the customer repos live in |
464
1013
  | `OXYC_CUSTOMER_TOPIC` | `oxy-customer` | topic that registers a customer repo |
@@ -478,6 +1027,17 @@ once npm reclaims it.
478
1027
  | `OXYC_DRY_RUN` | — | `1` makes `launch` print the command instead of running it |
479
1028
  | `NO_COLOR` / `FORCE_COLOR` | — | force plain / coloured output |
480
1029
 
1030
+ **An agent should set `OXY_AGENT`.** Every request `oxyc` makes to a deployment
1031
+ carries the user agent `oxyc/<version>`, with `agent/<label>` after it when an
1032
+ agent is driving, and `mcp` last when the call came through `oxyc mcp` —
1033
+ `oxyc/0.6.0 agent/release-bot mcp`. The server records it on audit rows and in
1034
+ each token's usage, and it is the one thing there that tells an agent from the
1035
+ engineer running the same commands. The label is `OXY_AGENT`, held to
1036
+ lowercase letters, digits, `.`, `_` and `-` and to 32 characters (anything else
1037
+ becomes `-`). With `OXY_AGENT` unset, Claude Code is recognised by the
1038
+ `CLAUDECODE` variable it exports and labelled `claude-code`. It identifies
1039
+ honest callers in a log; it is not a credential and grants nothing.
1040
+
481
1041
  ## Output contract
482
1042
 
483
1043
  **stdout carries the response body and nothing else.** Progress, warnings,