@oxy-hq/cli 0.7.0 → 0.9.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 (38) hide show
  1. package/README.md +131 -8
  2. package/dist/main.mjs +4909 -3551
  3. package/dist/main.mjs.map +1 -1
  4. package/dist/{mcp-xxWL3_dz.mjs → mcp-Bdp0rX0l.mjs} +2 -2
  5. package/dist/{mcp-xxWL3_dz.mjs.map → mcp-Bdp0rX0l.mjs.map} +1 -1
  6. package/package.json +5 -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 +37 -4
  13. package/skills/oxy-custom-apps/SKILL.md +193 -291
  14. package/skills/oxy-custom-apps/references/minimal-starter.md +63 -0
  15. package/skills/oxy-custom-apps/references/performance-and-usage.md +105 -0
  16. package/skills/oxy-custom-apps/references/reading-data.md +106 -0
  17. package/skills/oxy-custom-apps/references/sandbox-agent-loop.md +267 -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/.github/workflows/publish.yaml +15 -13
  34. package/template/.nvmrc +1 -1
  35. package/template/.oxyc-managed +5 -5
  36. package/template/README.md +21 -10
  37. package/template/package.json +3 -2
  38. package/template/pnpm-workspace.yaml +28 -17
@@ -0,0 +1,63 @@
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`:
19
+ ```json
20
+ { "schemaVersion": 2, "slug": "my-app", "orgSlug": "your-org", "name": "My App" }
21
+ ```
22
+
23
+ `src/main.tsx`:
24
+ ```tsx
25
+ import { OxyAppProvider } from "@oxy-hq/sdk";
26
+ import { createRoot } from "react-dom/client";
27
+ import { App } from "./App";
28
+
29
+ createRoot(document.getElementById("root")!).render(
30
+ <OxyAppProvider><App /></OxyAppProvider>
31
+ );
32
+ ```
33
+
34
+ `src/App.tsx`:
35
+ ```tsx
36
+ import { useQuery } from "@oxy-hq/sdk";
37
+
38
+ export function App() {
39
+ const { rows, loading, error } = useQuery({
40
+ sql: "SELECT 1 AS hello",
41
+ });
42
+ if (loading) return <p>loading…</p>;
43
+ if (error) return <p style={{ color: "red" }}>{error.message}</p>;
44
+ return <pre>{JSON.stringify(rows, null, 2)}</pre>;
45
+ }
46
+ ```
47
+
48
+ `vite.config.ts`:
49
+ ```ts
50
+ import oxyApp from "@oxy-hq/vite-plugin";
51
+ import react from "@vitejs/plugin-react";
52
+ import { defineConfig } from "vite";
53
+
54
+ export default defineConfig({
55
+ plugins: [react(), oxyApp()],
56
+ });
57
+ ```
58
+
59
+ The plugin handles `outDir = "out"`, base-path resolution from the
60
+ manifest, dev-server proxy to `/api`, and the `window.__OXY_APP__`
61
+ dev shim. Plain `pnpm build` produces a bundle with the correct
62
+ asset base baked in. Then publish it:
63
+ [SKILL.md → Publishing](../SKILL.md#publishing).
@@ -0,0 +1,105 @@
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
+ **Two gotchas that read as bugs:**
98
+ - **`oxy-` is a reserved event-name prefix.** `useTrackEvent("oxy-…")` is refused with a
99
+ 400 — that namespace is the platform's own instrumentation. Name your events anything
100
+ else. (It is a collision guard, not a security boundary: the beacon rides your app's own
101
+ auth gate, so it is *your* rows that are protected from accidental merging, not made
102
+ unforgeable.)
103
+ - **A prefetch is not a view.** A launcher may warm an app on hover; Oxy checks
104
+ `Sec-Purpose`/`Purpose`/`X-moz` and does not record a view or start a session for a
105
+ 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,267 @@
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
+ - **Evaluate that line and work with the token.** In a shell that persists:
27
+ `eval "$(oxyc tokens create --sandbox-agent --app <org>/<app> --env <deployment>)"`.
28
+ In one that does not, keep the value and set `OXY_TOKEN` on each command.
29
+ `oxyc` writes nothing to the credentials file. Never write the token to a
30
+ file, a commit or your report.
31
+ - **Make the variable the only credential.** Put `--token-env OXY_TOKEN` on
32
+ every command after the mint, as the loop below does. With it, a command
33
+ that lost the variable exits `4` (`OXY_TOKEN is not set`): set it and run the
34
+ command again. Without it, `oxyc` falls back to your engineer's login cache
35
+ on this machine, and a publish with no `--app-env` then goes to the app's
36
+ draft channel as them.
37
+ - **Name every app the task touches** with its own `--app`, five at most.
38
+ `<org>/<app>` is `orgSlug` and `slug` from the app's `oxy-app.json`, not the
39
+ folder names, which can differ. When the manifest has no `orgSlug`, take the
40
+ org slug from the app's README or from your engineer. Pass `--hours` when
41
+ the task will outlast the default 8; the most is 168 (seven days). Apps and
42
+ lifetime are fixed at mint, and the token cannot be extended.
43
+ - **Check it** with `oxyc whoami --env <deployment> --token-env OXY_TOKEN --json`:
44
+ `"kind": "sandbox_agent"`, your app in `apps`, and an `expires_at` after the
45
+ task ends. Run it first when `OXY_TOKEN` was already set before you started:
46
+ without that `kind` it is someone else's credential, and you mint your own.
47
+ Then `oxyc env list <org>/<app> --env <deployment> --token-env OXY_TOKEN --json`:
48
+ exit `0` means the app and the deployment are right.
49
+ - **Set `OXY_AGENT=<your name>`** beside it, so the audit log tells your
50
+ requests from your engineer's.
51
+ - `--env` is the deployment (`dev`, `staging`, `production`). Pass the same
52
+ one on every command; omitted, it is `production`. Use the one your engineer
53
+ named. When they named none, use `production` and say so when you tell them
54
+ the approval is waiting.
55
+
56
+ For anything that is not the sandbox loop — reading data with `oxyc api`,
57
+ reproducing a bug against live data — the agent's credential is a different
58
+ token, `oxyc tokens create --agent`: see [reading-data.md](reading-data.md).
59
+
60
+ ## What it reaches
61
+
62
+ One to five named apps, and only `dev-<handle>` sandboxes it created itself —
63
+ three at a time at most, counting ones still being deleted. Everything else
64
+ answers `404`: production and staging, another app, someone else's sandbox,
65
+ `/admin`, a secret's value. A publish to a channel (staging, production) or a
66
+ promote answers `403`. `oxyc` refuses those two, and `oxyc api`, `oxyc apps`
67
+ and `oxyc login-link`, before any request (exit `2`). **Shipping stays with a
68
+ person.**
69
+
70
+ ## The loop
71
+
72
+ Under the token the environment must be named: `publish`, `fn call`,
73
+ `checks run`, `invocations list` and `logs` are refused without
74
+ `--app-env dev-<handle>` (exit `2`). Run `publish` from the app directory: it
75
+ reads the app from the `oxy-app.json` there. Every other command takes
76
+ `<org>/<app>`.
77
+
78
+ ```bash
79
+ # 1. Create it. It starts with no build. `url` in the answer is the sandbox's link.
80
+ oxyc env create <org>/<app> dev-x --env <deployment> --token-env OXY_TOKEN --json
81
+ # 2. Build + publish into it. Success: "channel":"sandbox" and "environment":"dev-x".
82
+ oxyc publish --app-env dev-x --env <deployment> --token-env OXY_TOKEN --json
83
+ # Optional: a secret of the sandbox's own, read from a variable.
84
+ oxyc env secret set <org>/<app> KEY --app-env dev-x --value-env VAR --env <deployment> --token-env OXY_TOKEN
85
+ # 3. Call what you changed. Success: exit 0 and "ok":true. Then run the app's checks.
86
+ oxyc fn call <org>/<app> <function> --app-env dev-x --data '{}' --env <deployment> --token-env OXY_TOKEN --json
87
+ oxyc checks run <org>/<app> --app-env dev-x --env <deployment> --token-env OXY_TOKEN --json
88
+ # 4. Read back: what ran (a row's id is `id`), what it held instead of writing, its logs.
89
+ oxyc invocations list <org>/<app> --app-env dev-x --env <deployment> --token-env OXY_TOKEN --json
90
+ oxyc invocations held <org>/<app> <invocation-id> --env <deployment> --token-env OXY_TOKEN --json
91
+ oxyc logs <org>/<app> --app-env dev-x --env <deployment> --token-env OXY_TOKEN --json
92
+ # 5. When the task is done: delete, then revoke, in that order.
93
+ oxyc env delete <org>/<app> dev-x --yes --wait --env <deployment> --token-env OXY_TOKEN --json
94
+ oxyc tokens revoke --current --env <deployment> --token-env OXY_TOKEN
95
+ ```
96
+
97
+ `dev-x` is `dev-<handle>`: 1–12 characters of `a-z`, `0-9` and single hyphens,
98
+ not starting or ending with one. Name it after the task
99
+ (`dev-<short-task-slug>`), one per agent and task, never reused across
100
+ unrelated tasks, so parallel agents do not collide. If your runtime speaks
101
+ MCP, the same loop is served as tools — see [Over MCP](#over-mcp).
102
+
103
+ ## Publishing into it
104
+
105
+ Three things to know before the first publish:
106
+
107
+ - **`missing org: the app's base path needs it` (exit `2`).** `publish` takes
108
+ the org from `--org`, `OXY_ORG`, the `orgSlug` in the directory's
109
+ `oxy-app.json`, else the folder under an `apps/<org>/<app>/` path. An app
110
+ whose manifest names no org, outside such a path, has none. An app that
111
+ keeps its manifest in `public/` gets the folder name, which is wrong
112
+ wherever the folder is not the slug. In both cases add `--org <org-slug>`,
113
+ and `--app <slug>` when the directory has no manifest. Pass the slug, never
114
+ a UUID: the asset base path is baked from it.
115
+ - **`pnpm install` stops with `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`
116
+ (exit `1`).** The default install step is `pnpm install`; pnpm wants to
117
+ rebuild `node_modules` and your shell has no terminal for it to ask on. Run
118
+ the same command with `CI=true` in front.
119
+ - **A broken build costs an approval.** `oxyc publish --build-only` builds and
120
+ bundles with no credential, so run it before you ask for a token (it takes
121
+ `--org` too). `oxyc publish --dir out --prebuilt --app-env dev-x …` then
122
+ publishes that build without building again.
123
+
124
+ ## What a sandbox does with a call
125
+
126
+ Reads hit production data. A write lands in a home of the sandbox's own, or it
127
+ is *held*: recorded in `invocations held` and not performed. A held write is
128
+ the expected answer outside production. Report it. Unless the task asks for
129
+ it, do not edit `nonProduction` in the manifest to make the write land:
130
+ staging and every other sandbox share that map. Schedules and webhooks do not
131
+ fire in a sandbox: call the function. The `fn call` result carries the
132
+ function's `logs`; `oxyc logs` shows the same lines about 30 seconds later.
133
+
134
+ ## When a command fails
135
+
136
+ Branch on the exit code and the JSON fields, not on the text:
137
+
138
+ | Exit | From | It means | Do |
139
+ | --- | --- | --- | --- |
140
+ | `1` | `publish` | The build or the Oxy Function lint failed. | Fix what the error names, or see [Publishing into it](#publishing-into-it). |
141
+ | `1` | `fn call` | The function threw or answered a non-2xx: `"ok": false`. | Read `body` and `logs` in the result. It is your code. |
142
+ | `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. |
143
+ | `2` | any | The command was wrong, or the token may not do it. Nothing was sent. | Read the message. Do not retry it unchanged. |
144
+ | `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. |
145
+ | `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. |
146
+ | `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. |
147
+ | `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. |
148
+ | `8` | `env delete` | Refused: it had no `--yes` and no terminal to ask on. | Add `--yes`. |
149
+ | `8` | anything else | A refusal. | Stop and report. Do not retry with another credential or flag. |
150
+ | `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. |
151
+
152
+ ## Rules
153
+
154
+ - **Never borrow another credential.** Do not run `oxyc login`, read the login
155
+ cache (`credentials.json`, `oxyc token`), or use an engineer's personal
156
+ token or API key. What the token does not reach is a person's step: ask your
157
+ engineer. Exit `4` under the token means it expired, was revoked, or your
158
+ engineer lost access to the app: stop and report.
159
+ - **Never put an Oxy credential into a sandbox secret.** The server refuses a
160
+ value shaped like one (`400 credential_shaped_value`). Set a secret with
161
+ `oxyc env secret set` (`oxy_env_secret_set`), never a hand-built request,
162
+ and never try to read a value back.
163
+ - **Never pass `--promote`, and never publish without `--app-env`.** A sandbox
164
+ build is never promoted. When it is right, say so and leave the ship step to
165
+ your engineer. Never use `production` as `--app-env` to "check" something:
166
+ reads already hit production.
167
+ - **A held write is information to report**, not an error to engineer around.
168
+ - **Clean up, even when a step failed.** Delete every sandbox you created,
169
+ then revoke your token — in that order, because the delete needs the token.
170
+ A sandbox you could not delete is removed a day after its token ends.
171
+ - **Leave the sandbox up when your engineer has to look at it**: they asked
172
+ for its link, or the change shows on a page. Say that it is still up, and
173
+ revoke the token once you will publish nothing more. The sandbox stays for a
174
+ day after its token ends, and your engineer can delete it sooner with the
175
+ `env delete` command on their own login.
176
+
177
+ ## You cannot open a page
178
+
179
+ The token signs no browser in, and the sandbox's host answers only a signed-in
180
+ staff session. So you verify the functions (`fn call`, `checks run`,
181
+ `invocations held`, `logs`) and your engineer verifies what a person sees. Do
182
+ not log in to get a session, and do not drive the app's production URL
183
+ instead: it does not serve your build. What your engineer does with the link
184
+ is in [verifying-in-the-browser.md](verifying-in-the-browser.md).
185
+
186
+ ## What to hand back
187
+
188
+ Whether the task worked or not:
189
+
190
+ - the deployment and the sandbox's name, and its link: `url` from
191
+ `env create`, `environment_url` from `publish` (`null` means the sandbox has
192
+ no host: say so);
193
+ - the build id(s) the publish answered;
194
+ - each function you called, with its input and whether it was `ok`; each
195
+ check's name and `passed`, or that the app declares no checks;
196
+ - every held write (`op`, `plane`, `table`, `note`);
197
+ - what you did not verify: under this token, everything on the page;
198
+ - the cleanup: the sandbox deleted or still up, and the token revoked.
199
+
200
+ Copy what you need out of `invocations` and `logs` into the report before you
201
+ delete the sandbox: read-back is not permanent. Never put the token in the
202
+ report.
203
+
204
+ ## When you cannot get a token
205
+
206
+ Stop and ask your engineer. Do not fall back to their login in any of these
207
+ cases:
208
+
209
+ - **`oxyc` rejects `--sandbox-agent` as an unknown option (exit `2`).** The
210
+ CLI is too old. Ask your engineer to update `@oxy-hq/cli`.
211
+ - **Exit `8`.** The deployment did not return the token you asked for: it
212
+ predates sandbox agent tokens, or returned one for other apps. Nothing was
213
+ printed, and what it minted was revoked.
214
+ - **Exit `7`.** Nobody approved within five minutes. Ask your engineer what
215
+ the page said. Only Oxy staff holding `develop_apps` and `manage_apps` over
216
+ the app's org can approve: if the page refused, report what it said and
217
+ stop. If they missed it, run the command once more.
218
+ - **Exit `4`.** The approval code was rejected and nothing was minted. Run the
219
+ command once more, with fewer `--hours` if an organization caps a token's
220
+ lifetime.
221
+ - **No browser on this machine can open the page** (a remote container, a CI
222
+ job). The page answers to a local port on the machine `oxyc` runs on, so it
223
+ cannot be approved from another one.
224
+
225
+ ## Over MCP
226
+
227
+ If your runtime speaks MCP, `oxyc mcp --env <deployment>` serves the same loop
228
+ as tools — `oxy_env_create` / `oxy_env_list` / `oxy_env_show` /
229
+ `oxy_env_delete`, `oxy_publish_sandbox`, `oxy_fn_call`, `oxy_checks_run`,
230
+ `oxy_invocations_list` / `oxy_invocations_held`, `oxy_logs`. It reads its
231
+ credential from `OXY_TOKEN` and nowhere else (a person on their own login
232
+ adds `--login`). With a sandbox agent token the list is those ten,
233
+ `oxy_whoami`, `oxy_env_secret_list` / `_set` / `_delete` and
234
+ `oxy_token_revoke` (`confirm: true`) — and no `oxy_request`. Prefer them to
235
+ shelling out: each calls its verb's own request-building code, so the result
236
+ is the document that verb's `--json` prints, and the choice changes nothing
237
+ on the server. There is no exit code over MCP: a failure is `isError: true`
238
+ with the class at the end of its text (`[exit 5 NOT_FOUND]`, plus `server
239
+ code: <code>` when the server sent one) — branch on that the way you would
240
+ on the exit code. `oxy_fn_call` and `oxy_checks_run` set `isError` when the
241
+ function or a check failed, with the full document still in the result.
242
+ `oxy_env_delete` needs `confirm: true`. `oxy_publish_sandbox` takes only a
243
+ `dev-<handle>` and cannot reach staging or production.
244
+
245
+ ## A sandbox isolates the app, not the workspace
246
+
247
+ The semantic model, automations and Airway pipelines a sandbox reads are
248
+ `main`'s. To try a workspace branch, open a **workspace preview** (staff
249
+ only) — a separate loop:
250
+
251
+ ```bash
252
+ oxyc preview create <branch> --wait --workspace <id> --json # compile the branch; idempotent
253
+ oxyc preview checks <branch> --workspace <id> --json # Airway change check of that revision
254
+ oxyc preview run <branch> procedure <file.automation.yml> --wait --workspace <id> --json # held dry run
255
+ oxyc preview runs list <branch> --workspace <id> --json # every run, the server's own included
256
+ oxyc preview runs show <run-id> --wait --workspace <id> --json # one run: held steps, outcome
257
+ oxyc preview delete <branch> --yes --workspace <id> --json # done
258
+ ```
259
+
260
+ `preview run`'s kind is `procedure` or `airway_sample`; never post a
261
+ `transform_build` or `compare` run — the server queues those itself, and you
262
+ read them with `preview runs list` / `runs show`. A `--wait` verb exits `1`
263
+ when what it waited for ended `failed` or `cancelled`, `7` past its deadline.
264
+ Over MCP the same verbs are `oxy_preview_create` / `_list` / `_show` /
265
+ `_delete` (`confirm: true`) / `_checks` / `_run` / `_runs_list` / `_run_show`.
266
+ To make a sandbox read the branch's semantic model, publish to it with
267
+ `--semantic-branch <branch>`.
@@ -0,0 +1,65 @@
1
+ # Verify it in the browser (CI does not check a published bundle)
2
+
3
+ Nothing checks a bundle before it reaches users. `tsc` and `vite build` pass on
4
+ a card rendering a red error banner, a grid with ragged rows, a button wired to
5
+ nothing, or a component misreading its function's response shape. **The build is
6
+ not the check — driving the published app is.**
7
+
8
+ ## An agent on a sandbox agent token cannot do this on a cloud target
9
+
10
+ And says so. The token signs no browser in. Your sandbox's page is on its own
11
+ host (the `url` from `env create`), behind a staff session, and `oxyc proxy`
12
+ cannot target a sandbox. Verify the functions in the sandbox, hand your
13
+ engineer the link, and list what on the page you could not check
14
+ ([What to hand back](sandbox-agent-loop.md#what-to-hand-back)). The rest of
15
+ this file is what they do with that link.
16
+
17
+ ## Drive the page
18
+
19
+ Use a browser-automation MCP server — the Playwright one (`@playwright/mcp`)
20
+ fits. After `oxyc publish`, for anything touching `src/**` or a function a
21
+ card calls:
22
+
23
+ ```
24
+ browser_navigate → the published /customer-apps/<org>/<slug>/ URL
25
+ browser_find → locate a control by its text, get its ref
26
+ browser_click → drive it
27
+ browser_evaluate → MEASURE (grid widths, computed styles, element counts)
28
+ browser_take_screenshot → look, and compare before/after for visual work
29
+ ```
30
+
31
+ **Calling the function endpoint is not verifying the UI.** A bearer-token
32
+ `fetch` proves the handler returns JSON. It does not prove the card renders it,
33
+ that the button is wired, or that a timer stops when told — and it can mislead
34
+ outright: a bearer-token call and a browser session don't always resolve the
35
+ same app state, so such a check can "pass" against data the page never shows.
36
+ If the claim is about what a person sees, drive the page.
37
+
38
+ `browser_evaluate` is the underused one. Design questions are usually
39
+ measurable, and measuring beats squinting:
40
+
41
+ ```js
42
+ // Ragged rows? Sum each grid row's children against the container.
43
+ const grid = document.querySelector("main > div");
44
+ const rows = {};
45
+ for (const el of grid.children) {
46
+ const b = el.getBoundingClientRect();
47
+ (rows[Math.round(b.top)] ||= []).push(Math.round(b.width));
48
+ }
49
+ // any row summing well under the container width is a gap
50
+ ```
51
+
52
+ ## Two prerequisites that bite
53
+
54
+ - **The app is behind auth.** The browser must hold a session for the target
55
+ Oxy deployment — sign in there first.
56
+ - **`pnpm dev` against cloud data returns `origin not allowed`** — the data API
57
+ rejects a localhost origin for a published app. Drive the published URL, or
58
+ run the `oxyc proxy` sidecar
59
+ ([reading-data.md](reading-data.md#working-locally-against-cloud-data-oxyc-proxy)).
60
+
61
+ ## Check the whole page
62
+
63
+ Not just the card you changed. A shared component (a layout wrapper, a design
64
+ primitive) touches every card, and a section you didn't open is where the
65
+ regression hides.