@oxy-hq/cli 0.8.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 +89 -7
  2. package/dist/main.mjs +4914 -3760
  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 +32 -5
  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
@@ -1,36 +1,47 @@
1
1
  ---
2
2
  name: oxy-custom-apps
3
- description: Use when building, debugging, reviewing, or shipping an Oxy custom-app bundle (a code-first React/Vite app served by oxy), including writing Oxy Functions and driving them from the UI. Encodes the contract rules, pitfalls, performance guardrails, and the self-serve `oxyc publish` ship flow.
3
+ description: >-
4
+ Use when building, debugging, reviewing, shipping, or verifying an Oxy
5
+ custom-app bundle (a code-first React/Vite app served by Oxy at
6
+ /customer-apps/<org>/<slug>/), including writing Oxy Functions, driving them
7
+ from the UI, and checking a published app in the browser with the Playwright
8
+ MCP server (no CI checks a published bundle). Covers the oxy-app.json
9
+ contract, @oxy-hq/sdk and @oxy-hq/vite-plugin, where app data goes
10
+ (ctx.airhouse, ctx.oltp, ctx.storage), pitfalls, performance guardrails, load
11
+ speed and usage tracking (service worker, Activity tab, useTrackEvent), the
12
+ self-serve oxyc publish ship flow, dev-<handle> sandboxes and workspace
13
+ previews, how an AI agent gets its own short-lived credential (oxyc tokens
14
+ create --sandbox-agent or --agent, never the engineer's oxyc login), and
15
+ reading a deployment's live data with oxyc api and oxyc proxy (which replaced
16
+ oxy api and the Rust oxy login, publish and proxy).
4
17
  ---
5
18
 
6
- # Customer-app development
19
+ # Custom-app development
7
20
 
8
- Customer-app bundles are arbitrary JS apps served by oxy at
21
+ Custom-app bundles are arbitrary JS apps served by Oxy at
9
22
  `https://<env>/customer-apps/<org>/<slug>/`. Oxy is a data gateway:
10
23
  serves static assets, gates cookie auth, and proxies queries. The
11
24
  bundle is opaque above the data plane — no per-app server config.
12
25
 
26
+ This file is the contract. The detail lives in `references/` — see
27
+ [Read next](#read-next) for which file answers what.
28
+
13
29
  ## The Oxy App Kit (use it)
14
30
 
15
31
  Two small packages remove the footguns — default to them rather than wiring a
16
32
  bundle by hand:
17
33
 
18
34
  - **`@oxy-hq/sdk`** — the data layer (`useQuery`, `useSemanticQuery`,
19
- `useAgentRun`, `<OxyChat>`); see rule 2 and `oxy-hq/customer-apps:
20
- docs/sdk-flow.md`.
35
+ `useAgentRun`, `<OxyChat>`); see rule 2 and the `@oxy-hq/sdk` README.
21
36
  - **`@oxy-hq/vite-plugin`** (`oxyApp()` in `vite.config.ts`) — bakes the asset
22
37
  **base path** from the manifest, copies `oxy-app.json` into `out/`, proxies
23
38
  `/api` in dev, and injects the `window.__OXY_APP__` shim. Dropping it
24
39
  re-introduces the #1 bug — asset 404s → blank dashboard (rule 3).
25
40
 
26
- Scaffold both with `@oxy-hq/create-oxy-app` or by copying `oxy-hq/customer-apps:
27
- examples/hello-oxy/`. The
28
- platform is kit-agnostic (any bundle with a correct base path works), but
29
- hand-rolling means owning the base-path + dev-proxy wiring yourself.
30
-
31
- (**`oxy-hq/customer-apps:` prefixes a path in that repo**, not in whatever repo
32
- you are sitting in. This skill loads globally, so every such pointer is
33
- qualified.)
41
+ Scaffold both with `pnpm create @oxy-hq/oxy-app my-app` (templates `vite` — the
42
+ default — `functions`, `dashboard`, `single-store`; `-t functions` adds Oxy
43
+ Functions). The platform is kit-agnostic (any bundle with a correct base path
44
+ works), but hand-rolling means owning the base-path + dev-proxy wiring yourself.
34
45
 
35
46
  ## Three rules
36
47
 
@@ -60,9 +71,10 @@ qualified.)
60
71
  don't exist in v2 and the probe rejects them.
61
72
 
62
73
  File location: **`<root>/oxy-app.json`**, sibling to
63
- `vite.config.ts`. The vite-plugin reads it from there; legacy
64
- bundles that kept it under `public/` won't see the plugin's
65
- base-path derivation kick in.
74
+ `vite.config.ts`. The vite-plugin reads it from there, and so does
75
+ `oxyc publish` (only from the directory it runs in); legacy bundles
76
+ that kept it under `public/` get neither the plugin's base-path
77
+ derivation nor the publish's `slug` / `orgSlug`.
66
78
 
