@oxy-hq/cli 0.8.0 → 0.10.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.
Files changed (41) hide show
  1. package/README.md +312 -31
  2. package/dist/main.mjs +11236 -2822
  3. package/dist/main.mjs.map +1 -1
  4. package/dist/{mcp-xxWL3_dz.mjs → mcp-B2KBWNOU.mjs} +86 -3
  5. package/dist/mcp-B2KBWNOU.mjs.map +1 -0
  6. package/package.json +6 -5
  7. package/skills/oxy-agents/SKILL.md +318 -0
  8. package/skills/oxy-agents/references/quick-reference.md +222 -0
  9. package/skills/oxy-agents/templates/agentic-template.yml +141 -0
  10. package/skills/oxy-airway-pipelines/SKILL.md +223 -316
  11. package/skills/oxy-automations/SKILL.md +61 -15
  12. package/skills/oxy-cli/SKILL.md +40 -6
  13. package/skills/oxy-custom-apps/SKILL.md +380 -294
  14. package/skills/oxy-custom-apps/references/minimal-starter.md +71 -0
  15. package/skills/oxy-custom-apps/references/performance-and-usage.md +151 -0
  16. package/skills/oxy-custom-apps/references/reading-data.md +106 -0
  17. package/skills/oxy-custom-apps/references/sandbox-agent-loop.md +307 -0
  18. package/skills/oxy-custom-apps/references/verifying-in-the-browser.md +65 -0
  19. package/skills/oxy-data-apps/SKILL.md +362 -0
  20. package/skills/oxy-data-apps/references/examples.md +263 -0
  21. package/skills/oxy-data-apps/references/quick-reference.md +171 -0
  22. package/skills/oxy-data-apps/templates/dashboard-with-controls.app.yml +128 -0
  23. package/skills/oxy-data-apps/templates/simple-dashboard.app.yml +102 -0
  24. package/skills/oxy-data-architecture/SKILL.md +137 -152
  25. package/skills/oxy-evals/SKILL.md +362 -0
  26. package/skills/oxy-evals/references/diagnosis.md +243 -0
  27. package/skills/oxy-evals/references/results-json.md +145 -0
  28. package/skills/oxy-evals/references/writing-expected.md +112 -0
  29. package/skills/oxy-semantic-modeling/SKILL.md +239 -59
  30. package/skills/oxy-semantic-modeling/references/warehouse-casts.md +57 -0
  31. package/skills/oxy-semantic-modeling/templates/topic-template.yml +38 -0
  32. package/skills/oxy-semantic-modeling/templates/view-template.yml +119 -0
  33. package/template/.claude/hooks/oxyc-cloud-session.sh +66 -0
  34. package/template/.claude/settings.json +16 -0
  35. package/template/.github/workflows/publish.yaml +15 -13
  36. package/template/.nvmrc +1 -1
  37. package/template/.oxyc-managed +18 -5
  38. package/template/README.md +21 -10
  39. package/template/package.json +3 -2
  40. package/template/pnpm-workspace.yaml +28 -17
  41. package/dist/mcp-xxWL3_dz.mjs.map +0 -1
