# Welcome _Source: https://docs.omg.dev/docs_ import { Flow } from "~/components/flow" import { FlowTabs } from "~/components/flow-tabs" import { FlowSteps } from "~/components/flow-steps" import { CtaCard } from "~/components/cta-card" import { OmgLogo, AwsLogo, GcpLogo } from "~/components/brand-logos" **omg** turns a prompt into a real, running app — built, deployed, and live at its own URL in seconds. Every app runs fully isolated in its own sandbox, with a database, sign-in, storage, and an AI coding agent already wired in. You can use the hosted omg.dev, or **run the whole platform on your own infrastructure** so your internal teams ship apps without anything leaving your network. This site shows how it works, and how to stand it up yourself. ## How it fits together The platform is the same handful of layers whether it's hosted or self-run. Here's the path a request takes — flip to **omg hosted** for the components we run in production, or **all-in-one** to collapse everything onto a single box behind a cloud load balancer: Users Edge Orchestrator App sandbox Object storage Database Auth AI ), }, { label: "omg hosted", icon: , content: ( Users Cloudflare Worker } accent sub="runs on your box"> vibes-sandbox Firecracker Tigris (S3) sqld better-auth Meridian ), }, { label: "All-in-one", icon: ( <> ), content: ( Users } sub="load balancer + CDN" > Cloud edge } sub="orchestrator + sandboxes + db + auth, all on one host" > Bare-metal box } sub="snapshots + files (S3 / GCS)" > Object storage ), }, ]} /> Each layer maps to a small, proven component. This is what hosted omg.dev runs, and what `provision.sh` sets up on a self-hosted box: | Layer | Component | What it does | | --- | --- | --- | | **Edge** | Cloudflare Worker + Tunnel | Routes each app to its own preview URL; box needs no open inbound ports | | **Orchestrator** | `vibes-sandbox` (Go) | Starts, snapshots, and proxies sandboxes; zero-downtime restarts | | **App sandbox** | Firecracker microVM | Hardware-isolated per app, boots in seconds, cheap to keep warm | | **Object storage** | Tigris (or any S3) | Snapshots, build artifacts, litestream WAL | | **Database** | sqld (libsql) | Authoritative state for sandboxes, deploys, snapshots | | **Auth** | better-auth | Email magic-link + passkey, mints the service tokens | | **AI** | Meridian proxy | Points the coding agent at your own Claude subscription | ## Why run it yourself Building this in-house means solving microVM isolation, snapshot/restore, zero-downtime deploys, and an AI coding loop — months of platform work before your first app ships. omg gives you all of it as one service: - **Hard parts, already solved.** Firecracker isolation, full-rootfs snapshots, atomic versioned deploys with rollback, and litestream-replicated per-app SQLite are in the box. - **Nothing leaves your network.** Code, data, and AI usage stay on your hardware. Point the coding agent at your own Claude subscription. - **One box, one script.** No platform team. `provision.sh` stands up the whole stack; add a box when you need more capacity. ## Self-host omg, end to end Hosted omg is just one of these boxes. Run the same setup script on your own server and your internal teams build on your hardware, inside your network. ### 1 · Provision Start with a fresh Ubuntu 22.04 server (virtualization enabled, a spare disk for sandbox storage). One script sets up storage, the sandbox runtime, the orchestrator, the base image, the system service, networking, and optional HTTPS: ```bash git clone https://github.com/BennyKok/vibes /opt/vibes cd /opt/vibes # HTTPS via Caddy; omit DOMAIN for plain HTTP on :7070 DOMAIN=infra.your-co.internal bash apps/infra/deploy/provision.sh ``` The script writes its config to `/etc/vibes/env` and starts the orchestrator as a system service. Health-check it: ```bash curl https://infra.your-co.internal/health ``` ### 2 · Open a tunnel Routing goes through a Cloudflare Tunnel, so the box never exposes an inbound port. `provision.sh` already wrote the tunnel config — finish the one-time auth: ```bash cloudflared tunnel login cloudflared tunnel create box-1 # paste the UUID into /etc/cloudflared/config.yml systemctl enable --now cloudflared ``` ### 3 · Mint a service token Your dashboard authenticates to the orchestrator with a short service token. Mint it on your auth host — never over HTTP: ```bash # on the auth host cd /opt/vibes && bun run apps/auth/scripts/mint.ts svc:convex 31536000 ``` ### 4 · Point the dashboard at your box Set three values in the dashboard backend. This sends apps to your box instead of the hosted fleet — no code changes: ```bash VIBES_SANDBOX_PROVIDER=firecracker VIBES_SANDBOX_URL=https://infra.your-co.internal VIBES_INFRA_SERVICE_TOKEN= ``` To keep AI usage on your own Claude subscription, point the on-box LLM proxy at it in `/etc/vibes/env`. Need more capacity? Run `provision.sh` on another box with `NODE_ID=box-2` — traffic routes to it automatically. Talk through enterprise self-hosting — provisioning, scaling across boxes, and keeping code, data, and AI usage inside your network. ## Hosted vs self-hosted | | Hosted — omg.dev | Self-hosted — your box | | --- | --- | --- | | **Ops** | Zero — nothing to provision | One box, one script (`provision.sh`) | | **Tenancy** | Shared multi-tenant fleet | Single-tenant — only your internal users | | **Data** | Runs on omg's infrastructure | Code + data never leave your network | | **AI** | Metered through omg | Bring your own Claude subscription | | **Snapshots** | omg's storage | Your own S3-compatible storage | ## Where to start - **[Architecture](/docs/architecture)** — the request path, edge to sandbox. - **[Deploy flow](/docs/deploy-flow)** — snapshot, build, go live. - **[Auth](/docs/auth)** — sign-in and service tokens. - **[SDK overview](/docs/sdk)** — what your team builds on it. For LLM agents: [`llms.txt`](https://docs.omg.dev/llms.txt) indexes every page; [`llms-full.txt`](https://docs.omg.dev/llms-full.txt) is the full docs as plain text. --- # Architecture _Source: https://docs.omg.dev/docs/architecture_ import { Flow } from "~/components/flow" ## Hostnames | Host | Owner | Purpose | | ---- | ----- | ------- | | `omg.dev` | control-plane box (Caddy) | Marketing + authenticated dashboard | | `auth.omg.dev` | vibes-auth (Bun) | JWKS, login, service-token mint/revoke | | `infra.omg.dev` | CF Worker → box-1 | Sandbox / deploy / preview API | | `*.preview.omg.dev` | CF Worker → box-1 | Live sandbox dev-server forwarding. `*.preview.omgs.app` is staged at the edge before generated URLs flip. | | `*.apps.omg.dev` | CF Worker → box-1 | Deployed apps (static + serverful) | | `assets.omg.dev` | CF Worker → box-1 | Public asset proxy (thumbnails) | ## Request path Browser Cloudflare edge box-1 · Hetzner Firecracker microVM Tigris Per-hop responsibilities: - **Cloudflare edge** — `KV.get("deploy:{slug}")` cached at colo; falls back to Go `/v1/deploys-internal` on miss. - **vibes-sandbox** — JWT verify against `auth.omg.dev/.well-known/jwks.json`; revocation cache polled every 30s. - **Firecracker microVM** — bun runtime + litestream replicate; outbound LLM calls intercepted by the host. - **Tigris** — snapshots, artifacts, litestream WAL, static assets. ## Storage layout (Tigris) | Prefix | Owner | Lifecycle | | ------ | ----- | --------- | | `sandbox-snapshots/:id/` | `manager.Snapshot` | Tied to snapshot row in sqld | | `project-tarballs/:artifactId/` | `manager.packBuildArtifact` | Until next deploy garbage-collects | | `deploys-db/:slug/v:N/` | per-deploy litestream | GC'd 90s after redeploy | | `static-deploys/:slug/v:N/` | static deploy upload loop | GC'd 90s after redeploy | | `thumbnails/:slug/latest.png` | shotter | Replaced on capture | Versioned `v:N` prefixes are the load-bearing piece for zero-downtime redeploy: old and new sandboxes never share a Tigris key. ## Datastores - **sqld (libsql)** — authoritative state for sandboxes, deploys, snapshots, usage logs. Schema in `packages/db/src/schema.ts`. - **CF KV** — routing cache only (`sandbox::id` → node, `deploy::slug` → node). Read-after-write via `kvIsolateCache` + `cacheTtl: 300s`. - **Tigris** — see above. - **Per-deploy SQLite** — inside the Firecracker VM, litestream-replicated to Tigris. --- # Auth _Source: https://docs.omg.dev/docs/auth_ `vibes-auth` (`apps/auth/`) is the only place that mints JWTs. Everything else verifies via `auth.omg.dev/.well-known/jwks.json`. ## Token shape ```jsonc { "iss": "https://auth.omg.dev", "aud": "vibes-infra", "sub": "user-abc" | "svc:convex" | "svc:worker" | "svc:ci", "appId": "vibes-infra", "iat": 1777000000, "exp": 1808500000, // 1 year for service tokens "jti": "29629508-..." // for revocation } ``` - **User tokens** carry the principal of the logged-in human; minted on login, short-lived. - **Service tokens** (`sub` starts with `svc:`) are minted on the auth box via `bun apps/auth/scripts/mint.ts `. There is **no public mint endpoint** — minting requires SSH access to the auth box. This is intentional: the only thing that could leak from the network is a *single* token, never the signing key or a mint capability. ## On-behalf-of A service principal can act for a user by adding `X-On-Behalf-Of: ` to its request. Handlers read identity from the resolved Principal struct (see `apps/infra/internal/auth/middleware.go`), which carries either: ``` Principal{ Kind: "user", UserID: "abc" } Principal{ Kind: "service", Service: "svc:convex", OnBehalfOf: "abc" } ``` Every handler treats both shapes identically for authorization purposes; audit logs distinguish them. ## Revocation Each minted token has a `jti`. Active tokens live in the `service_token` sqld row (`apps/auth/src/schema.ts`). Revocation is two endpoints: - `POST /admin/service-token/revoke` — sets `revokedAt`. (Run on-box via `bun scripts/revoke.ts`.) - `GET /tokens/revoked` — returns `{ jtis: string[] }`. Public, cached 10s at the edge. Every infra binary polls `/tokens/revoked` every 30s into a `map[string]struct{}` and rejects matching `jti` on the hot path. So a revoke takes effect everywhere within ~30s plus edge-cache TTL. ## Rotating a service token 1. `bun apps/auth/scripts/mint.ts svc:worker 31536000` (1y) on the auth box. Capture the new JWT. 2. Update the consumer: - `svc:convex` → `bash apps/web/scripts/convex-prod.sh -- npx convex env set VIBES_INFRA_SERVICE_TOKEN ` - `svc:worker` → `wrangler secret put VIBES_INFRA_SERVICE_TOKEN` - `svc:ci` → `gh secret set VIBES_API_KEY --env e2e` 3. Verify the new key works (one round-trip through the consumer). 4. `bun apps/auth/scripts/revoke.ts ` to cut off the previous token. `bun apps/auth/scripts/revoke.ts list` shows everything live + warns on tokens within 30 days of expiry. --- # Brand — visual + voice reference _Source: https://docs.omg.dev/docs/brand_ This is the single source of truth for how omg.dev should look and sound. Any new surface (a marketing page, a Worker error page, an email template, an exported asset) starts here. The runtime tokens live in `apps/web/src/index.css` — that's the canonical file. The docs site (`apps/docs/app/global.css`) and the blog (`apps/blog/WRITING_GUIDELINES.md`'s SVG infographic spec) mirror those values. Edit `apps/web/src/index.css` first, then propagate. ## Logo A coral disc with a small disc subtracted from the top-right — "moon cut" out of a sun. One color, one shape. The file is a 30-line SVG; render it any size. ```xml ``` **Locations** - `apps/web/public/favicon.svg` — canonical (32×32 area, 96px exported) - `apps/web/public/icons/logo-mark-{192,512}.png` — PWA install icons - `apps/docs/public/favicon.svg` — mirror - `apps/blog/public/favicon.svg` — mirror **Don't** - Don't recolor it. The coral is the brand. - Don't put it on a colored background. Use cream or near-black. - Don't add text inside it. It stands alone or alongside "OMG" / "omg.dev" set in Geist semibold. ## Color palette The palette is **cream paper + coral + near-black ink**. Light mode reads like paper; dark mode like ink on inverted paper. ### Light mode | Token | Hex | Use | |---|---|---| | `--background` | `#F6F2EC` | Page background — cream paper | | `--foreground` | `#15110D` | Body text + most UI ink | | `--card` / `--popover` | `#FBF8F2` | Slightly lifted surfaces (cards, dialogs) | | `--secondary` / `--accent` / `--muted` | `#EDE6DC` | Section blocks, dividers, subtle fills | | `--muted-foreground` | `#6B6459` | Captions, helper text, secondary labels | | `--brand` | `#FF5530` | Coral — the only saturated color in the whole UI | | `--brand-foreground` | `#FBF8F2` | Text on top of coral | | `--border` | `oklch(0 0 0 / 7%)` | Hairlines | ### Dark mode | Token | Hex | Use | |---|---|---| | `--background` | `#15110D` | Page background — warm near-black | | `--foreground` | `#F6F2EC` | Body text | | `--card` / `--popover` / `--secondary` / `--accent` / `--muted` | `#1F1A14` | Lifted surfaces | | `--muted-foreground` | `#A89F94` | Captions | | `--brand` | `#FF7A57` | Brighter coral, calibrated for dark surface | | `--brand-foreground` | `#15110D` | Text on top of brighter coral | | `--border` | `oklch(1 0 0 / 8%)` | Hairlines | ### Rules - **Coral is rare.** Use `--brand` for the primary CTA, the active state, the rare emphasis. Most of the UI is paper + ink. If everything is coral, nothing is. - **Backgrounds are never pure white or pure black.** `#F6F2EC` light / `#15110D` dark. Always. - **No drop shadows, no gradients on surfaces.** Depth comes from the card → background difference, not from blur. - **The coral gradient is for hero moments only.** See below. ## Brand gradient ```css linear-gradient(135deg, #FF7A4C 0%, #FF5530 55%, #E63E1C 100%) ``` Coral-to-deep-coral, top-left to bottom-right. Use it on the primary CTA, the home hero text, the rare promotional surface. **Never use it as a page background** — paper does that. **Never use it on more than one element per screen.** The variable is `--brand-gradient`. There's also `--brand-gradient-soft` (a radial halo using coral with alpha) for backlit-style accents under buttons. ## Typography One typeface family across everything: **Geist Variable** (and Geist Mono Variable for code). | Token | Value | Use | |---|---|---| | `--font-sans` | `Geist Variable` | Body + UI text | | `--font-display` | `var(--font-sans)` | Headings — same family, set heavier | | `--font-mono` | `Geist Mono Variable` | Code, tables of identifiers, ASCII diagrams | **Sizing scale.** Tailwind's defaults, no overrides. Headings are `tracking-tight` and usually 600-700 weight; body is 400-500. Don't reach for Syne, Inter, or any display face — Geist set at the right weight already does the job. ## Border radius `--radius: 0.95rem` (~15px), the same on cards and buttons. The blog's SVG infographics use 8px — a visual-asset-only convention, since 15px reads heavy at 680px width. In the product and docs, everything is 15px. ## Voice & tone The voice was already settled by the blog: clear, declarative, builder- friendly, no marketing-speak. The canonical reference is `apps/blog/WRITING_GUIDELINES.md`. Three things to internalize: 1. **Never name underlying tech in user-facing copy.** No "OAuth", no "WebSocket", no "API endpoint", no "Supabase". Frame everything in terms of what the reader GETS. Engineering docs are exempt — those need accuracy over accessibility. 2. **No em dashes (—).** Use periods, colons, or short separate sentences. The blog enforces this; the product UI should too. 3. **Confident, specific, no hedging.** "omg.dev includes sign-in" beats "omg.dev may include sign-in functionality." The blog also maintains a list of forbidden words (delve, leverage, ecosystem, robust, seamlessly, comprehensive, cutting-edge, game-changer, etc.). Same rules apply to UI copy. ## Where it lives in code | Surface | File | Notes | |---|---|---| | Canonical CSS tokens | `apps/web/src/index.css` | Edit this first | | Docs site CSS | `apps/docs/app/global.css` | Mirror — keep in sync with index.css | | Blog SVG infographics | `apps/blog/WRITING_GUIDELINES.md` (§ SVG infographics) | Has slightly different palette tuned for 680px-wide flat illustrations; documented inline | | Logo | `apps/web/public/favicon.svg` | Canonical SVG, copied to other apps | | Voice & tone | `apps/blog/WRITING_GUIDELINES.md` | Forbidden words, structure rules, audience | | Product features (for writers) | `apps/blog/scripts/OMGDEV_FEATURES.md` | What omg.dev does, in consumer language | When you change a token in `apps/web/src/index.css`, propagate the same hex to `apps/docs/app/global.css` in the same PR. The docs site is the one place a contributor will look for the brand reference, and it should always be on the current values. ## Quick reference ```css /* Light */ --background: #F6F2EC; /* cream */ --foreground: #15110D; /* ink */ --brand: #FF5530; /* coral */ --radius: 0.95rem; /* 15px */ /* Dark */ --background: #15110D; --foreground: #F6F2EC; --brand: #FF7A57; /* Gradient */ linear-gradient(135deg, #FF7A4C 0%, #FF5530 55%, #E63E1C 100%); /* Fonts */ --font-sans: "Geist Variable"; --font-display: var(--font-sans); --font-mono: "Geist Mono Variable"; ``` That's the whole brand. One coral, two near-cream/ink backgrounds, one typeface, big rounded corners, sparing emphasis. Resist adding more. --- # Deploy flow _Source: https://docs.omg.dev/docs/deploy-flow_ import { FlowSteps } from "~/components/flow-steps" A "deploy" turns a durable version snapshot into a public app at `:slug.apps.omg.dev`. The editor sandbox does not become the production app. Control-plane publishes a `runId` by resolving its `resultSnapshotId` first, then infra forks a short-lived builder from that snapshot. The flow lives in `apps/infra/internal/orchestrator/manager.go : Deploy()`. ## Steps 1. **Reserve slug** — `InsertDeploy` with status=building. Concurrent first-deploys of the same slug see `ErrSlugExists`. 2. **Resolve version snapshot** — control-plane waits for the version's end-of-turn files snapshot (`runs.resultSnapshotId`) and calls `POST /v1/deploys` with `{ snapshotId, slug, runId, async: true }`. Publishing never snapshots or stops the live editor VM. 3. **Fork builder** — fresh Firecracker VM forked from the version snapshot, used only to run the build. For `files_only` snapshots this restores the project tarball on top of the warm template. 4. **Build** — runs `vibes-build` inside the builder. Emits `dist/`, `.vibes/server.mjs`, `.vibes/data.db`, `.vibes/artifacts.json`. 5. **Branch on manifest** — read `.vibes/artifacts.json`. If `static && !hasDb && functions==0` → static path; else serverful. ### Static path (no VM at runtime) Tar `dist/` only inside the builder, read the tarball back via guest agent, walk it in-memory with `archive/tar`, and upload each entry to `static-deploys/:slug/v:N/:path` with per-file Cache-Control. No runtime VM. Finish with `UpdateDeploy(Static=true, StaticVersion=N)` — atomic flip. ### Serverful path 5. Tar `dist/` + `.vibes/` into a single artifact tarball, upload to `project-tarballs/:artifactId/`. Stop builder. 6. Fork **runtime** from the production base or warm template (not from the version snapshot). If that base is absent locally, restore the warm rootfs from Tigris once per snapshot, then extract `project.tar.gz` on top. 7. `startDeployServer(slug, restoreV=N-1, replicateV=N)`: - `litestream restore` from `deploys-db/:slug/v:N-1/data.db` (skipped on first deploy). - `litestream replicate -exec bun .vibes/server.mjs` against `deploys-db/:slug/v:N/data.db`. 8. `WaitForDeployReady(forked.VMIP, 30s)` — block until bun is accepting TCP on :3000. 9. `UpdateDeploy(SandboxID=forked.ID, ArtifactID=..., Version=N)`. Atomic flip. ### Node loss and cold-start failover The project artifact and warm rootfs are both durable in Tigris; local rootfs files are caches. The edge keeps a deploy on its assigned node while that node's heartbeat is healthy. A stale, draining, or storage-starved owner is replaced by a healthy peer, which restores the missing base and project artifact. The peer claims the deploy in sqld with a compare-and-swap, clearing the old sandbox pointer, then binds its new sandbox under the claimed node. This prevents two peers from cold-starting the same deploy or a late old-node write from stealing ownership back. ## Atomic flip Both paths converge on `UpdateDeploy` as the single source of truth for "what is the current version?". `DeployProxy.ServeHTTP` reads `store.GetDeploy(slug)` *per request* — there is no caching layer between sqld and the proxy decision. Edge-cache TTLs are short enough (60s for HTML) and `v:N` GC is deferred enough (90s) that the cached old HTML never out-lives its referenced assets. ## Rollback Pre-flip: anything failing in steps 5–8 cleans up the new sandbox and GCs the `v:N` Tigris prefix it was about to populate. Post-flip: a rollback flips the active static version by `runId` when retained; otherwise the same version can be rebuilt and published again. A failed deploy never modifies the live `Deploy` row. ## Redeploy GC After a successful flip: - **Serverful** — drain old sandbox (litestream sigterm flush), stop, GC `v:N-1` Tigris prefix immediately. - **Static** — defer `DeleteStaticVersion(N-1)` by 90s so edge-cached old `index.html` (max-age=60s) doesn't 404 on its hashed assets during the transition. The 90s defer must stay > the index.html `max-age`. `serveStatic` also falls back to `v:N-1` on a hashed-asset miss, which is the lookup half of the same property. --- # MCP — drive omg from Claude Code _Source: https://docs.omg.dev/docs/mcp_ import { Flow } from "~/components/flow" `mcp.omg.dev` is an OAuth-protected Model Context Protocol server. Hook it up to Claude Code (or any MCP client that speaks Streamable HTTP + OAuth 2.1) and you get a small toolbelt for driving your omg account from the editor: create apps from a prompt, steer existing apps with follow-up instructions, inspect runs. The Worker is hosted on Cloudflare with a custom-domain route. The Authorization Server is `auth.omg.dev` (the same Better Auth instance that mints session JWTs for `omg.dev`). Tokens are JWT access tokens with `aud=https://mcp.omg.dev/`. ## Install in Claude Code ```bash claude mcp add --transport http omg https://mcp.omg.dev/mcp ``` That's the whole install. On first use Claude Code does Dynamic Client Registration against `auth.omg.dev/api/auth/oauth2/register`, runs PKCE-authorize, opens a browser for sign-in + consent, and stores the resulting access + refresh tokens. Subsequent sessions are silent. Verify: ```bash claude mcp list # omg: https://mcp.omg.dev/mcp (HTTP) - ✓ Connected ``` If you see `! Needs authentication`, run a tool from any session and Claude Code will trigger the OAuth flow. The refresh token (issued under the `offline_access` scope) lives for 30 days, so reauthentication is rare. ## Tools | Tool | Args | Purpose | |---|---|---| | `omg_status` | — | Health + configured-subsystems probe. Use first when something seems off. | | `omg_create_app` | `prompt` | Spin up a fresh project and kickoff agent run. Returns `{projectId, runId, slug}`. | | `omg_steer` | `runId`, `text` | Send a natural-language follow-up to an existing app. Not a code diff. | | `omg_get_run` | `runId` | Current run state (queued/running/done/failed) + most recent message. | | `omg_tail_run` | `runId`, `afterMessageCreatedAt?` | Incremental tail of assistant + tool messages. | All tool calls forward the same JWT to Convex's `/api/cli/*` endpoints, which accept dual-audience tokens (legacy `aud=vibes` from `auth.omg.dev/token` and new `aud=https://mcp.omg.dev/` from this flow). See [Auth](/docs/auth) for the JWT model. ## OAuth flow ```jsonc // .well-known discovery, per MCP spec // Step 1 — Resource Server metadata (RFC 9728) GET https://mcp.omg.dev/.well-known/oauth-protected-resource → { "resource": "https://mcp.omg.dev", "authorization_servers": ["https://auth.omg.dev"], "scopes_supported": ["omg:apps"] } // Step 2 — Authorization Server metadata (RFC 8414) GET https://auth.omg.dev/.well-known/oauth-authorization-server → { "issuer": "https://auth.omg.dev", "authorization_endpoint": "https://auth.omg.dev/api/auth/oauth2/authorize", "token_endpoint": "https://auth.omg.dev/api/auth/oauth2/token", "registration_endpoint": "https://auth.omg.dev/api/auth/oauth2/register", "jwks_uri": "https://auth.omg.dev/api/auth/jwks", "code_challenge_methods_supported": ["S256"], "scopes_supported": ["openid","profile","email","offline_access","omg:apps"] } ``` PKCE is mandatory (S256 only). Dynamic Client Registration is open and unauthenticated, per MCP-client-onboarding norms. Consent is shown the first time a given client requests a given scope; later authorize calls from the same client + user return the redirect immediately. ## Scopes | Scope | What the token can do | |---|---| | `omg:apps` | Read + write the user's omg apps (everything the tool surface above exposes) | | `offline_access` | Mint refresh tokens — without this the access token expires in 1h and the user has to re-authorize | | `openid` / `profile` / `email` | OIDC scopes; not currently required by the Worker but available | The Worker validates `aud=https://mcp.omg.dev/` and `iss=https://auth.omg.dev` on every `/mcp` request via the JWKS at `/api/auth/jwks` (EdDSA Ed25519). 401 responses include `WWW-Authenticate: Bearer resource_metadata=…` so clients can auto-reauthenticate on stale tokens. ## Token shape ```jsonc // Decoded access token issued by /api/auth/oauth2/token { "sub": "PmcFDWIjUum…", // omg user id "email": "you@example.com", // via customAccessTokenClaims "name": "", "aud": ["https://mcp.omg.dev/", // the resource indicator "https://auth.omg.dev/api/auth/oauth2/userinfo"], "azp": "gJHYnPVlNx…", // DCR-issued client_id "scope": "openid email omg:apps", "iss": "https://auth.omg.dev", "iat": 1780050232, "exp": 1780053832 // 1h default } ``` The `email` claim is added by `apps/auth/src/auth.ts` via `customAccessTokenClaims`. Convex's `requireSessionUserId` uses it to look up the omg user — keeping legacy `/token`-minted JWTs and the new OAuth tokens interchangeable at the Convex boundary. ## Troubleshooting **`! Needs authentication` in `claude mcp list`.** No tokens stored yet, or the refresh token expired (30d). Call any `omg_*` tool from a Claude Code session — it'll trigger the OAuth flow. **401 from `/mcp` with `WWW-Authenticate: Bearer resource_metadata=…`.** Access token expired and refresh failed (rare). Same fix: trigger any tool to reauthorize. **Tool call works but returns `Unauthorized` from Convex.** The token's `aud` doesn't match Convex's accepted audiences. The deployed Convex verifier accepts both `https://mcp.omg.dev` and the trailing-slash form `https://mcp.omg.dev/`. If a custom client is sending something else, that won't match. **`requested resource invalid` at the token endpoint.** The `resource` parameter (RFC 8707) wasn't in `validAudiences`. Two forms are accepted (`…dev` and `…dev/`). Anything else is rejected. Update the client to match. ## Request path Claude Code MCP server Convex /api/cli/* The Worker verifies each Bearer token's EdDSA signature against `auth.omg.dev`'s JWKS before forwarding to Convex. The tokens themselves come from the [OAuth flow](#oauth-flow) above — a separate Claude-Code-to-`auth.omg.dev` handshake (PKCE authorize + token), not part of this hot path. Source lives at `apps/mcp/` in the monorepo. The contributor-facing README at `apps/mcp/README.md` covers how to add a new tool, the deploy pipeline (push → `deploy-mcp.yml` → `wrangler deploy`), and the trailing-slash / `validAudiences` gotcha. --- # Runbooks _Source: https://docs.omg.dev/docs/runbooks_ ## Worker returning 1101 / 500 on every request **Symptom**: `error code: 1101` from `infra.omg.dev`, every request, including ones that should be cheap. **Most likely cause**: CF KV daily quota exceeded (free tier = 100k reads/day). Every Worker invocation does 1 KV.get; SSE retry storms on deleted deploys can burn it from a single tab. **Confirm**: ```bash cd apps/infra/worker && \ CLOUDFLARE_API_TOKEN=$CF_API_TOKEN CLOUDFLARE_ACCOUNT_ID=$CF_ACCOUNT_ID \ npx wrangler tail ``` Look for `Error: KV get() limit exceeded for the day.` **Fixes** (in order of preference): 1. Already on Workers Paid? Wait — quota resets at 00:00 UTC. 2. Not on Paid? `dash.cloudflare.com → Workers → Plans → Workers Paid`. 3. The runtime is already minimised (isolate cache + 5min `cacheTtl` + SDK exponential backoff with circuit-break). If reads are still climbing, check for new SSE-using deploys hitting 4xx and dragging the SDK retry into a tight loop. ## DELETE /v1/sandboxes/:id returns 500 from the Worker **Cause** (history): `vibes-router` set `duplex: "half"` on every forwarded request. That is only valid for body-carrying methods. DELETE fetched without a body crashed the Worker. **Fix**: shipped as commit `5357661`. If you see this regress, the forward block in `apps/infra/worker/src/index.ts` should still read: ```ts if (bodyCarryingMethod) { init.body = request.body init.duplex = "half" } ``` ## Stuck Firecracker VMs after a service restart **Symptom**: `pgrep firecracker | wc -l` on box-1 grows over time; DELETE on a sandbox returns "sandbox X not found" even though `systemctl list-units 'vibes-fc-*'` shows it running. **Cause**: pre-migration sandbox rows had `scope_unit IS NULL` and weren't re-adopted by the boot reconciler. Post-2026-04-20 rows re-adopt cleanly; this only affects rows that survived from before `scope_unit` was added. **Fix** (one-time, manual): ```bash ssh root@178.63.205.231 \ 'for u in $(systemctl list-units --no-pager --no-legend "vibes-fc-*.service" | awk "{print \$1}"); do systemctl stop "$u" done' ``` ## Deploy stuck at status=building Query sqld directly — `bunx drizzle-studio`, or `select * from deploys where slug=?` from the auth box. If status is `building` for >3 minutes, the async build goroutine probably crashed; the next deploy with the same slug will overwrite the row. ## Deploy says `tarball restore unavailable` This means the project tarball exists but the node could not obtain a usable base rootfs. Check `findmnt /var/lib/vibes` and `lsblk` on the assigned node. Production requires `/var/lib/vibes` to be its dedicated Btrfs mount; the service must not run against the writable mountpoint directory on `/`. Current nodes fail closed at startup and runtime when the mount identity, filesystem, write probe, or NVMe health check fails. Readiness also requires a usable local warm rootfs or its durable Tigris object. The edge routes a deploy away from an unhealthy owner, and the healthy peer restores both warm rootfs and project artifact from Tigris. Do not recreate files under the bare mountpoint or increase cold-start timeouts; repair/replace the missing volume after the node has drained. ## Service token revocation See [Auth → Rotating a service token](/docs/auth#rotating-a-service-token). ## Killing a runaway deploy ```bash curl -X DELETE \ -H "Authorization: Bearer $VIBES_INFRA_SERVICE_TOKEN_CI" \ -H "X-On-Behalf-Of: ops" \ "https://infra.omg.dev/v1/deploys/${SLUG}" ``` This stops the sandbox (drains litestream first), removes the deploy row, deletes the KV entry, and async-GCs both the litestream and static prefixes. --- # Sandbox API _Source: https://docs.omg.dev/docs/sandbox-api_ The Sandbox API lets you create and control omg sandboxes from your own code — no dashboard, no browser. Each sandbox is a fully isolated Firecracker microVM with its own filesystem, network, and lifecycle. You authenticate with an **API key** and drive everything over plain HTTP. Use it to spin up throwaway build environments, run untrusted code, give your own app a "create a sandbox" button, or automate anything you'd otherwise do by hand in the dashboard. ## Base URL All endpoints live under a single host: ``` https://infra.omg.dev ``` Every `/v1/sandboxes*` route is authenticated with a Bearer API key (`omg_sk_…`; see [Authentication](/docs/sandbox-api/authentication)). Everything is **owner-scoped** — you only ever see your own sandboxes — and sandboxes you create with a key are raw programmatic sandboxes that list with `kind: "api"`. ## What you can do | Action | Endpoint | | --- | --- | | Create a sandbox | `POST /v1/sandboxes` | | List your sandboxes | `GET /v1/sandboxes` | | Get one sandbox | `GET /v1/sandboxes/{id}` | | Hibernate (pause) | `POST /v1/sandboxes/{id}/hibernate` | | Wake (resume) | `POST /v1/sandboxes/{id}/wake` | | Snapshot | `POST /v1/sandboxes/{id}/snapshot` | | Fork from a snapshot | `POST /v1/sandboxes/fork` | | Fork from a sandbox | `POST /v1/sandboxes/{id}/fork` | | Stop and delete | `DELETE /v1/sandboxes/{id}` | | Interactive shell (WebSocket) | `wss://ssh-ws.omg.dev/` | Full request and response shapes are in the [Reference](/docs/sandbox-api/reference). ## The shape of a sandbox A sandbox moves through a small set of states: - **provisioning** — booting; not yet reachable. - **running** — live and serving. - **hibernated** — paused to a snapshot; wakes on `resume` in ~0.3s. - **stopped** — torn down; the id is gone. Creating a sandbox returns its `id` and connection details immediately. The microVM boots asynchronously, so poll `GET /v1/sandboxes/{id}` (or `GET /v1/sandboxes`) until `status` is `running` before you connect. ## Next steps - [Authentication](/docs/sandbox-api/authentication) — create a key and use it as a Bearer token. - [Quickstart](/docs/sandbox-api/quickstart) — key → create → poll → connect, end to end. - [Templates](/docs/sandbox-api/templates) — baked snapshots, coding-agent presets, and baking your own with snapshot + fork. - [Webhooks](/docs/sandbox-api/webhooks) — signed lifecycle events and what's self-serve today. - [Reference](/docs/sandbox-api/reference) — every endpoint, with `curl` and JavaScript examples. --- # Authentication _Source: https://docs.omg.dev/docs/sandbox-api/authentication_ The Sandbox API authenticates with an **API key** — a long-lived secret that stands in for your account. You create keys in the dashboard and send them as a Bearer token on every request. ## Key format A key is an opaque secret: the prefix `omg_sk_` followed by 32 random bytes (base64url). The server stores only a **SHA-256 hash** of the key and shows you the plaintext exactly once, at creation: ``` omg_sk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6... ``` The first **12 characters** (e.g. `omg_sk_abc12`) are the key's non-secret **prefix** — that's what the dashboard shows in the key list so you can tell keys apart. The full secret is never shown again. API keys currently carry a single scope: **`sandbox`**. They resolve to the better-auth user that owns them and act as a normal **user principal** — a key can only create and manage sandboxes owned by that user, and **a key cannot manage API keys**. ## Create a key 1. Open [omg.dev/sandbox](https://omg.dev/sandbox) and sign in (signed-out visitors get a sandbox landing first, then continue into the product). 2. Go to **Keys** (or navigate straight to [`/sandbox/keys`](https://omg.dev/sandbox/keys)). 3. Click **New key**, give it a name (required, up to 80 characters), and **Create key**. 4. Copy the secret **immediately** — it's shown exactly once and starts with `omg_sk_`. If you lose it, revoke the key and make a new one. Key creation and management happen with your **dashboard session** — the `POST /v1/api-keys` endpoint requires a dashboard JWT, not an API key. See the [Reference](/docs/sandbox-api/reference#api-key-management) for the raw management shapes. **Limits:** at most **10 active keys** per account, and at most **5 key creations per hour**. Exceeding the creation rate returns `429`. > **Treat the key like a password.** Anyone holding it can create and control > sandboxes billed to your account. Store it in a secret manager or an > environment variable — never commit it to git or paste it into client-side > code. ## Use the key Send the key in the `Authorization` header as a Bearer token. **Every `/v1/sandboxes*` route accepts it**: ```bash curl https://infra.omg.dev/v1/sandboxes \ -H "Authorization: Bearer $OMG_API_KEY" ``` In JavaScript: ```js const res = await fetch("https://infra.omg.dev/v1/sandboxes", { headers: { Authorization: `Bearer ${process.env.OMG_API_KEY}` }, }) ``` Everything is **owner-scoped**: a `GET /v1/sandboxes` returns only your sandboxes, and you can only control sandboxes you own. Sandboxes you create with an API key are **raw programmatic sandboxes** (no project/session/app attribution) and list with `kind: "api"`. ## Revoking a key Revoke a key any time from **Sandboxes → API keys** — click the trash icon on the row and confirm (or `DELETE /v1/api-keys/{id}` with your dashboard session). Revocation is **permanent and effective almost immediately**: the next request made with that key gets `401 Unauthorized`. Revoking one key never affects your others. Rotate keys the same way: create a new key, deploy it, then revoke the old one once nothing is using it. You can also set an **`expiresAt`** when creating a key so it expires on its own. ## Errors | Status | Meaning | | --- | --- | | `401 Unauthorized` | Missing, unknown, revoked, expired, or malformed key. | | `403 Forbidden` | The key is valid but doesn't own the target resource. | | `429 Too Many Requests` | Key-creation rate limit (5/hour). | ## Keeping keys safe - Prefer a **per-environment** key (one for CI, one for production) so you can revoke a single blast radius. - Read the key from the environment (`process.env.OMG_API_KEY`), not a literal in source. - Never ship a key to the browser. If your frontend needs a sandbox, mint it from your **server** and hand the client only what it needs (e.g. a preview URL). --- # Quickstart _Source: https://docs.omg.dev/docs/sandbox-api/quickstart_ This walks the whole path: get a key, create a sandbox, wait for it to boot, and connect. Every call is a plain HTTPS request authenticated with your API key. ## 1. Get a key Create one at [omg.dev/sandbox/keys](https://omg.dev/sandbox/keys) and copy the `omg_sk_…` secret (shown once). See [Authentication](/docs/sandbox-api/authentication). Put it in your environment: ```bash export OMG_API_KEY="omg_sk_…" ``` ## Use the SDK From TypeScript, `@omg-dev/sandbox` wraps the same API: ```bash bun add @omg-dev/sandbox ``` ```ts import { SandboxClient } from "@omg-dev/sandbox" const client = new SandboxClient({ baseUrl: "https://infra.omg.dev", token: process.env.OMG_API_KEY!, }) const sandbox = await client.create({ templateId: "react-ts", ports: [5173] }) await sandbox.exec("echo", ["hello from a microVM"]) // → { stdout: "hello from a microVM\n", stderr: "", exitCode: 0 } await sandbox.shell("cd /home/user/project && bun run build") await sandbox.snapshot() await sandbox.stop() ``` Server-side only — the client holds your API key. The SDK also ships a typed template baker; see [Templates](/docs/sandbox-api/templates). The rest of this page is the same flow over raw HTTP. ## 2. Create a sandbox `POST /v1/sandboxes` with an empty body starts a default sandbox (medium — 2 vCPUs / 2 GB). It returns immediately with the new sandbox's `id` and status. Sandbox creation requires a **Pro or Max** plan (`402` otherwise). This create picks a machine size and starts from the `react-ts` template — a ready-to-run Vite + React + TypeScript app with dependencies preinstalled: ```bash curl -X POST https://infra.omg.dev/v1/sandboxes \ -H "Authorization: Bearer $OMG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "size": "medium", "ports": [5173], "templateId": "react-ts" }' ``` `size` is `small` (1 vCPU / 1 GB), `medium` (2/2 GB), or `large` (4/4 GB). Instead of a template you can pass a coding-agent preset like `{ "template": "claude" }` — see [Templates](/docs/sandbox-api/templates). ```json { "id": "abc123def456", "status": "running", "cwd": "/home/user", "vmIP": "10.10.0.2", "portMap": { "41000": 5173 }, "createdAt": "2026-07-07T13:00:00Z", "nodeId": "box-1" } ``` API-key creates are **raw programmatic sandboxes** and list with `kind: "api"`. If you store `nodeId`, pin follow-up calls with `X-Vibes-Node: `. ## 3. Poll until it's running The microVM boots asynchronously, so wait for `status: "running"` before you connect. Poll the single-sandbox endpoint: ```bash curl https://infra.omg.dev/v1/sandboxes/$SANDBOX_ID \ -H "Authorization: Bearer $OMG_API_KEY" ``` In JavaScript, poll with a small backoff: ```js const BASE = "https://infra.omg.dev" const auth = { Authorization: `Bearer ${process.env.OMG_API_KEY}` } async function createAndWait() { // create const created = await fetch(`${BASE}/v1/sandboxes`, { method: "POST", headers: { ...auth, "Content-Type": "application/json" }, body: "{}", }).then((r) => r.json()) // poll until running (or provisioning fails) for (let i = 0; i < 60; i++) { const sb = await fetch(`${BASE}/v1/sandboxes/${created.id}`, { headers: auth, }).then((r) => r.json()) if (sb.status === "running") return sb if (sb.status === "stopped") throw new Error("sandbox failed to boot") await new Promise((r) => setTimeout(r, 1000)) } throw new Error("timed out waiting for sandbox") } ``` ## 4. List what you've got `GET /v1/sandboxes` returns every sandbox your key owns, with status and usage: ```bash curl https://infra.omg.dev/v1/sandboxes \ -H "Authorization: Bearer $OMG_API_KEY" ``` ## 5. Connect Once a sandbox is `running`, connect however your workload needs: - **Interactive shell** — run the first-party bridge script with `OMG_API_KEY` set, or open a WebSocket to `wss://ssh-ws.omg.dev/` with subprotocol `vibes.shell.v1` and your key as `vibes.api_key.` (or an `Authorization: Bearer omg_sk_…` header / `?access_token=`). Frames are binary, tagged by a leading byte (`0` data, `1` control, `2` ping, `3` pong). Full handshake in the [Reference](/docs/sandbox-api/reference#interactive-shell-websocket). - **Its own services** — a sandbox that serves HTTP is reachable at its preview URL; `GET /v1/sandboxes/{id}/url/{port}` returns it. ## 6. Clean up Sandboxes hibernate on idle automatically, but you can pause or tear one down explicitly: ```bash # hibernate (wakes again on resume, ~0.3s) — /pause is an alias curl -X POST https://infra.omg.dev/v1/sandboxes/$SANDBOX_ID/hibernate \ -H "Authorization: Bearer $OMG_API_KEY" # stop and delete for good curl -X DELETE https://infra.omg.dev/v1/sandboxes/$SANDBOX_ID \ -H "Authorization: Bearer $OMG_API_KEY" ``` ## What's live today - **Create, list, get, pause, resume, snapshot, fork, and stop** are all live over the API — plus **exec, file read/write, and preview URLs**. - **Sizes**: `small` / `medium` / `large` (1/2/4 vCPUs, 1/2/4 GB) on every create; **templates**: baked snapshots (`templateId`) and coding-agent presets (`template`). - Idle sandboxes **hibernate automatically** and **wake on connect** — a paused sandbox resumes under the same id in about 0.3s. - The interactive **shell** is a WebSocket upgrade (not a REST call); see the reference for the exact handshake. For every field and every endpoint, see the [Reference](/docs/sandbox-api/reference). --- # Reference _Source: https://docs.omg.dev/docs/sandbox-api/reference_ All endpoints are under `https://infra.omg.dev`. Sandbox routes accept a Bearer **API key** (`omg_sk_…`); the API-key **management** routes require a dashboard session. JSON in, JSON out. IDs are the short sandbox id returned by create. Errors are always `{ "error": "message" }` with a matching status: | Status | Meaning | | --- | --- | | `400` | Invalid request body or parameters. | | `401` | Missing, unknown, revoked, expired, or malformed credential. | | `402` | Plan gate — sandbox creation requires Pro/Max — or insufficient compute credit. | | `403` | Credential is valid but doesn't own the resource. | | `404` | Resource not found. | | `409` | Lifecycle conflict. | | `421` | Request reached the wrong node for an `X-Vibes-Node` pin. | | `429` | API-key creation rate limit. | | `503` | Sandbox capacity or dependency unavailable. | ## API key management These routes manage keys and require a **dashboard JWT** (`Authorization: Bearer `) — an API key **cannot** manage keys. The dashboard's **Sandboxes → API keys** screen drives them for you. Limits: **10 active keys** per owner, **5 creations per owner per hour**. ### Create a key `POST /v1/api-keys` `name` is **required** (max 80 chars). `expiresAt` is optional RFC-3339 and must be in the future. The plaintext `key` is returned **exactly once**. ```bash curl -sS https://infra.omg.dev/v1/api-keys \ -H "Authorization: Bearer $OMG_DASHBOARD_JWT" \ -H "Content-Type: application/json" \ -d '{ "name": "Production backend", "expiresAt": "2026-12-31T23:59:59Z" }' ``` ```json { "key": "omg_sk_...", "apiKey": { "id": "ak_...", "name": "Production backend", "prefix": "omg_sk_abc12", "scopes": ["sandbox"], "createdAt": "2026-07-07T13:00:00Z", "lastUsedAt": null, "revokedAt": null, "expiresAt": "2026-12-31T23:59:59Z" } } ``` The top-level `key` is the one-time plaintext — store it now. The `apiKey` object is the metadata you'll see again in the list. ### List keys `GET /v1/api-keys` — metadata only; secrets and hashes are never returned. ```json { "apiKeys": [ { "id": "ak_...", "name": "Production backend", "prefix": "omg_sk_abc12", "scopes": ["sandbox"], "createdAt": "2026-07-07T13:00:00Z", "lastUsedAt": "2026-07-07T13:10:00Z", "revokedAt": null, "expiresAt": null } ] } ``` ### Revoke a key `DELETE /v1/api-keys/{id}` — revokes a key you own; **permanent**. Returns the key with `revokedAt` set. ```json { "apiKey": { "id": "ak_...", "revokedAt": "2026-07-07T13:15:00Z", "...": "..." } } ``` ## Sandbox response Create, get, lifecycle, fork, and delete all return the same shape. Every `/v1/sandboxes*` route below accepts `Authorization: Bearer omg_sk_…` and is owner-scoped. ```json { "id": "abc123def456", "status": "running", "cwd": "/home/user", "vmIP": "10.10.0.2", "portMap": { "41000": 5173 }, "createdAt": "2026-07-07T13:00:00Z", "preferredModel": "", "nodeId": "box-1", "llmProxyUrl": "http://..." } ``` `llmProxyUrl` is omitted when not applicable. When you store `nodeId`, pin follow-up calls with `X-Vibes-Node: ` (a mismatch returns `421`). ## Create a sandbox `POST /v1/sandboxes` Creates a sandbox owned by the caller. **API-key creates are raw programmatic sandboxes** — no project/session/app attribution — and appear in the list with `kind: "api"`. When authenticated with an API key and `agentServerSource` is omitted, the server defaults `skipAppProcesses` to `true` (i.e. a raw sandbox, not a dashboard dev-editor sandbox). Creating a sandbox requires a **Pro or Max plan** — free accounts get `402`. All body fields are optional: | Field | Type | Notes | | --- | --- | --- | | `ports` | `number[]` | Ports to expose from the guest. | | `env` | `object` | Environment variables. | | `timeout` | `number` | Idle timeout in seconds. | | `size` | `string` | Machine size: `small`, `medium`, `large`. Default `medium`. | | `vcpus` + `memMb` | `number` | Exact shape instead of `size` — must both be set and match a curated shape. | | `templateId` | `string` | Fork from a baked snapshot template (e.g. `"react-ts"`). See [Templates](/docs/sandbox-api/templates). | | `template` | `string` | Coding-agent preset: `opencode`, `pi`, `codex`, `claude`, `lfg`. Mutually exclusive with `templateId`. | | `gitRepositoryUrl` | `string` | Source attribution for git imports (`gitRef` optional alongside). | | `preferredModel` | `string` | LLM the guest's proxy routes to. | | `skipAppProcesses` | `boolean` | Defaults `true` for API-key callers. | ### Sizes | `size` | vCPUs | RAM | | --- | --- | --- | | `small` | 1 | 1 GB | | `medium` (default) | 2 | 2 GB | | `large` | 4 | 4 GB | An explicit `size` wins over `vcpus`/`memMb`. If you pass `vcpus`/`memMb` instead, the pair must exactly match one of the shapes above — anything else is `400 unsupported shape`. All sizes are available on Pro and Max. ```bash curl -sS https://infra.omg.dev/v1/sandboxes \ -H "Authorization: Bearer $OMG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "size": "large", "ports": [5173], "templateId": "react-ts" }' ``` ```js const sb = await fetch("https://infra.omg.dev/v1/sandboxes", { method: "POST", headers: { Authorization: `Bearer ${process.env.OMG_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ size: "large", ports: [5173], templateId: "react-ts" }), }).then((r) => r.json()) ``` When `template` (agent preset) is set, the response adds `template`, `templateStatus` (`ready` | `failed`), `installLogPath`, and — for presets that serve a web UI — `servePort` + `serveLogPath`. See [Templates](/docs/sandbox-api/templates#agent-presets-template). The VM boots asynchronously — poll `GET /v1/sandboxes/{id}` until `status` is `running` before connecting. ## List your sandboxes `GET /v1/sandboxes` — the caller's account-scoped list plus usage totals. Query params: `limit` (capped at **500**) and `offset`. ```json { "sandboxes": [ { "id": "abc123def456", "slug": null, "project": null, "projectId": null, "appId": null, "kind": "api", "status": "running", "region": "box-1", "vcpus": 2, "memMb": 2048, "createdAt": "2026-07-07T13:00:00Z", "lastActiveAt": "2026-07-07T13:00:00Z", "usageSeconds": 60, "usageCost": null, "computeCost": 0.0001, "sshEnabled": false, "sshHost": null, "sshUser": null, "sshCommand": null, "sshKeysConfigured": false } ], "totalUsageSeconds": 60, "totalComputeCost": 0.0001, "computeCostBilled": false, "totalLlmCost": null, "total": 1, "limit": 0, "offset": 0 } ``` > `computeCost` / `totalComputeCost` are list-rate **estimates**; > `computeCostBilled` is `false` while sandbox compute billing is in shadow mode. ## Get a sandbox `GET /v1/sandboxes/{id}` — returns one sandbox if owned by the caller (same shape as create), with optional SSH metadata. Use it to poll for `status: "running"`. ## Stop and delete `DELETE /v1/sandboxes/{id}` — stops the sandbox. **Idempotent** for already-stopped sandboxes. ```bash curl -sS -X DELETE https://infra.omg.dev/v1/sandboxes/$SANDBOX_ID \ -H "Authorization: Bearer $OMG_API_KEY" ``` ## Hibernate (pause) `POST /v1/sandboxes/{id}/hibernate` — alias `POST /v1/sandboxes/{id}/pause`. Snapshots and stops the VM while keeping the row wakeable. Returns the sandbox with `status: "hibernated"`. ## Wake (resume) `POST /v1/sandboxes/{id}/wake` — alias `POST /v1/sandboxes/{id}/resume`. Both body fields optional. Returns the sandbox with `status: "running"`. ```bash curl -sS -X POST https://infra.omg.dev/v1/sandboxes/$SANDBOX_ID/wake \ -H "Authorization: Bearer $OMG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "readinessPort": 5173, "ports": [5173] }' ``` ## Snapshot `POST /v1/sandboxes/{id}/snapshot` — creates a full snapshot. ```json { "id": "snap...", "nodeId": "box-1", "kind": "user_project", "rootfsSha": "...", "sizeBytes": 123456, "tarballSizeBytes": 12345, "sourceSandboxId": "abc123def456", "uploadedToTigris": false, "createdAt": "2026-07-07T13:00:00Z" } ``` ## Fork - `POST /v1/sandboxes/{id}/fork` — fork from a **source sandbox** you own. If it's running the server snapshots it first; if hibernated it forks from the wake snapshot. Body: `ports`, `sessionId`, `projectId`, `projectSlug`, `appId`, `preferredModel`, `skipAppProcesses` (all optional). - `POST /v1/sandboxes/fork` — fork from an explicit **snapshot id** you own. Same body plus required `snapshotId`. Forks are automatically routed to the node that holds the snapshot — no `X-Vibes-Node` pin needed here. ```bash curl -sS https://infra.omg.dev/v1/sandboxes/fork \ -H "Authorization: Bearer $OMG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "snapshotId": "'$SNAPSHOT_ID'", "ports": [5173], "skipAppProcesses": true }' ``` Pass `skipAppProcesses: true` when the snapshot came from a raw sandbox — it keeps the fork raw instead of starting the dashboard dev-editor process set. Snapshot + fork is also the way to bake reusable custom templates; see [Templates](/docs/sandbox-api/templates#bake-your-own-template). ## Execute a command `POST /v1/sandboxes/{id}/exec` — runs a command in the guest (as root) and returns its output. The sandbox must be `running`. ```bash curl -sS -X POST https://infra.omg.dev/v1/sandboxes/$SANDBOX_ID/exec \ -H "Authorization: Bearer $OMG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "cmd": "bash", "args": ["-lc", "bun install"], "cwd": "/home/user/project", "timeoutMs": 300000 }' ``` ```json { "stdout": "...", "stderr": "", "exitCode": 0 } ``` Body: `cmd` plus optional `args`, `cwd`, `env`, `timeoutMs`, `detached`. `exec` injects `ANTHROPIC_BASE_URL`, `OPENAI_BASE_URL`, `OMG_AI_URL`, and `OMG_MEDIA_URL` pointing at the per-sandbox LLM proxy (your `env` values win), so AI SDKs in the guest work without provider keys. ## Read and write files `POST /v1/sandboxes/{id}/files` — write files. The body is a JSON **array**; `content` is base64. Returns `204`. ```bash curl -sS -X POST https://infra.omg.dev/v1/sandboxes/$SANDBOX_ID/files \ -H "Authorization: Bearer $OMG_API_KEY" \ -H "Content-Type: application/json" \ -d '[{ "path": "/home/user/project/hello.txt", "content": "'$(printf 'hi' | base64)'", "mode": 420 }]' ``` `GET /v1/sandboxes/{id}/files?path=/home/user/project/hello.txt` — read one file back as base64 JSON: ```json { "content": "aGk=" } ``` ## Preview URL `GET /v1/sandboxes/{id}/url/{port}` — the public HTTPS URL for a port the sandbox serves: ```json { "url": "https://" } ``` ## Other sandbox routes The same API-key auth + owner check apply to: | Method | Path | Notes | | --- | --- | --- | | `POST` | `/v1/sandboxes/{id}/filesystem-snapshot` | Project tarball snapshot | | `POST` | `/v1/sandboxes/{id}/tarball` | Alias for filesystem snapshot | | `PUT` | `/v1/sandboxes/{id}/uploads/{path...}` | Browser upload path | | `GET` | `/v1/sandboxes/{id}/usage` | Sandbox usage (`cpuMs`, `wallClockMs`) | | `POST` | `/v1/sandboxes/{id}/extend` | Extend the idle timeout | ## Interactive shell (WebSocket) Not a REST call — this upgrades to a **WebSocket** carrying a live PTY. - **Endpoint:** `wss://ssh-ws.omg.dev/` (the public shell gateway host). The underlying API route is `GET /v1/sandboxes/{id}/shell`. - **Subprotocol:** `vibes.shell.v1`. - **Auth (API-key client)** — any one of: - `Authorization: Bearer omg_sk_...` header, or - `?access_token=omg_sk_...` query param, or - a `vibes.api_key.` entry in `Sec-WebSocket-Protocol` (alongside `vibes.shell.v1`) — the option browsers need, since they can't set headers on a WebSocket. (The dashboard uses `vibes.jwt.` instead.) A key can open the shell for any sandbox its owner owns. - **Frames** are binary, tagged by a leading byte: | Byte | Type | | --- | --- | | `0` | data | | `1` | control | | `2` | ping | | `3` | pong | ### Local terminal command Use the first-party bridge script when you want a local terminal attached to a sandbox: ```bash export OMG_API_KEY=omg_sk_... tmp=$(mktemp) curl -fsSL https://omg.dev/sandbox-shell.js -o "$tmp" node "$tmp" rm -f "$tmp" ``` The hosted shell is not raw SSH. This command speaks the same framed shell protocol as the browser terminal and requires Node.js 22+ or another runtime with a global `WebSocket`. ```js const key = process.env.OMG_API_KEY const b64url = (s) => btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") const ws = new WebSocket(`wss://ssh-ws.omg.dev/${sandboxId}`, [ "vibes.shell.v1", `vibes.api_key.${b64url(key)}`, ]) ws.binaryType = "arraybuffer" ws.onmessage = (e) => { const frame = new Uint8Array(e.data) if (frame[0] === 0) process.stdout.write(frame.subarray(1)) // data } // send keystrokes as data frames: [0, ...utf8bytes] ``` --- # Templates _Source: https://docs.omg.dev/docs/sandbox-api/templates_ Two template fields on `POST /v1/sandboxes`, for two different things: - **`templateId`** — fork from a **baked snapshot** in the template registry (e.g. `"react-ts"`). The sandbox boots with files and `node_modules` already in place. - **`template`** — a **coding-agent preset** slug (`opencode | pi | codex | claude | lfg`). The sandbox comes up with that CLI agent installed and ready in the terminal. They are mutually exclusive — sending both returns `400`. ## Snapshot templates (`templateId`) ```bash curl -sS https://infra.omg.dev/v1/sandboxes \ -H "Authorization: Bearer $OMG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "templateId": "react-ts", "ports": [5173] }' ``` `react-ts` gives you a ready-to-run web app at `/home/user/project`: - Vite + React 19 + TypeScript, Tailwind CSS v4 - `@omg-dev/*` SDK preinstalled (sdk, schema, server, auth, ai, media, pwa, db-collection) with `functions/` and `server/` scaffolding - Dependencies already installed — `bun run dev` serves on `:5173` immediately, no `bun install` wait Because the sandbox is restored from a snapshot instead of cold-booting and installing, create-to-serving is seconds, not minutes. ## Agent presets (`template`) A preset creates a **raw sandbox** (`skipAppProcesses` is forced `true`) with one or more coding-agent CLIs installed. Unknown slugs fail with `400`. | Slug | Installs | Serves | | --- | --- | --- | | `opencode` | opencode CLI | TUI only | | `pi` | pi coding agent | TUI only | | `codex` | OpenAI Codex CLI | TUI only | | `claude` | Claude Code | TUI only | | `lfg` | all four agents + the lfg web UI | web UI on `:8766` | ```bash curl -sS https://infra.omg.dev/v1/sandboxes \ -H "Authorization: Bearer $OMG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "template": "claude" }' ``` The create response adds preset fields on top of the normal sandbox shape: ```json { "id": "abc123def456", "status": "running", "template": "claude", "templateStatus": "ready", "installLogPath": "/home/user/.omg/agent-install.log" } ``` - `templateStatus` is `ready` or `failed` (install/verify runs before the response returns). On `failed`, `bootstrapError` says why and the sandbox is kept so you can inspect the install log. - `lfg` also returns `servePort: 8766` and `serveLogPath`; its web UI is reachable via `GET /v1/sandboxes/{id}/url/8766`. For `lfg`, `ports` defaults to `[8766]` when you don't pass any. - TUI presets have no web server — connect over the [shell WebSocket](/docs/sandbox-api/reference#interactive-shell-websocket) and run the agent by name (`claude`, `codex`, …). ## What the default image has Every sandbox (no `templateId`) boots from the same base rootfs: | | | | --- | --- | | OS | Ubuntu 22.04 | | Runtime | Bun (system-wide, `/usr/local/bin/bun`) | | Tools | `git`, `curl`, `wget`, `unzip`, `python3`, `openssh-server` | | User | `user` (uid 1000, passwordless sudo), home + cwd `/home/user` | | Package cache | pre-warmed Bun cache for the `@omg-dev/*` SDK and the shadcn/Radix/Tailwind stack — `bun install` of those is near-instant | There is no Node.js binary — Bun stands in (agent presets shim `node → bun` for `#!/usr/bin/env node` CLIs). `exec` also injects `ANTHROPIC_BASE_URL`, `OPENAI_BASE_URL`, and `OMG_AI_URL` pointing at the per-sandbox LLM proxy, so AI SDKs inside the sandbox work without keys. ## Bake your own template `@omg-dev/sandbox` ships a typed template baker: declare install steps, checks, and a start command, and `bakeTemplate` runs the whole create → apply → verify → snapshot → publish sequence. ```ts import { SandboxClient, defineTemplate, bakeTemplate, apt, run, check, } from "@omg-dev/sandbox" const client = new SandboxClient({ baseUrl: "https://infra.omg.dev", token: process.env.OMG_API_KEY!, }) const template = defineTemplate({ id: "my-agent", version: "1", title: "My agent", ports: [3000], install: [ apt.packages(["tmux"]), run({ user: "user", command: "bun install -g my-agent@1.2.3" }), ], checks: [check.command("tmux"), check.command("my-agent", { user: "user" })], start: { user: "user", command: "my-agent serve --host 0.0.0.0 --port 3000", readiness: { port: 3000 }, }, }) await bakeTemplate(client, template) ``` It publishes both `my-agent-v1` and `my-agent` (latest); create from either with `{ "templateId": "my-agent" }`. Direct service clients mark the system version immutable. User template names are private to their account, so two users can publish the same name without replacing each other. If an account has no private template with that name, create falls back to the global system catalogue. The registry records the start command, ports, and readiness probe. Creation copies that runtime contract onto the sandbox row, so hibernate/resume restores the service without the caller resending options and a later publish cannot change an existing sandbox. Re-bake by bumping `version`. `install` steps run as root unless you pass `user`; `apt.packages` and `download.archive` are the other typed steps. `checks` fail the bake early (`check.command` / `check.file`), before anything is published. ### Raw HTTP: snapshot + fork No SDK? **Snapshot + fork** gives you the same instant-boot behavior: set up a sandbox once, snapshot it, then fork the snapshot for every fresh instance. ```js const BASE = "https://infra.omg.dev" const auth = { Authorization: `Bearer ${process.env.OMG_API_KEY}` } const json = { ...auth, "Content-Type": "application/json" } // 1. create a base sandbox (and poll it to "running" — see Quickstart) const sb = await fetch(`${BASE}/v1/sandboxes`, { method: "POST", headers: json, body: JSON.stringify({ size: "medium", ports: [3000] }), }).then((r) => r.json()) // 2. write your files — body is a JSON ARRAY, content is base64 await fetch(`${BASE}/v1/sandboxes/${sb.id}/files`, { method: "POST", headers: json, body: JSON.stringify([ { path: "/home/user/project/package.json", content: btoa(JSON.stringify({ name: "my-template", type: "module" })), mode: 0o644, }, ]), }) // 3. run setup (exec runs as root; returns stdout/stderr/exitCode) await fetch(`${BASE}/v1/sandboxes/${sb.id}/exec`, { method: "POST", headers: json, body: JSON.stringify({ cmd: "bash", args: ["-lc", "cd /home/user/project && bun install"], timeoutMs: 300_000, }), }).then((r) => r.json()) // 4. snapshot — this is your "template" const snap = await fetch(`${BASE}/v1/sandboxes/${sb.id}/snapshot`, { method: "POST", headers: auth, }).then((r) => r.json()) // 5. fork the snapshot whenever you need a fresh instance (~sub-second boot) const fresh = await fetch(`${BASE}/v1/sandboxes/fork`, { method: "POST", headers: json, body: JSON.stringify({ snapshotId: snap.id, ports: [3000], skipAppProcesses: true, }), }).then((r) => r.json()) // Or publish/update a name in your private template registry. await fetch(`${BASE}/v1/templates/my-template/latest`, { method: "POST", headers: json, body: JSON.stringify({ snapshotId: snap.id, startCommand: "cd /home/user/project && exec bun run start", readinessPort: 3000, ports: [3000], }), }) ``` Notes: - The snapshot captures the **full VM state** — filesystem and memory — so a fork resumes with your processes' output already on disk. - Forks are owner-checked and automatically routed to the node that holds the snapshot; you don't need an `X-Vibes-Node` pin for `POST /v1/sandboxes/fork`. - Pass `skipAppProcesses: true` on the fork unless your snapshot is a dashboard dev-editor sandbox — it keeps the fork a raw sandbox that only runs what you set up. - Keep the source sandbox or delete it after snapshotting — the snapshot stays forkable either way. Re-baking = run the steps again and switch your code to the new `snapshotId`. - A user may publish only snapshots produced by their own sandbox. `GET`, `POST`, and `DELETE /v1/templates/{id}/latest` are scoped to that user's namespace. Global system templates and immutable version refs can be changed only by a service principal. - The start command, readiness port, and ports are copied onto each sandbox when it is created. Updating or deleting the registry row cannot change what an existing sandbox executes after wake. Underneath, the typed baker makes exactly these calls — the raw flow and `bakeTemplate` produce the same registry entries that `templateId` resolves against. First-party templates such as `react-ts` are published into the service-owned global catalogue. --- # Webhooks _Source: https://docs.omg.dev/docs/sandbox-api/webhooks_ The platform pushes sandbox lifecycle events over HTTP in two forms: **platform webhooks** (signed, Stripe-style) and **per-sandbox agent callbacks**. Read this first: > **Self-serve subscription is not available yet.** There is no API to > register your own webhook URL today — receivers are configured > platform-side. The wire format below is documented so you can build a > receiver ahead of time (and so self-hosted deployments can wire > `VIBES_WEBHOOK_RECEIVER_URL`). Until subscription opens up, poll > `GET /v1/sandboxes/{id}` for state changes. ## Platform webhooks Each event is a `POST` to the subscriber URL: ```http POST Content-Type: application/json X-Vibes-Webhook-Id: evt_ X-Vibes-Webhook-Timestamp: X-Vibes-Webhook-Type: sandbox.snapshot.recorded X-Vibes-Webhook-Signature: v1= ``` ```json { "id": "evt_...", "type": "sandbox.snapshot.recorded", "ownerId": "...", "occurredAt": "2026-07-07T13:00:00Z", "data": {} } ``` `data` is event-type-specific. Parse the fields you need and ignore extras — producers add fields without notice. ### Event types | Type | Fires when | | --- | --- | | `sandbox.started` | A sandbox booted (cold or fork) and is reachable. | | `sandbox.stopped` | A sandbox transitioned to stopped. | | `sandbox.snapshot.recorded` | A snapshot was captured and is durable on its node (rootfs upload may still be running). | | `sandbox.snapshot.uploaded` | The snapshot's rootfs upload finished; it's usable as a fork source from any node. | ### Verifying the signature The signature covers `timestamp + "." + raw body` with HMAC-SHA256, so the timestamp can't be swapped onto a replayed body. Verify against the **raw request bytes**, not re-serialized JSON: ```js import { createHmac, timingSafeEqual } from "node:crypto" function verifyWebhook(secret, headers, rawBody) { const ts = headers["x-vibes-webhook-timestamp"] const expected = createHmac("sha256", secret) .update(`${ts}.${rawBody}`) .digest("hex") const given = (headers["x-vibes-webhook-signature"] ?? "") .split(",") .map((p) => p.trim()) .find((p) => p.startsWith("v1=")) ?.slice(3) if (!given || given.length !== expected.length) return false if (!timingSafeEqual(Buffer.from(given, "hex"), Buffer.from(expected, "hex"))) return false // reject stale timestamps (replay window — pick your tolerance) return Math.abs(Date.now() / 1000 - Number(ts)) < 300 } ``` The header may carry multiple comma-separated versions (`v1=...,v2=...`) during a rotation — verify any one you support. ### Delivery semantics - **Best-effort:** 3 attempts per event with backoff of 1s, 5s, 20s (~26s total), then the event is dropped. Treat webhooks as hints and reconcile by polling when you need strict consistency. - Any 2xx from your receiver counts as delivered. Respond fast; do real work async. - Events can arrive out of order — order by `occurredAt`, not arrival. ## Per-sandbox agent callbacks Separate mechanism, different scope: sandboxes running the platform's **dev-editor agent-server** (the process behind dashboard build sandboxes) POST plain JSON events to a per-sandbox `callbackUrl`: ```http POST Content-Type: application/json Authorization: Bearer { "type": "turn_complete", ... } ``` Event types: `agent_server_ready`, `turn_complete`, `errors_notified`, `session_reset`, `steering_queued`, `agent_aborted`. These are **unsigned and fire-and-forget** — one attempt, 5-second timeout, no retries. `callbackUrl` / `callbackToken` are set via the `agentBootstrap` create field, which configures the in-VM agent-server. **Raw API-key sandboxes don't run the agent-server** (they default `skipAppProcesses: true` and can't supply the internal agent-server bundle), so these callbacks are effectively internal to the dashboard today. If you create raw sandboxes over the API, nothing will POST to you — poll instead. ## What to use today | You want | Use | | --- | --- | | Know when a sandbox is running | Poll `GET /v1/sandboxes/{id}` for `status: "running"` | | Know when a snapshot is fork-ready everywhere | Poll the snapshot response / re-try the fork | | Push notifications for lifecycle events | Not self-serve yet — the format above is what will ship | --- # SDK overview _Source: https://docs.omg.dev/docs/sdk_ The Vibes SDK is three packages cooperating across one project: | Package | Where it runs | What it does | | ------- | ------------- | ------------ | | `@omg-dev/schema` | Build time | Declarative schema; emits SQL DDL, TS types, Zod, and the build manifest's `hasDb` flag. | | `@omg-dev/sdk` | Browser | `useQuery` — WebSocket snapshot + live row deltas, optional `where` scope, and mutations (`useCollection` is the deprecated alias). | | `@omg-dev/server` | Bun runtime | Mounts `/api/` against sqlite, dispatches `functions/*.ts`, broadcasts invalidations. | The pipeline keys off one file: `schema.ts` at project root. Find `default.collections` and the deploy classifies as **runtime** — a Firecracker VM serves `/api/*`. Otherwise it's **static**, the VM is skipped, and mutations return `method not allowed`. Getting that file shape right is the single load-bearing step; see [Schema reference](/docs/sdk/schema) for the contract. ## Minimum viable DB-backed app The fresh sandbox at `/home/user/project` already has Vite, React, Tailwind, shadcn, and `@omg-dev/*` installed. To turn it into a DB app, write two files. ### 1. `schema.ts` (project root, default-exported) ```ts import { defineSchema, collection, fields } from "@omg-dev/schema" export default defineSchema({ collections: { tasks: collection({ fields: { title: fields.string(), done: fields.boolean(), }, }), }, }) ``` This file MUST be at the project root, not under `src/` — the build does `await import("./schema.ts")` from `process.cwd()`. Anywhere else silently ships a static deploy. ### 2. `src/App.tsx` ```tsx import { useState } from "react" import { useQuery } from "@omg-dev/sdk" interface Task { id: string; title: string; done: boolean } export default function App() { const { data, create, update, remove } = useQuery({ api: "/api/tasks", collection: "tasks", }) const [draft, setDraft] = useState("") return (