67
79
  2. **All data flows through SDK hooks → oxy HTTP endpoints.** The
68
80
  surface is intentionally small:
@@ -86,8 +98,8 @@ qualified.)
86
98
  <OxyChat agentId="analytics" />
87
99
  ```
88
100
 
89
- See `oxy-hq/customer-apps: docs/sdk-flow.md` for the full reference,
90
- including `OxyApiError` parsing and beta-surface caveats.
101
+ See the `@oxy-hq/sdk` README for the full reference, including
102
+ `OxyApiError` parsing and beta-surface caveats.
91
103
 
92
104
  **Server-side work → Oxy Functions, not the browser.** `useQuery` &
93
105
  friends run in the client; for work the browser shouldn't do
@@ -118,9 +130,10 @@ qualified.)
118
130
  `ctx.storage` — and customer warehouses are read-only. See
119
131
  **Where data goes** below before writing anything.
120
132
  `oxyc publish` bundles `functions/*.ts` with
121
- esbuild — no separate backend to stand up. Worked example +
122
- author-facing `ctx` types: `oxy-hq/customer-apps:
123
- examples/hello-oxy/functions/`.
133
+ esbuild — no separate backend to stand up. Author-facing types:
134
+ `OxyFunctionContext` / `OxyFunctionRequest` from `@oxy-hq/sdk`; a worked
135
+ example ships in the `functions` template (`pnpm create @oxy-hq/oxy-app
136
+ my-app -t functions`).
124
137
 
125
138
  3. **`OXY_APP_BASE_PATH` must match the path the bundle is linked
126
139
  under.** `@oxy-hq/vite-plugin` resolves it from (in order):
@@ -139,20 +152,12 @@ qualified.)
139
152
  | **Files** — photos, PDFs, exports | Your app's storage silo | `ctx.storage` | function: `"storage": { "read": true, "write": true }` |
140
153
  | **The customer's warehouse** — ClickHouse, Snowflake, BigQuery, their Postgres | Theirs | `ctx.query` · `ctx.warehouse.query(db, sql)` | read-only: a write is refused unless the function declares `"customerWarehouseWrites": { "<db>": "<why>" }` |
141
154
 
142
- **Availability.** `ctx.airhouse`, `ctx.oltp.tx`, `airhouseMigrations`, the
143
- read-only rule for customer warehouses and `oxyc validate`'s data-placement
144
- and manifest checks arrive with the Oxy release carrying oxygen-internal#3187,
145
- together with the `@oxy-hq/sdk` and `@oxy-hq/cli` published from it. Against
146
- an older Oxy, `ctx.airhouse` is `undefined` and the new manifest fields are
147
- ignored; vendored `oxy.d.ts` copies (`examples/hello-oxy/functions/oxy.d.ts`)
148
- gain the types when they are re-synced from that SDK.
149
-
150
155
  Three questions, in order: **Is it bytes?** → storage (keep the key in a record
151
156
  or fact). **Does it change after it's written?** No → a fact; yes → a record;
152
157
  both when you need history (the record says what is true now, the fact says
153
158
  what changed). **Is it already in the customer's warehouse?** → read it there.
154
159
 
155
- `<writer>` is your slug with `-` → `_` (`store-ops` → `app_store_ops`); a slug
160
+ `<writer>` is your slug with `-` → `_` (`my-app` → `app_my_app`); a slug
156
161
  with `_` can't back a schema. Both stores are written **as the app**, whoever
157
162
  invoked the function, so a scheduled run writes like a click.
158
163
 
@@ -189,6 +194,90 @@ async function completeChecklist(ctx, runId: string, storeGuid: string) {
189
194
  }
190
195
  ```
191
196
 
197
+ ## Telling people something
198
+
199
+ Name **people**; the platform reaches them. Don't build a recipient table, a
200
+ "sent" log or a Slack webhook helper:
201
+
202
+ ```ts
203
+ await ctx.notify({
204
+ to: { role: "Store Manager", location: storeId }, // or { userId } · { team }
205
+ kind: "list_overdue", // declared in oxy-app.json
206
+ title: "Opening list is 30 minutes late",
207
+ link: `/lists/${listId}`, // a path inside the app
208
+ email: true, // optional; slack: true too
209
+ idempotencyKey: `overdue:${occurrenceId}`, // retry-safe
210
+ });
211
+ ```
212
+
213
+ `oxy-app.json`: `"notify": { "kinds": { "list_overdue": {} } }` on the function
214
+ (`{ "critical": true }` for a kind people must not mute). Everyone lands in
215
+ their Oxygen inbox — frontline crew too, who have no email — and is pushed,
216
+ emailed or Slacked where they can be. `slack: true` DMs each person's own Slack
217
+ account (found by their email once the org has connected Slack), so an app
218
+ never holds a Slack token or maps users to Slack ids. Needs the org's OLTP
219
+ store. Use
220
+ `ctx.email.send` only for addresses **outside** the org (a vendor, an
221
+ accountant), and give it an `idempotencyKey` in a scheduled function.
222
+
223
+ ### Categories, levels, threads and actions
224
+
225
+ Describe your kinds once, at the top level of `oxy-app.json`. It is the tree
226
+ people mute in (a whole app, a category, or one kind) and the one a phone groups
227
+ by, so declare it with the first `ctx.notify`, not later:
228
+
229
+ ```json
230
+ "notifications": {
231
+ "categories": {
232
+ "operations": { "label": "Store operations" },
233
+ "operations/food-safety": { "label": "Food safety" }
234
+ },
235
+ "kinds": {
236
+ "list_overdue": { "category": "operations", "label": "Overdue lists", "level": "time_sensitive" },
237
+ "temp_alarm": { "category": "operations/food-safety", "label": "Temperature alarm", "level": "critical",
238
+ "actions": [{ "id": "ack", "label": "Acknowledge", "function": "ack-alarm" }] }
239
+ }
240
+ }
241
+ ```
242
+
243
+ - **Category**: a path of up to 3 segments (`a-z0-9`, `_`, `-`); a declared
244
+ path's parents need no entry. A kind with none is in `general`.
245
+ - **Level**: `passive`, `active` (default), `time_sensitive`, `critical`. Only
246
+ `critical` ignores mutes; a function's `"critical": true` still makes it so.
247
+ - **Actions**: at most 3, label ≤ 24 characters. With `function` (one this app
248
+ declares), a tap calls it as the person with `{ notificationId, action, link }`
249
+ and marks it read; without, it opens the link.
250
+ - **Thread**: ``ctx.notify({ …, thread: `store:${storeId}` })`` (≤ 128
251
+ characters) folds a run of notifications about one thing into the newest.
252
+ - The function's `notify.kinds` still decides what it may send; a kind it sends
253
+ that the catalogue leaves out gets the defaults. `oxyc validate`, the Vite
254
+ plugin (`pnpm dev` / `pnpm build`) and `oxyc publish` refuse a malformed or
255
+ unknown category, a bad level, more than 3 actions, an action naming an
256
+ undeclared function, a misspelt field and any `oxy.` kind — every problem at
257
+ once; a catalogue kind no function sends is a warning.
258
+
259
+ The app opens in its own tab, so show the inbox **in the app** — people who
260
+ live in it (crew on a kiosk) never see Oxygen's bell:
261
+
262
+ ```tsx
263
+ import { NotificationsBell } from "@oxy-hq/sdk";
264
+
265
+ <NotificationsBell onNavigate={(link) => navigate(link)} /> // in the header
266
+ ```
267
+
268
+ It lists this app's notifications (`scope="all"` for the whole inbox), each
269
+ with its category under the title, `critical` and `time_sensitive` ones marked,
270
+ and threads folded; a tap
271
+ hands the `link` you passed to `ctx.notify` to `onNavigate` (without it, the
272
+ page loads that path under the app's base). It renders nothing where there is
273
+ no inbox (signed out, an older server, `pnpm dev`). `useNotifications()` gives
274
+ `{ items, unread, markRead, markAllRead, refresh, … }` to draw your own, and
275
+ takes `{ category, kind, thread }` to narrow it. Theme
276
+ it with `--oxy-notifications-*` CSS variables. Push sign-up and mutes stay in
277
+ Oxygen's Settings → Notifications — don't build them. In tests,
278
+ `createTestInbox({ manifest })` from `@oxy-hq/sdk/testing` is a fake inbox to pass as
279
+ `<OxyAppProvider fetcher={inbox.fetcher}>`.
280
+
192
281
  ## Four pitfalls
193
282
 
194
283
  - **CORS errors are usually wrong-URL errors in disguise.** The SDK's
@@ -211,154 +300,64 @@ async function completeChecklist(ctx, runId: string, storeGuid: string) {
211
300
  rely on the nil UUID `00000000-...` as a default project; don't
212
301
  assume `OXY_STATE_DIR` is writable.
213
302
 
214
- ## Performance guardrails
215
-
216
- Any app that ships an Oxy Function or drives a batch of them should keep these —
217
- cheap up front, hard to retrofit. (Established by the pokehouse `post-je` function.)
218
-
219
- **In an Oxy Function — count the round-trips.** Each isolate run is stateless and
220
- pays a fresh network hop per `ctx.query` / `ctx.warehouse.*`, so serial *dependent*
221
- calls dominate latency.
222
- - Run independent data-plane calls with `Promise.all`, not sequential `await`
223
- (e.g. resolve reference data **and** provision tables concurrently — only the
224
- final step needs both).
225
- - Fold a check into a query that already runs: a duplicate/existence guard is a
226
- scalar subquery on the main `SELECT`, not a second round-trip.
227
- - `Promise.all` idempotent DDL (`CREATE TABLE IF NOT EXISTS`); better, keep
228
- provisioning off the hot path if a migration can own it.
229
- - A "cheap" extra `ctx.query`/`ctx.log` is N× the cost across N invocations.
230
-
231
- **Driving many function calls from the UI (bulk actions) — don't go serial.**
232
- - Independent calls run with **bounded concurrency** (~3–4) via a worker-pool over a
233
- queue, not one-at-a-time — turns an O(N) wall-clock batch into a few waves.
234
- - If the calls share mutable server state that races under concurrency (a rotating
235
- token, a lazily-created table), the best fix is to **remove the shared mutation**
236
- (e.g. a single-writer scheduled refresher owns the token; requests only read).
237
- When you can't, warm the state with one call first, then fan out.
238
- - Don't refetch derived state (a status ledger) after *every* item — the progress UI
239
- is the live feedback; **throttle** the refetch (~1/s) and flush once at the end.
240
- - Guard `setState` after the component may have unmounted mid-batch (`mounted` ref).
241
-
242
- **Trust the server, not the client body.** Environment / tenant / which-secret
243
- selection is derived server-side (a function secret, a server-side mapping table,
244
- the authed identity), never from the request body — validate or override anything
245
- the client sends that picks a target.
246
-
247
- **Reuse the SDK's data-plane caching.** `useQuery` already dedups concurrent identical
248
- queries and short-caches results — don't hand-roll a fetch that bypasses it. Mark
249
- read-only functions `cache: { ttlSeconds }` in the manifest; **never** cache a
250
- side-effecting one (it silently drops writes). `idempotencyKey` = a fresh UUID
251
- per ATTEMPT (click), never a stable per-subject key: the server stores each
252
- key's response indefinitely and replays it WITHOUT running the function, so a
253
- `store:period` key silently breaks any post → compensate (delete) → post-again
254
- cycle — the second post "succeeds" with the stale response and writes nothing.
255
- The key shields one request from transport duplication; cross-attempt dedup
256
- belongs in the function itself (a server-side guard over real state, e.g. a
257
- posted-ledger row the compensating action clears).
258
-
259
- **Watch-outs when optimizing (each surfaced in review):**
260
- - **Clear UI running-state in `finally`.** An async batch driver must reset its
261
- `running`/loading flag (and do its final refetch) in a `finally`, not after the
262
- `await` — a stray throw otherwise leaves the bar stuck at "…" with the button
263
- disabled.
264
- - **Parallelizing can move a side effect ahead of a gate.** Running an independent
265
- side-effecting call concurrently with a validation query means it can now fire
266
- even when validation would reject. Fine if the side effect is benign — just know
267
- you traded the ordering for the latency.
268
- - **Additive migration for a scoped guard → backfill legacy rows to their TRUE value,
269
- not a constant.** When a dedup/uniqueness guard starts keying on a new column,
270
- `ADD COLUMN … DEFAULT <constant>` silently defeats the guard for pre-existing rows.
271
- Derive the real value from an existing column (e.g. a DocNumber prefix) so old rows
272
- stay correctly scoped.
273
-
274
- ## Minimal starter
275
-
276
- Easiest path: `pnpm dlx @oxy-hq/create-oxy-app@latest my-app --template vite`.
277
- The scaffolded layout:
278
-
279
- ```
280
- my-app/
281
- ├── oxy-app.json ← project root, not public/
282
- ├── src/
283
- │ ├── App.tsx
284
- │ └── main.tsx
285
- ├── package.json
286
- ├── vite.config.ts
287
- └── tsconfig.json
288
- ```
289
-
290
- `oxy-app.json`:
291
- ```json
292
- { "schemaVersion": 2, "slug": "my-app", "orgSlug": "test", "name": "My App" }
293
- ```
294
-
295
- `src/main.tsx`:
296
- ```tsx
297
- import { OxyAppProvider } from "@oxy-hq/sdk";
298
- import { createRoot } from "react-dom/client";
299
- import { App } from "./App";
300
-
301
- createRoot(document.getElementById("root")!).render(
302
- <OxyAppProvider><App /></OxyAppProvider>
303
- );
304
- ```
305
-
306
- `src/App.tsx`:
307
- ```tsx
308
- import { useQuery } from "@oxy-hq/sdk";
309
-
310
- export function App() {
311
- const { rows, loading, error } = useQuery({
312
- sql: "SELECT 1 AS hello",
313
- });
314
- if (loading) return <p>loading…</p>;
315
- if (error) return <p style={{ color: "red" }}>{error.message}</p>;
316
- return <pre>{JSON.stringify(rows, null, 2)}</pre>;
317
- }
318
- ```
319
-
320
- `vite.config.ts`:
321
- ```ts
322
- import oxyApp from "@oxy-hq/vite-plugin";
323
- import react from "@vitejs/plugin-react";
324
- import { defineConfig } from "vite";
325
-
326
- export default defineConfig({
327
- plugins: [react(), oxyApp()],
328
- });
329
- ```
330
-
331
- The plugin handles `outDir = "out"`, base-path resolution from the
332
- manifest, dev-server proxy to `/api`, and the `window.__OXY_APP__`
333
- dev shim. Plain `pnpm build` produces a bundle with the correct
334
- asset base baked in.
303
+ Two more that read as bugs — `useTrackEvent("oxy-…")` is refused (`400`,
304
+ the prefix is reserved), and a hovered-but-never-opened app records no view —
305
+ are in [references/performance-and-usage.md](references/performance-and-usage.md).
335
306
 
336
307
  ## Publishing
337
308
 
338
- Ship with `oxyc publish` — **no CI, no S3 sync, no folder picker**. From
339
- the app directory (`oxy-hq/customer-apps: examples/hello-oxy/` is the worked
340
- shell; note its manifest deliberately carries no `orgSlug`, leaving the org to
341
- be resolved at publish time):
309
+ **These commands are a person's.** `oxyc login` is the engineer's own browser
310
+ login, and a publish to a channel or a promote is refused under a sandbox
311
+ agent token. As an agent, publish into your own sandbox
312
+ ([references/sandbox-agent-loop.md](references/sandbox-agent-loop.md)) and
313
+ leave the ship step to your engineer.
314
+
315
+ Ship with `oxyc publish` (`npm i -g @oxy-hq/cli`) — **no CI step, no S3 sync,
316
+ no folder picker**. From the app directory:
342
317
 
343
318
  ```bash
344
319
  oxyc login --env production # once; browser flow, caches a token,
345
320
  # and prints whether you're an app-admin
346
321
  oxyc validate # data-placement and manifest checks first
347
- oxyc publish --env production # build → tar → upload (draft channel)
322
+ oxyc publish --env production # build → bundle functions → tar → upload (draft channel)
348
323
  oxyc publish --env production --promote # …straight to live
349
324
  ```
350
325
 
351
- - **Identity** is the manifest's `slug` + `orgSlug` (override with
352
- `--app` / `--org`). The server upserts the app row — there is no
353
- separate `oxy apps create` step.
354
- - **First publish of a new app** needs `--project <workspace-uuid>`: the
355
- build-config lookup can't resolve a project until the app exists. Drop
356
- the flag after the first publish — it's auto-resolved from then on.
357
- - **`--env` targets:** `local` → the oxy web-app Vite dev server `:5173`
358
- (run `pnpm dev` there; it serves `/cli-auth` and proxies `/api` to
359
- oxy on `:3000`), `dev`/`staging`/`production` →
360
- `app{-dev,-staging,}.oxygen-hq.com`.
361
- - `--dir out` skips the build and uploads a prebuilt directory.
326
+ - **Identity**, for each half, is `--app` / `--org`, then `OXY_APP` /
327
+ `OXY_ORG`, then the manifest's `slug` + `orgSlug`, then an
328
+ `apps/<org>/<app>/` working directory. `--org` takes a slug or a UUID; pass
329
+ the slug whenever `oxyc` runs the build, because the asset base path is
330
+ baked from what you pass. With no org from any of those, the build stops
331
+ with `missing org: the app's base path needs it` (exit `2`): add
332
+ `--org <org-slug>`, and `--app <slug>` when the directory has no manifest.
333
+ The server upserts the app row — there is no separate create step.
334
+ - **First publish of a new app** needs `--project <workspace-uuid>` (unless
335
+ the app was registered in the admin UI): the build-config lookup can't
336
+ resolve a project until the app exists. A pinned workspace also supplies
337
+ the org. Drop the flag after the first publish — it's auto-resolved from
338
+ then on.
339
+ - **`--env` targets:** `local` → a local Oxy web-app Vite dev server `:5173`
340
+ (it serves `/cli-auth` and proxies `/api` to oxy on `:3000`), `dev` →
341
+ `aip.dev.oxy.tech`, `staging` → `aip.staging.oxy.tech`, `production` (the
342
+ default) → `app.oxygen-hq.com`. A value with a `.`, `/` or `:` is read as a
343
+ URL.
344
+ - **Function lint.** Before the build, each Oxy Function's source is checked
345
+ for what the host would refuse at the first call: a `ctx.*` call whose
346
+ capability the manifest lacks, a global the isolate does not have
347
+ (`Buffer`, `TextEncoder`, `process`, …), a write outside `destinations`. A finding fails
348
+ the publish (exit `1`); `--allow-function-lint` publishes past a false
349
+ positive.
350
+ - `--dir out` skips the build and uploads a prebuilt directory (declared
351
+ functions are still bundled into it); `--build-only` builds and bundles
352
+ without a credential or an upload; `--prebuilt` (with `--dir`) uploads a
353
+ directory whose functions are already bundled. Server warnings print on
354
+ stderr; `--json` prints the result.
355
+ - `.env.local` / `.env` are loaded from the app directory and its parents
356
+ (never overriding what is already set), so a stray `OXY_PROJECT` there
357
+ retargets a publish.
358
+ - **Credential:** `OXY_TOKEN`, then `oxyc login`'s cache — or, in a GitHub
359
+ Actions job with `id-token: write`, the job's OIDC identity.
360
+ `oxyc init-ci` writes a workflow that publishes with no stored secret.
362
361
  - Promote / roll back / unpublish from the oxy admin app-detail console;
363
362
  every publish is audited (who shipped each build, when).
364
363
  - **What is live now:** `oxyc apps show <org>/<app>` prints the live build, the
@@ -367,7 +366,8 @@ oxyc publish --env production --promote # …straight to live
367
366
  lists the commits in your checkout that touch the app after the live build's
368
367
  commit — `unknown` there means it could not compare, not that nothing changed.
369
368
 
370
- Full hands-on guide: `oxy-hq/customer-apps: docs/local-development.md`.
369
+ The build is not the check: after a publish, drive the page
370
+ ([references/verifying-in-the-browser.md](references/verifying-in-the-browser.md)).
371
371
 
372
372
  ## Sandboxes — try a change on real data before shipping it
373
373
 
@@ -375,13 +375,12 @@ A sandbox (`dev-<handle>`, 1-12 lowercase letters/digits/hyphens, no leading,
375
375
  trailing or double hyphen) is a named, short-lived environment of one app: its
376
376
  own build pointer, storage silo, secrets and Airhouse sibling schema. Reads
377
377
  hit production; most writes are isolated or *held* (recorded, not performed —
378
- `ctx.oltp`/`ctx.warehouse` stay shared — see
379
- `internal-docs/custom-app-sandboxes.md` if you have this repo checked out; if
380
- not, the per-call table is in this skill's "Where data goes" section above,
381
- and a held write's `note` field in `invocations held` names the manifest key
382
- that would make it land instead). It starts with no build, costs nothing
383
- idle, and is gone after 7 idle days or an explicit delete — safe to use for
384
- every iteration that would otherwise touch staging. Limit: 20 per app.
378
+ `ctx.oltp`/`ctx.warehouse` stay shared; a held write's `note` field in
379
+ `invocations held` names the manifest key that would make it land instead).
380
+ Schedules and webhooks do not fire in a sandbox: call the function. It starts
381
+ with no build, costs nothing idle, and is gone after 7 idle days or an
382
+ explicit delete — safe to use for every iteration that would otherwise touch
383
+ staging. Limit: 20 per app, counting ones still being deleted.
385
384
 
386
385
  ```bash
387
386
  oxyc env create acme/store dev-x --env dev --json # starts with no build
@@ -391,47 +390,12 @@ oxyc fn call acme/store submit-order --app-env dev-x --env dev --data '{}' --jso
391
390
  oxyc checks run acme/store --app-env dev-x --env dev --json # run its checks
392
391
  oxyc invocations list acme/store --app-env dev-x --env dev --json # what ran; a row's id is `id`
393
392
  oxyc invocations held acme/store <invocation-id> --env dev --json # what it held, not wrote
393
+ oxyc logs acme/store --app-env dev-x --env dev --json # its function logs
394
394
  oxyc env delete acme/store dev-x --env dev --yes --wait --json # done; tears its homes down
395
395
  ```
396
396
 
397
397
  (`--data` has no short form — don't write `-d`.)
398
398
 
399
- **An agent's credential is a sandbox agent token** (`oxy_sbx_…`), not a staff
400
- login. Get it yourself, once per task — your operator approves in the browser
401
- that opens — and end it when the task is done:
402
-
403
- ```bash
404
- eval "$(oxyc tokens create --sandbox-agent --app acme/store --env dev)" # prints: export OXY_TOKEN=oxy_sbx_…
405
- oxyc whoami --env dev --json # kind "sandbox_agent", your app in `apps`, `expires_at` after the task ends
406
- # … the loop above …
407
- oxyc tokens revoke --current --env dev # last, after the delete
408
- ```
409
-
410
- It does this loop on the apps it was minted for (`--app`, up to five), in
411
- sandboxes it created, for 8 hours (`--hours`, up to 168) — and nothing else:
412
- production, staging, other apps, `oxyc api`, `oxyc apps` and a secret's value
413
- are all refused. With it, every verb that takes `--app-env` requires a
414
- `dev-<handle>` (exit `2` otherwise), and at most three of its sandboxes may be
415
- live at once.
416
-
417
- **Over MCP.** If your runtime speaks MCP, `oxyc mcp --env dev` serves the same
418
- loop as tools — `oxy_env_create` / `oxy_env_list` / `oxy_env_show` /
419
- `oxy_env_delete`, `oxy_publish_sandbox`, `oxy_fn_call`, `oxy_checks_run`,
420
- `oxy_invocations_list` / `oxy_invocations_held`, `oxy_logs`. It reads its
421
- credential from `OXY_TOKEN` and nowhere else (a person on their own login
422
- adds `--login`). With a sandbox agent token the list is those ten,
423
- `oxy_whoami`, `oxy_env_secret_list` / `_set` / `_delete` and
424
- `oxy_token_revoke` (`confirm: true`) — and no `oxy_request`. Prefer them to
425
- shelling out: each calls its verb's own request-building code, so the result
426
- is the document that verb's `--json` prints, and the choice changes nothing
427
- on the server. There is no exit code over MCP: a failure is `isError: true`
428
- with the class at the end of its text (`[exit 5 NOT_FOUND]`, plus `server
429
- code: <code>` when the server sent one) — branch on that the way you would
430
- on the exit code. `oxy_fn_call` and `oxy_checks_run` set `isError` when the
431
- function or a check failed, with the full document still in the result.
432
- `oxy_env_delete` needs `confirm: true`. `oxy_publish_sandbox` takes only a
433
- `dev-<handle>` and cannot reach staging or production.
434
-
435
399
  Iterate from `publish` as the code changes — each publish is a new build, and
436
400
  the sandbox always serves the newest. A sandbox build is **never promoted**:
437
401
  ship by publishing the same tree to staging, then promoting from the console.
@@ -439,86 +403,24 @@ Staff only (needs `develop_apps` + `manage_apps`) — or a sandbox agent token
439
403
  such staff approved; a publish token is refused on every sandbox operation,
440
404
  so CI cannot use one.
441
405
 
442
- **Driving this loop unattended (an agent, not a human at a keyboard):**
443
-
444
- - Name your sandbox `dev-<short-task-slug>` — one per agent/task, never reused
445
- across unrelated tasks, so parallel agents don't collide. `env create`
446
- answers exit `6` if the name is already taken.
447
- - Branch on exit codes and JSON fields only, never on stderr text: `0` ok,
448
- `1` it ran and the thing itself failed, `2` you called it wrong, `4` not
449
- authenticated — your token expired, was revoked, or your operator lost
450
- access (stop — don't try another credential, a cached `oxyc login`
451
- included), `5` not found
452
- (environment/function/invocation — check the name, or publish first if
453
- the environment has no build), `6` a conflict or bad name (pick another
454
- name, wait out a teardown, or free up the 20-sandbox limit — or, on
455
- `token_sandbox_limit`, delete one of your own three with `--wait` first: a
456
- sandbox still being deleted counts toward the three), `7` retryable
457
- (network/5xx, or a `--wait` deadline), `8` refused (stop and report), `9`
458
- a check failed — read `invocations held` for that check's invocation
459
- before assuming the function is broken.
460
- - Hard rules: never pass `--promote` in this loop; never `publish` without
461
- `--app-env` while iterating in a sandbox; never use `production` as
462
- `--app-env` to "check" something — reads already hit production. Always
463
- delete the sandbox you created, **even when an earlier step failed**. On
464
- exit `4` or `8`, stop and report to a human rather than retrying with a
465
- different credential or flag. A held write is information to report, not
466
- an error to engineer around. Set a sandbox's secret with `oxyc env secret
467
- set` (`oxy_env_secret_set`), never a hand-built request, and never try to
468
- read a value back. Revoke your token last: `oxyc tokens revoke --current`.
469
- - Report back: the sandbox name, the build id(s) published, each check's
470
- `passed`, every held write (`op`/`plane`/`table`/`note`), and confirmation
471
- the sandbox was deleted.
406
+ **An agent runs this loop on its own sandbox agent token**, never a person's
407
+ login, with `--token-env OXY_TOKEN` on every command — the mint, exit codes,
408
+ rules and what to hand back are in
409
+ [references/sandbox-agent-loop.md](references/sandbox-agent-loop.md). Read it
410
+ before an agent runs any `oxyc` command against a deployment.
472
411
 
473
412
  **A sandbox isolates the app, not the workspace.** The semantic model,
474
413
  automations and Airway pipelines it reads are `main`'s. To try a workspace
475
- branch, open a **workspace preview** (staff only) — a separate loop:
476
-
477
- ```bash
478
- oxyc preview create <branch> --wait --workspace <id> --json # compile the branch; idempotent
479
- oxyc preview checks <branch> --workspace <id> --json # Airway change check of that revision
480
- oxyc preview run <branch> procedure <file.automation.yml> --wait --workspace <id> --json # held dry run
481
- oxyc preview runs list <branch> --workspace <id> --json # every run, the server's own included
482
- oxyc preview runs show <run-id> --wait --workspace <id> --json # one run: held steps, outcome
483
- oxyc preview delete <branch> --yes --workspace <id> --json # done
484
- ```
485
-
486
- `preview run`'s kind is `procedure` or `airway_sample`; never post a
487
- `transform_build` or `compare` run — the server queues those itself, and you
488
- read them with `preview runs list` / `runs show`. A `--wait` verb exits `1`
489
- when what it waited for ended `failed` or `cancelled`, `7` past its deadline.
490
- Over MCP the same verbs are `oxy_preview_create` / `_list` / `_show` /
491
- `_delete` (`confirm: true`) / `_checks` / `_run` / `_runs_list` / `_run_show`.
492
- To make a sandbox read the branch's semantic model, publish to it with
493
- `--semantic-branch <branch>`.
494
-
495
- ## Reading data while vibe-coding
496
-
497
- You don't need an `X-API-Key` to poke at the data API by hand — your
498
- `oxyc login` token authenticates as a bearer. From an app directory:
499
-
500
- ```bash
501
- oxyc login --env local # once; caches a token per host
502
-
503
- # gh-api-style client (resolves the target + bearer for you, no headers):
504
- oxyc api user --env local # who am I?
505
- oxyc api projects/<projectId>/query --env local -f sql='select 1'
506
-
507
- # raw curl with your token (e.g. to script or debug):
508
- curl -H "Authorization: Bearer $(oxyc token --env local)" \
509
- -H 'content-type: application/json' \
510
- localhost:5173/api/projects/<projectId>/query -d '{"sql":"select 1"}'
511
- ```
512
-
513
- `oxyc api <path>` takes the path relative to `/api/`; `-X` sets the method
514
- (POST when a body is given), `-f`/`-F` set the body (string / JSON-typed),
515
- `--input` sends a raw one, `-i` includes response headers. `--env` resolves the
516
- same targets as `oxyc publish` (`local` → the Vite dev server `:5173`, which
517
- proxies `/api` → oxy `:3000`).
518
-
519
- Do not guess a path: `oxyc routes <filter>` lists what exists and
520
- `oxyc schema <path>` gives the body it expects.
521
-
522
- Access is your **normal org membership** — fine for your own test
523
- workspace, not a cross-customer backdoor. The `projectId` is the one the
524
- vite-plugin already resolved for the app (it's in `window.__OXY_APP__`).
414
+ branch, open a workspace preview (staff only, `oxyc preview …`), or publish to
415
+ the sandbox with `--semantic-branch <branch>` — see
416
+ [references/sandbox-agent-loop.md](references/sandbox-agent-loop.md#a-sandbox-isolates-the-app-not-the-workspace).
417
+
418
+ ## Read next
419
+
420
+ | File | Read it when |
421
+ | --- | --- |
422
+ | [references/sandbox-agent-loop.md](references/sandbox-agent-loop.md) | You are an AI agent about to touch a deployment: minting the sandbox agent token, the loop, exit codes, rules, what to hand back, when you cannot get a token, the MCP tools, workspace previews. |
423
+ | [references/verifying-in-the-browser.md](references/verifying-in-the-browser.md) | You changed something a person sees (`src/**`, or a function a card calls) and need to prove it on the published page. |
424
+ | [references/reading-data.md](references/reading-data.md) | You need a deployment's live data while building: an agent's own `--agent` token, `oxyc api` / `routes` / `schema`, and `oxyc proxy` for `pnpm dev` against cloud data. |
425
+ | [references/performance-and-usage.md](references/performance-and-usage.md) | You write an Oxy Function, a bulk action, caching or idempotency keys — or need to know what Oxy does for load speed and usage tracking, and the opt-outs. |
426
+ | [references/minimal-starter.md](references/minimal-starter.md) | You are writing an app by hand instead of scaffolding it, or need the smallest correct layout. |