@@ -0,0 +1,71 @@
1
+ # Minimal starter
2
+
3
+ Easiest path: `pnpm dlx @oxy-hq/create-oxy-app@latest my-app --template vite`
4
+ (`--template functions` adds Oxy Functions).
5
+ The scaffolded layout:
6
+
7
+ ```
8
+ my-app/
9
+ ├── oxy-app.json ← project root, not public/
10
+ ├── src/
11
+ │ ├── App.tsx
12
+ │ └── main.tsx
13
+ ├── package.json
14
+ ├── vite.config.ts
15
+ └── tsconfig.json
16
+ ```
17
+
18
+ `oxy-app.json` (`$schema` is optional — editor completion against the SDK's
19
+ generated schema; `oxyc validate` and the Vite plugin check the file against it
20
+ either way):
21
+ ```json
22
+ {
23
+ "$schema": "https://unpkg.com/@oxy-hq/sdk/schema/oxy-app.schema.json",
24
+ "schemaVersion": 2,
25
+ "slug": "my-app",
26
+ "orgSlug": "your-org",
27
+ "name": "My App"
28
+ }
29
+ ```
30
+
31
+ `src/main.tsx`:
32
+ ```tsx
33
+ import { OxyAppProvider } from "@oxy-hq/sdk";
34
+ import { createRoot } from "react-dom/client";
35
+ import { App } from "./App";
36
+
37
+ createRoot(document.getElementById("root")!).render(
38
+ <OxyAppProvider><App /></OxyAppProvider>
39
+ );
40
+ ```
41
+
42
+ `src/App.tsx`:
43
+ ```tsx
44
+ import { useQuery } from "@oxy-hq/sdk";
45
+
46
+ export function App() {
47
+ const { rows, loading, error } = useQuery({
48
+ sql: "SELECT 1 AS hello",
49
+ });
50
+ if (loading) return <p>loading…</p>;
51
+ if (error) return <p style={{ color: "red" }}>{error.message}</p>;
52
+ return <pre>{JSON.stringify(rows, null, 2)}</pre>;
53
+ }
54
+ ```
55
+
56
+ `vite.config.ts`:
57
+ ```ts
58
+ import oxyApp from "@oxy-hq/vite-plugin";
59
+ import react from "@vitejs/plugin-react";
60
+ import { defineConfig } from "vite";
61
+
62
+ export default defineConfig({
63
+ plugins: [react(), oxyApp()],
64
+ });
65
+ ```
66
+
67
+ The plugin handles `outDir = "out"`, base-path resolution from the
68
+ manifest, dev-server proxy to `/api`, and the `window.__OXY_APP__`
69
+ dev shim. Plain `pnpm build` produces a bundle with the correct
70
+ asset base baked in. Then publish it:
71
+ [SKILL.md → Publishing](../SKILL.md#publishing).
@@ -0,0 +1,151 @@
1
+ # Performance and usage
2
+
3
+ Two halves: what you keep in your own code (the guardrails), and what Oxy does
4
+ for every published app without any (load speed and usage tracking).
5
+
6
+ ## Performance guardrails
7
+
8
+ Any app that ships an Oxy Function or drives a batch of them should keep these —
9
+ cheap up front, hard to retrofit.
10
+
11
+ **In an Oxy Function — count the round-trips.** Each isolate run is stateless and
12
+ pays a fresh network hop per `ctx.query` / `ctx.warehouse.*`, so serial *dependent*
13
+ calls dominate latency.
14
+ - Run independent data-plane calls with `Promise.all`, not sequential `await`
15
+ (e.g. resolve reference data **and** provision tables concurrently — only the
16
+ final step needs both).
17
+ - Fold a check into a query that already runs: a duplicate/existence guard is a
18
+ scalar subquery on the main `SELECT`, not a second round-trip.
19
+ - `Promise.all` idempotent DDL (`CREATE TABLE IF NOT EXISTS`); better, keep
20
+ provisioning off the hot path if a migration can own it.
21
+ - A "cheap" extra `ctx.query`/`ctx.log` is N× the cost across N invocations.
22
+
23
+ **Driving many function calls from the UI (bulk actions) — don't go serial.**
24
+ - Independent calls run with **bounded concurrency** (~3–4) via a worker-pool over a
25
+ queue, not one-at-a-time — turns an O(N) wall-clock batch into a few waves.
26
+ - If the calls share mutable server state that races under concurrency (a rotating
27
+ token, a lazily-created table), the best fix is to **remove the shared mutation**
28
+ (e.g. a single-writer scheduled refresher owns the token; requests only read).
29
+ When you can't, warm the state with one call first, then fan out.
30
+ - Don't refetch derived state (a status ledger) after *every* item — the progress UI
31
+ is the live feedback; **throttle** the refetch (~1/s) and flush once at the end.
32
+ - Guard `setState` after the component may have unmounted mid-batch (`mounted` ref).
33
+
34
+ **Trust the server, not the client body.** Environment / tenant / which-secret
35
+ selection is derived server-side (a function secret, a server-side mapping table,
36
+ the authed identity), never from the request body — validate or override anything
37
+ the client sends that picks a target.
38
+
39
+ **Reuse the SDK's data-plane caching.** `useQuery` already dedups concurrent identical
40
+ queries and short-caches results — don't hand-roll a fetch that bypasses it. Mark
41
+ read-only functions `cache: { ttlSeconds }` in the manifest; **never** cache a
42
+ side-effecting one (it silently drops writes). `idempotencyKey` = a fresh UUID
43
+ per ATTEMPT (click), never a stable per-subject key: the server stores each
44
+ key's response indefinitely and replays it WITHOUT running the function, so a
45
+ `store:period` key silently breaks any post → compensate (delete) → post-again
46
+ cycle — the second post "succeeds" with the stale response and writes nothing.
47
+ The key shields one request from transport duplication; cross-attempt dedup
48
+ belongs in the function itself (a server-side guard over real state, e.g. a
49
+ posted-ledger row the compensating action clears).
50
+
51
+ **Watch-outs when optimizing (each surfaced in review):**
52
+ - **Clear UI running-state in `finally`.** An async batch driver must reset its
53
+ `running`/loading flag (and do its final refetch) in a `finally`, not after the
54
+ `await` — a stray throw otherwise leaves the bar stuck at "…" with the button
55
+ disabled.
56
+ - **Parallelizing can move a side effect ahead of a gate.** Running an independent
57
+ side-effecting call concurrently with a validation query means it can now fire
58
+ even when validation would reject. Fine if the side effect is benign — just know
59
+ you traded the ordering for the latency.
60
+ - **Additive migration for a scoped guard → backfill legacy rows to their TRUE value,
61
+ not a constant.** When a dedup/uniqueness guard starts keying on a new column,
62
+ `ADD COLUMN … DEFAULT <constant>` silently defeats the guard for pre-existing rows.
63
+ Derive the real value from an existing column (e.g. a DocNumber prefix) so old rows
64
+ stay correctly scoped.
65
+
66
+ ## Load speed & usage tracking (automatic — know what you get, and the opt-outs)
67
+
68
+ **You do not wire load performance; Oxy does it at publish and serve time.**
69
+ Every published app gets, with no bundle code:
70
+
71
+ - **Preload hints.** At publish, Oxy parses your built `index.html` and emits
72
+ `Link: rel=preload`/`modulepreload` on the shell, so the browser fetches your entry
73
+ chunks while the HTML is still in flight. This is why the **base-path wiring in
74
+ [rule 3](../SKILL.md#three-rules) is load-bearing twice over**: a bundle whose
75
+ entry `<script>`/`<link>` tags are real, hashed, same-origin assets (what
76
+ `@oxy-hq/vite-plugin` produces) gets hinted; one that inlines everything into a
77
+ single HTML file has nothing to hint, which is fine but flat.
78
+ - **A service worker** (`__oxy/sw.js`) that precaches those entry assets and serves
79
+ content-hashed files cache-first — a repeat load is near-instant and works offline.
80
+ - **Usage instrumentation** — SPA pageviews, Core Web Vitals, engagement time, and
81
+ uncaught-error counts, posted to `__oxy/beacon` and shown on the app's Activity tab.
82
+ This is *in addition to* `useTrackEvent` (your own named events), which is unaffected.
83
+
84
+ **The two opt-outs, in `oxy-app.json` (both default on — omit to keep them):**
85
+
86
+ ```jsonc
87
+ { "schemaVersion": 2, "slug": "…",
88
+ "performance": { "serviceWorker": false }, // ships its own SW, or must never cache
89
+ "analytics": false } // silences the platform's client events only
90
+ ```
91
+
92
+ Typed as `OxyAppManifest["performance"]` / `["analytics"]` in `@oxy-hq/sdk`. `analytics:
93
+ false` stops only the *client* runtime — Oxy still records one view row per navigation
94
+ server-side, so the Activity tab never goes dark. Reach for these rarely: the defaults are
95
+ the fast, observable path.
96
+
97
+ ### A tab left open across a publish (automatic — and what it can cost you)
98
+
99
+ Oxy's injected runtime also watches for a newer build. Every app gets this with no bundle
100
+ code, so an app that keeps work in memory has to know about it:
101
+
102
+ - **It notices a publish within ~5 minutes.** A visible tab polls `__oxy/build`, and checks
103
+ again when it comes back to the foreground or online. When the live build differs, it
104
+ fires a cancelable `oxy:update-available` event on `window`.
105
+ - **A broken lazy chunk after a publish reloads the page — in every mode except the
106
+ platform's `off`.** A route the tab never opened asks for a chunk the new build no
107
+ longer has; rather than white-screen, the runtime checks `__oxy/build` and, if the
108
+ build moved, reloads onto it. That reload **discards in-memory state** unless the app
109
+ holds `useBusyWhile` / `holdUpdates`, an input is focused, or it cancels the
110
+ `oxy:chunk-stale` event.
111
+ - **Whether a tab reloads by itself is `performance.autoUpdate`.** The platform default is
112
+ set by the operator (announce only, today). Set it per app:
113
+
114
+ ```jsonc
115
+ { "performance": { "autoUpdate": true } } // reload at a safe moment: a navigation,
116
+ // 2 idle minutes with nothing focused, or a
117
+ // return after 10+ minutes away
118
+ { "performance": { "autoUpdate": false } } // never reload by itself; offer it with useAppUpdate
119
+ { "performance": { "autoUpdate": "prompt" } } // shell shows "Update ready"; reloads at the
120
+ // next safe moment if nobody taps it in 24 h
121
+ ```
122
+
123
+ **The rule: wrap unsaved in-memory work.** A checklist half filled, a form not yet saved,
124
+ a write in flight — hold updates while it is dirty, and the runtime will not reload under
125
+ it (a held token older than 12 h is ignored, so a leak cannot pin a tab forever):
126
+
127
+ ```tsx
128
+ import { useAppUpdate, useBusyWhile } from "@oxy-hq/sdk";
129
+
130
+ useBusyWhile(isDirty, "checklist not saved"); // or: const release = holdUpdates("…")
131
+
132
+ const { available, apply } = useAppUpdate(); // offer the update in your own UI
133
+ {available && <button onClick={() => apply()}>Update now</button>}
134
+ ```
135
+
136
+ `useFunction` already holds updates for the life of each call. `UpdatePill` (from
137
+ `@oxy-hq/sdk/shell`) is the ready-made "Update ready" chip; `useKioskIdle` signs a kiosk
138
+ out after the device's idle timeout, which is also a full reload onto the live build.
139
+ An app's own `beforeunload` "leave this page?" prompt still applies. When the viewer
140
+ chooses to stay, the runtime does not reload onto that build by itself again, but
141
+ `apply()` and the chip still work.
142
+
143
+ **Two gotchas that read as bugs:**
144
+ - **`oxy-` is a reserved event-name prefix.** `useTrackEvent("oxy-…")` is refused with a
145
+ 400 — that namespace is the platform's own instrumentation. Name your events anything
146
+ else. (It is a collision guard, not a security boundary: the beacon rides your app's own
147
+ auth gate, so it is *your* rows that are protected from accidental merging, not made
148
+ unforgeable.)
149
+ - **A prefetch is not a view.** A launcher may warm an app on hover; Oxy checks
150
+ `Sec-Purpose`/`Purpose`/`X-moz` and does not record a view or start a session for a
151
+ speculative load. So a hovered-but-never-opened app shows no phantom traffic.
@@ -0,0 +1,106 @@
1
+ # Reading data while vibe-coding: `oxyc`
2
+
3
+ You don't need an `X-API-Key` to poke at the data API by hand — a bearer
4
+ token works, and **`oxyc`** (`npm i -g @oxy-hq/cli`) is the client that sends
5
+ it for you. **It replaced `oxy api`, and the Rust `oxy login`, `oxy publish`
6
+ and `oxy proxy` with it**: every command that talks to a deployment's HTTP API
7
+ is `oxyc`. It reads the `credentials.json` the Rust `oxy login` wrote, so an
8
+ existing login still works.
9
+
10
+ The bearer is `OXY_TOKEN` (or the variable `--token-env` names), else the
11
+ `oxyc login` cache for that deployment.
12
+
13
+ ## As an agent: your own agent token
14
+
15
+ **An agent does not read data on its engineer's login.** It mints its own
16
+ token for the task:
17
+
18
+ ```bash
19
+ eval "$(oxyc tokens create --agent --env <deployment>)" # prints: export OXY_TOKEN=oxy_pat_…
20
+ oxyc api user --env <deployment> --token-env OXY_TOKEN # …work…
21
+ oxyc tokens revoke --current --env <deployment> --token-env OXY_TOKEN # last
22
+ ```
23
+
24
+ - A page opens in your engineer's browser; **the click is theirs** — tell
25
+ them it is waiting, and never click it with a browser tool. The token is
26
+ printed once as `export OXY_TOKEN=…` and never written to disk; never put it
27
+ in a file, a commit or your report.
28
+ - It reaches what its approver can reach, for 8 hours
29
+ by default (`--hours`, 1 to 168). Staff or partner access comes only with
30
+ `--standing`, and the approver still chooses it on the page.
31
+ - Pass `--token-env OXY_TOKEN` to every command: one that lost the variable
32
+ then exits `4` instead of running on a cached login.
33
+ - It is not the sandbox agent token. To build and test a custom-app change,
34
+ mint `--sandbox-agent` instead ([sandbox-agent-loop.md](sandbox-agent-loop.md)).
35
+ A sandbox agent token is refused by `oxyc api` before any request (exit `2`),
36
+ and the data API answers it `404`: read what your change did through your
37
+ sandbox (`oxyc fn call`, `oxyc invocations`, `oxyc logs`). Do not unset
38
+ `OXY_TOKEN` to reach the login cache.
39
+
40
+ ## Ask the deployment, never guess a path
41
+
42
+ The point of the tool is that **you never guess a path** — ask the deployment
43
+ you are actually talking to:
44
+
45
+ ```bash
46
+ oxyc login --env local # a person, once; caches a token per host
47
+
48
+ oxyc routes projects --env local # what exists, and what each does
49
+ oxyc schema projects/{project_id}/query -X POST # the body it expects
50
+ oxyc api user --env local # who am I?
51
+ oxyc api projects/<projectId>/query --env local -f sql='select 1' --md
52
+ ```
53
+
54
+ - **Path** is relative to `/api/` (`oxyc api user` → `<target>/api/user`); a
55
+ leading `/` or `api/` is fine, and `/external/api/**` passes through to the
56
+ API-key surface untouched.
57
+ - **Fields follow `gh api`**: `-f k=v` is a **string** field, `-F k=v` a
58
+ **typed** one (`true`, `3`, `["a"]`, `@file`), `--input @body.json` (or `-`)
59
+ a raw body. On GET/HEAD/DELETE, fields become query parameters instead.
60
+ `-X` sets the method (POST when a body is given); `-i` includes the status
61
+ line and response headers.
62
+ - **Read cheaply.** `--md` renders a markdown table and `--jq '<expr>'` (`-q`)
63
+ filters before you pay for the rows — both far fewer tokens than raw JSON,
64
+ which repeats every field name per row. `/sql/query` answers arrays of
65
+ strings **header row first**, not objects; `--md` is what makes that
66
+ readable.
67
+ - **Exit codes are a contract** (`oxyc exit-codes`) — branch on them rather
68
+ than parsing stderr: `4` = no usable credential, `5` = not found, `6` = the
69
+ request was malformed and will fail identically forever, `7` = retryable.
70
+ - **Credentials are per deployment and `--env` defaults to production.** A
71
+ 401 that reads like an expired session is usually a dev call made with a
72
+ prod token — check the `--env`. A `200` with a `null` body is the *other*
73
+ expired-token shape; `oxyc whoami` tells them apart.
74
+ - For a raw `curl`, `oxyc token` prints the resolved bearer (`oxy api
75
+ --print-token` went with the rest of that command):
76
+
77
+ ```bash
78
+ curl -H "Authorization: Bearer $(oxyc token --env local)" \
79
+ -H 'content-type: application/json' \
80
+ localhost:5173/api/projects/<projectId>/query -d '{"sql":"select 1"}'
81
+ ```
82
+
83
+ `--env` resolves the same targets as `oxyc publish` (`local` → the Vite dev
84
+ server `:5173`, which proxies `/api` → oxy `:3000`; `dev`, `staging`,
85
+ `production`), and a value with a `.`, `/` or `:` is read as a URL.
86
+
87
+ Access is your **normal org membership** — fine for your own test workspace,
88
+ not a cross-customer backdoor. The `projectId` is the one the vite-plugin
89
+ already resolved for the app (it's in `window.__OXY_APP__`).
90
+
91
+ ## Working locally against cloud data: `oxyc proxy`
92
+
93
+ `pnpm dev` is cross-origin, so the browser sends no oxy cookie and the data
94
+ API answers `origin not allowed`. The sidecar listens where a local
95
+ `oxy serve` would (`:3000`) and forwards your dev server's Oxy calls to the
96
+ cloud target with your token attached:
97
+
98
+ ```bash
99
+ oxyc proxy --env dev # then `pnpm dev` as usual
100
+ ```
101
+
102
+ Its guardrails are the reason to use it rather than a bare token:
103
+ side-effecting calls are **held** (`--allow-writes` forwards them), tracking
104
+ events **dropped** (`--allow-events`), and a production target is refused
105
+ without `--yes`. The token stays in the proxy process and never reaches the
106
+ browser. It cannot target a sandbox.
@@ -0,0 +1,307 @@
1
+ # The sandbox agent loop
2
+
3
+ How an AI agent builds and tests a custom-app change on a deployment without a
4
+ person's login: its own short-lived token, its own `dev-<handle>` sandbox, and
5
+ a report its engineer can act on. What a sandbox is, and the same loop as a
6
+ person runs it, are in
7
+ [SKILL.md → Sandboxes](../SKILL.md#sandboxes--try-a-change-on-real-data-before-shipping-it).
8
+
9
+ ## An agent's own credential: the sandbox agent token
10
+
11
+ **If you are an AI agent, you do not work on your engineer's login.** A login
12
+ carries everything the engineer can do, for 90 days. You mint a token for the
13
+ task instead (`oxy_sbx_…`): it reaches only the apps you name, your engineer
14
+ approves it once, and you end it when the task is done.
15
+
16
+ ```bash
17
+ oxyc tokens create --sandbox-agent --app <org>/<app> --env <deployment>
18
+ # → a page opens in your engineer's browser: the apps, the lifetime, Approve
19
+ # → stdout, once: export OXY_TOKEN=oxy_sbx_…
20
+ ```
21
+
22
+ - **The click is your engineer's.** Tell them a page is waiting in their
23
+ browser. The command waits five minutes for the click, so run it with a
24
+ timeout at least that long: a shell tool that stops a command after two
25
+ minutes kills it first. Never open or click that page with a browser tool.
26
+ - **Where no browser can reach your machine** (a cloud container, a CI job, a
27
+ remote box), add `--no-browser`. `oxyc` prints a URL and an eight-letter
28
+ code on stderr instead of opening a page, and waits up to ten minutes. Give
29
+ your engineer both: they open the URL on any device where they are signed
30
+ in, type the code the terminal shows, and approve. The token is printed
31
+ exactly as above. The rule does not change: never open or approve that page
32
+ yourself, with a browser tool or any other way.
33
+ - **Name who approves with `--approver <email>`** (or `OXY_APPROVER`), only
34
+ with `--no-browser`: the engineer is told by push, email and Slack; they type
35
+ the code from your output on the page. Use the email your engineer gave you,
36
+ and still show them the code `oxyc` printed, since the notice does not carry
37
+ it. Only that person can open the request: if you were given no address, omit
38
+ the flag and hand them the URL and the code as before. Never open or click
39
+ the page yourself.
40
+ - **Evaluate that line and work with the token.** In a shell that persists:
41
+ `eval "$(oxyc tokens create --sandbox-agent --app <org>/<app> --env <deployment>)"`.
42
+ In one that does not, such as an agent's shell tool, redirect it to
43
+ `~/.config/oxy/agent-token.env` under `umask 077`, and start each command
44
+ with `. ~/.config/oxy/agent-token.env &&`. In a Claude Code cloud session,
45
+ the repo's SessionStart hook does that for you
46
+ ([SKILL.md → Headless and cloud sessions](../SKILL.md#headless-and-cloud-sessions)).
47
+ `oxyc` writes nothing to the credentials file. That file is the only place
48
+ the token goes. Never put it in a commit or your report.
49
+ - **Make the variable the only credential.** Put `--token-env OXY_TOKEN` on
50
+ every command after the mint, as the loop below does. With it, a command
51
+ that lost the variable exits `4` (`OXY_TOKEN is not set`): set it and run the
52
+ command again. Without it, `oxyc` falls back to your engineer's login cache
53
+ on this machine, and a publish with no `--app-env` then goes to the app's
54
+ draft channel as them.
55
+ - **Name every app the task touches** with its own `--app`, five at most.
56
+ `<org>/<app>` is `orgSlug` and `slug` from the app's `oxy-app.json`, not the
57
+ folder names, which can differ. When the manifest has no `orgSlug`, take the
58
+ org slug from the app's README or from your engineer. Pass `--hours` when
59
+ the task will outlast the default 8; the most is 168 (seven days). Apps and
60
+ lifetime are fixed at mint, and the token cannot be extended.
61
+ - **Check it** with `oxyc whoami --env <deployment> --token-env OXY_TOKEN --json`:
62
+ `"kind": "sandbox_agent"`, your app in `apps`, and an `expires_at` after the
63
+ task ends. Run it first when `OXY_TOKEN` was already set before you started:
64
+ without that `kind` it is someone else's credential, and you mint your own.
65
+ Then `oxyc env list <org>/<app> --env <deployment> --token-env OXY_TOKEN --json`:
66
+ exit `0` means the app and the deployment are right.
67
+ - **Set `OXY_AGENT=<your name>`** beside it, so the audit log tells your
68
+ requests from your engineer's.
69
+ - `--env` is the deployment (`dev`, `staging`, `production`). Pass the same
70
+ one on every command; omitted, it is `production`. Use the one your engineer
71
+ named. When they named none, use `production` and say so when you tell them
72
+ the approval is waiting.
73
+
74
+ For anything that is not the sandbox loop — reading data with `oxyc api`,
75
+ reproducing a bug against live data — the agent's credential is a different
76
+ token, `oxyc tokens create --agent`: see [reading-data.md](reading-data.md).
77
+
78
+ ## What it reaches
79
+
80
+ One to five named apps, and only `dev-<handle>` sandboxes it created itself —
81
+ three at a time at most, counting ones still being deleted. Everything else
82
+ answers `404`: production and staging, another app, someone else's sandbox,
83
+ `/admin`, a secret's value. A publish to a channel (staging, production) or a
84
+ promote answers `403`. `oxyc` refuses those two, and `oxyc api`, `oxyc apps`
85
+ and `oxyc login-link`, before any request (exit `2`). **Shipping stays with a
86
+ person.**
87
+
88
+ ## The loop
89
+
90
+ Under the token the environment must be named: `publish`, `fn call`,
91
+ `checks run`, `invocations list` and `logs` are refused without
92
+ `--app-env dev-<handle>` (exit `2`). Run `publish` from the app directory: it
93
+ reads the app from the `oxy-app.json` there. Every other command takes
94
+ `<org>/<app>`.
95
+
96
+ ```bash
97
+ # 1. Create it. It starts with no build. `url` in the answer is the sandbox's link.
98
+ oxyc env create <org>/<app> dev-x --env <deployment> --token-env OXY_TOKEN --json
99
+ # 2. Build + publish into it. Success: "channel":"sandbox" and "environment":"dev-x".
100
+ oxyc publish --app-env dev-x --env <deployment> --token-env OXY_TOKEN --json
101
+ # Optional: a secret of the sandbox's own, read from a variable.
102
+ oxyc env secret set <org>/<app> KEY --app-env dev-x --value-env VAR --env <deployment> --token-env OXY_TOKEN
103
+ # 3. Call what you changed. Success: exit 0 and "ok":true. Then run the app's checks.
104
+ oxyc fn call <org>/<app> <function> --app-env dev-x --data '{}' --env <deployment> --token-env OXY_TOKEN --json
105
+ oxyc checks run <org>/<app> --app-env dev-x --env <deployment> --token-env OXY_TOKEN --json
106
+ # 4. Read back: what ran (a row's id is `id`), what it held instead of writing, its logs.
107
+ oxyc invocations list <org>/<app> --app-env dev-x --env <deployment> --token-env OXY_TOKEN --json
108
+ oxyc invocations held <org>/<app> <invocation-id> --env <deployment> --token-env OXY_TOKEN --json
109
+ oxyc logs <org>/<app> --app-env dev-x --env <deployment> --token-env OXY_TOKEN --json
110
+ # 5. When the task is done: delete, then revoke, in that order.
111
+ oxyc env delete <org>/<app> dev-x --yes --wait --env <deployment> --token-env OXY_TOKEN --json
112
+ oxyc tokens revoke --current --env <deployment> --token-env OXY_TOKEN
113
+ ```
114
+
115
+ `dev-x` is `dev-<handle>`: 1–12 characters of `a-z`, `0-9` and single hyphens,
116
+ not starting or ending with one. Name it after the task
117
+ (`dev-<short-task-slug>`), one per agent and task, never reused across
118
+ unrelated tasks, so parallel agents do not collide. If your runtime speaks
119
+ MCP, the same loop is served as tools — see [Over MCP](#over-mcp).
120
+
121
+ ## Publishing into it
122
+
123
+ Three things to know before the first publish:
124
+
125
+ - **`missing org: the app's base path needs it` (exit `2`).** `publish` takes
126
+ the org from `--org`, `OXY_ORG`, the `orgSlug` in the directory's
127
+ `oxy-app.json`, else the folder under an `apps/<org>/<app>/` path. An app
128
+ whose manifest names no org, outside such a path, has none. An app that
129
+ keeps its manifest in `public/` gets the folder name, which is wrong
130
+ wherever the folder is not the slug. In both cases add `--org <org-slug>`,
131
+ and `--app <slug>` when the directory has no manifest. Pass the slug, never
132
+ a UUID: the asset base path is baked from it.
133
+ - **`pnpm install` stops with `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`
134
+ (exit `1`).** The default install step is `pnpm install`; pnpm wants to
135
+ rebuild `node_modules` and your shell has no terminal for it to ask on. Run
136
+ the same command with `CI=true` in front.
137
+ - **A broken build costs an approval.** `oxyc publish --build-only` builds and
138
+ bundles with no credential, so run it before you ask for a token (it takes
139
+ `--org` too). `oxyc publish --dir out --prebuilt --app-env dev-x …` then
140
+ publishes that build without building again.
141
+
142
+ ## What a sandbox does with a call
143
+
144
+ Reads hit production data. A write lands in a home of the sandbox's own, or it
145
+ is *held*: recorded in `invocations held` and not performed. A held write is
146
+ the expected answer outside production. Report it. Unless the task asks for
147
+ it, do not edit `nonProduction` in the manifest to make the write land:
148
+ staging and every other sandbox share that map. A sandbox's schedules are off
149
+ unless staff turned them on, and your token cannot turn them on: to test a
150
+ scheduled function, run it once as its schedule would — `oxyc fn run
151
+ <org>/<app> <function> --app-env dev-x --as-schedule --env <deployment>
152
+ --token-env OXY_TOKEN --json` (no `--data`; `oxy_fn_run` with `asSchedule:
153
+ true` over MCP). It runs as the system, with the function's retry policy, and
154
+ answers `409 not_scheduled` (exit `6`) for a function with no `schedule`. The
155
+ `fn call` result carries the function's `logs`; `oxyc logs` shows the same
156
+ lines about 30 seconds later.
157
+ To watch a run you do not call yourself — a check on the worker fleet, a slow
158
+ function — start `oxyc logs <org>/<app> --app-env dev-x --follow --timeout 300
159
+ --env <deployment> --token-env OXY_TOKEN --json` in the background first. It
160
+ prints one JSON event per line (`open`, then `start` / `log` / `finish`),
161
+ needs no observability capture, and exits `0` on `done`. A `dropped` event
162
+ means lines were lost, not that the function failed.
163
+
164
+ ## When a command fails
165
+
166
+ Branch on the exit code and the JSON fields, not on the text:
167
+
168
+ | Exit | From | It means | Do |
169
+ | --- | --- | --- | --- |
170
+ | `1` | `publish` | The build or the Oxy Function lint failed. | Fix what the error names, or see [Publishing into it](#publishing-into-it). |
171
+ | `1` | `fn call` | The function threw or answered a non-2xx: `"ok": false`. | Read `body` and `logs` in the result. It is your code. |
172
+ | `1` | `checks run` | The app declares no checks (no function has `"check": true`). | Not a failure. Verify with `fn call`, and report that the app has no checks. |
173
+ | `2` | any | The command was wrong, or the token may not do it. Nothing was sent. | Read the message. Do not retry it unchanged. |
174
+ | `4` | any | `OXY_TOKEN is not set`: the command lost the variable. Any other message: the token expired, was revoked, or your engineer lost access to the app. | Set the variable and run it again. Otherwise stop and report. Never log in. |
175
+ | `5` | any | Not found: the app, the sandbox, the function or the invocation, or one your token does not reach. From `fn call` before the first publish: the sandbox has no build. | Check the slugs and `--env` once. Do not try other spellings. From `env delete` it means already gone. |
176
+ | `6` | `env create` | The name is taken or reserved, a sandbox of that name is still tearing down, the app is at its 20-sandbox limit, or your token already holds three (`token_sandbox_limit`; one still being deleted counts). | Pick another handle, or delete one of yours with `--wait` first. Do not retry the same name. |
177
+ | `7` | any | Unavailable (network, 5xx), or a `--wait` deadline ran out. | Run it once more. If `env delete --wait` exits `7` twice, report the sandbox as not deleted. |
178
+ | `8` | `env delete` | Refused: it had no `--yes` and no terminal to ask on. | Add `--yes`. |
179
+ | `8` | anything else | A refusal. | Stop and report. Do not retry with another credential or flag. |
180
+ | `9` | `checks run` | A check failed or timed out. | Read `checks[].error`, then `invocations held` for that check's `invocationId` before assuming the function is broken: a held write can be the expected answer. |
181
+
182
+ ## Rules
183
+
184
+ - **Never borrow another credential.** Do not run `oxyc login`, read the login
185
+ cache (`credentials.json`, `oxyc token`), or use an engineer's personal
186
+ token or API key. What the token does not reach is a person's step: ask your
187
+ engineer. Exit `4` under the token means it expired, was revoked, or your
188
+ engineer lost access to the app: stop and report.
189
+ - **Never put an Oxy credential into a sandbox secret.** The server refuses a
190
+ value shaped like one (`400 credential_shaped_value`). Set a secret with
191
+ `oxyc env secret set` (`oxy_env_secret_set`), never a hand-built request,
192
+ and never try to read a value back.
193
+ - **Never pass `--promote`, never run `oxyc promote` or `oxyc rollback`, and
194
+ never publish without `--app-env`.** When the sandbox build is right, say so
195
+ and leave the ship step to your engineer: they promote that build to staging
196
+ (`oxyc promote <app> --from dev-<handle>`), then to production. Your token is
197
+ refused both verbs anyway. Never use `production` as `--app-env` to "check" something:
198
+ reads already hit production.
199
+ - **A held write is information to report**, not an error to engineer around.
200
+ - **Clean up, even when a step failed.** Delete every sandbox you created,
201
+ then revoke your token — in that order, because the delete needs the token.
202
+ A sandbox you could not delete is removed a day after its token ends.
203
+ - **Leave the sandbox up when your engineer has to look at it**: they asked
204
+ for its link, or the change shows on a page. Say that it is still up, and
205
+ revoke the token once you will publish nothing more. The sandbox stays for a
206
+ day after its token ends, and your engineer can delete it sooner with the
207
+ `env delete` command on their own login.
208
+
209
+ ## You cannot open a page
210
+
211
+ The token signs no browser in, and the sandbox's host answers only a signed-in
212
+ staff session. So you verify the functions (`fn call`, `checks run`,
213
+ `invocations held`, `logs`) and your engineer verifies what a person sees. Do
214
+ not log in to get a session, and do not drive the app's production URL
215
+ instead: it does not serve your build. What your engineer does with the link
216
+ is in [verifying-in-the-browser.md](verifying-in-the-browser.md).
217
+
218
+ ## What to hand back
219
+
220
+ Whether the task worked or not:
221
+
222
+ - the deployment and the sandbox's name, and its link: `url` from
223
+ `env create`, `environment_url` from `publish` (`null` means the sandbox has
224
+ no host: say so);
225
+ - the build id(s) the publish answered;
226
+ - each function you called, with its input and whether it was `ok`; each
227
+ check's name and `passed`, or that the app declares no checks;
228
+ - every held write (`op`, `plane`, `table`, `note`);
229
+ - what you did not verify: under this token, everything on the page;
230
+ - the cleanup: the sandbox deleted or still up, and the token revoked.
231
+
232
+ Copy what you need out of `invocations` and `logs` into the report before you
233
+ delete the sandbox: read-back is not permanent. Never put the token in the
234
+ report.
235
+
236
+ ## When you cannot get a token
237
+
238
+ Stop and ask your engineer. Do not fall back to their login in any of these
239
+ cases:
240
+
241
+ - **`oxyc` rejects `--sandbox-agent` as an unknown option (exit `2`).** The
242
+ CLI is too old. Ask your engineer to update `@oxy-hq/cli`.
243
+ - **Exit `8`.** The deployment did not return the token you asked for: it
244
+ predates sandbox agent tokens, or returned one for other apps. Nothing was
245
+ printed, and what it minted was revoked.
246
+ - **Exit `7`.** Nobody approved in time (five minutes, or ten with
247
+ `--no-browser`). Ask your engineer what the page said. Only Oxy staff
248
+ holding `develop_apps` and `manage_apps` over the app's org can approve: if
249
+ the page refused, report what it said and stop. If they missed it, run the
250
+ command once more. If you named an `--approver`, say so: the request went to
251
+ that person's phone and inbox, so they may have missed the notice rather
252
+ than the page.
253
+ - **Exit `4`.** The approval code was rejected and nothing was minted. Run the
254
+ command once more, with fewer `--hours` if an organization caps a token's
255
+ lifetime. With `--no-browser`, exit `4` with `access_denied` means your
256
+ engineer declined, or the page refused the request: the reason is printed.
257
+ Report it and stop.
258
+ - **`--no-browser` is an unknown option (exit `2`), or the deployment has no
259
+ device-code approval (exit `8`).** The CLI or the deployment is too old for
260
+ a machine no browser can reach. Ask your engineer to update, or to run the
261
+ command on a machine with a browser and hand you the token.
262
+ - **`--approver` is an unknown option (exit `2`).** The CLI predates it. Run
263
+ without it and give your engineer the URL and the code yourself.
264
+
265
+ ## Over MCP
266
+
267
+ If your runtime speaks MCP, `oxyc mcp --env <deployment>` serves the same loop
268
+ as tools — `oxy_env_create` / `oxy_env_list` / `oxy_env_show` /
269
+ `oxy_env_delete`, `oxy_publish_sandbox`, `oxy_fn_call`, `oxy_checks_run`,
270
+ `oxy_invocations_list` / `oxy_invocations_held`, `oxy_logs`. It reads its
271
+ credential from `OXY_TOKEN` and nowhere else (a person on their own login
272
+ adds `--login`). With a sandbox agent token the list is those ten,
273
+ `oxy_whoami`, `oxy_env_secret_list` / `_set` / `_delete` and
274
+ `oxy_token_revoke` (`confirm: true`) — and no `oxy_request`. Prefer them to
275
+ shelling out: each calls its verb's own request-building code, so the result
276
+ is the document that verb's `--json` prints, and the choice changes nothing
277
+ on the server. There is no exit code over MCP: a failure is `isError: true`
278
+ with the class at the end of its text (`[exit 5 NOT_FOUND]`, plus `server
279
+ code: <code>` when the server sent one) — branch on that the way you would
280
+ on the exit code. `oxy_fn_call` and `oxy_checks_run` set `isError` when the
281
+ function or a check failed, with the full document still in the result.
282
+ `oxy_env_delete` needs `confirm: true`. `oxy_publish_sandbox` takes only a
283
+ `dev-<handle>` and cannot reach staging or production.
284
+
285
+ ## A sandbox isolates the app, not the workspace
286
+
287
+ The semantic model, automations and Airway pipelines a sandbox reads are
288
+ `main`'s. To try a workspace branch, open a **workspace preview** (staff
289
+ only) — a separate loop:
290
+
291
+ ```bash
292
+ oxyc preview create <branch> --wait --workspace <id> --json # compile the branch; idempotent
293
+ oxyc preview checks <branch> --workspace <id> --json # Airway change check of that revision
294
+ oxyc preview run <branch> procedure <file.automation.yml> --wait --workspace <id> --json # held dry run
295
+ oxyc preview runs list <branch> --workspace <id> --json # every run, the server's own included
296
+ oxyc preview runs show <run-id> --wait --workspace <id> --json # one run: held steps, outcome
297
+ oxyc preview delete <branch> --yes --workspace <id> --json # done
298
+ ```
299
+
300
+ `preview run`'s kind is `procedure` or `airway_sample`; never post a
301
+ `transform_build` or `compare` run — the server queues those itself, and you
302
+ read them with `preview runs list` / `runs show`. A `--wait` verb exits `1`
303
+ when what it waited for ended `failed` or `cancelled`, `7` past its deadline.
304
+ Over MCP the same verbs are `oxy_preview_create` / `_list` / `_show` /
305
+ `_delete` (`confirm: true`) / `_checks` / `_run` / `_runs_list` / `_run_show`.
306
+ To make a sandbox read the branch's semantic model, publish to it with
307
+ `--semantic-branch <branch>`.