Tasks

{ e.preventDefault() if (!draft.trim()) return await create({ title: draft, done: false }) setDraft("") }} > setDraft(e.target.value)} />
    {data.map((t) => (
  • update(t.id, { done: !t.done })} /> {t.title}
  • ))}
) } ``` That is the whole DB app. No `useEffect`, no fetch, no DB client, no auth wiring. `data` is always an array (defaults to `[]` before the first fetch resolves) so `.map` is safe from the first render. ## What happens at deploy time 1. `bun run build` runs the user-facing client build (Vite). 2. The orchestrator chains `vibes-build` (`@omg-dev/vite-plugin`'s bin), which: - Imports `schema.ts`, reads `default.collections`, decides `hasDb`. - Scans `functions/*.ts`, generates `.vibes/routes.generated.ts`. - Bundles everything into a single `.vibes/server.mjs` via `bun build --target=bun`. - Writes `.vibes/artifacts.json` with `{ static, hasDb, functions, ... }`. 3. The orchestrator (`apps/infra/internal/orchestrator/manager.go`) reads the manifest. If `!hasDb && functions === 0` it takes the static path (no VM). Otherwise it forks a runtime VM that runs `bun .vibes/server.mjs`. 4. At runtime, `@omg-dev/server`: - Opens `.vibes/data.db` (litestream-restored from `deploys-db//v:N-1/`, replicates to `v:N/`). - Auto-mounts `/api/` for every collection in the schema. - Dispatches `functions/*.ts` exports as additional routes. - Streams invalidations on `/__vibes_events` so every connected client refreshes when any mutation lands. ## Custom routes (functions) `useCollection` covers the common CRUD shape. For anything else, drop a file in `functions/`: ```ts // functions/search.ts export default { POST: async (req: Request) => { const { q } = await req.json() return Response.json({ results: [] }) }, } ``` Builds at `/api/search`. The build scanner detects every file in `functions/`, codegens preloaded module imports, and ships them inside `.vibes/server.mjs` — no `node_modules/` lookup at runtime. ## Common errors | Browser console | Real cause | Fix | | --- | --- | --- | | `TypeError: r.map is not a function` | `useCollection` was destructured wrong (it returns an object, not an array). | `const { data } = useCollection(...)`, then `data.map(...)`. | | `Unexpected token 'm', "method not allowed" ...` | Static-only deploy. `schema.ts` didn't default-export collections, so the orchestrator dropped the runtime VM. | Verify `schema.ts` is at root and default-exports `defineSchema(...)`. See [Schema reference](/docs/sdk/schema). | ## Reference - [Schema reference](/docs/sdk/schema) — `defineSchema`, `collection`, every `fields.*` builder, scope, indexes. - [useCollection reference](/docs/sdk/use-collection) — the hook's exact return type, API contract, and SSE behaviour. - [Architecture](/docs/architecture) — how the request reaches the runtime VM. - [Deploy flow](/docs/deploy-flow) — what happens between `bun run build` and a live URL. --- # AI & agents _Source: https://docs.omg.dev/docs/sdk/ai_ Every Vibes sandbox can call LLMs through the platform with no setup. You import the providers from `@omg-dev/ai`, write your `streamText` / `generateText` call, and the runtime handles the API key, the upstream URL, and per-end-user cost attribution. Cost is billed to **you** (the app creator). The end-user identifier is used for your own dashboards and per-user features (rate limits, free tiers, etc.) — the platform doesn't pass costs through to your end users. ## TL;DR ```ts title=".vibes/server.mjs" import { streamText, anthropic, runWithEndUser } from "@omg-dev/ai" import { createAuthMiddleware } from "@omg-dev/auth" const verify = createAuthMiddleware("vibes") Bun.serve({ port: 3000, hostname: "0.0.0.0", async fetch(req) { const auth = await verify(req) return runWithEndUser(auth?.userId, () => handler(req)) }, }) async function handler(req: Request) { if (new URL(req.url).pathname !== "/api/chat") { return new Response("not found", { status: 404 }) } const { messages } = await req.json() const result = streamText({ model: anthropic("claude-sonnet-4-6"), messages, }) return result.toTextStreamResponse() } ``` That's it. No `apiKey`, no `baseURL`, no per-call `headers`. Every LLM call inside the `runWithEndUser` scope is attributed to the verified end-user in your usage dashboard. ## What you get - **Zero config.** `anthropic("claude-sonnet-4-6")` and `openai("gpt-...")` point at the platform proxy automatically. Your code never sees a key. - **Streaming, tools, structured output.** Everything from the AI SDK (`ai` package) is re-exported from `@omg-dev/ai` — `streamText`, `generateText`, `streamObject`, `generateObject`, `tool`, `stepCountIs`, plus the types. - **Automatic end-user attribution** when paired with `@omg-dev/auth` — the cost dashboard breaks down by app and by end-user without any per-call bookkeeping. ## Available models `@omg-dev/ai` exposes the Anthropic and OpenAI provider shapes. The platform proxy currently routes the following: | Model id | Provider | | --- | --- | | `claude-sonnet-4-6` (default) | Anthropic | | `claude-haiku-4-5-20251001` | Anthropic | | `claude-opus-4-8` | Anthropic | | `claude-opus-4-7` | Anthropic | | `qwen/qwen3.6-plus` | OpenRouter (via OpenAI shape) | | `deepseek/deepseek-v4-pro` | OpenRouter (via OpenAI shape) | | `deepseek/deepseek-v4-flash` | OpenRouter (via OpenAI shape) | Use `anthropic()` for the Claude family and `openai()` for the OpenAI-compatible models. ## How attribution works Two pieces, both invisible to most app code: 1. **`runWithEndUser(userId, fn)`** — wraps a request handler so any LLM call inside it stamps `X-OMG-User: ` on the upstream request via `AsyncLocalStorage`. 2. **`@omg-dev/auth`'s middleware** verifies the inbound JWT and gives you the end-user id. You hand it to `runWithEndUser`. Two lines. The platform proxy reads that header, resolves it against the deploy's `app_id`, and writes a row keyed by `(owner, app, end-user, model)` into `usage_logs`. The Convex `getAppUsage(appId)` action returns a per-end-user breakdown for your dashboard. If `auth?.userId` is missing (request is unauthenticated), `runWithEndUser` falls through and the call is logged as anonymous (`user_id = ""`). It still counts against your owner-level total — you pay for it either way. ## Escape hatch: explicit headers If you want to attribute a call to something other than the auth subject — a bot identity, a cron job, a per-conversation tag — pass `headers` directly: ```ts const result = streamText({ model: anthropic("claude-haiku-4-5-20251001"), messages, headers: { "X-OMG-User": "cron-summarizer" }, }) ``` Explicit headers always win over the ALS context. ## Tools / agents Standard AI SDK shape — `tool({ description, inputSchema, execute })`, multi-step with `stopWhen: stepCountIs(N)`. The provider, ALS attribution, and proxy plumbing are unchanged: ```ts import { generateText, anthropic, tool, stepCountIs } from "@omg-dev/ai" import { z } from "zod" const result = await generateText({ model: anthropic("claude-sonnet-4-6"), stopWhen: stepCountIs(5), tools: { getWeather: tool({ description: "Get weather for a city", inputSchema: z.object({ city: z.string() }), execute: async ({ city }) => ({ city, temperatureF: 72 }), }), }, messages, }) ``` Each step's tool calls and final response all attribute to the same end-user. ## Reading usage Owner-level (everything you've spent across all apps): ```ts import { useAction } from "convex/react" import { api } from "../convex/_generated/api" const myUsage = useAction(api.usage.getMyUsage) // → { aggregates: [{ model, totalInputTokens, totalCostUsd, ... }], totalCostUsd } ``` Per-app, broken down by end-user: ```ts const appUsage = useAction(api.usage.getAppUsage) const r = await appUsage({ appId: "" }) // → { breakdown: [{ userId, model, totalCostUsd, requestCount, ... }], totalCostUsd } ``` The `appId` for a deploy is on the deploy response (`GET /v1/deploys/{slug}`) or in the deploy create result. ## Deploy classification A project that depends on `ai` or any `@ai-sdk/*` (which `@omg-dev/ai` transitively pulls in) is automatically deployed to a **runtime VM**, not the static-asset path — the in-VM proxy is only reachable from a running sandbox. You don't have to opt in; the deploy classifier checks `package.json` for AI deps and routes accordingly. If your app is otherwise a pure SPA and you only need AI on a server route, that one server route forces the runtime path for the whole deploy. Keep the function thin and the static SPA renders fast. --- # Multi-user auth _Source: https://docs.omg.dev/docs/sdk/auth_ import { DemoFrame } from "~/components/demo-frame" import { VibesLoginDemo, VibesLoginShadcnDemo } from "~/components/vibes-login-demo" Every deployed Vibes app at `.apps.omg.dev` ships with multi-user auth out of the box. The runtime verifies signed JWTs from `auth.omg.dev`, the SDK attaches them automatically, and `.scoped("user")` collections filter rows by `_owner` server-side. You don't run a backend, you don't register an OAuth client, and you don't write a token endpoint. ## TL;DR ```ts // schema.ts — mark a collection user-scoped import { defineSchema, collection, fields } from "@omg-dev/schema" export default defineSchema({ collections: { notes: collection({ fields: { title: fields.string(), body: fields.string() }, }).scoped("user"), }, }) ``` ```tsx // src/App.tsx — gate the UI on login import { VibesAuthProvider, VibesAuthGuard, useUser } from "@omg-dev/sdk" import { useQuery } from "@omg-dev/sdk" interface Note { id: string; title: string; body: string; _owner: string } function Notes() { const user = useUser() const { data, create } = useQuery({ api: "/api/notes", collection: "notes" }) return ( <>

Hello, {user?.email}

    {data.map(n =>
  • {n.title}
  • )}
) } export default function App() { return ( ) } ``` That's it. Two users hitting the same deploy each see only their own `notes` rows. ## How it fits together | Layer | What happens | | --- | --- | | `schema.ts` | `.scoped("user")` adds an `_owner` column and tells the runtime to filter on it. | | `` | Mounts the auth context, derives `appId` from `window.location.host`, exchanges the `.omg.dev` session cookie for a short-lived JWT. | | `useCollection()` | Reads the JWT from the auth context and attaches `Authorization: Bearer ` to every fetch. | | `@omg-dev/server` | Verifies the JWT via `auth.omg.dev`'s JWKS, seeds `ctxStore.userId`, and the auto-CRUD handlers filter scoped tables by `_owner`. | The first time a user signs in to a fresh deploy, `auth.omg.dev` lazily registers the slug — no separate "create app" step. ## Marking a collection user-scoped ```ts collection({ fields: { … } }).scoped("user") ``` The runtime adds a hidden `_owner: string` column at migration time. On `POST /api/` the handler stamps `_owner = ctx.userId`; on `GET`, `PATCH`, and `DELETE` it filters by `_owner = ctx.userId`. A request with no JWT to a scoped collection returns `401`. A `PATCH` or `GET /:id` for another user's row returns `404` (we treat "not yours" and "doesn't exist" identically — anything else leaks row existence). For shared/global apps, omit `.scoped("user")`. Every signed-in user can read and mutate every row. Optionally stamp authorship from the client: ```tsx const user = useUser() await create({ title: "hi", author: user?.name }) ``` ## Login UI `` renders `` (magic-link email + passkey) when no user is signed in. The bundled component is styled with shadcn *semantic* tokens (`bg-card`, `bg-primary`, `text-foreground`, …) so it picks up whatever theme the host app uses. Want a polished version composed from real shadcn primitives? Install the `vibes-login` block from the OMG registry: ```bash bunx shadcn@latest add https://docs.omg.dev/r/vibes-login.json ``` That writes `components/auth/vibes-login.tsx` into your project, pulls in `button`, `input`, `card`, and `label`, and adds `@omg-dev/sdk` to your dependencies. Pass it to the guard via `fallback`: ```tsx import { VibesAuthGuard } from "@omg-dev/sdk" import { VibesLogin } from "@/components/auth/vibes-login" }> ``` Either component posts to `https://auth.omg.dev/api/auth/sign-in/magic-link` with `credentials: "include"` so the session cookie roundtrips to `.omg.dev`. After magic-link click-through the user lands back on your app and `useUser()` flips non-null on the next render. You can also drop in a fully custom UI: ```tsx }> ``` ## Hooks ```ts useUser(): VibesUser | null useAuth(): { user: VibesUser | null loading: boolean token: string | null // the JWT, for custom fetch signOut(): Promise signInWithMagicLink(email: string, callbackURL?: string): Promise signInWithPasskey(): Promise } interface VibesUser { id: string; email: string; name?: string } ``` `useUser` is the friendly read-only hook; `useAuth` is the full surface. ## What you DON'T need to do - No `appId` config — `` derives it from `window.location.host` (`.apps.omg.dev` → ``). - No CORS setup — `auth.omg.dev` allows every `*.apps.omg.dev` subdomain via a wildcard allowlist. - No JWT plumbing — `useCollection` reads the token from context and attaches it to every request. Custom `fetch` to your own `functions/.ts`? Pull `token` off `useAuth()` and add the header yourself, or call `vibesFetch(client)`. - No backend deploy — the runtime container already runs `createVibesServer({ auth: "vibes" })` and verifies tokens via JWKS. ## Local dev `npm run dev` runs without auth. The provider falls back to no-auth (token = null), `useUser()` returns `null`, and scoped collections 401. To exercise the real flow, deploy to `.apps.omg.dev`. --- # Schema reference _Source: https://docs.omg.dev/docs/sdk/schema_ `@omg-dev/schema` is pure TypeScript (no runtime side effects). The build imports it once, derives the deploy classification, the SQL DDL, the generated TS types, and the Zod schemas, and bundles nothing of it into the runtime — `@omg-dev/server` consumes the same `Schema` object directly. ## The file location contract `schema.ts` MUST live at the **project root** (not `src/schema.ts`) and MUST be the **default export**. Both rules are load-bearing. ``` /home/user/project/ ├── schema.ts ← here ├── package.json ├── src/ │ └── App.tsx └── functions/ ``` Why root: `packages/vite-plugin/src/build.ts` does `await import(path.join(root, "schema.ts"))` — it never looks elsewhere. Why default-exported: the build reads `module.default?.collections`. Anything elsewhere is invisible to the classifier, `hasDb` resolves false, and the deploy goes **static** — then every `POST /api/*` returns the literal text `method not allowed` and the SDK's `JSON.parse` throws. No flag or env var overrides this: either `schema.ts` default-exports collections, or the deploy is static. ## Canonical shape ```ts import { defineSchema, collection, fields } from "@omg-dev/schema" export default defineSchema({ collections: { tasks: collection({ fields: { title: fields.string(), done: fields.boolean(), }, }), }, }) ``` `defineSchema` accepts a record of `CollectionBuilder | CollectionConfig` under `collections`. Every builder is `.build()`-ed in place, producing a plain `Schema` object that can be JSON-serialized. ## `collection(opts)` Returns a `CollectionBuilder`. The opts shape: ```ts collection({ fields: Record }) ``` Builder methods (chainable, all return `this`): | Method | Effect | | --- | --- | | `.scoped("user")` | Adds a `_owner TEXT` column. The runtime auto-fills it from the request's JWT and filters every `list`/`get` by `_owner = currentUser`. | | `.scoped("global")` | Default. No row-level filtering. | | `.index(...columns)` | Adds `CREATE INDEX idx__ ON
()`. Call once per index; pass multiple columns for a composite. | | `.unique(...columns)` | Adds `CREATE UNIQUE INDEX uidx_
_ ON
()`, enforcing one row per value or column tuple. | Example: ```ts posts: collection({ fields: { title: fields.string(), body: fields.string(), tag: fields.string(), }, }) .scoped("user") .index("tag") .index("tag", "created_at"), ``` ## Auto-added columns Every collection automatically gets: | Column | Type | Notes | | --- | --- | --- | | `id` | `TEXT PRIMARY KEY` | Server-assigned on `create`. Don't declare it in `fields`. | | `created_at` | `TEXT` | ISO timestamp. Server-set. | | `updated_at` | `TEXT` | ISO timestamp. Server-bumps on update. | | `_owner` | `TEXT` | Only on `.scoped("user")` collections. | Redeclaring any of these in `fields` is a contract violation; the migrator will append a duplicate column and the runtime will silently misbehave. ## `fields` builders Each returns a `FieldDef` (`{ type: FieldType, optional?: boolean }`). ```ts fields.string() // TEXT fields.number() // REAL fields.boolean() // INTEGER (0/1) fields.date() // TEXT (ISO 8601 string) fields.array("string") // TEXT (JSON-serialized) fields.enum(["todo", "doing"]) // TEXT, runtime-validated against the literal set fields.ref("users") // TEXT (foreign-key reference by collection name) ``` ### Optional fields There is no `.optional()` chain on the field builder; spread the result and add the flag: ```ts fields: { title: fields.string(), notes: { ...fields.string(), optional: true }, } ``` Optional only affects the generated TS types and Zod schemas (`?:` and `.optional()` respectively). The DDL emits the same column either way. ### Refs `fields.ref("users")` is a discipline marker, not a constraint — the column is plain `TEXT` and the TS type is `string`. It signals intent and gives the codegen a hook for future relation tooling; don't assume a `FOREIGN KEY` exists at runtime. ## Indexes Indexes are declarative. Re-running the migrator on an existing DB diffs against `sqlite_master` and only emits `CREATE INDEX IF NOT EXISTS` for ones that don't already exist. Index names are deterministic: `idx_
__`. You can therefore add an index in a follow-up deploy without affecting existing data — the next cold-start migrate will backfill it. Drop semantics: removing an index from `schema.ts` does **not** drop the existing index on disk. Schema diff handles add/drop of tables and columns, but only adds for indexes. ## Worked example — multi-collection app ```ts import { defineSchema, collection, fields } from "@omg-dev/schema" export default defineSchema({ collections: { users: collection({ fields: { email: fields.string(), name: fields.string(), role: fields.enum(["admin", "member"]), }, }).index("email"), projects: collection({ fields: { name: fields.string(), ownerId: fields.ref("users"), tags: fields.array("string"), archived: { ...fields.boolean(), optional: true }, }, }) .scoped("user") .index("ownerId"), events: collection({ fields: { projectId: fields.ref("projects"), type: fields.enum(["create", "update", "delete"]), at: fields.date(), }, }) .index("projectId", "at"), }, }) ``` ## Verifying before publish Inside the sandbox: ```bash bun -e "import('./schema.ts').then(m => console.log(Object.keys(m.default?.collections ?? {})))" ``` Should print the collection names. If it prints `[]` or throws, the deploy will classify static and every mutation will fail in production. ## What the build emits After `vibes-build` runs: | Output | Source | | --- | --- | | `.vibes/data.db` | `schemaToSQL(schema)` executed against an empty SQLite file. | | `src/db.generated.ts` | `schemaToTypes(schema)` — TS interfaces for every collection. | | `src/db.drizzle.ts` | Drizzle `sqliteTable(...)` definitions generated from the same `schema.ts` collections for server-side read/query helpers. | | `.vibes/artifacts.json` | `{ hasDb: collections.length > 0, ... }`. | The runtime never re-imports `schema.ts`; the bundled `server.mjs` carries the pre-resolved `Schema` object. That means schema changes require a redeploy, but cold-starts skip both `migrate()` (already ran at build time) and `import("./schema.ts")` (already inlined). ## Hard rules | Rule | Why | | --- | --- | | Schema lives at project root, not `src/`. | Build resolves from `process.cwd()`. | | Default-exported. | Build reads `module.default.collections`. | | One schema per project. | Build only imports one path; multiple files don't merge. | | Don't redeclare `id`, `created_at`, `updated_at`, `_owner`. | They are appended automatically. | | Don't hand-edit `src/db.generated.ts`, `src/db.drizzle.ts`, or `.vibes/data.db`. | They are regenerated every build. | --- # useQuery reference _Source: https://docs.omg.dev/docs/sdk/use-collection_ > **`useCollection` is deprecated** — use **`useQuery`** instead. `useQuery` is > the WebSocket-first default and adds an optional `where` predicate that scopes > rows on the server. `useCollection({ api, collection })` still works (it now > forwards to `useQuery`), but new code should use `useQuery`. `useQuery` owns the realtime subscription, the initial snapshot, and the mutation primitives. By default it subscribes over a WebSocket (`/__vibes_sub`) and receives a full snapshot plus live row deltas; it falls back to the legacy SSE+REST loop only if the WebSocket endpoint is unreachable. ## Signature ```ts import { useQuery } from "@omg-dev/sdk" interface UseQueryOptions { collection: string // e.g. "tasks" where?: SubscribePredicate // optional server-side row filter api?: string // e.g. "/api/tasks" — enables create/update/remove + legacy fallback } interface UseQueryReturn { data: T[] loading: boolean error: string | null create: (item: Partial) => Promise update: (id: string, patch: Partial) => Promise remove: (id: string) => Promise refresh: () => void } useQuery(opts: UseQueryOptions): UseQueryReturn ``` `where` accepts a predicate AST (`{ op: "eq", column: "done", value: false }`, `{ op: "and", clauses: [...] }`, etc.) and is evaluated server-side against the snapshot and every delta, so a subscriber only ever receives matching rows. `data` is initialised to `[]` on the very first render — `data.map(...)` is safe before the first fetch resolves. The hook never returns `undefined` for `data`, even on error. ## Canonical use ```tsx import { useState } from "react" import { useQuery } from "@omg-dev/sdk" interface Task { id: string; title: string; done: boolean } export default function Tasks() { const { data, loading, error, create, update, remove } = useQuery({ api: "/api/tasks", collection: "tasks", // optional: only open tasks → where: { op: "eq", column: "done", value: false }, }) const [draft, setDraft] = useState("") if (error) return

Failed: {error}

return (
{ e.preventDefault() if (!draft.trim()) return await create({ title: draft, done: false }) setDraft("") }} > setDraft(e.target.value)} /> {loading && data.length === 0 ?

Loading...

: null}
    {data.map((t) => (
  • update(t.id, { done: !t.done })} /> {t.title}
  • ))}
) } ``` ## REST contract The hook talks to four endpoints, all relative to `opts.api`: | Method | Path | Body | Response | | --- | --- | --- | --- | | `GET` | `` | — | `T[]` | | `POST` | `` | `Partial` (JSON) | `T` | | `PATCH` | `/:id` | `Partial` (JSON) | `T` | | `DELETE` | `/:id` | — | `204` (body ignored) | `opts.collection` does NOT affect any of these URLs — it's only used to filter SSE events. Convention: pass the same string as the path segment in `api` (e.g. `api: "/api/tasks"`, `collection: "tasks"`). The runtime mounts these handlers automatically for every collection in `schema.ts`. If a collection is `.scoped("user")`, list/get filter by `_owner = currentUser` and create stamps `_owner` on insert. ## Realtime: WebSocket first, SSE fallback By default the hook subscribes over a WebSocket (`/__vibes_sub`): it gets a full snapshot on subscribe and then per-row `insert`/`update`/`delete` deltas — no re-fetch per change. The SSE+REST loop described below is the **fallback**, used only when the WebSocket endpoint can't be reached (e.g. a backend deployed before the WS engine existed). On that path: After the first successful fetch the hook opens an `EventSource` against `/__vibes_events`. Every message is parsed as JSON; only events of the shape `{ type: "invalidate", collection: }` trigger a re-fetch. Anything else is dropped. The runtime broadcasts `{ type: "invalidate", collection }` on every successful `create`/`update`/`remove` for that collection — across every connected client. That means two browser tabs viewing the same deploy stay in sync without any per-client polling. ### Reconnect strategy Bounded so a permanently-broken endpoint (deleted deploy, gone host) can't turn into a per-tab DDoS. From `packages/sdk/src/index.ts`: - On `error`: close, double the delay (1s, 2s, 4s, 8s, 16s, 32s; cap 60s). - After **6 consecutive errors with no successful message in between**, the hook gives up — reload the page to retry. - Each successful message resets the counter. Don't remount the component to "fix" a dead stream; it just hits the give-up logic again. ## Mutation responses `create` and `update` return the server's JSON response. The hook does **not** optimistically update `data` — it relies on the SSE invalidation to re-fetch, which is fast on a warm runtime VM. For optimistic UI, hold it in component state and reconcile on the next `data`. `remove` returns `void`. ## Auth and scope The runtime verifies the `Authorization: Bearer ` on every request via `createAuthMiddleware("vibes")`. If missing or invalid, the request runs as `userId = "anonymous"`. For `.scoped("user")` collections, the row-level filter then reduces to `_owner = "anonymous"`, which is fine for unscoped data but means anonymous and signed-in users see different rows. The platform's web app injects an auth-scoped token into every fetch from a deployed page. You don't need to wire this manually. ## Common errors | Browser error | Real cause | Fix | | --- | --- | --- | | `TypeError: r.map is not a function` | Code wrote `const tasks = useCollection(...)` and then `tasks.map(...)`. The hook returns the full options object, not the array. | `const { data } = useCollection(...)`, then `data.map(...)`. | | `Cannot read properties of undefined (reading 'map')` | Same root cause as above. The hook NEVER returns undefined `data`; if `data.map` throws on `undefined`, you're calling it on the wrong value. | Same fix. | | `SyntaxError: Unexpected token 'm', "method not allowed" ... is not valid JSON` | The deploy classified as static. The orchestrator skipped the runtime VM, so `POST /api/` is unrouted; the static asset proxy returns the literal text `method not allowed`, and the SDK's `JSON.parse` chokes on `m`. | Fix `schema.ts` so it default-exports a `Schema` with at least one collection. See [Schema reference](/docs/sdk/schema). | | `EventSource connection failed` (silent, no more updates) | Six consecutive SSE failures — the give-up threshold. | Reload the page. If it persists, the deploy or the host is down. | ## What the hook does NOT do - No optimistic mutations. Use component state. - No pagination, sorting, or filtering helpers. Use a custom `functions/.ts` route and call it via `fetch`. - No client-side caching across components. Two `useCollection` calls for the same collection issue two `GET`s. They both invalidate on the same SSE event, but they don't share state. - No retry on mutation failure. `create`/`update`/`remove` reject the promise; handle in your `await` site. - No type-narrowing on `error`. It's `string | null`, the JS error message verbatim. ## Where the source lives `packages/sdk/src/index.ts` is the entire implementation — under 150 lines. If a behaviour isn't documented here, the file is short enough to read end to end. --- # Service tokens _Source: https://docs.omg.dev/docs/service-tokens_ Three service tokens are in production. All have `iss=https://auth.omg.dev`, `aud=vibes-infra`, and a 1-year TTL. | Sub | Used by | Lives in | Purpose | | --- | ------- | -------- | ------- | | `svc:convex` | Convex actions | Convex env (`VIBES_INFRA_SERVICE_TOKEN`) | Server-side calls into infra (sandbox, deploys) | | `svc:worker` | CF Worker `vibes-router` | Worker secret (`VIBES_INFRA_SERVICE_TOKEN`) | KV-miss fallback to `/v1/deploys-internal` | | `svc:ci` | GitHub Actions e2e | GH secret `VIBES_API_KEY` (env: `e2e`) | Tier 2/3 tests against real infra | A backup of every token lives in `.env.production` (gitignored) under `VIBES_INFRA_SERVICE_TOKEN_CONVEX`, `_WORKER`, and `_CI`. Each line carries the `jti` and expiry as a comment so a quick `grep` shows whether you're about to need a rotation. ## When to rotate - **Routine**: 60 days before expiry. The `revoke.ts list` command on the auth box prints `EXPIRING` for tokens within 30 days; grep for that in ops review. - **Compromise**: any token leak (committed to git, sent in an email, etc.) → rotate immediately and revoke the leaked `jti`. The denylist propagates in ~30s. See [Auth → Rotating a service token](/docs/auth#rotating-a-service-token) for the step-by-step. ## Why on-box minting only The mint script is `bun apps/auth/scripts/mint.ts` running on the auth box; there is *no* HTTP mint endpoint. Reasoning: - A network-reachable mint endpoint requires an admin bearer, which would itself become the highest-value secret in the stack. - On-box CLI access is gated by SSH key — no key, no mint. - Compromise blast radius is limited to whatever lateral movement got someone shell on the auth box, which is also where the JWKS private key lives, so the bar is the same either way. --- # Static deploys _Source: https://docs.omg.dev/docs/static-deploys_ A deploy takes the static (no-VM) path when **all three** of these are true at build time: - `manifest.static === true` (set by `vibes-build` when `!hasDb && functions.length === 0`) - The deploy's schema has zero collections (`hasDb === false`) - Zero functions discovered by the scanner The orchestrator re-derives all three signals server-side; the build flag alone is never trusted. ## Storage layout ``` static-deploys/{slug}/v{N}/ index.html assets/index-.js assets/index-.css ... ``` Per-file uploads run in parallel (16-wide) directly from the in-memory tar walker (`Manager.packAndUploadStaticAssets`). ## Cache-Control Assigned per-directory at upload time so the bytes carry the right header out of S3 and CF edge can cache the right way: | Path | Cache-Control | | ---- | ------------- | | `assets/*` | `public, max-age=31536000, immutable` | | `index.html`, root | `public, max-age=60, must-revalidate` | Vite hashes filenames under `dist/assets/` by default; if `assetsDir` is ever pinned elsewhere, update `staticAssetsCacheControl` in `manager.go` to match. ## Serving `DeployProxy.ServeHTTP` branches on `dep.Static`: ``` GET https://{slug}.apps.omg.dev/ ─→ DeployProxy.serveStatic ─→ tigris.GetStaticAsset(slug, dep.StaticVersion, path) hit: stream body + meta headers (CT, Cache-Control, ETag) miss with extension: try v{N-1} ← cached-HTML fallback still miss → 404 miss without extension or Accept: text/html: serve v{N}/index.html ← SPA fallback ``` The `v:N-1` fallback for hashed-asset misses is what makes redeploys zero-downtime for clients holding a max-age=60 cached old HTML. Combined with the 90s-deferred `DeleteStaticVersion(N-1)` GC, the window where a stale-pointer asset can 404 is closed. ## What this saves Per-deploy, vs the serverful path: - No fork / no VM scheduling - No bun runtime memory (~80-150 MB) - No litestream replicate process - No SQLite WAL traffic - Cold-start latency for a static deploy is the time to fetch the closest CF edge cache hit (~ms) Trade-off: a static deploy can't grow into a serverful one without a redeploy. The build pipeline detects the transition automatically the moment a function or schema collection appears.