# somewhere.tech — reference for coding agents The compact quick-reference (AGENT.md) is first; per-primitive topics follow. Recipes/walkthroughs live at /guides.txt. Current pricing is in the Pricing section at the end (and live at /v1/pricing). # AGENT.md — somewhere.tech for coding agents > **Before building any feature you haven't used, call `docs({ topic })` — it's the manual.** The tool list shows the verbs; `docs` returns the full reference (signatures, examples, gotchas) for one surface at a time (deploy, sw.db, sw.auth, fs, payments, …). Skipping it is the #1 cause of wrong-shape code. (`docs` is also callable as `platform_help` — same tool.) > > **Three entry points — know which is which:** > - **`catalog`** = which tools exist (the index of all ~230 tools). Start here when you don't know the verb. > - **`docs({ topic })`** = the manual for one surface (static reference). Read it BEFORE you build that surface. (Alias: `platform_help`.) > - **`platform_advisor({ question })`** = ask an expert that sees YOUR live project (tailored). For open-ended "how should I build this?" / cross-surface questions. > > Last updated: 2026-06-17. Everything you need to write working code is in > the first 60 lines so it survives client truncation. ## Client SDK — `npm i @somewhere-tech/sdk` Building a frontend, or porting an existing app? Install the client and you're running queries in two lines (familiar `createClient` shape): ```js import { createClient } from '@somewhere-tech/sdk' const client = createClient('https://.somewhere.tech', SOMEWHERE_KEY) const { data, error } = await client.from('todos').select('*').eq('user_id', id) await client.auth.signInWithPassword({ email, password }) client.storage.from('avatars').getPublicUrl('me.png') client.channel('room').on('broadcast', { event: 'msg' }, fn).subscribe() await client.functions.invoke('checkout', { body: { plan: 'pro' } }) ``` `createClient`, `from().select()`, `{ data, error }`, `auth`, `storage`, `channel`, `functions` — the shape most apps already expect. Full reference: `docs({ topic: 'sdk' })`. Porting an app: `docs({ topic: 'migration-supabase' })`. Other languages: `docs({ topic: 'sdks' })`. In the browser, `auth.signInWithPassword` / `signUp` / Google sign-in run in **cookie mode** by default (SDK 0.6.0): they post to your app's own auth routes and the session is set as httpOnly cookies — no token ever lands in JS or localStorage, a wrong password comes back as `{ error }` with the real message, and sessions refresh server-side automatically. The one backend file those routes need (paste it verbatim) is in `docs({ topic: 'auth-client' })`. Everything below is the **server runtime** (`sw.*` inside deployed functions) — a different surface from the client SDK above. ## Function format — the one you'll actually copy ```js export default async function (req, sw) { const body = await req.json(); const r = await sw.db.query( 'SELECT id, email FROM users WHERE id = ?', [body.id], ); // r.data = array of row objects (NOT .rows, NOT .results) // r.count = number of rows returned // r.changes = rows affected by INSERT / UPDATE / DELETE return Response.json({ user: r.data[0] ?? null }); } ``` That's the only function signature that works. `req` is a standard `Request`; you return a `Response`. `sw` is the platform namespace (`ctx` is the legacy alias — same object). `sw.endpoint({ auth, body, rateLimit, handler })` exists as a thin wrapper around the above (handles auth + zod validation + rate limit for you). It's optional, not required. The bare `export default async function(req, sw)` form always works and is what you should reach for when in doubt. ## The sw object ``` sw.db.query(sql, params) → { data, count, changes } sw.auth.signup / login / me / fromRequest(req) sw.email.send({ to, subject, text|html }) sw.ai.chat / ai.embed / ai.transcribe / ai.tts / ai.generate_image sw.fs.read / fs.write / fs.list / fs.delete / fs.public_url sw.env.YOUR_KEY (env var; server-only, never returned) sw.jobs.create / cron.create / queue.send sw.payments.checkout (Stripe Connect; per-user onboarding) sw.billing.has(userId, feature) (gate YOUR app's plans/features — definePlans / entitlements) ``` Detail per area: `docs({ topic: 'sw.db' | 'sw.auth' | … })`. **Building client-side login (browser session + Google)?** The happy path is httpOnly **cookie sessions**: one pasteable backend file, zero auth code in the browser, nothing in localStorage, and expected failures (wrong password, duplicate email) surface as structured 4xx with the real message. `docs({ topic: 'auth-client' })` has the exact code — works with the SDK's cookie mode or plain `fetch`, nothing to install. Do NOT hand-roll a token/session layer; that's where every auth bug lives. ### npm imports work inside functions `import { z } from 'zod'` and friends resolve automatically at deploy. Versions in `package.json` become pins (`zod@^3.22` pins to `3.22`); unpinned imports get the latest. `node:*` standard-library imports and the platform runtime built-ins pass through as externals. No `npm install` step — you deploy raw source and the platform resolves your imports for you. ### CORS is handled by the platform `/api/*` requests get a free CORS layer in front of every function: OPTIONS preflights return 204 with the right headers, and every function response is auto-decorated with `Access-Control-Allow-Origin` (echoes request Origin), `-Credentials: true`, and the common method/ header allow-lists. Don't write CORS boilerplate in your handlers. ### Every deploy is automatically analyzed After every `project_deploy` / `project_patch` / `project_rollback`, the platform produces four post-deploy outputs: - **LLM security review** (premium, Builder+ tier) — an LLM pass over your deployed source. Returns structured Markdown findings against 9 risk categories (auth bypass, raw SQL, unsafe payment metadata, env leakage, privilege escalation, RCE, email spoofing, conversation hijack, CSRF). Run on-demand after deploy: → `security_review({ project_id, focus? })` or `POST /v1/security/review`. - `project_screenshots({ project_id })` or `GET /v1/deploy/screenshots` — desktop + mobile PNGs of the live homepage per version (every tier). - `project_architecture({ project_id })` or `GET /v1/deploy/architecture` — Mermaid flowchart of the project. - `project_description({ project_id })` or `GET /v1/deploy/description` — 2-3 sentence plain-English summary ("Restaurant booking app with AI chatbot. 89 users browse 24 restaurants and book tables."). Screenshots, architecture, and description fire automatically in a post-promote async block. The LLM security review is on-demand today (auto-on-promote is roadmap). Separately, every deploy ALSO runs a quick regex scanner (`scanFunctionGuardrails`) during compile. Catches obvious footgun patterns and surfaces them as advisory `warnings` in the deploy response. Not the LLM review — it's the cheap synchronous gate. Use these before editing: pull the architecture endpoint to know what exists; read the description to remember what an unfamiliar project does; call `security_review` after a meaningful change. Full reference: `docs({ topic: 'deploy-intelligence' })`. ## The five rules 1. **Deploy raw source — the platform compiles.** Ship `.jsx` / `.tsx` / `.ts` / `.html` directly. Never run `npm run build`, `vite build`, `esbuild`, `tsc`, or similar first. `/v1/deploy` HARD-REJECTS pre-bundled output with `BUNDLED_DEPLOY_REJECTED` (400) — this includes pre-bundled `api/*.mjs` functions, not just `dist/` static output. See `## Deploy src/` below for the escape hatch. 2. **Use the CLI for deploys, MCP for reads.** `somewhere deploy` reads files from disk in one trip; `project_deploy` over MCP re-serializes the same files to JSON (~750× more tokens). Use MCP for `db_query`, `project_export`, `fs_read`, `log`, etc. 3. **Functions use the format above.** `export default async function(req, sw)`, never `curl` with a `smt_` bearer from inside a deployed function — the bundle injects the key automatically through `sw.*`. 4. **`project_patch` for edits, not full re-deploys.** Sending `{find, replace}` is ~200 bytes on the wire; resending a whole project burns the conversation. See `## Building an app`. 5. **`/v1/fs/*` for files** (not `/v1/storage/*` — legacy). `database`, `auth`, `email`, `payments` are built in — never reach for a separate database, auth, or payments service. Before generating frontend code, read the design system: `docs({ topic: 'design-system' })` (or `https://somewhere.tech/llms-design-system.txt`). Covers the dev→prod loop, CSS-var theming, font defaults, layout + animation rules. ## Get connected (pick the path that fits your environment) ### A. Local terminal — install CLI, get MCP for free ```sh npm i -g @somewhere-tech/cli somewhere auth login # browser flow; writes ~/.somewhere/config.json ``` CLI and MCP share `~/.somewhere/config.json` — one login, both work. Use the CLI from this point on; reach for MCP for in-context reads. ### B. Ephemeral environment (Claude Code Web, sandboxes, container) If the user is in Claude Code Web, a sandboxed container, or any environment without a persistent home directory, the browser-based `somewhere auth login` won't outlive the session. Pair the CLI to your already-authenticated MCP session: ```text 1. npm i -g @somewhere-tech/cli # 5 seconds 2. Call MCP tool auth_cli_pair() # returns { key, expires_at } 3. somewhere auth set # 24h-TTL token 4. somewhere whoami # confirm identity 5. somewhere deploy # ready to go ``` The minted key is `kind='cli_pair'`, expires in 24h, and is revocable in isolation if leaked. If the agent doesn't have shell access at all (pure MCP — e.g. Claude.ai connector), skip the CLI entirely and use the MCP tools directly: `project_deploy` for writes, `fs_read` / `db_query` / `log` for reads. ### C. Connector / no shell at all Pure MCP. Every CLI command has an MCP equivalent — `project_deploy`, `project_patch`, `project_export`, `db_query`, `fs_*`, `log`. Slower per write than the CLI (files round-trip as JSON) but identical behavior. Skip steps that require the shell. ## What is somewhere.tech A platform for building and deploying apps. One login, one MCP connection. Database, auth, storage, email, AI, jobs, cron, domains — all built in. **Reference app:** [emailsomewhere.com](https://emailsomewhere.com) — a complete email product built end-to-end on the same `sw.*` primitives you have access to. 15 API routes, 12 tables, AI inbox classification, realtime UI. Worth a look if you want a worked example of what a real app looks like on this platform. ## Deploy `src/`, never `dist/` or `build/` **Do not run `npm run build`, `vite build`, `next build`, or any other build step before deploying.** The platform compiles JSX / TSX at deploy time. Ship raw source — `.jsx`, `.tsx`, `.html`, `.css`, your images, your `api/` functions. Why this matters: every "edit live code" feature on the platform breaks on bundled output. `project_export` returns minified artifacts you can't meaningfully edit. `project_design_tokens` extracts mangled variable names. `project_patch find/replace` substrings won't match across content-hashed filenames. The visual annotator has no source-map back to your real component file. `somewhere pull` is for round-tripping the source you shipped — if you shipped a build artifact, that's what comes back. **`/v1/deploy` rejects bundled output with HTTP 400 `BUNDLED_DEPLOY_REJECTED`.** Triggers: files under `dist/` / `build/` / `.next/` / `out/`, content-hashed assets like `assets/index-.js`, source-map files at deploy root, large minified JS, OR pre-bundled function files (`api/*.mjs` containing esbuild/webpack helper banners like `__toCommonJS`, `__defProp`, `__toESM`). Escape hatches (use only when you genuinely need pre-built output — a native binary, a legacy build pipeline you can't refactor right now): - Single deploy: `{ "allow_bundled": true }` in the deploy body. - Whole project: ask support to set `allow_bundled = 1` on the project row (existing projects are grandfathered to 1; new projects default to 0). For functions specifically: do not run `esbuild` / `tsc` on `api/**/*.ts` before deploying. The platform compiles each `.ts` / `.tsx` function at deploy time and resolves cross-function imports (`import { foo } from './webhook'`) correctly — pre-bundling breaks that because each function becomes its own entry with no shared module graph. ## Authentication Run `somewhere auth login`. The browser opens, the user approves, the session is saved to `~/.somewhere/config.json`, and Claude Code's MCP config is wired automatically. The CLI and the MCP bridge both read that file — no API key juggling. Do not ask the user for their API key. Do not tell them to set `SMT_API_KEY=...` or paste a token into a config. The CLI handles auth the same way `gh auth login` does. The `smt_` developer key still exists for CI/CD, server-to-server jobs, and webhooks. It is never part of a human setup flow. Developer keys can be scope-limited at mint time: `POST /v1/keys` with `{ "scopes": ["ai:complete", "db"] }` (a scope is the `/v1/…` path with `:` separators, max 32 per key) returns a key that gets 403 outside those paths. Keys minted without `scopes` have full access. ## Two ways to use the platform FROM OUTSIDE (MCP tools + CLI / SDK / REST API): Call tools like `db_query` from Claude Code via MCP for reads. For deploys, prefer the CLI: `somewhere deploy`. It authenticates from the same session as `somewhere auth login`. FROM INSIDE (deployed functions): Your server-side code runs ON the platform. Functions use `sw.db`, `sw.fs`, `sw.email`, `sw.ai`, `sw.auth` directly (`ctx` is the legacy alias — same object, both names work forever). Direct access, no HTTP, no API key needed inside functions. ```js export default async function(req, sw) { // Throws 401 if not signed in. Use sw.auth.fromRequest(req) instead // (returns null) when the route should also work for anonymous users. const user = await sw.auth.requireUser(req) // Pass { user } as the 3rd arg and the platform automatically rewrites // the SQL to AND owner_id = ?. Works for single-table SELECT / UPDATE / // DELETE / INSERT against any user-scoped table — no SCOPE_VIOLATION, // no need to write the WHERE yourself. const posts = await sw.db.query( 'SELECT * FROM posts ORDER BY created_at DESC', [], { user } ) return Response.json(posts) } ``` ### User-scoped tables `POST /v1/db/scopes` (developer key) declares a table as user-owned: ```json { "project_id": "abc", "table": "notes", "owner_column": "user_id", "sensitive_columns": ["body", "email"] } ``` After declaration, three ways to query — pick whichever reads best in context. All three are safe; the platform enforces ownership in every path. New code should default to `{ user }`. **Auto-scope (recommended):** pass the user into any `sw.db.query` / `sw.db.batch` call. The platform rewrites the SQL to add the owner filter for you. ```js const rows = await sw.db.query('SELECT * FROM notes', [], { user }) const made = await sw.db.query('INSERT INTO notes (title) VALUES (?)', ['x'], { user }) const fixed = await sw.db.query('UPDATE notes SET title = ? WHERE id = ?', ['y', 5], { user }) ``` The `{ user }` auto-rewrite injects the owner filter on single-table SELECT/INSERT/UPDATE/DELETE. For **JOINs**, use the `$current_user` sentinel with an owner filter on each scoped table (below) — the platform proves it. UNIONs and subqueries: query each table on its own. **Builder API:** when you want chained calls instead of raw SQL. - `sw.db.scoped(user.id).from('notes').list({ orderBy: 'created_at', descending: true })` - `sw.db.scoped(user.id).from('notes').insert({ title: 'x' })` — auto-sets `user_id` - `sw.db.scoped(user.id).from('notes').update({ id: 5 }, { title: 'y' })` — AND'd with owner **`$current_user` sentinel:** if you want to keep raw SQL but skip the `{ user }` option (legacy code), `sw.db.scoped(user.id).query('SELECT * FROM notes WHERE user_id = $current_user')` substitutes the JWT subject. This also covers flat **JOINs / comma-joins**: give every scoped table its own `. = $current_user` filter, AND-ed in the WHERE, and the platform proves the JOIN is owner-scoped before substituting — otherwise it fails closed with `SCOPE_VIOLATION` naming the unfiltered table. (Subqueries, CTEs, and UNIONs still fail closed — query each scoped table separately.) **Opt-out for cross-user reads** (admin dashboards, analytics): `sw.db.query('SELECT COUNT(*) FROM notes', [], { unscoped: true })`. **Footguns the platform catches:** - Plain `sw.db.query('SELECT * FROM notes')` on a scoped table throws `SCOPE_VIOLATION` — pick one of the safe paths above. - Browser apps using app-user JWTs hit the same enforcement at `/v1/db/query` and `/v1/db/batch`. Scope changes take effect inside deployed functions on the **next deploy** (the bundle bakes them in). Browser/JWT path picks them up immediately. **`sensitive_columns`** is a dashboard-side privacy nudge, not a worker-enforced wall. The dashboard's database browser redacts those column values with a click-to-reveal control; each reveal is logged to `scope_access_log` so casual browsing leaves a paper trail. The developer's own SQL queries return full values — this is friction-as-security for incidental dashboard browsing, not a hard block. List recent reveals via `GET /v1/db/scopes/reveals?project_id=…` (developer key only). ### Change webhooks Register an HTTPS endpoint to be notified after every successful INSERT / UPDATE / UPSERT / DELETE through `/v1/db/query`. One webhook per project; no row contents leak through — payload is just `{ project_id, table, op, rows_affected, ts }`. ```js // In a deployed function: const { secret } = await sw.db.onchange.set('https://example.com/db-hook', { events: ['insert', 'update', 'delete'], // optional; default is all three }); // Read or remove the registration later: await sw.db.onchange.get(); await sw.db.onchange.delete(); ``` REST equivalents (developer key only): - `PUT /v1/db/webhook` — `{ project_id, url, events? }` returns the registration including `secret`. - `GET /v1/db/webhook?project_id=…` — read current registration (or `{ webhook: null }`). - `DELETE /v1/db/webhook?project_id=…` — remove the registration. Every POST carries `X-Somewhere-Signature: t={ms},v1={hex}` where `hex = HMAC-SHA256(secret, "{ms}." + rawBody)`. Verify with a constant-time compare and reject requests older than your chosen tolerance (5 minutes is typical) to block replays. The webhook fires from `c.executionCtx.waitUntil` after the write commits, so your handler's latency never blocks the developer's query. Failed deliveries are recorded as `last_status: "failed"` / `last_error: …` on the registration — there is no automatic retry, so make your endpoint idempotent and fast. ### Exporting your data (no lock-in) `sw.db.dump()` (inside a function) and `db_dump` (MCP) and `POST /v1/db/dump` (REST) all return the project's database as a single `.sql` file — schema + every row as `INSERT` statements. It's a standard, portable SQL file: restore it into any standard SQL database, or convert it to Postgres. One call, you have everything, take it anywhere. Per-table cap is 1,000,000 rows (in-worker memory bound); larger tables get a truncation comment at the bottom of the dump and a streaming export available on request. For a single table as CSV, use `db_export` / `sw.db.export()` instead. **For demos and production, default to a premium frontier model** — the platform routes to a current frontier model when you don't pin one. The free included models are for dev/testing only — quality doesn't land in front of a customer. Call `ai_catalog` for the live list of available models and their per-model pricing. ## Building an app — typical flow 1. `project_create` → creates the project 2. `db_migrate` or `db_migrate_to` → set up your database 3. `project_deploy` with files + functions → deploy frontend + API 4. Live at `https://{subdomain}.somewhere.tech` For incremental edits after the first deploy, use `project_patch`. One file per call. Two modes: send `find` + `replace` for surgical tweaks (the token-optimal path), or send `content` to rewrite the whole file. **Find / replace (cheapest, preferred for small edits):** ```json { "project_id": "my-app", "path": "index.html", "find": "

Pricing

", "replace": "

Plans & pricing

" } ``` ~200 bytes on the wire. Replaces every occurrence. If `find` doesn't match, you get `FIND_NOT_FOUND` with a snippet of the current file so you can retry with the right substring. **Full content (when you're rewriting most of the file):** ```json { "project_id": "my-app", "path": "api/hello.ts", "content": "..." } ``` Static files go live in ~1s; function changes in 2–4s. Paths are auto-routed (anything under `api/` / `_lib/` or matching a root `[id].ts`-style route is a function; everything else is static). Binary assets (images, fonts) go through the binary write surface, not `content` or `find`/`replace`. **Use `project_deploy` only for the first deploy of a brand-new project** or when you actually want to replace the whole tree. For every other edit, reach for `project_patch`. **JSX / TSX compile on deploy.** Files ending in `.jsx` or `.tsx` are compiled to JS automatically when you deploy or patch them. You ship raw `.jsx` source; the platform stores the source on its own internal path (round-tripped by `project_export`) and serves the compiled JS to the browser. There is no build step, no `npm run build`, no Vite config. Functions can also `import` `.json` files directly (`import data from '../data/stations.json'`) — no need to bake JSON into code. **Patch responses verify what's actually served.** After a patch that rebuilds the app, the platform fetches the live page and checks every referenced bundle really landed. If something didn't, the response carries `status: "served_incomplete"` plus a `served_incomplete` array naming the missing assets — treat that as "the page may be broken right now, re-run the patch or roll back," not as success. **Full bundling.** When the deploy includes an entry like `src/main.jsx` or `src/main.tsx` referenced from `index.html`, the platform follows every relative import (`./Header`, `../lib/auth`) and bundles them into one (or more, with code-splitting) hashed chunk. Bare `import React from 'react'` and similar npm-style imports are resolved automatically at deploy — no `node_modules`, no `npm install`, no `package.json` required. If your project does ship a `package.json`, the platform uses the `dependencies` versions to pin your imports — that's how you upgrade to React 19, ship a specific lodash version, etc. Your `index.html` is rewritten at deploy time so its ` ``` `src/main.tsx` — React app (raw TSX, deployed as-is): ```tsx import { createRoot } from 'react-dom/client'; import { useEffect, useState } from 'react'; function App() { const [msg, setMsg] = useState('loading...'); useEffect(() => { fetch('/api/hello').then(r => r.json()).then(d => setMsg(d.message)); }, []); return

{msg}

; } createRoot(document.getElementById('root')!).render(); ``` `api/hello.ts` — server function (raw TS, deployed as-is): ```ts export default async function(req, sw) { return Response.json({ message: 'hello from a function' }); } ``` Now deploy: ```bash somewhere deploy ``` The CLI uploads the source files. The platform's compiler bundles `src/main.tsx` into a hashed JS chunk under `/_compiled/`, rewrites `index.html` to load it, and compiles `api/hello.ts` into a function the runtime can execute. By default the URL serves prod. To preview a dev build first, add `?env=dev` to the URL. (For just-a-static-page, you can ship a single `index.html` with plain HTML — the JSX/TSX compiler only runs on files that need it.) ## Common follow-up commands ```bash somewhere logs # tail recent function logs somewhere deploy # redeploy current directory somewhere mcp stdio # raw stdio MCP bridge (for non-plugin clients) somewhere auth status # show current login ``` For everything else (custom domains, project rename, env vars, billing) go through the dashboard at https://somewhere.tech or call the matching MCP tool from your editor. ## Ephemeral environments (Claude Code Web, sandboxes, containers) If the user is in an environment without a persistent home directory (Claude Code Web, a fresh CI container, a sandboxed runner), the browser-based `somewhere auth login` won't survive the session — but the MCP connection already has an authenticated session. Pair the CLI to that session in one step: ```text 1. npm i -g @somewhere-tech/cli # ~5 seconds 2. Call MCP tool auth_cli_pair # returns { key, expires_at } 3. somewhere auth set # 24h-TTL token 4. somewhere whoami # confirm identity 5. somewhere deploy # ready to go ``` `auth_cli_pair` mints a short-lived `smt_` key (kind='cli_pair', 24h TTL) scoped to the same user as the MCP session. The agent writes the token to the CLI config; the CLI works for the rest of the session. The key auto-expires; revoke early with `DELETE /v1/keys/` if you need to. Pure-MCP path (Claude.ai connector, no shell at all): skip the CLI entirely. `project_deploy` does the same write `somewhere deploy` would do — it just costs more tokens because the files round-trip as JSON. Reads (`fs_read`, `db_query`, `log`) are identical either way. --- ## @somewhere-tech/sdk — the client SDK (sdk) The client for talking to the platform from a browser, a Node server, or any JS/TS runtime. It's the on-ramp for existing apps and AI-generated code: a familiar `createClient` → `from().select()` → `{ data, error }` shape. ```bash npm i @somewhere-tech/sdk ``` ## createClient ```js import { createClient } from '@somewhere-tech/sdk' const client = createClient(SOMEWHERE_URL, SOMEWHERE_KEY) ``` - SOMEWHERE_URL — your project URL, https://.somewhere.tech. It's the functions.invoke host and how the client infers your project id. On a custom domain pass { projectId }: createClient(url, key, { projectId: 'my-app' }). - SOMEWHERE_KEY — an app-user token (browser) or a developer smt_ key (server only — never ship it to a browser). The smt_ prefix is detected automatically. The explicit form new Somewhere({ key, projectId }) is the same client. ## Database — from() ```js const { data, error } = await client.from('todos').select('*').eq('user_id', id) await client.from('todos').insert({ title: 'New', user_id: id }) await client.from('todos').update({ done: true }).eq('id', todoId) await client.from('todos').delete().eq('id', todoId) await client.from('users').upsert({ id, name }, { onConflict: 'id' }) // OR group, AND-ed with the rest: await client.from('todos').select('*').or('status.eq.active,priority.gt.3').eq('user_id', id) ``` Filters: eq, neq, gt, gte, lt, lte, like, ilike, in, is, match, or. Modifiers: order, limit, range, single, maybeSingle. Every call returns { data, error, count } — error is null on success, data is null on error. ## Auth — sw.auth ```js await client.auth.signUp({ email, password }) await client.auth.signInWithPassword({ email, password }) await client.auth.signInWithOAuth({ provider: 'google' }) // one call → data.url const { data: { user } } = await client.auth.getUser() client.auth.onAuthStateChange((event, session) => { /* SIGNED_IN / SIGNED_OUT / ... */ }) await client.auth.signOut() ``` In the browser these run in **cookie mode** by default (0.6.0): the SDK posts to your app's own auth routes (`/api/auth/*` — one pasteable backend file, see platform_help('auth-client')) and the session is set as httpOnly cookies. No tokens in JS or localStorage; `error.message` carries the real cause ("Wrong email or password."). `getSession()` returns `{ cookie_session: true, user }`. Node/CLI keep header mode (tokens in SDK memory); pass `{ authMode: 'header' }` to opt a browser out. Everything else works directly from the browser. ## Storage — client.storage.from(bucket) ```js await client.storage.from('avatars').upload('me.png', file) client.storage.from('avatars').getPublicUrl('me.png') // { data: { publicUrl } } await client.storage.from('avatars').createSignedUrl('me.png', 3600) await client.storage.from('avatars').download('me.png') await client.storage.from('avatars').remove(['me.png']) ``` ## Realtime — client.channel(name) ```js client.channel('room') .on('broadcast', { event: 'message' }, ({ payload }) => render(payload)) .subscribe() await client.channel('room').send({ type: 'broadcast', event: 'message', payload: { text: 'hi' } }) ``` ## Functions — client.functions.invoke / client.rpc ```js const { data, error } = await client.functions.invoke('checkout', { body: { plan: 'pro' } }) const { data } = await client.rpc('compute_total', { user_id: id }) // alias of invoke ``` Other languages → platform_help('sdks'). Porting an existing app → the migration guide via platform_help('migration-supabase'). --- ## Client SDKs — languages and status (sdks) Two official SDKs today. We'd rather ship two we maintain than a pile we don't. | Language | Package | Install | Status | | --- | --- | --- | --- | | JavaScript / TypeScript | @somewhere-tech/sdk | npm i @somewhere-tech/sdk | Stable — primary (v0.4.0) | | Python | somewhere-tech | pip install somewhere-tech | Stable (v0.5.0) | Both share the same shape: createClient → from().select() → { data, error }, plus auth, storage, and functions. The JavaScript SDK is the primary and carries the newest surface (createClient, functions.invoke, onAuthStateChange, realtime channel subscribe) — reach for it first, especially when porting an app. → platform_help('sdk'). For the command line, → platform_help('cli'). --- ## @somewhere-tech/cli — the command line (cli) The CLI deploys from disk (faster than inlining files through MCP) and shares one login with the MCP bridge. ```bash npm i -g @somewhere-tech/cli somewhere auth login # opens a browser; session lands in ~/.somewhere/config.json ``` The same session powers the CLI, the MCP bridge, and `somewhere deploy` — like `gh auth login`. Don't pass an API key for a human setup flow; the smt_ key is for CI/CD only. ## Core commands ```bash somewhere init --name my-app # create the project + claim the subdomain somewhere deploy # deploy the current directory somewhere pull # pull a project's deployed source to disk ``` `somewhere deploy` ships RAW SOURCE (src/, index.html, public/, package.json) — never a build output. The platform compiles JSX/TSX on deploy, so there's no `npm run build` step. → platform_help('deploy'). ## Promote & roll back production ```bash somewhere promote # promote the dev preview to production somewhere rollback # revert production to the previous deployed version ``` `somewhere rollback [project]` reverts production to the version that was live before the last promote — including its functions. Reach for it when a promote shipped a bad build. `-y` skips the confirm prompt; it needs at least two prior promotes to have a previous version to restore. ## See what's breaking ```bash somewhere errors # recent exceptions for the linked project somewhere logs --follow # live log stream ``` `somewhere errors [project]` is the curated recent-exceptions view — status, endpoint, error, and time for the last 24h — the fastest way to see what's breaking in production without scrolling raw logs (`--limit `, `--json`). To talk to the platform from app code (browser / Node), use the client SDK instead → platform_help('sdk'). For the local edit loop — pull, typecheck, run a function before you deploy — see platform_help('local-dev'). --- ## Deployed Functions (functions) Server-side code that runs on the platform. Deploy via project_deploy. File path = route path. api/hello.ts → /api/hello ## Compilation: ship .ts, not pre-built .mjs Functions go through the same deploy-time bundler the frontend uses. You write `api/chat.ts` (with whatever imports it needs); the platform produces a self-contained `.mjs` the runtime executes. **Do not pre-bundle.** No `esbuild api/chat.ts -o api/chat.mjs`, no `tsc`, no `vite build` — `/v1/deploy` HARD-REJECTS pre-built output (`BUNDLED_DEPLOY_REJECTED`). What works inside a function source file — **all natively, no restructuring**: - TypeScript syntax (type annotations, interfaces) — stripped. - Type-only imports (`import { type X } from './types'`) — stripped. - Imports from ANY folder, not just `_lib/` — `import { foo } from '../utils/helpers'`, `import { z } from './schemas'`, etc. - **No extension required** — `'./helper'` resolves to `./helper.ts`/`.tsx`/`.js`/`.mjs`/`.json` automatically. - **JSON imports** — `import data from './seed.json'` works ONLY when the `.json` is part of your function source set (uploaded alongside your handlers, not as a separate static asset). A `.json` that lives in your static `files` — or a blob in a parent folder — is NOT a bundler module and fails to compile with `No such module`. For data you deploy as a static file, read it at runtime with `sw.fs.read()`; inline small constants directly in code. - Runtime built-ins — `import crypto from 'node:crypto'` and other `node:*` standard-library imports pass through. **npm packages work** — `import { z } from 'zod'`, `import Stripe from 'stripe'`, and any other npm package import resolve automatically at deploy. Pin versions in your `package.json` (e.g. `zod@^3.22`); unpinned imports get the latest. No build step on your side — you deploy raw source and the platform handles it. ## Format Every function file has one default export: // api/hello.ts export default async function(req, sw) { return Response.json({ hello: 'world' }) } req = standard Request object (method, headers, json(), text(), etc.) sw = platform context with all services (ctx is the legacy alias) ## sw.endpoint — auth + validation + rate-limit (usually what you want) The bare `export default async function(req, sw)` above always works, but most endpoints need auth, body validation, or a rate limit. `sw.endpoint` folds all of that in so you only write business logic. It is optional — reach for it when you want auth, validation, and rate-limiting handled for you; the bare function form above always works too: // api/signup.ts export default sw.endpoint({ auth: 'none', // 'required' | 'optional' | 'none' body: { email: 'email', password: 'string' }, // validated → 400 on mismatch rateLimit: '5/minute', // → 429 + Retry-After handler: async ({ body, user }, sw) => { const u = await sw.auth.signup(body.email, body.password); return { ok: true, user_id: u.id }; // plain object → Response.json }, }); Full reference (schema types, handler args { body, user, headers, params, request }, error formatting, CORS): `platform_help({ topic: 'sw.endpoint' })`. ## sw services available sw.db — database (query, batch, migrate, tables) sw.fs — file storage (read, write, delete, move, stat, list, glob, diff, search, replace) sw.email — send email (send) sw.ai — AI models (complete, embed, transcribe, tts, generateImage, removeBackground, catalog) sw.env — environment variables (sw.env.STRIPE_KEY etc.) sw.jobs — background jobs (create) sw.queue — fire-and-forget work (push) sw.logs — application logging (write) ## Routing File path becomes the URL path: api/hello.ts → /api/hello api/users/list.ts → /api/users/list api/auth/login.ts → /api/auth/login Parametric segments use square brackets and become req.params: api/sites/[id].ts → /api/sites/123 → req.params.id === '123' api/users/[userId]/posts.ts → /api/users/42/posts → req.params.userId === '42' api/files/[...path].ts → /api/files/a/b/c → req.params.path === 'a/b/c' [name] — single-segment placeholder [...name] — catch-all, must be the last segment, matches one or more segments Specific routes beat dynamic ones. api/sites/new.ts wins over api/sites/[id].ts for /api/sites/new. [...rest] only matches when nothing more specific does. A function fetching its OWN origin (https://.somewhere.tech/...) hits the function router, NOT your static files — an unmatched path returns FUNCTION_NOT_FOUND (404), even for a path that serves fine to an external browser. Don't self-fetch your own static URLs: read static assets with sw.fs.read() or inline them. (External clients still get the static file — only the function's own same-origin subrequest skips the static fallback.) All HTTP methods hit the same function. Check req.method inside: export default async function(req, sw) { if (req.method === 'GET') { const users = await sw.db.query('SELECT * FROM users') return Response.json(users.data) } if (req.method === 'POST') { const body = await req.json() await sw.db.query('INSERT INTO users (email) VALUES (?)', [body.email]) return Response.json({ ok: true }) } return new Response('Method not allowed', { status: 405 }) } ## Reading route params For parametric routes, req.params holds the decoded segment values: // api/sites/[id].ts export default async function(req, sw) { const site = await sw.db.query( 'SELECT * FROM sites WHERE id = ?', [req.params.id] ) if (!site.data.length) return new Response('Not found', { status: 404 }) return Response.json(site.data[0]) } // api/files/[...path].ts export default async function(req, sw) { const file = await sw.fs.read(req.params.path) return new Response(file) } ## Outbound WebSocket client A function can open an OUTBOUND WebSocket to another server — a server-to-server realtime bridge — with a fetch upgrade: export default async function(req, sw) { const resp = await fetch('https://example.com/stream', { headers: { Upgrade: 'websocket' } }) const ws = resp.webSocket ws.accept() ws.send('hello') ws.addEventListener('message', (e) => { /* handle e.data */ }) return new Response('bridged') } This is for connecting OUT to someone else's socket. To push realtime updates to your OWN app's browser clients, use the realtime channels API instead — platform_help({ topic: 'realtime' }). ## Secrets / env vars Set via dashboard Settings or env MCP tool. Access inside functions as sw.env.KEY_NAME: export default async function(req, sw) { const apiKey = sw.env.STRIPE_SECRET_KEY const response = await fetch('https://api.stripe.com/v1/charges', { headers: { 'Authorization': 'Bearer ' + apiKey } }) return new Response(response.body) } ## Binary files in deploy Static files go in the files parameter. Binary files (images, fonts) go in binary_files as base64: project_deploy({ project_id: "my-app", files: { "index.html": "...", "api/hello.ts": "export default async (req, sw) => Response.json({ ok: true })" }, binary_files: { "images/logo.png": "iVBORw0KGgo..." } }) ## What NOT to do - NEVER deploy a separate backend, Express server, or external BFF — the platform IS your backend - NEVER put secrets in the files parameter (use env vars) - NEVER call api.somewhere.tech from inside a function (use sw directly) - NEVER install or import @somewhere-tech/sdk inside functions (use sw) --- ## sw.endpoint — declarative endpoint wrapper (sw.endpoint) `sw.endpoint` wraps a server function with auth, body validation, rate limiting, error formatting, and CORS so you only write business logic. Eight lines of boilerplate every endpoint needs — gone. // api/signup.ts export default sw.endpoint({ auth: 'none', body: { email: 'email', password: 'string', name: 'string?' }, rateLimit: '5/minute', cors: 'same-origin', handler: async ({ body }, sw) => { const user = await sw.auth.signup(body.email, body.password, { name: body.name }); return { ok: true, user_id: user.id }; } }); ## What the platform handles before your handler runs - **auth** — `'required'` calls `sw.auth.requireUser(request)` and returns 401 if the request isn't signed in. `'optional'` enriches `user` when present, otherwise leaves it `null`. `'none'` skips. - **body** — parses JSON, validates against the schema, returns 400 `VALIDATION_ERROR` with a list of field-level messages if anything's off. Schema types: `'string'`, `'email'`, `'number'`, `'boolean'`, `'array'`, `'object'`. Suffix `'?'` to make a field optional (`'string?'`). Nest objects by nesting the schema. - **rateLimit** — string like `'10/minute'`, `'60/hour'`, `'1000/day'`. Counter is keyed by user id when authed, by client IP otherwise, with the request path appended so two endpoints don't share a bucket. Returns 429 `RATE_LIMITED` + `Retry-After` header. - **cors** — `'same-origin'` (default; no CORS headers), `'*'` (allow any), or an allow-list array (`['app.example.com']` — subdomain matches accepted). Preflight `OPTIONS` is auto-answered with 204. ## What your handler receives `async ({ body, user, headers, params, request }, sw) => …` - `body` — the validated, parsed JSON (`null` when no schema or for GET/DELETE/HEAD). - `user` — the authed user, or `null` for `auth: 'optional'`/`'none'`. - `headers` — the request `Headers` object. - `params` — route params (from `api/[id].ts`-style paths). - `request` — the original `Request` if you need it. - `sw` — the per-request platform context, same as a plain handler's second arg. Return a plain object → wrapped in `Response.json`. Return a `Response` → passed through unchanged. Return `null`/`undefined` → 204. ## Errors Throw anywhere in your handler and the wrapper formats it: thrown errors with `.status` and `.code` become exact HTTP responses, everything else lands as 500 `HANDLER_ERROR`. No stack traces ever leak to the caller — the full error goes to your function logs. throw Object.assign(new Error('Not enough credit.'), { status: 402, code: 'INSUFFICIENT_FUNDS' }); ## When NOT to use it You don't have to. A plain `async (req, sw) => …` handler still works. Reach for `sw.endpoint` whenever the endpoint needs auth, validation, or rate limiting — which is "almost always." Related topics: `sw.auth`, `sw.rateLimit`. --- ## Deploy (deploy) ## Reliability guarantees on every deploy (the industry-standard checklist) - **Deploy health check (synthetic smoke test).** After every promote the platform screenshots the live root URL; if the screenshot is blank / under threshold the deploy is REJECTED, not promoted. No "two-second white screen" outage. - **Version history + rollback.** Every deploy writes a versioned snapshot. `project_rollback` restores files + functions + schema-state to any prior version in one call. Customer-facing rollback latency is ~5 seconds. - **Canary deploys** for the platform itself: every new platform version rolls out to a small slice of traffic first, our team watches for 5–15 minutes, then promotes. Roll-back to the previous version is one command. - **Continuous code review (LLM-driven, daily + per-deploy).** A static analysis (SAST) scanner runs at build time, plus an LLM-driven review (DAST-style end-to-end) inspects every deploy AND re-scans every project nightly. Findings land in the dashboard's Security tab and alert our team. - **Failure alerting** on deploy / promote / patch / blank-page / health checks. Our team is alerted to any regression within minutes. Full reviewer-facing depth: . --- **Ship raw source. The platform compiles JSX/TSX/TS automatically — for both the frontend AND the backend.** `/v1/deploy` compiles your sources at deploy time: - Frontend: `.jsx` / `.tsx` / `.css` are bundled into hashed chunks under `/_compiled/`, and `index.html` is rewritten to point at them. - Functions: each `api/*.ts` (or `.tsx`, `.js`) is compiled to JS the runtime can execute — no client-side `tsc`, no `esbuild api/chat.ts`, no pre-bundled `.mjs`. You ship the same files you write. Do not run `npm run build`, `vite build`, `esbuild`, `tsc`, or similar first — `/v1/deploy` HARD-REJECTS pre-bundled output with HTTP 400 `BUNDLED_DEPLOY_REJECTED`. This includes pre-bundled `api/*.mjs` functions, not just `dist/` static output. Why the functions side matters: the compiler bundles each function as its own entry with cross-import resolution. If you pre-bundle `api/chat.ts` with esbuild into `api/chat.mjs`, the resulting file typically contains an `import './webhook'` that points at a sibling that doesn't exist as a runtime module → 500 at first request. Ship the `.ts` source and the platform's compiler handles cross-imports. Escape hatch (legacy build pipeline, native binary, etc): - Single deploy: pass `{ allow_bundled: true }` in the body. - Whole project: ask support to flip `allow_bundled = 1` on the project. Existing projects are grandfathered; new ones default off. **Have a shell? Lead with the CLI: `somewhere deploy` from the project root.** It reads your files straight from disk in one trip. The MCP `project_deploy` / `project_patch` tools below re-serialize every file to JSON through the conversation (far more tokens) — reach for them in a pure-MCP environment with no shell, or for in-context edits. Same compile pipeline, same result either way. Two ways to ship code (the MCP tools — CLI maps onto the same pipeline): project_deploy — replaces static files (omitted files deleted), preserves omitted functions unless replace_functions:true; use for the initial deploy or a major rebuild project_patch — incremental, use for small edits, single-file changes, adding one endpoint **Deploys go live immediately by default.** A normal `project_deploy` or `project_patch` writes the live serving slot in one step — the result is instantly at `https://{subdomain}.somewhere.tech` and on any verified custom domain. No extra step to publish. **To preview a change before it goes live, use the draft flow:** deploy with `draft:true` (a.k.a. `preview:true`) — it builds to the owner-only `https://{subdomain}-dev.somewhere.tech` preview and leaves your live site untouched — then `project_promote({ draft_id })` publishes exactly what you previewed. `?env=dev` tests the same bytes from the dev slot. `project_rollback` reverts to a previous version. ## project_deploy — replaces files; preserves omitted functions by default project_deploy fully replaces static files — a file you omit is DELETED — but a function you omit is KEPT LIVE (merge-preserved), NOT deleted, and the deploy WARNS you, naming each preserved function. To actually drop a function pass replace_functions: true (then omitted functions ARE deleted) or use project_patch with delete_files. This merge-preserve default exists because silently deleting omitted functions once caused a production outage. There is no partial / incremental file update through this tool — use project_patch for that. If you deploy this: { files: { "index.html": "..." } } ...then deploy this next: { files: { "about.html": "..." } } ...index.html is GONE. Only about.html exists. To update one file through project_deploy you must include EVERY file you want live in the same call. For small edits, reach for project_patch instead. **Partial deploys (`scope`).** Pass `scope: 'functions'` to deploy ONLY your functions and leave the static frontend untouched — so a backend-only deploy can't wipe the frontend — or `scope: 'static'` for the inverse. The default touches both (static files replaced, omitted functions preserved unless replace_functions:true). **Every deploy response carries** a `build_log` (entry detected, chunks + sizes, function-bundle sizes, warnings — the compile runs server-side, so this is how you SEE what it did) and a `rollback` hint (`project_rollback` undoes this deploy instantly; `project_restore_version` targets a specific older version). `project_deploy_log({ project_id, version? })` returns the build log for any past deploy without re-running it. ## project_patch — incremental One file per call. Two modes, same tool — pick the cheaper one each time. Unchanged files are always preserved server-side. Static-file changes go live in ~1 second; function changes in 2–4 seconds. **Find / replace (~200 bytes on the wire — preferred for small edits):** project_patch({ project_id: "my-app", path: "index.html", find: "

Pricing

", replace: "

Plans & pricing

" }) Replaces every occurrence of `find` with `replace`. If `find` doesn't match anywhere in the file, you get a 400 `FIND_NOT_FOUND` with a snippet of what the file currently says — retry with the right substring. This is the token-optimal path for visual-editor iteration (Claude Design, Cursor). **Full content (rewriting the file):** project_patch({ project_id: "my-app", path: "api/hello.ts", content: "export default async (req, sw) => Response.json({ v: 2 })" }) Paths are auto-routed: anything under `api/` or `_lib/` (or a root `[id].ts`-style parametric route) is a function; everything else is a static file. **Binary assets** (images, fonts) — `content` and `find`/`replace` operate on text. Use the binary write surface (`update_binary_files`) for raw bytes; trying to ship them through this tool's text fields corrupts them via UTF-8. **Delete:** `delete_files` is an array of paths. Works for both static files and functions; missing paths are silently ignored. **Conflict check (optional, recommended for shared projects):** pass `expected_version` from your last deploy/patch response. If another deployer landed changes since, returns 409 `VERSION_CONFLICT` with `data.current_version` instead of overwriting. Omit to skip. Everything you don't name stays untouched. What is NOT affected by a deploy: - Env vars (managed by env_set) - Database contents (managed by db_query, db_migrate) - Uploaded files written via fs_write / sw.fs (separate storage) ## Example project_deploy({ project_id: "my-app", files: { "index.html": "...", "styles.css": "body { color: red }" }, functions: { "api/hello.ts": "export default async (req, sw) => Response.json({ ok: true })" }, binary_files: { "images/logo.png": "iVBORw0KGgo..." } }) files = static assets served as-is (HTML, CSS, JS, images as text) functions = server-side code with sw access, routed by file path binary_files = images/fonts/PDFs as base64, decoded on the server ## Rollback `project_rollback` reverts the last deploy — customers immediately see the previous working version. Use it when a deploy broke something. `project_deploys` lists prior numbered versions; `project_restore_version` restores a specific one. The last 10 are kept. ## After every deploy, the platform automatically: 1. **LLM security review** (premium, Builder+) — an LLM pass over your deployed source returns structured findings against 9 risk categories (auth bypass, raw SQL, unsafe payment metadata, env leaks, privilege escalation, RCE, email spoofing, conversation hijack, CSRF). On-demand: → `security_review({ project_id })` or `POST /v1/security/review` after promote. 2. **Screenshots the live site** — desktop + mobile, captured per version. The dashboard uses these as previews. → `project_screenshots({ project_id })` or `GET /v1/deploy/screenshots`. 3. **Generates a Mermaid architecture diagram** — rendered inline on the project Overview. → `GET /v1/deploy/architecture`. 4. **Writes a plain-English description of what your app does** — shown under the project name on the dashboard. → `GET /v1/deploy/description`. Screenshots, architecture, and description run after the deploy response returns. The LLM security review runs on-demand today — call `security_review` after a deploy. (Planned: an automatic run on promote.) Separately, a quick regex scanner also runs during compile and surfaces obvious footguns as advisory `warnings` in the deploy response — cheap synchronous gate, every tier. Full reference: `platform_help({ topic: 'deploy-intelligence' })`. ## Check BEFORE you deploy (no deploy required) Two tools give a fast green/red without shipping, so you fix everything before deploying once instead of deploy → error → fix → redeploy: - **`project_check({ project_id, files?, functions? })`** — typecheck / compile / lint the source you just edited. Runs the SAME compile gate a real deploy runs (syntax, undefined symbols / dropped imports, JSX-in-.js, Vite-only `import.meta.glob`) and returns `{ ok, errors: [{ file, line, column, message, kind }], error_count, reference_checked, syntax_checked, notes }` — writing nothing, no version change. A green here means the deploy's compile step passes too. (`POST /v1/deploy/check`.) - **`project_check_handler({ project_id, functions, path, method?, body? })`** — run ONE server function against inputs and get its output, console logs, and errors back in ~1–2s, no deploy. Runs against the project's ISOLATED draft database + env (never live data). Returns `{ response, logs, errors, served, isolated_db, duration_ms }`. (`POST /v1/deploy/check/run`.) --- ## GitHub push-to-deploy (github) Connect a repository to a project and every push to the tracked branch deploys automatically — same pipeline as `somewhere deploy` (raw source in, the platform compiles). Live for every project; no tier gate. ## Connect POST /v1/github/connect { "project_id": "my-app", "repo": "owner/name", "branch": "main", // optional, default "main" "root_dir": "web", // optional — deploy one subfolder (monorepos) "github_token": "ghp_…" // optional — enables auto-setup + private repos } → { repo, branch, root_dir, webhook_url, webhook_secret, hook_installed, manual_setup } With a token, the platform installs the repo webhook for you (`hook_installed: true`). Without one, `manual_setup` carries the payload URL + secret to paste into the repo's Settings → Webhooks. Push events are verified against the secret (HMAC) before anything deploys. Dashboard equivalent: project Settings → GitHub — connect, see last commit + deploy status, edit, disconnect. ## Status + disconnect GET /v1/github/connection?project_id=… → { connected, repo, branch, root_dir, hook_installed, last_commit_sha, last_commit_message, last_status: "deploying" | "deployed" | "failed", last_error } DELETE /v1/github/connection?project_id=… // disconnect (best-effort // webhook removal on GitHub) ## What deploys A push to the tracked branch deploys the repo (or `root_dir` subfolder) as raw source — JSX/TSX compile on deploy exactly like `somewhere deploy`. Pushes to other branches are ignored. - Vite/React SPAs deploy unchanged. - Monorepos: set `root_dir` to the app folder (e.g. a repo with `vite-version/` + `nextjs-version/` deploys with `root_dir: "vite-version"`). - Env vars are baked at deploy time. After changing project env vars, push again (any commit) — env changes alone don't trigger a deploy, and the running bundle keeps its deploy-time values until the next push. - Next.js apps are NOT supported — the platform serves SPAs + functions, not server-side rendering. Use a Vite build of the app instead. - Very large repos (over ~25 MB of source) can fail to deploy — trim with `root_dir` or deploy from disk with the CLI. --- ## Projects — Full Lifecycle (projects) Every app on the platform is a project. A project owns: - a subdomain (https://{subdomain}.somewhere.tech) - a database - a file store - environment variables - deployed functions and static files ## project_create({ name, subdomain?, description? }) Creates an empty project. If subdomain is omitted, one is generated from the name. Subdomains must be globally unique, 3–32 chars, [a-z0-9-]. Returns { project_id }. ## project_list() Returns every project on your account, newest first. Each row: { id, name, subdomain, description, created_at, last_deployed_at } ## project_get({ project_id }) Single project record + tier + deploy state. ## project_view_urls({ project_id }) Returns the project's URLs and current version. The bare subdomain serves your latest deploy. `?env=dev` is preserved as an internal opt-in for testing the same bytes from the dev slot. ## project_deploy({ project_id, files, functions?, binary_files? }) Full replacement, served live. See the deploy topic for the exact semantics and when to choose project_patch instead. ## project_patch({ project_id, path, content? | find?+replace?, delete_files?, expected_version? }) Single-file edit, served live. Two modes — `content` rewrites the whole file; `find`+`replace` does a server-side substring substitute (~200 bytes on the wire, preferred for tweaks). Unchanged files are preserved. `delete_files` removes paths. See the deploy topic for the full shape. ## project_rollback({ project_id }) Revert the last deploy. Customers immediately see the previous working version. Instant — no rebuild. ## project_deploys({ project_id }) List the last 20 deploy versions: [{ version, message, created_at, live }] ## project_restore_version({ project_id, version }) Restore a specific past version as the live version. ## project_undeploy({ project_id }) Take the project off the public subdomain. Files and database stay. Reverse with project_deploy. ## project_archive({ project_id }) / project_unarchive({ project_id }) Hide from the project list and revoke the subdomain. Data preserved. Use for old projects you don't want cluttering project_list but might revive later. ## project_rename({ project_id, name?, description? }) Renames the display name and/or description. Subdomain unchanged. ## project_rename_subdomain({ project_id, subdomain }) Changes the live URL. Old subdomain stops resolving immediately. Any links the user has shared elsewhere will 404 — warn before calling. ## project_export({ project_id }) Pull every file in a project (static + functions) in one call — works from any MCP client. Pair with project_deploy to write back. (CLI: somewhere pull does the same to disk.) ## project_transfer({ project_id, to_email }) Transfer ownership. If the target email isn't a platform account yet, they get an invite email. Includes the project's data, files, functions, and env vars. ## project_delete({ project_id }) → project_delete_confirm({ project_id, code }) Two-step. First call returns a 6-digit code with error "CONFIRMATION_REQUIRED". Pass that code to project_delete_confirm within 10 minutes. Second call wipes everything — database, storage, functions, env vars, custom domains. Irreversible. ## deploy_status({ project_id }) Returns deploy metadata: current version, last-deploy timestamp, file counts per slot. Mostly useful as a sanity check that a deploy landed. ## What NOT to do - Don't call project_deploy when you only want to change one file — use project_patch. project_deploy fully replaces static files (any file you omit is deleted), but functions you omit are merge-preserved, NOT deleted — and the deploy warns you, naming each preserved function. Pass replace_functions:true only when you actually intend to drop them. - Don't rename the subdomain on a live project without warning the user — old links break. - Don't call project_delete in a script without surfacing the confirmation code to a human first. --- ## Deploy serves live (dev-prod) A normal `project_deploy` / `project_patch` goes live immediately — one step, no extra publish. To see a change BEFORE it's live, deploy it as a draft and promote it when you're happy (the draft section below). The result lands at: https://{subdomain}.somewhere.tech (default, what your users hit) https://yourcustomdomain.com (if a custom domain is verified) `?env=dev` is preserved as an internal opt-in for testing the same bytes from the dev slot — same code as prod, but useful for cache busting during testing. Not advertised to end users. Workflow: 1. Deploy changes → live immediately on the bare URL and any custom domain 2. Want to look first? Deploy `draft:true` → preview → `project_promote` 3. Bug found? `project_rollback` → reverts to the previous deploy ## Try a change without publishing (drafts) When the app is live with real users and you want to try a change WITHOUT shipping it, use the draft flow instead of deploying straight to live: 1. `project_deploy({ ..., draft: true })` — builds to a private draft slot at `https://{subdomain}-dev.somewhere.tech` (owner-gated). Your live site is untouched. The response includes a `draft_id`. 2. Test the draft: open `https://{subdomain}-dev.somewhere.tech` with the `browser` tool — see it, click through the change, and check the console errors and failed network requests it reports. This is your "npm run dev": see what the change actually does before anyone else does. 3. Happy with it? `project_promote({ project_id, draft_id })` — publishes exactly the draft you tried (no rebuild). Pass the `draft_id` and promote refuses if a newer draft landed since, so you never ship something you didn't test. ## Review mode (owner reviews before live) Some projects are in Review mode: the owner wants to see changes before they go live. There, a normal `project_deploy` / `project_patch` is AUTO-CONVERTED to a draft — the response carries `review_mode: true`, `requires_promote: true`, and a `review_message` with the preview URL. The flow: 1. Make the change as usual (it lands as a draft automatically). 2. Show the user the preview URL + a plain-language summary of the change — flag anything that touches data, pricing, or auth. 3. When the user approves, `project_promote` (pass the `draft_id` from the response) publishes exactly the previewed version to live. Talk in plain words: "preview", "approve", "go live" — never "dev", "draft", "promote", or "staging". Direct-mode projects (the default) deploy straight to live, unchanged. ## Conflict check (collaborated projects) Every project_deploy / project_patch returns `version` — a monotonic counter that bumps on each successful deploy or patch. Pass it back as `expected_version` on the next call and the platform returns 409 `VERSION_CONFLICT` if another deployer landed changes since: // First deploy const r1 = await project_deploy({ project_id, files }); // r1.version === 5 // Later, after editing locally const r2 = await project_deploy({ project_id, files, expected_version: 5 }); // If a collaborator deployed in the meantime, r2 returns: // { ok: false, error: "VERSION_CONFLICT", // data: { current_version: 6, expected_version: 5 } } // → Re-fetch the latest project state, merge, redeploy with // expected_version: 6. The check is opt-in. Single-deployer projects can omit `expected_version` and the deploy always succeeds. --- ## Inspect — Read live state before writing (inspect) You open a session and the user says "fix the auth bug in adapted-co" or "add a /pricing page to my-saas". The local workspace may be stale, empty, or out-of-date — and another agent (or the user themselves on another machine) may be editing the same project right now. Inspecting before writing is the difference between a clean patch and overwriting someone else's work. ## The default inspect-first order 1. project_get { project_id } → Confirms the project exists. Returns subdomain, current code `version`, custom domain, owner. Save the version — it is your `expected_version` floor. 2. project_export { project_id } → Pulls the live deployed source (static + functions) in one call. The platform is the authoritative copy. (CLI equivalent: `somewhere pull ` writes it to .//.) 3. project_deploys { project_id } → Last 20 versions with timestamps and deploy messages. Look for: deploys in the last hour (someone is active), gaps between versions (interrupted shipping), unfamiliar messages. 4. deploy_status { project_id } → Confirms the mirrored dev/prod serving slots are in sync. Drift here means a previous deploy was interrupted — stop and resolve before piling new changes on top. For runtime symptoms, layer on: errors { project_id } → Last 50 runtime errors with stack and request path. Run this BEFORE assuming the bug is wherever the user pointed you. log { project_id, function_path?, since? } → console.log output from deployed functions. Filter by function path or time window when investigating a specific endpoint. project_diff_versions { project_id, from, to } → Per-file delta between two preserved versions: added, removed, changed (with truncated text previews), plus function source. Use after a VERSION_CONFLICT to see what the other agent shipped before merging and retrying. fs_versions { project_id, path } → History for a single user-uploaded file (anything written under /v1/fs/*). NOT for project code — see below. ## Two version namespaces, never confused There are TWO independent histories. Picking the wrong one is a common mistake. Code history: project_deploys / project_restore_version Covers files + functions shipped by project_deploy or project_patch. Upload history: fs_versions / fs_restore Covers user-uploaded files under /v1/fs/* — uploads, generated PDFs, cached blobs. Each path has its own version chain. `project_restore_version` rolls back code but leaves uploaded data alone. `fs_restore` rewinds one uploaded file without touching code. Pick the right tool for what is broken. ## Editing what you pulled After `project_export`, the response includes the current deploy `version` on each pulled project. Hold that number. Pass it as `expected_version` on every subsequent `project_deploy` / `project_patch` against this project. const exported = await project_export({ project_id: "adapted-co" }); // exported.version === 47 // ...edit files locally... await project_patch({ project_id: "adapted-co", path: "api/auth.ts", content: "...", expected_version: 47, }); If another agent deployed between your clone and your patch, the platform returns: { ok: false, error: "VERSION_CONFLICT", data: { current_version: 48, expected_version: 47 } } To see what they changed BEFORE retrying: await project_diff_versions({ project_id: "adapted-co", from: 47, // your expected_version to: 48, // current_version from the 409 }); Merge their changes into your local copy, then retry with `expected_version: 48`. ## Subscribe to deploys in real time Every successful `project_deploy` / `project_patch` / `project_restore_version` publishes a message on the `system:project` realtime channel for that project. Subscribe from inside a deployed function or with the realtime MCP tool to be notified the instant another agent ships: // From a deployed function sw.realtime.subscribe("system:project", (msg) => { if (msg.event === "deployed" || msg.event === "patched") { console.log("New live version:", msg.data.version, "by", msg.data.by); } }); Event shape: { event: "deployed" | "patched" | "restored" | "rolled_back", data: { version: 49, by: "user_…", // actor user_id message: null, // optional deploy message has_functions: true, at: "2026-05-12T18:04:11.000Z", } } A long-running editing agent should subscribe to this channel and treat any `event: "deployed"` with a higher version than its held `expected_version` as "stop, re-clone or diff, then retry." ## Who deployed v13? `project_deploys` now returns each row with a `deployed_by` object containing the actor's `user_id` and `email`. NULL means the row was written before actor tracking was added (the project owner is the safe fallback in that case). ## When you can skip the inspect-first dance - You just created the project in this session. - The user says "I'm the only person editing this." - You are only reading (db_query, log, errors). Inspect before writing whenever: - The user mentions another machine, another agent, or a collaborator. - `project_deploys` shows multiple deploys today. - You see "I have local edits" or "I cloned this last week". - The last `project_deploy` response is more than ~10 minutes old in your conversation — assume someone else may have shipped since. ## Remaining limits (be honest with the user) - `project_deploy` is full-replacement (every file you send replaces the live set). There is no dry-run or three-way merge — preserving anything you want to keep is the caller's job. - Single-file optimistic concurrency for `fs_write` does not exist yet — `expected_version` is a project-code check, not a per-file CAS for uploads under /v1/fs/*. When either of those limits matters, surface it to the user instead of pretending the platform has the feature. --- ## Local dev — the safe edit loop (pull → typecheck → deploy) (local-dev) The platform runs your code; you don't need a local server. But when you're editing an EXISTING project, you want to catch a mistake (a dropped import, a type error) BEFORE it deploys and 500s a real user. The CLI gives you a local loop for exactly that. The pattern: ```bash somewhere pull # download the deployed source + scaffold tsconfig # ...edit files... somewhere typecheck # tsc --noEmit — "is this safe to deploy?" somewhere deploy # ship it ``` This beats editing the live project and discovering the dropped import as a production error. ## somewhere pull — get the source + a typecheck scaffold ```bash somewhere pull # current linked project, dev environment somewhere pull --env prod # pull what's live in production instead somewhere pull # a specific project by slug / id ``` `pull` writes the deployed files to disk AND scaffolds a `tsconfig.json` + `package.json` so `somewhere typecheck` and your editor's TypeScript work immediately. Use `--out ` to write elsewhere, `--force` to overwrite without prompting. ## somewhere typecheck — the "safe to deploy?" gate ```bash somewhere typecheck # tsc --noEmit over the current dir ``` Runs the TypeScript compiler in check-only mode and prints every error with `file:line`. It calls out undefined symbols specially — an undefined symbol is almost always a dropped import, and a dropped import is exactly the bug that compiles-but-500s at request time. Clean typecheck = safe to deploy; a non-zero exit = fix it first. Needs the tsconfig that `somewhere pull` scaffolds (run `pull` first if there isn't one). ## somewhere dev --local — run functions locally against the real project ```bash somewhere dev --local # start a local server for your functions somewhere dev --local --check # same, but EXIT on type errors instead of warning ``` Runs your functions in local Node, but `sw.db`, `sw.fs`, `sw.ai`, and `sw.auth` proxy to the LIVE project — so you exercise real data without a deploy in the loop. It typechecks before starting and on every file reload, so a type error shows up in your terminal instead of as a 500 when you hit the route. `--check` makes a type error stop the runtime rather than warn. Use `--port` to change the local port. (`somewhere dev` with no flags is a different thing — a private preview watcher that pushes an owner-only preview on save, nothing local. See platform_help('cli').) ## somewhere exec — run ONE function and print the response ```bash somewhere exec api/users.ts somewhere exec api/users.ts --method POST --body '{"id":1}' somewhere exec 'api/sites/[id].ts' --path /api/sites/123 ``` Invokes a single function locally against your real project and prints the status + response body. Great for a quick "does this handler work?" without starting a server or deploying. For a parametric route (`[id]`), pass `--path` with a concrete URL. Add headers with `-H "Name: value"` and a query string with `-q "a=1&b=2"`. ## somewhere run — run a one-off script against the live dev bindings ```bash somewhere run seed.js somewhere run backfill.js --json ``` Runs a one-off script once against the project's live dev bindings (`sw.db`, `sw.fs`, `sw.ai`, `sw.search`, …) and prints its return value plus console logs as `{ result, logs }`. No deploy, no version bump — this is the shell-agent equivalent of `run_code`: a scratchpad for seeds, backfills, and one-shot checks against real data. The script is an ES module: ```js export default async function (sw) { const r = await sw.db.query('SELECT count(*) AS n FROM users'); return r.data[0]; // becomes the "result" in the output } ``` `--timeout ` caps the run (default 10000, max 30000), `--include-env` exposes `sw.env` (off by default), `--json` prints the raw `{ result, logs, duration_ms }` envelope. ## When to use which - **Editing an existing app safely:** `pull` → edit → `typecheck` → `deploy`. - **Iterating on a function against real data:** `somewhere dev --local`. - **One-shot "did this handler work?":** `somewhere exec `. - **A scratch script against real data (seed / backfill / check):** `somewhere run `. - **Shipping a brand-new app from scratch:** you don't need any of this — just `somewhere deploy` and read the build log. → platform_help('deploy'). --- ## sw.db — Database (inside deployed functions) (sw.db) Available inside any deployed function via the sw argument (ctx still works as an alias forever — same object, both names). sw.db talks to the project database through a direct binding — no HTTP, no API key, no network hop. Each project has its own isolated database attached to the deployed function bundle. This is dramatically faster than calling the REST /v1/db/query surface from inside a function; reach for sw.db whenever you're inside a function. The REST surface is for code running OUTSIDE the platform (your local machine, a different host). ## What you get out of the box (the industry-standard checklist) - **Multi-tenant isolation** — one database per project, bindings baked at deploy time, no shared schema. - **Row-Level Security (RLS)** — pass `{ user }` to `sw.db.query` and the platform server-side-rewrites your SQL to inject `WHERE owner_id = ?` from the JWT subject. See "Per-user scoping" below. - **ACID transactions** — `sw.db.batch([…])` runs all statements atomically. If statement 3 fails, 1 and 2 roll back. Same shape as Postgres `BEGIN; …; COMMIT;`. - **Postgres-flavored SQL accepted** — `$1, $2` placeholders, `ILIKE`, `NOW()`, `TRUE`/`FALSE`, `col->>'key'`, `RETURNING *`, `SERIAL`, `BOOLEAN` all translated automatically. Literal- and comment-aware (so a column value of `'ILIKE'` is preserved). If you outgrow the default database, reach out — we'll work with you on a larger managed database. - **Serialized writes** — concurrent writes to the same project are serialized automatically, so database-busy errors never reach your handler. Reads run in parallel and are never blocked by writes. - **Point-in-time recovery (PITR)** — 30 days of time-travel from the underlying database, plus named bookmarks via `db_bookmark_create({ label })` for restore points around migrations. - **Schema introspection** — `db_describe` returns tables + columns + row counts + foreign keys in one call. - **Runaway-query detection** — a query that monopolizes database resources is detected and surfaced automatically. Full reviewer-facing depth: . ## sw.db.query(sql, params?, options?) Run a SQL query from inside a deployed function. Always returns this exact shape: { data: rows[], // array of row objects (READ + RETURNING) error: null, // never thrown; errors propagate as exceptions count: number, // rows.length last_row_id: number|null, // INSERT rowid (writes only) changes: number, // rows touched (INSERT/UPDATE/DELETE) } Use `r.data` for the rows. The MCP `db_query` tool returns a different shape (`{ data: { columns, rows, meta } }`) because it surfaces the raw platform response — don't confuse them. Inside functions it's always `r.data`. // Read const users = await sw.db.query( 'SELECT id, email FROM users WHERE active = ? ORDER BY created_at DESC LIMIT ?', [1, 20] ) // users.data = [{ id: 1, email: "alice@test.com" }, ...] // users.count = users.data.length // Write const result = await sw.db.query( 'INSERT INTO users (email, name) VALUES (?, ?) RETURNING *', ['bob@test.com', 'Bob'] ) // result.data = [{ id: 3, email: "bob@test.com", name: "Bob" }] // result.last_row_id = 3, result.changes = 1 ## Per-user scoping — pass { user } and we filter for you Tables declared as user-scoped (see "sw.db.scoped" section) are protected against accidental cross-user reads. The platform refuses to run a raw query against them unless you tell it whose data you want — that way one forgotten `WHERE owner_id = ?` can't leak another user's rows. The simple, recommended pattern: const me = sw.auth.fromRequest(req).user; const emails = await sw.db.query( 'SELECT * FROM emails ORDER BY created_at DESC LIMIT 20', [], { user: me }, ); The platform sees `emails` is scoped, sees you've bound `me`, and rewrites the query to `… WHERE owner_id = ?` with `me.id` filled in. You never wrote a WHERE clause. You can't forget it. `{ user }` accepts the user object from `sw.auth.fromRequest` OR a plain string user id — `{ user: 'usr_…' }` works too. Auto-scoping auto-injects the owner filter on SELECT / UPDATE / DELETE / single-row INSERT against a SINGLE scoped table — you never write the WHERE. For an **app-user** query that **JOINs or comma-joins** scoped tables, the platform supports it when **every** user-scoped table in the query carries its own owner filter: `. = $current_user`, qualified with the table's alias and AND-ed in the WHERE (not inside an `OR`, not only in a JOIN's `ON`). The platform parses the query, proves each scoped table is constrained, and substitutes `$current_user` with the caller's id. If any scoped table lacks that filter it **fails closed** with a `SCOPE_VIOLATION` (naming the offending table) rather than risk reading another user's rows. A JOIN that passes: SELECT n.id, c.body FROM notes n JOIN comments c ON c.note_id = n.id WHERE n.user_id = $current_user AND c.user_id = $current_user **Subqueries, CTEs, and UNIONs** on a scoped table still **fail closed** — the platform won't auto-attribute ownership through those shapes yet. What to do: - Add the ` = $current_user` filter to each scoped table in a flat JOIN (as above), or query each table on its own and combine the results in your function. - Or run the read in **developer / runtime** mode (your function's key), where there is no app-user scoping — and enforce ownership yourself with an explicit `WHERE owner_id = ?`. (Roadmap: engine-level row-level security so even subqueries / CTEs auto-scope — until then, any shape the parser can't prove fails loud rather than leak.) ### { unscoped: true } — opt out of safety Some queries are intentionally cross-user — analytics, admin dashboards, public reads. Pass `{ unscoped: true }` and the platform stops checking: // Admin endpoint: see every project's email count. const allCounts = await sw.db.query( 'SELECT owner_id, COUNT(*) AS n FROM emails GROUP BY owner_id', [], { unscoped: true }, ); The security advisor will flag `{ unscoped: true }` so reviewers know it was a deliberate decision, not a forgotten predicate. Use it sparingly and only where cross-user reads are the whole point. ## Placeholders: ? (positional) or $1, $2, ... (numbered) Both styles work — pick one per statement, don't mix them. // Numbered placeholders — params are reordered to match the $N indexes. // Useful when the same value appears in multiple positions. await sw.db.query( 'UPDATE users SET name = $2, updated_by = $2 WHERE id = $1', ['u_123', 'Alice'] ) // Mixing ? and $N in one statement is rejected with VALIDATION_ERROR. ## sw.db.batch(statements) Run multiple statements as one atomic transaction. If ANY statement fails, ALL roll back. All-or-nothing. const results = await sw.db.batch([ { sql: 'DELETE FROM sessions WHERE user_id = ?', params: ['u_123'] }, { sql: 'DELETE FROM posts WHERE author_id = ?', params: ['u_123'] }, { sql: 'DELETE FROM users WHERE id = ?', params: ['u_123'] } ]) // results = [{ data: [], changes: 3 }, { data: [], changes: 12 }, { data: [], changes: 1 }] // If the third DELETE failed, the first two would also roll back. ## sw.db.migrate — removed from functions; migrate as the developer Run DDL (CREATE TABLE, ALTER TABLE, CREATE INDEX) with DEVELOPER credentials — NOT from inside a deployed function. `sw.db.migrate(...)` was removed from the function runtime (2026-05-21): a handler that calls it gets an error, because running DDL on every request is a footgun. Migrate the schema ONCE, from outside the request path, with the `db_migrate` MCP tool, the CLI (`somewhere fetch /v1/db/migrate`), or the dashboard Database tab. Functions then read/write rows with `sw.db.query`. // developer-side — db_migrate MCP tool (NOT inside a function): db_migrate({ project_id, sql: ` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, name TEXT, created_at TEXT DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); ` }) ## sw.db.tables() List all tables in the database. Returns string[]. const tables = await sw.db.tables() // ['users', 'posts', 'sessions'] ## SQL dialect Standard SQL. Postgres dialect is auto-translated: NOW() → datetime('now') TRUE/FALSE → 1/0 ILIKE → LIKE (case-insensitive by default) SERIAL → INTEGER PRIMARY KEY AUTOINCREMENT BOOLEAN → INTEGER RETURNING * → supported ## Common patterns // Paginated list const page = await sw.db.query( 'SELECT * FROM posts ORDER BY created_at DESC LIMIT ? OFFSET ?', [pageSize, (pageNum - 1) * pageSize] ) // Count const total = await sw.db.query('SELECT COUNT(*) as count FROM posts') // total.data[0].count = 42 // Upsert await sw.db.query( 'INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?', ['theme', 'dark', 'dark'] ) // Join const postsWithAuthors = await sw.db.query(` SELECT p.*, u.name as author_name FROM posts p JOIN users u ON p.author_id = u.id WHERE p.published = 1 ORDER BY p.created_at DESC `) ## Performance Queries are fast (~1-50ms). For static reference data that rarely changes, baking it into your function bundle as a JSON constant avoids the round-trip entirely. ## Change webhooks — sw.db.onchange Get notified after every successful INSERT / UPDATE / UPSERT / DELETE. One webhook per project. Payload is small and predictable; no row contents are sent, so your webhook handler can't accidentally leak sensitive data: { project_id, table, op, rows_affected, ts } Register / read / remove from inside a function: const reg = await sw.db.onchange.set('https://example.com/db-hook', { events: ['insert', 'update', 'delete'], // optional; default = all }); // reg.secret → save this if you didn't already; it's how you verify // the signature on incoming POSTs. await sw.db.onchange.get(); await sw.db.onchange.delete(); Every POST carries: X-Somewhere-Signature: t={ms},v1={hmac-sha256-hex} where the signed string is `${ms}.${rawBody}`. Verify with a constant-time compare and reject requests where `Math.abs(Date.now() - ms)` is over your replay tolerance (5 minutes is typical). The webhook fires from waitUntil after the write commits, so your handler's latency never blocks the developer's query. There is no automatic retry — keep your endpoint idempotent and fast. The registration row tracks last_status / last_error so you can see the most recent delivery outcome via sw.db.onchange.get(). Equivalent REST surface (developer key only): PUT /v1/db/webhook { project_id, url, events? } GET /v1/db/webhook?project_id=… DELETE /v1/db/webhook?project_id=… --- ## Database — SQL support, capabilities, and limits (database-engine) ## Short answer A fully managed relational database, deployed at the edge (one database per project). Full SQL: foreign keys, joins, indexes, transactions, triggers, JSON operators, full-text search, math functions. Reads + writes run on the same node as your function — no separate database roundtrip across the network. ## What you get vs Postgres Supported (works identically): - Standard SQL: SELECT / INSERT / UPDATE / DELETE / JOIN / GROUP BY / HAVING / window functions - Constraints: PRIMARY KEY, FOREIGN KEY (with ON DELETE/UPDATE), UNIQUE, NOT NULL, CHECK - Indexes (B-tree), partial indexes, expression indexes - Triggers (BEFORE / AFTER / INSTEAD OF) - Transactions with full ACID, savepoints - JSON columns + JSON operators (`json_extract`, `->`, `->>`) - Full-text search - Common Table Expressions, recursive CTEs What's different from Postgres (most don't matter for app code): - No `RETURNING` clause across UPDATE/DELETE — use `RETURNING` on INSERT only, or query back. - No `ENUM` types — use CHECK constraints + TEXT. - No `UUID` type — use TEXT, generate via `crypto.randomUUID()` in your function. - No stored procedures / PL/pgSQL — write logic in your server function. - No materialized views — cache results in a regular table refreshed via cron. - Writes are serialized per project (handled automatically for you); reads are fully concurrent. ## What the platform compensates for - **Vector search** — `sw.search.*` gives you embeddings + similarity queries over your tables without bolting on a separate vector database. - **Realtime DB events** — `sw.db.onchange.set({ table, events })` fires a webhook on INSERT/UPDATE/DELETE. Cleaner than LISTEN/NOTIFY for typical app use. - **Row-level security via scoping** — `sw.db.scoped(userId)` auto-injects per-user filters. No CREATE POLICY ceremony. - **Write serialization** — a single-writer queue in front of the database keeps concurrent INSERT/UPDATE bursts ordered without you tuning isolation levels. - **Per-project quotas** — Free 1 GB / Builder 2 GB / Pro 10 GB / Scale 50 GB / Enterprise contract. Storage is enforced; reads are unmetered. ## When to use somewhere.tech's DB ✓ App data tied to end-users (accounts, content, sessions, orders, messages, embeddings). ✓ Small-to-medium relational data — anything from 1 row to ~50 GB per project. ✓ Co-located reads + writes: the DB lives on the same node as your function, so a single request can do 10 queries with no network cost. ## When to reach for Postgres ✗ Heavy analytics / complex window queries over hundreds of millions of rows (use a warehouse). ✗ Existing Postgres-native ecosystem (PostGIS, pgvector tuning, specific extensions you depend on). ✗ Cross-region multi-master writes that need >1 simultaneous writer. For everything else: the database is faster, cheaper, and simpler — and `sw.db.dump()` gives you a portable SQL file you can take anywhere if you ever change your mind. Related: `sw.db`, `portability`, `security-model`. --- ## SQL compatibility — what Postgres code ports, and what doesn't (sql-compatibility) Bring your Postgres SQL and most of it just works — the platform translates the common dialect differences automatically. Where it can't translate something faithfully, you get a **clear error with the fix**, never a silent wrong result. This is the full boundary, so the 30% that used to feel random is now predictable. ## Translated automatically (write Postgres, it just works) - `NOW()` becomes `datetime('now')` - `TRUE` / `FALSE` become `1` / `0` - `ILIKE` becomes `LIKE` (case-insensitive for ASCII) - `SERIAL` becomes an auto-increment INTEGER PRIMARY KEY - `$1, $2, …` placeholders become positional `?` - `col->>'key'` and `col->'key'` become `json_extract(col, '$.key')` - `RETURNING` on INSERT works natively These are syntactic — same meaning, different spelling. Safe. ## Use this form (clean equivalent; auto-translation rolling out) - Date math: `created_at > NOW() - INTERVAL '7 days'` → `created_at > datetime('now','-7 days')` (units: second, minute, hour, day, month, year) - `generate_series(1, 10)` → `WITH RECURSIVE s(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM s WHERE n<10) SELECT n FROM s` - `DISTINCT ON (user_id) … ORDER BY user_id, created_at DESC` → wrap in `ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC)` and keep `rn = 1` (DISTINCT ON itself errors loudly with this exact fix — it isn't auto-rewritten, because a `*` select-list can't be rewritten safely without your schema) - `CREATE TYPE … AS ENUM('a','b')` → `col TEXT CHECK(col IN ('a','b'))` (auto-translated when the CREATE TYPE and the table using it are in the same migration) - `array_agg(tag)` → `json_group_array(tag)` JSONB containment `data @> '{"k":"v"}'` auto-translates to `json_extract(data,'$.k') = 'v'` for the **flat case** — one or more keys whose values are scalar strings or numbers (multiple keys are AND-ed together, matching containment). Nested objects, array values, and boolean/null values are NOT auto-translated — they would risk silently matching the wrong rows, so they fail loud; write the explicit `json_extract` conditions yourself for those. ## Errors loudly with the fix (not supported as written) - `tags TEXT[]` (any array type) → store as JSON text: `tags TEXT DEFAULT '[]'`, query with `WHERE tag IN (SELECT value FROM json_each(tags))` - `BEGIN; … COMMIT;` in one query → use `sw.db.batch([{ sql, params }, …])` (atomic, all-or-nothing) - `GEOMETRY`, `GEOGRAPHY`, `TSVECTOR`, `HSTORE`, `INET`, `BYTEA`, … → use full-text / `sw.search` / `sw.fs`, or switch to managed Postgres Arrays are the most common: store them as a JSON text column and unroll with `json_each()` for querying. ## Provided as a primitive (don't emulate Postgres) - Full-text search — native: `CREATE VIRTUAL TABLE docs USING fts5(title, body)`, then `WHERE docs MATCH 'term'`. Or `sw.search` for embeddings + ranking. - Vector / semantic search — `sw.search` (no separate vector database). - Per-user row security — `sw.db.scoped(user.id)` instead of CREATE POLICY. ## Not available — escalate to managed Postgres PL/pgSQL (write logic in your server function), PostGIS, table partitioning, and custom engine index types (GIN/GiST/BRIN — use an app-managed index table + FTS5). For genuinely Postgres-only needs the platform routes you to managed Postgres with a clear message. Related: `database-engine`, `sw.db`, `portability`. --- ## Auth — End-User Authentication (sw.auth) Built-in user management for the people who USE your app. They sign up, log in, reset passwords, verify email — and inside deployed functions you call all of it through `sw.auth.*` without managing API keys or URLs. `sw.auth` is the same shape as `sw.email` or `sw.ai`: the platform's REST surface wrapped, scoped to your project, ready to go. ## Security model (the industry-standard checklist) - **Tenant-scoped signing keys (per-project HKDF derivation).** Each project's JWT signing key is derived from a master secret + project ID via HKDF-SHA256. A leaked token in project A can't forge anything in project B. - **Immediate revocation via `token_version`.** Bump the column on `platform_users` and every existing JWT for that user fails the next verify — no separate revocation store needed. - **Atomic single-use tokens** for password reset / magic link / refresh / MFA. Consumption uses `UPDATE … WHERE token = ? AND consumed_at IS NULL` so two parallel uses can't both succeed. - **MFA (TOTP)** built in: enroll / verify / unenroll / challenge + recovery codes. - **OAuth (Google)** as a first-class flow — the platform owns the callback, sets the cookie, no `passport` boilerplate. - **Header-based auto-refresh.** If the access token is in its last 10% of TTL and a refresh token rides along, the platform mints a new pair, runs the request under the refreshed identity, and returns the new pair on `X-New-Access-Token` / `X-New-Refresh-Token`. No 401-loop in your client code. - **Built-in Row-Level Security (RLS)** — covered in the `sw.db` topic. `sw.db.query(sql, params, { user })` rewrites raw queries to enforce `WHERE owner_id = ?` server-side. - **Role-based access control (RBAC) — platform layers.** Platform admin (`platform_users.is_admin`), project membership (`project_collaborators`), API-key authority (`developer | admin | cli_pair` via `api_key_authority`), and subscription tier all gate what an actor can do. Application-level RBAC for your app's OWN end-users (admin/editor/viewer) is customer-managed today via a `role` column on your users table + a check in your handler. Full reviewer-facing depth: . For agent-driven flows from your tools, the same surface is exposed as MCP (auth_signup, auth_login, auth_me, auth_refresh, auth_users_list, auth_user_delete, auth_user_update, auth_google_url) and as REST under /v1/auth/*. ## Token types Every signup/login returns three tokens: token — short-lived JWT (1 hour). Used as Bearer on protected endpoints. Sent by the browser on every API call. refresh_token — 30 days. Single-use; rotates on every refresh. Use it to mint a fresh access token without making the user log in again. session_token — opaque server-side session. Pass to logout to revoke. The smt_ developer key is for YOUR backend. App-user JWTs are for the end user's browser. `sw.auth` holds the developer key for you — you never type `smt_` inside a function. ## Scoped developer keys Developer keys can be limited to specific API areas at mint time — right for CI jobs and single-purpose automations that shouldn't hold full account authority: POST /v1/keys { "name": "ci-embeddings", "scopes": ["ai:complete", "db"] } → { id, key: "smt_…", prefix, name, scopes } A scope is the `/v1/…` path with `:` separators — `"db"` covers `/v1/db/*`, `"ai:complete"` covers `/v1/ai/complete`. Max 32 scopes per key. A scoped key calling outside its scopes gets 403 FORBIDDEN with a message naming the key's scopes and the blocked path. Keys minted without `scopes` have full access. The same `scopes` field works on `POST /v1/keys/cli-pair` for ephemeral 24h keys. ## Sign up const { user, token, refresh_token, session_token } = await sw.auth.signup({ email, password, display_name, // optional locale, // optional, BCP-47 like "en-US" timezone, // optional, IANA like "America/Los_Angeles" }); REST: POST /v1/auth/signup · MCP: auth_signup ## Log in const { user, token, refresh_token, session_token } = await sw.auth.login({ email, password, }); REST: POST /v1/auth/login · MCP: auth_login ## Get the user from a request — start here ```typescript export default async function (req, sw) { const user = await sw.auth.fromRequest(req) if (!user) return Response.json({ error: 'unauthorized' }, { status: 401 }) // user.id, user.email, etc. — go straight to your query const { data } = await sw.db.query('SELECT * FROM posts WHERE author_id = ?', [user.id]) return Response.json(data) } ``` `fromRequest(req)` reads the token in this order: 1. Cookie named `token`, then `auth_token`, then `session` 2. `Authorization: Bearer ` header Then validates with `sw.auth.me` under the hood (token cache included) and returns the user object — or `null` if no token, the token is expired, or the token is invalid. Never throws. This is the single call you should reach for from every protected function. ## Lower-level: validate a token you already pulled const { user } = await sw.auth.me(jwt); Use this only when you already extracted the JWT yourself (e.g. from a non-standard header, a query param, a WebSocket subprotocol). For 99 % of routes, prefer `fromRequest` — it does the extraction + validation in one call. `me` throws `AUTH_INVALID_CREDS` if the token is bad. Returns `{ user: { id, email, email_verified, display_name, locale?, timezone?, plan, plan_status } }`. `plan` is always populated (defaults to the string `'free'` for users who have never checked out). `plan_status` is `null` for free users, and `'active' | 'past_due' | 'canceled'` once they've subscribed via `sw.payments.checkoutForUser(user.id, { plan, ... })`. Gate features off these two fields directly — no separate subscription table needed. See the `payments` topic for the full flow. REST: GET /v1/auth/me with the user JWT in Authorization · MCP: auth_me ## Browser sessions — use httpOnly cookies, not tokens Browser apps should hold NO tokens at all: the happy path is cookie sessions — your backend calls `sw.auth.loginWithCookie` and the platform sets the session as httpOnly cookies the browser owns. Zero client auth code, nothing in localStorage, XSS can't steal what JS can't read, and the session refreshes server-side automatically. `platform_help({ topic: 'auth-client' })` has the exact code to paste. The full cookie-session surface (all on the runtime): // Sign in/up AND set the session cookies on the response; returns // the user. Also accept (req, { email, password }). Expected // failures (wrong password, duplicate email) surface as structured // 4xx with the real message — no try/catch needed. await sw.auth.loginWithCookie(req, email, password); await sw.auth.signupWithCookie(req, email, password); // Your Google OAuth callback route in one call: reads ?code, // exchanges it, sets the cookies, returns a 302 to redirectTo. return sw.auth.googleCallbackWithCookie(req, '/'); // Revoke the session server-side and clear the cookies. await sw.auth.logoutWithCookie(req); // Lower-level primitives the helpers wrap — for backends that // already hold a token pair (e.g. after verifyOtp or mfa.challenge) // and want THAT session as cookies. sw.auth.setSessionCookies(access, refresh); sw.auth.clearSessionCookies(); Cookie-authed requests are origin-checked server-side: a cross-origin page can't ride the cookies into your API (blocked attempts log loudly). `fromRequest` auto-refreshes expired cookie sessions and re-issues the cookies, so users stay signed in across browser restarts. ## Header-based auto-refresh (manual token mode) For clients that DO hold tokens — native apps, CLIs, non-browser runtimes, or a browser app that explicitly opted out of cookie sessions — tell the client to send both tokens on every request: Authorization: Bearer X-Refresh-Token: When the access token has expired the platform mints a fresh pair, runs the request under the refreshed identity, and returns the new pair on the response: X-New-Access-Token: X-New-Refresh-Token: The client persists the new pair and moves on — no 401-handling loop. Inside deployed functions `sw.auth.fromRequest(req)` does the same dance automatically and the platform attaches the X-New-* headers to your function's response. Per-request opt-out: `X-No-Auto-Refresh: 1`. Two flavors for guarded handlers: // Returns the user OR null — use when the route works for anonymous // visitors too (e.g. a feed that personalizes when signed in). const user = await sw.auth.fromRequest(req); // Throws 401 if not signed in — use when the route requires auth. // The handler shim converts the thrown error into a 401 response. const user = await sw.auth.requireUser(req); Enrichment (optional 2nd arg) — most apps follow up fromRequest with a SELECT against their own user-table to pull role/metadata/etc. Pass enrichFrom and the join happens for you, one DB call total: const me = await sw.auth.requireUser(req, { enrichFrom: 'members', // your table fields: ['role', 'metadata'], // optional, default * on: 'id', // optional, default 'id' (matches user.id) }) // me = { id, email, ...platform fields..., role, metadata } Platform fields always win on a name collision (id/email/etc), and a missing table or column logs server-side without throwing — your handler still gets the unenriched user back. Manual-token browser snippet (advanced — browser apps should prefer the cookie sessions above; use this only when you must hold tokens in JS): // auth.ts const ACCESS = 'sw_access_token'; const REFRESH = 'sw_refresh_token'; export function setTokens(t: { access: string; refresh: string }) { localStorage.setItem(ACCESS, t.access); localStorage.setItem(REFRESH, t.refresh); } export function clearTokens() { localStorage.removeItem(ACCESS); localStorage.removeItem(REFRESH); } export async function swFetch(input: RequestInfo | URL, init: RequestInit = {}) { const headers = new Headers(init.headers); const access = localStorage.getItem(ACCESS); const refresh = localStorage.getItem(REFRESH); if (access && !headers.has('Authorization')) headers.set('Authorization', 'Bearer ' + access); if (refresh && !headers.has('X-Refresh-Token')) headers.set('X-Refresh-Token', refresh); const res = await fetch(input, { ...init, headers }); const newA = res.headers.get('X-New-Access-Token'); const newR = res.headers.get('X-New-Refresh-Token'); if (newA && newR) { localStorage.setItem(ACCESS, newA); localStorage.setItem(REFRESH, newR); } return res; } Use it: // After signup/login: setTokens({ access: result.access_token, refresh: result.refresh_token }); // From now on, every call rotates the pair silently: const r = await swFetch('https://api.somewhere.tech/v1/db/query', { method: 'POST', body }); ## Refresh expired token (legacy / explicit) const { access_token, refresh_token, expires_in } = await sw.auth.refresh({ refresh_token, }); Old refresh tokens stop working immediately on use. Store the new pair. Prefer the header-based flow above for browser apps — explicit `/v1/auth/refresh` is for non-browser SDKs or clients that want to control rotation manually. REST: POST /v1/auth/refresh ## Password reset flow // Step 1 — request a reset email. Always succeeds (no enumeration). await sw.auth.forgot({ email }); // Step 2 — user clicks the email link, your form posts the token + new pw. await sw.auth.reset({ token, new_password }); REST: POST /v1/auth/forgot, POST /v1/auth/reset ## Email verification `sw.auth.signup` auto-sends a 6-digit code to the user's email on success — they're prompted to verify on first visit. The full surface: // Re-send a 6-digit code (15 min expiry). await sw.auth.requestEmailVerification(jwt); // User types the code into your form; you verify it. await sw.auth.verifyEmail(jwt, { code }); // Or — resend the email if it didn't arrive. await sw.auth.resendVerification(jwt); ## Magic links (passwordless sign-in) For users who don't want a password (or forgot it). `signInWithOtp` emails a one-click link; `verifyOtp` exchanges the token for a session the same shape as `login`. // Step 1 — your form posts the email; we email a 15-min single-use // sign-in link. Always succeeds (no enumeration). Auto-creates the // user if they're new. await sw.auth.signInWithOtp({ email }); // Step 2 — link points at your app with ?token=...; you exchange it. const { user, token, refresh_token, session_token } = await sw.auth.verifyOtp({ token }); REST: POST /v1/auth/magic-link, POST /v1/auth/magic-link/verify MCP: auth_send_magic_link, auth_verify_magic_link ## MFA / TOTP RFC 6238 TOTP — Google Authenticator, 1Password, Authy all work. Once enrolled, the user's normal `login` returns `{ mfa_required: true, mfa_token }` instead of a session; complete the challenge with the 6-digit code to get the session. // Enrollment — pass the user's JWT. const { secret, otpauth_uri } = await sw.auth.mfa.enroll({ token: jwt }); // Show otpauth_uri as a QR code; user scans it, types the first code. const { backup_codes } = await sw.auth.mfa.verify({ token: jwt, code }); // backup_codes is returned ONCE on first verify — show it to the // user now (null on re-verify; existing codes are preserved). // On login, if MFA is enrolled: const login = await sw.auth.login({ email, password }); if (login.mfa_required) { // Prompt the user for their TOTP code (or a backup code), then complete: const session = await sw.auth.mfa.challenge({ mfa_token: login.mfa_token, code, }); } // Remove MFA — requires a fresh TOTP or backup code, so a stolen // JWT alone can't turn it off. await sw.auth.mfa.unenroll({ token: jwt, code }); REST: POST /v1/auth/mfa/{enroll,verify,challenge,unenroll} MCP: auth_mfa_{enroll,verify,challenge,unenroll} ## Update password (logged-in user) await sw.auth.updatePassword(jwt, { current_password, new_password, }); Wipes ALL active sessions and refresh tokens on success — the user has to log in again everywhere. For OAuth-only users setting their first password, omit `current_password`. ## Update profile (logged-in user) await sw.auth.updateProfile(jwt, { display_name, // optional, set to null to clear metadata, // optional, replaces any existing metadata blob }); ## Self-service account deletion await sw.auth.deleteUser(jwt); Cascade-deletes the user's sessions, reset tokens, verification codes, and `app_users` row. Project data (your own tables) is the developer's concern — wipe it before calling. ## Admin / test-harness deletion (developer key) await auth_user_delete({ project_id, user_id }); // MCP tool // or REST: DELETE /v1/auth/users/:id?project_id=... with smt_ key For wiping throwaway test users from your own tooling, when you don't have the user's JWT. Same cascade as the self-service variant. The project must be one you own. ## Admin / test-harness edit (developer key) await auth_user_update({ project_id, user_id, display_name, metadata }); // or REST: PATCH /v1/auth/users/:id?project_id=... with smt_ key For admin tooling: edit `display_name` and/or `metadata` on any end-user in a project you own. Same field shape as the user's own PATCH /users/me. Email and password are intentionally NOT here — those have their own verification + reset flows. `metadata` is REPLACED, not merged; max 16 KB. Also accepts `banned` and `banned_reason` — see "Ban / suspend" below. ## Admin operations are NOT on the runtime `sw.auth.admin.*` is not callable inside deployed functions — any call throws `AUTH_ADMIN_REMOVED_FROM_RUNTIME`. A public handler that accepted a user id from the request body would be an account-takeover primitive, so admin actions on app users run only from your own tooling with a developer `smt_` key (MCP tools or REST), never from a request handler. The sections below are that surface. ## Ban / suspend (developer key) Block a user from signing in without deleting their data. Bans are reversible — the row stays put, sessions are wiped, future `login` returns AUTH_INVALID_CREDS (no enumeration), and any active JWT bounces with 403 AUTH_BANNED on `/me` so your SPA can log them out. await auth_user_update({ project_id, user_id, banned: true, banned_reason: 'spam' }); await auth_user_update({ project_id, user_id, banned: false }); // unban; clears reason+timestamp The `banned_reason` is staff-only — never returned to the banned user. Banning a user invalidates every active session + refresh token. REST: PATCH /v1/auth/users/:id?project_id=… with `banned` / `banned_reason` fields · MCP: auth_user_update ## Impersonation (developer key) Mint a 1-hour JWT for an end-user as YOU — useful for support tickets, test harnesses, and debugging customer reports. The token carries `impersonating: true` and `impersonator_id` claims so audit logs can distinguish impersonated traffic from genuine user traffic. const { access_token, user } = await auth_impersonate({ project_id, user_id }); // access_token is 1h, no refresh — re-mint when it expires. Banned users cannot be impersonated (returns 403 AUTH_BANNED). REST: POST /v1/auth/users/:id/impersonate · MCP: auth_impersonate ## Session management (developer key) List + revoke a user's active sessions. Tokens themselves are never returned — only session IDs, creation timestamps, and expiries. const sessions = await auth_list_sessions({ project_id, user_id }); // [{ id, created_at, expires_at }, ...] — non-expired only, max 100 await auth_revoke_session({ project_id, session_id }); // log this device out await auth_revoke_all_sessions({ project_id, user_id }); // log every device out REST: GET /v1/auth/users/:id/sessions, DELETE /v1/auth/sessions/:id, DELETE /v1/auth/users/:id/sessions MCP: auth_list_sessions, auth_revoke_session, auth_revoke_all_sessions ## Hosted auth pages Drop-in branded UI for apps that don't want to build their own forms. https://auth.somewhere.tech/login?project_id=...&redirect=https://yourapp.somewhere.tech/ Pages: `/login`, `/signup`, `/forgot-password`, `/reset`, `/verify-email`, `/magic-link`, `/mfa`. On success the page redirects to `redirect` with `#access_token=...&refresh_token=...` in the URL hash. Your SPA reads `location.hash`, stores the tokens in localStorage, and replaces history. The pages POST directly to `api.somewhere.tech` — no business logic lives on the auth subdomain. ## Lifecycle webhook (clean up your own tables on user delete) The platform's user-delete cascade only touches platform-owned tables (sessions, password resets, email verifications, the `app_users` row). Anything you wrote that references the user_id — `posts.author_id`, `comments.user_id`, files in `/avatars/`, etc. — is your concern. Without a hook, those rows orphan silently and your app starts returning rows pointing at users that no longer exist. Configure a project-scoped webhook to clean them up transactionally: // one-time setup (developer key) const { secret } = await auth_webhook_set({ project_id: 'default', url: 'https://yourapp.somewhere.tech/api/auth/webhook', }); // store `secret` server-side — we don't surface it again. // re-call with rotate_secret: true to rotate. Inside your webhook handler, verify the signature and run cleanup: // POST handler in your deployed function const sig = req.headers.get('X-Somewhere-Signature') ?? ''; const m = sig.match(/^t=(\\d+),v1=([0-9a-f]+)$/); if (!m) return new Response('bad sig', { status: 401 }); const [, t, hex] = m; if (Math.abs(Date.now() - Number(t)) > 5 * 60 * 1000) { return new Response('stale sig', { status: 401 }); } const raw = await req.text(); const expected = await sw.crypto.hmacSha256Hex(`${t}.${raw}`, secret); if (!sw.crypto.timingSafeEqual(expected, hex)) { return new Response('bad sig', { status: 401 }); } const evt = JSON.parse(raw); if (evt.type === 'auth.user.created') { // The classic "signup trigger → profiles row" pattern: mirror the // new user into your own table. await sw.db.query( `INSERT INTO profiles (id, email, display_name) VALUES (?, ?, ?) ON CONFLICT(id) DO NOTHING`, [evt.user.id, evt.user.email, evt.user.display_name], ); } if (evt.type === 'auth.user.deleted') { await sw.db.query( `DELETE FROM posts WHERE author_id = ?`, [evt.user.id], ); // ...etc. for every table that references user_id } return new Response('ok'); Fired events: `auth.user.created`, `auth.user.updated`, `auth.user.deleted`. - `created` fires on password signup, Google-created, and magic-link-created users. Payload: `{ id, email, display_name, project_id, created_at }`. - `updated` fires when a user edits their profile (display_name / metadata). - `deleted` fires on account deletion. Same HMAC scheme as inbox webhooks — one verifier covers both. ## Joining users from your app data — the `auth_users` table You don't have to mirror users by hand. The platform keeps a read-only `auth_users` table inside your project database, kept in sync on signup, profile change, and deletion. JOIN it straight from your app tables: SELECT p.id, p.body, u.display_name AS author, u.email FROM posts p JOIN auth_users u ON u.id = p.user_id; Columns: `id, email, display_name, email_verified, metadata, created_at, last_login_at, plan, plan_status, updated_at`. Treat it as read-only — it's maintained for you; write your own tables, JOIN against this one. The mirror is best-effort and never blocks signup. If you'd already defined your own incompatible `auth_users` table, the sync simply no-ops and leaves your table untouched — use the `auth.user.*` webhooks above instead. To stop firing webhooks for the project: await auth_webhook_delete({ project_id: 'default' }); ## Logout // Cookie sessions (browser default): revoke + clear the cookies. await sw.auth.logoutWithCookie(req); // Token mode: pass the session_token and/or refresh_token. await sw.auth.logout({ session_token }); Revokes the session. The current JWT keeps working until it expires (max 1 hour) — JWTs are stateless. To force immediate logout, also rotate the user's password. ## Anonymous sessions (pre-signup) Lots of demos let visitors do something useful before they sign up (send a chat message, upload a file, fill a form). Track them with `sw.auth.anonSession(req)`: ```typescript export default async function (req, sw) { const session = sw.auth.anonSession(req) // session.id → 'anon_4f9b3...' (stable across requests via cookie) // session.isAnon → true // Tag rows with session.id while they're anonymous. await sw.db.query('INSERT INTO messages (user_id, body) VALUES (?, ?)', [session.id, 'hello']) // applyTo() attaches the Set-Cookie header on first visit (no-op // afterwards). One line, no manual cookie parsing. return session.applyTo(Response.json({ ok: true })) } ``` Cookie shape: `sw_anon_id=; Path=/; Max-Age=31536000; HttpOnly; Secure; SameSite=Lax`. When the visitor signs up or logs in, hand the anon id over to the real user with `migrateAnon`: ```typescript const { user, token } = await sw.auth.signup({ email, password }) const session = sw.auth.anonSession(req) const result = await sw.auth.migrateAnon({ anonId: session.id, userId: user.id, }) // result.migrated → number of rows updated // result.tables → list of tables touched ``` By default `migrateAnon` auto-detects every user-table that has a `user_id` column (skipping platform-managed `_-prefix` tables) and runs `UPDATE … SET user_id = WHERE user_id = ` on each. Override with `tables: ['messages', 'orders']` to be explicit when your schema isn't anon-id friendly. Don't reinvent this in three files of demo code. ## Google OAuth **The platform owns the Google OAuth client.** You do NOT create a Google Cloud Console project. You do NOT register your own OAuth 2.0 credentials. You do NOT add authorized redirect URIs in any Google console. The platform's single OAuth client handles every project on the platform — you just call `sw.auth.googleUrl({ redirect_uri })` and the platform accepts `redirect_uri` for any verified project subdomain or claimed custom domain attached to your project. If a `redirect_uri` is rejected, the answer is either (a) the host doesn't belong to your project yet (run `domain_attach`) or (b) the project subdomain is wrong — never "go set up Google credentials". // Build the URL to send the user's browser to: const url = sw.auth.googleUrl({ redirect_uri: 'https://myapp.somewhere.tech/auth/callback' }); // ...redirect the user there. After the user approves, the platform handles the Google code exchange and then bounces them back to your `redirect_uri` with a short-lived authorization code in the query string: https://your-app.somewhere.tech/auth/callback?code=... The JWT is never put in the URL. To get the JWT, your callback function exchanges the code: const { token, user } = await sw.auth.googleExchange({ code }); Codes are single-use and expire after 60 seconds. Typical callback function — one line with the cookie helper: // api/auth-callback.ts export default async function(req, sw) { // Reads ?code, exchanges it, sets the httpOnly session cookies, // and 302s home. Tokens never touch client JS. return sw.auth.googleCallbackWithCookie(req, '/'); } Call `googleExchange` directly only when you manage tokens yourself (native apps / non-browser clients) — it returns the same `{ user, token, refresh_token, session_token }` shape as `login`. REST: GET /v1/auth/google for the URL, POST /v1/auth/google/exchange for the code. · MCP: auth_google_url ## Common pattern: protected endpoint inside a deployed function export default async function(req, sw) { const user = await sw.auth.fromRequest(req); if (!user) return Response.json({ error: 'Unauthorized' }, { status: 401 }); const posts = await sw.db.query( 'SELECT * FROM posts WHERE author_id = ?', [user.id] ); return Response.json(posts.data); } That's it. No cookie parsing, no header slicing, no try/catch on expired tokens — all of that lives inside `fromRequest`. If you find yourself writing `req.headers.get('cookie')` inside a function, you re-implemented something that already exists. ## Auth emails Password reset, email verification, and welcome emails are platform email — they're sent from a platform-owned address (noreply@somewhere.tech) on every project's behalf. No setup required: every project gets working auth out of the box. Customize the subject + HTML in dashboard Settings → Email Templates (or via the email-templates API). The from-address is platform-owned and not configurable per project — that's by design, so deliverability isn't gated on each user verifying their own domain. The per-project verified sender domain only matters for sw.email.send — your app's own outbound mail (marketing, receipts, transactional). Auth emails bypass that check. --- ## Auth on the client — the correct session code (auth-client) The platform owns the hard part server-side: the Google OAuth client, JWT validation (`sw.auth.fromRequest`), and session refresh. The happy path puts ZERO auth code in the browser: **httpOnly cookie sessions**. No tokens in JS, nothing in localStorage, nothing for XSS to steal — and expected failures (wrong password, duplicate email, weak password) come back as structured 4xx with the real message, never an opaque 500. ## 1. The backend — one file (paste exactly this) `login` / `signup` are developer-key-gated (the browser can't call them), so this one server function mediates and sets the session cookies: ```js // functions/api/auth/[...path].js export default async function (req, sw) { const url = new URL(req.url); const sub = url.pathname.replace(/.*\\/auth/, '') || '/'; const json = (d, s) => Response.json(d, { status: s || 200 }); const body = async () => { try { return await req.json(); } catch (e) { return {}; } }; if (req.method === 'POST' && sub === '/login') { const b = await body(); return json(await sw.auth.loginWithCookie(req, b.email, b.password)); } if (req.method === 'POST' && sub === '/signup') { const b = await body(); return json(await sw.auth.signupWithCookie(req, b.email, b.password)); } if (req.method === 'GET' && sub === '/callback') return sw.auth.googleCallbackWithCookie(req, '/'); // returns a 302 if (req.method === 'POST' && sub === '/logout') return json(await sw.auth.logoutWithCookie(req)); if (req.method === 'GET' && sub === '/me') return json({ user: await sw.auth.fromRequest(req) }); return json({ error: 'NOT_FOUND' }, 404); } ``` No try/catch needed: expected auth failures surface automatically as structured 4xx — `{ error: 'INVALID_CREDENTIALS', message: 'Wrong email or password.' }` and friends — so your UI can show `body.message` directly. Real bugs still return opaque 500s. (The cookie helpers also accept an options object: `loginWithCookie(req, { email, password })` works too.) ## 2. The client — option A: the SDK (cookie mode is the browser default) ```js import { createClient } from '@somewhere-tech/sdk' // 0.6.0+ const client = createClient('https://.somewhere.tech', SOMEWHERE_KEY) // Posts to your /api/auth routes above; the session is httpOnly cookies. const { error } = await client.auth.signInWithPassword({ email, password }) if (error) showMessage(error.message) // "Wrong email or password." await client.auth.signUp({ email, password }) const { data: { user } } = await client.auth.getUser() // probes /api/auth/me await client.auth.signOut() // Google — zero token-handoff code: the platform redirects back through // your /callback route, which sets the cookies and 302s home. const { data } = await client.auth.signInWithOAuth({ provider: 'google' }) window.location.href = data.url ``` In cookie mode the SDK holds no tokens: `getSession()` returns `{ cookie_session: true, user }`, a network blip never reads as a logout (only a definitive 401 does), and `functions.invoke` rides the cookie automatically. ## 2. The client — option B: zero dependencies Nothing to install — the cookie does the work: ```js // Sign in (sign up is the same against /api/auth/signup): const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), credentials: 'include', }); if (!res.ok) showMessage((await res.json()).message); // "Wrong email or password." // Who is signed in? (any page load) const me = await (await fetch('/api/auth/me', { credentials: 'include' })).json(); // Every authed request — just include credentials: const r = await fetch('/api/whatever', { credentials: 'include' }); // Sign out: await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }); ``` ## 3. React — one hook ```jsx import { useState, useEffect } from 'react'; export function useUser() { const [user, setUser] = useState(null); useEffect(() => { let on = true; fetch('/api/auth/me', { credentials: 'include' }) .then((r) => r.json()).then((d) => { if (on) setUser(d.user || null); }) .catch(() => {}); // network blip — never treat as logged out return () => { on = false; }; }, []); return user; } ``` The cookies are HttpOnly + Secure (invisible to JS — XSS can't steal them), SameSite=Lax, Path=/, 30-day lifetime. `sw.auth.fromRequest` auto-refreshes the session on every call and re-issues the cookie, so users stay logged in across browser restarts and you never write a line of refresh logic. ## Advanced — manual token mode (native apps / non-browser clients) Browser apps should use the cookie sessions above. Hold tokens yourself ONLY when there is no httpOnly cookie jar (native apps, CLIs). The session layer below is correct — paste it, don't hand-roll it: a hand-rolled layer is where every auth bug lives (logged out on a network blip, the rotating-refresh-token desync, a half-written token pair). It encodes three rules that are easy to get wrong (called out at the bottom). ### The session client — `src/auth.js` ```js const KEY = 'sw_auth'; const UKEY = 'sw_auth_user'; // cached user for optimistic restore — never flash logged-out on a network blip function load() { try { const r = localStorage.getItem(KEY); const s = r && JSON.parse(r); return (s && s.accessToken && s.refreshToken) ? s : null; } catch (e) { return null; } } let session = load(); // Optimistic: restore the last-known user so a page refresh shows the signed-in // UI instantly, and a transient /me failure keeps the user instead of logging // out. Only trust the cache when a session exists (no tokens = no user). function loadUser() { try { const r = localStorage.getItem(UKEY); const u = r && JSON.parse(r); return (session && u && typeof u === 'object') ? u : null; } catch (e) { return null; } } let cachedUser = loadUser(); export function getCachedUser() { return cachedUser; } function setUser(u) { // persist/clear the cached user cachedUser = u; if (u) localStorage.setItem(UKEY, JSON.stringify(u)); else localStorage.removeItem(UKEY); } const subs = new Set(); function setSession(next) { // ATOMIC: one value — both tokens or none session = next; if (next) localStorage.setItem(KEY, JSON.stringify(next)); else { localStorage.removeItem(KEY); setUser(null); } // logging out clears the cached user too subs.forEach(function (fn) { fn(session); }); } export function getSession() { return session; } export function onAuthChange(fn) { subs.add(fn); fn(session); return function () { subs.delete(fn); }; } // Use this for EVERY request to your backend that needs the user. export async function authFetch(input, init) { init = init || {}; const headers = new Headers(init.headers); if (session) { headers.set('Authorization', 'Bearer ' + session.accessToken); headers.set('X-Refresh-Token', session.refreshToken); // ride-along: lets the server auto-refresh } let res; try { res = await fetch(input, Object.assign({}, init, { headers })); } catch (err) { throw err; // NETWORK BLIP: keep the session, just re-throw. NEVER log out here. } const na = res.headers.get('X-New-Access-Token'); const nr = res.headers.get('X-New-Refresh-Token'); if (na && nr) setSession({ accessToken: na, refreshToken: nr }); // rotation: both or neither if (res.status === 401) setSession(null); // server already tried refresh — session is dead return res; } async function exchange(path, payload) { const res = await fetch('/api/auth' + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const data = await res.json().catch(function () { return {}; }); if (!res.ok) throw new Error(data.message || data.error || 'Auth failed'); setSession({ accessToken: data.token || data.access_token, refreshToken: data.refresh_token }); return data.user || null; } export const signIn = function (email, password) { return exchange('/login', { email: email, password: password }); }; export const signUp = function (email, password) { return exchange('/signup', { email: email, password: password }); }; export async function signOut() { setSession(null); try { await fetch('/api/auth/logout', { method: 'POST' }); } catch (e) {} } export async function googleSignIn() { window.location.href = (await (await fetch('/api/auth/google-url')).json()).url; } export async function getUser() { if (!session) return null; let res; try { res = await authFetch('/api/auth/me'); } catch (e) { return cachedUser; } // network blip — keep the cached user, never log out if (res.status === 401) return null; // authFetch already cleared the dead session if (!res.ok) return cachedUser; // 5xx / transient — keep the cached user const u = (await res.json()).user || null; setUser(u); // refresh + persist return u; } ``` ### The backend — one function that wires it to `sw.auth` A thin server function mediates the developer-key-gated calls and returns the token pair to the client (this is the manual-mode sibling of the cookie handler above — same file path, tokens in the body instead of cookies): ```js // functions/api/auth/[...path].js export default async function (req, sw) { const url = new URL(req.url); const parts = url.pathname.split('/auth'); const sub = parts.length > 1 ? (parts[parts.length - 1] || '/') : '/'; const json = function (d, s) { return Response.json(d, { status: s || 200 }); }; const body = async function () { try { return await req.json(); } catch (e) { return {}; } }; try { if (req.method === 'POST' && sub === '/login') return json(await sw.auth.login(await body())); if (req.method === 'POST' && sub === '/signup') return json(await sw.auth.signup(await body())); if (req.method === 'POST' && sub === '/logout') { try { await sw.auth.logout({}); } catch (e) {} return json({ ok: true }); } if (req.method === 'GET' && sub === '/me') return json({ user: await sw.auth.fromRequest(req) }); if (req.method === 'GET' && sub === '/google-url') return json(await sw.auth.googleUrl({ redirect_uri: url.origin + '/auth/callback' })); if (req.method === 'POST' && sub === '/google') return json(await sw.auth.googleExchange(await body())); return json({ error: 'NOT_FOUND' }, 404); } catch (e) { return json({ error: 'AUTH_ERROR', message: e.message }, 400); } } ``` ### React — one hook (manual token mode) ```jsx import { useState, useEffect } from 'react'; import { onAuthChange, getUser, getCachedUser } from './auth'; export function useUser() { const [user, setUser] = useState(getCachedUser); // paint the cached user immediately — no logged-out flash useEffect(function () { return onAuthChange(function () { getUser().then(setUser); }); }, []); return user; } ``` Now `const user = useUser()` anywhere, and `authFetch('/api/...')` for authed calls. Google callback page: read `?code` from the URL and POST it to `/api/auth/google`, then redirect home. Gating by paid plan? The same provider exposes `useEntitlements()` and a `` component — the user's feature list rides the session, so the check is instant. See `docs({ topic: 'sw.billing' })`. ### The three rules (don't "simplify" these away — each is a real bug) - **Persist the pair ATOMICALLY** — write both tokens as ONE value. A code path that writes the access token without the refresh token (or vice-versa) desyncs the session and logs the user out unpredictably. - **A network error must NEVER clear the session** — the `catch` re-throws and leaves the tokens alone. Wiping the session because `fetch` rejected is the #1 cause of "I got logged out for no reason" (a wifi blip should not sign you out). - **Rotate only when BOTH `X-New-*` headers are present** — a partial header set means no rotation happened; don't half-apply it. ## Nothing here requires an install The cookie path is one pasted backend file plus plain `fetch` — option B needs no package at all, and this page is maintained with the platform so it can't drift. The `@somewhere-tech/sdk` cookie mode (option A) is the same flow behind the familiar `createClient` surface — pick whichever your app already uses. --- ## Security model — who can read/write what (security-model) ## Three actor classes 1. **End users** of your app — sign up via `sw.auth`. Get a JWT in cookies or `Authorization: Bearer …`. Identified by `sw.auth.fromRequest(req)`. Cannot run raw SQL from the browser. 2. **Your server functions** — code you deploy under `api/*`. Run with project-level credentials. Can run raw SQL, hit any `sw.*` method, read env vars. 3. **You (the developer)** — somewhere.tech account, smt_ API key. Can deploy, change settings, query the database via MCP / dashboard, access logs. ## Per-user data access For data scoped to an end-user (notes, orders, messages, etc.) use `sw.db.scoped(user.id)` — every query the scoped client runs has `user_id = ?` injected automatically: ```js const user = await sw.auth.fromRequest(req); const db = sw.db.scoped(user.id); await db.query('SELECT * FROM notes WHERE id = ?', [noteId]); // → SELECT * FROM notes WHERE id = ? AND user_id = ? ``` No CREATE POLICY ceremony, no row-level security setup. The scope travels with the client object so a forgotten WHERE clause can't leak another user's row. The deploy-time scanner warns if it sees `sw.auth.fromRequest` + raw `sw.db.query` (without `scoped`) on a public route — that's the data-leak shape we catch automatically. ## Can raw SQL bypass scoping? - **From browser code:** no. The browser cannot call `sw.db.query` directly; only your server functions can. Browser requests reach the database only through a function you wrote. - **From your own server code:** yes, deliberately — you may need to run admin reports or cross-user queries. The scanner flags this as an advisory warning, not a block. Add `somewhere-security-allow` comment in the file when it's intentional. - **Via the MCP `db_query` tool:** yes — but only with your developer key (smt_), never with an end-user JWT. ## Deploy-time + on-demand review - **Every deploy** runs a regex scanner that catches the obvious shapes (auth bypass, raw SQL, payment metadata spoof, env leakage, eval, RCE). Hits surface as `warnings[]` in the deploy response — advisory only, never blocks. - **On-demand** (`security_review` MCP tool, also via dashboard) runs an LLM over the deployed code and returns a Markdown report with WHAT/WHERE/IMPACT/FIX per finding. Higher tiers use a stronger model; per-run cost is at /v1/pricing. ## Server-side secrets - `sw.env.SECRET_NAME` — server-only. Reading it inside a handler is fine. Returning it in a response (`Response.json(sw.env)`) is a leak; the security review flags this shape. - Stripe keys, API keys, model keys: set via `somewhere env set KEY value` or the dashboard. Stored encrypted. ## Admin paths - `sw.auth.admin` (privileged auth ops — impersonate, delete user, list all users) is not callable inside deployed functions — any call throws `AUTH_ADMIN_REMOVED_FROM_RUNTIME`. Use the developer-key surface instead (auth_user_update, auth_impersonate, …) from your own tooling. - `sw.db.migrate` runs DDL — gate it to your own user. - `sw.keys.*` / `sw.deploy.*` are developer-key only. ## Runtime function key scope Your deployed functions share one server credential (`PROJECT_API_KEY`) that lives inside the `sw.*` helpers and is never exposed to your code. On deploy/promote it is scoped to exactly the surfaces your functions use: a function that only calls `sw.db` and `sw.payments` gets a key that can reach the database and payments areas and nothing else — a call to an unused area is refused. This is derived from your code automatically (no config), and every `sw.*` surface keeps working unchanged. Functions deployed before this shipped keep their prior full grant until their next deploy, so nothing breaks mid-flight. If you reach the platform only through the documented `sw.*` methods (the normal case), there is nothing to do. ## Auth token lifecycle - Access tokens: 1 hour. Auto-refreshed inline — if expired and the refresh is valid, the response includes `X-New-Access-Token` + `X-New-Refresh-Token` headers (CORS exposes them). - Refresh tokens: 30 days, rotated on use, with a 24h grace window so a slow client doesn't get logged out from a race. - Revoke a session: `auth_revoke_session` (MCP) or `DELETE /v1/auth/sessions/:id` with a developer key. Related: `sw.auth`, `sw.db`, `common-mistakes`. --- ## sw.fs — File Storage (inside deployed functions) (sw.fs) Read, write, delete, and manage files in the project's storage. Direct access, no HTTP. Files are PRIVATE by default — pass `visibility: 'public'` on the write (or call the make-public action) to serve a file at its public `/storage` URL. ## What you get out of the box (the industry-standard checklist) - **File-level ACL (visibility).** Every file row carries a `visibility` flag (`public` | `private`); the platform checks it before bytes go out the door. Bucket-level ACLs alone can't give you private-by-row. - **Object versioning + content-addressed writes.** Every write generates a new immutable object with a content-hash suffix and swaps the metadata pointer atomically. A half-written file never becomes visible. - **Safe-delete pattern.** Metadata is marked deleted first; byte cleanup is deferred and reconciled by an integrity-check cron. Files never vanish before the metadata says they did. - **Versions + retention** by tier (3 / 10 / 50 / unlimited prior versions kept). - **Signed URLs with TTL** via `sw.fs.signedUrl(path, { expiresIn })`. - **SSRF protection on every platform-side fetch** (render, scrape, webhooks, AI ingest) — private and metadata IPs blocked before the request leaves. - **Full-text + path-glob search** via `fs_search`. Full reviewer-facing depth: . ## sw.fs.write(path, content, options?) const result = await sw.fs.write('/uploads/avatar.png', binaryData, { content_type: 'image/png' }) // result = { ok: true, data: { path: '/uploads/avatar.png', // size_bytes: 12400, content_type: 'image/png', version: 1 } } // Read fields off result.data — e.g. result.data.path, result.data.size_bytes // (size_bytes, NOT size). result.path is undefined. ## sw.fs.read(path, options?) const file = await sw.fs.read('/uploads/avatar.png') // file = Response object — file.arrayBuffer() / file.text() / file.json() // Directory listing const files = await sw.fs.read('/uploads/') // Read a line range from a text file (returns parsed JSON) const slice = await sw.fs.read('/src/index.ts', { lines: [50, 75] }) // slice = { content: '...', lines: [50, 75], total_lines: 420 } ## sw.fs.list(path, options?) // Directory listing. Pass { recursive: true } for the full subtree, // or { recursive: true, depth: 2 } to cap the walk. const all = await sw.fs.list('/src/', { recursive: true }) // all = { path: '/src/', type: 'directory', entries: [...] } ## sw.fs.glob(pattern, options?) // Match file paths against a glob. Metadata only — no content reads. // Supported: *, **, ?, {a,b,c}. const tsFiles = await sw.fs.glob('/src/api/**/*.ts') // tsFiles = { // pattern: '/src/api/**/*.ts', // matches: [{ path, size_bytes, content_type, version, updated_at }, ...] // } ## sw.fs.diff(path, options?) // Unified diff between the current file and an archived version. // Defaults to the most recent previous version. const d = await sw.fs.diff('/src/config.ts') // d = { path, from_version: 3, to_version: 4, changed_lines: 5, diff: '@@ ...' } // // Or diff against a specific version: await sw.fs.diff('/src/config.ts', { version: 2 }) ## sw.fs.delete(path) await sw.fs.delete('/uploads/old-file.txt') // Directory deletes are recursive ## sw.fs.move(from, to, opts?) await sw.fs.move('/uploads/temp/photo.jpg', '/uploads/users/alice/photo.jpg') // Instant regardless of file size. // // To replace an existing destination, pass { overwrite: true } — // this is an atomic-enough swap (destination row dropped before the // source rename, blob cleanup deferred to waitUntil). Use it instead of // the delete-then-move pattern, which can race and leave metadata // pointing at bytes that no longer exist: // // // ❌ Don't do this — racy across concurrent invocations: // await sw.fs.delete('/avatars/alice.jpg').catch(() => {}) // await sw.fs.move('/avatars/alice.upload', '/avatars/alice.jpg') // // // ✅ Do this — single atomic operation: // await sw.fs.move( // '/avatars/alice.upload', // '/avatars/alice.jpg', // { overwrite: true } // ) // // Without overwrite, a destination collision returns // VALIDATION_ERROR ("Destination ... already exists"). ## sw.fs.copy(from, to) await sw.fs.copy('/templates/welcome.html', '/users/alice/welcome.html') // Server-side copy. Instant — no bytes flow through your function. ## sw.fs.versions(path) const versions = await sw.fs.versions('/src/config.ts') // Each write creates a new version. Returns most-recent first: // [{ version: 7, size, content_type, created_at }, ...] ## sw.fs.restore(path, version) await sw.fs.restore('/src/config.ts', 5) // Restores a previous version (from sw.fs.versions) as the latest. // Original versions stay in history. ## sw.fs.stat(path) const info = await sw.fs.stat('/uploads/avatar.png') // { path, type: 'file', size, content_type, version, created_at, updated_at } ## sw.fs.search({ path?, query, limit?, max_files? }) // Full-text search across text files under a directory. A per-project // search index is maintained automatically on every fs write/delete/ // move/replace, so searches stay fast as the project grows. const hits = await sw.fs.search({ path: '/src/', query: 'TODO' }) // hits = { // query: 'TODO', path: '/src/', mode: 'fts5', total_matches: 7, // results: [ // { path: '/src/api/signup.ts', snippet: '// [TODO]: rate limit' }, // ... // ] // } // mode is 'fts5' once the index is built (typical), or 'scan' on the // first-ever search (fallback walks storage line-by-line and returns // { path, line, snippet, before, after }; the index backfills in the // background, so the next query is fast). // Defaults: path '/', limit 50, max_files 500. Binaries and files // over 1 MB are skipped by the indexer (large files truncated to // first 1 MB). Case-sensitive. ## sw.fs.replace({ path, find, replace }) // Find-and-replace on a single file, server-side — no read-modify-write // cycle. Archives the current version before writing (rollback via fs.restore). const result = await sw.fs.replace({ path: '/src/config.ts', find: "API_URL = 'https://staging.example.com'", replace: "API_URL = 'https://api.example.com'", }) // result = { ok: true, replacements: 1, path: '/src/config.ts', version: 4 } // Literal match (not regex). Text files only. ## sw.fs.uploadFromRequest(req, { path, maxBytes?, allowedTypes?, fieldName? }) // One-call upload handler for browser forms. Parses // multipart/form-data from the request, validates size + type, writes to // storage, returns the public URL the browser can render right back. // // The whole point: the developer chooses the path, the helper handles // every failure mode. Three lines instead of fifteen, and you never // JSON-stringify raw bytes (which is what corrupts images on project_patch // — use binary_files for asset deploys, sw.fs.uploadFromRequest for // runtime uploads). // // In api/upload.ts: export default async function handler(req) { const user = await sw.auth.fromRequest(req) if (!user) return new Response('unauthorized', { status: 401 }) const { url, path, size, contentType } = await sw.fs.uploadFromRequest(req, { path: `/uploads/${user.id}/${crypto.randomUUID()}.bin`, maxBytes: 5 * 1024 * 1024, // optional, 5 MB cap allowedTypes: ['image/png', 'image/jpeg'], // optional whitelist fieldName: 'file', // optional, default 'file' }) return Response.json({ url, path, size, contentType }) } // On the browser: const fd = new FormData(); fd.append('file', input.files[0]) // const r = await fetch('/api/upload', { method: 'POST', body: fd }) // const { url } = await r.json(); img.src = url // // Throws on every failure with a stable code in the message: // UPLOAD_PATH_REQUIRED, UPLOAD_NOT_MULTIPART, UPLOAD_PARSE_FAILED, // UPLOAD_FIELD_MISSING, UPLOAD_TYPE_NOT_ALLOWED, UPLOAD_FILE_EMPTY, // UPLOAD_TOO_LARGE // Wrap in try/catch and map to the HTTP status you want. // // For files larger than ~25 MB, mint a signed URL with sw.fs.uploadUrl // instead and have the browser PUT direct — keeps the bytes off your // function CPU. ## sw.fs.uploadUrl({ path, maxSize?, contentType?, expiresIn? }) // Mints a short-lived signed URL the browser can PUT bytes to directly // — without ever seeing your platform key. Use this for big uploads // (videos, large images, archives) so the bytes don't round-trip through // your function. const { url } = await sw.fs.uploadUrl({ path: '/uploads/users/alice/photo.jpg', maxSize: 10 * 1024 * 1024, // optional, defaults to tier max contentType: 'image/jpeg', // optional, '*' if omitted expiresIn: 300, // optional, seconds (default 300, max 3600) }) // Browser side: // await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'image/jpeg' }, body: file }) // Returns { path, size_bytes, content_type, version }. ## sw.fs.signedUrl(path, { expiresIn? }) // Mints a short-lived URL anyone can GET to download the file — no // platform key, no app-user JWT. Use for email attachments, image // previews, share links, "download once" handoffs. Default 1h, max 7d. const { url, expires_at } = await sw.fs.signedUrl('/uploads/invoice-42.pdf', { expiresIn: 3600, // optional, seconds (60..604800) }) // → url: 'https://api.somewhere.tech/v1/fs-signed/' // → expires_at: ISO 8601 string // Send the url in an email body, embed it in , etc. // The URL stops working at expires_at; rotating JWT_SECRET invalidates // every outstanding signed URL (bulk revocation lever). ## sw.fs.public_url(path, { makePublic? }) // Returns a file's permanent, unauthenticated public URL. Asking for the // URL does NOT silently expose a private file: // - already-public file → returns the URL, visibility unchanged. // - private file + makePublic → publishes it, returns the URL. // - private file, no opt-in → throws FILE_PRIVATE (file stays private). // For a time-limited link to a private file WITHOUT making it world-readable, // use sw.fs.signedUrl(path) instead. const { public_url } = await sw.fs.public_url('/uploads/avatar.png', { makePublic: true, // required to publish a private file }) // → public_url: 'https://myapp.somewhere.tech/storage/uploads/avatar.png' ## Public URL Files written with `visibility: 'public'` are served (no auth) at: https://{subdomain}.somewhere.tech/storage/{path} Private files (the default) return 404 here — they're reachable only from your authenticated code (sw.fs.read) or a signed URL. const url = 'https://myapp.somewhere.tech/storage/uploads/avatar.png' // Use this URL in HTML: ## Integrity check (developer key only) // POST /v1/fs/:project_id/integrity-check { auto_clean?: bool, limit?: int, cursor?: string } // Scans file metadata for rows pointing at missing blobs — the "ghost // file" failure mode (fs.stat says exists, fs.read 404s). Returns the // list of orphans; pass auto_clean:true to delete the stale rows. // // fs.read already self-heals one row at a time on 404. This is the // proactive sweep for post-incident cleanup or mass-delete recovery. // Paginate via { cursor: } for large projects (default // 1000 rows / call, max 5000). --- ## sw.email — Send Email (inside deployed functions) (sw.email) Requires Builder plan. Requires verified sender domain. await sw.email.send({ to: 'alice@example.com', from: 'notifications@yourapp.com', subject: 'Your order shipped', html: '

Tracking: ABC123

' }) The from domain must match a verified sender domain on this project. Cannot send from somewhere.tech or somewhere.site domains. Limit: the Free tier has a daily + monthly send cap; paid tiers are metered monthly. Live per-tier caps at /v1/pricing. For auth emails (password reset, verification, welcome), the platform sends them automatically — you don't call sw.email for those. Customize templates in Settings → Email Templates. ## Delivery health (scrub bounces before sending) The email provider posts bounce + complaint webhooks; the platform records every event on the project automatically. Two MCP tools expose them: - email_events_list({ project_id, limit?, offset? }) — recent sends with their latest event (sent | delivered | opened | clicked | bounced | complained | delivery_delayed). Build an audit view or spot a bounce spike after a blast. - email_bounces_list({ project_id, days?, limit? }) — deduplicated recipients that bounced or complained inside the window (default 30 days, max 365). One row per address with last_status, last_at, occurrences. From INSIDE a function, scrub before you send (no MCP round-trip): - `sw.email.checkSuppression(address)` → `{ address, suppressed, status, last_at, occurrences }` — is this ONE recipient dead (bounced or complained)? Check it before `sw.email.send`. - `sw.email.bounces({ days?, limit? })` → the dead-address list, to filter a mailing batch in one call. For a single message's full timeline use email_status({ id }). **Before every send loop, scrub against email_bounces_list.** Mailing a dead address repeatedly is the single fastest way to land your verified domain in a spam folder. Pattern: const bounced = new Set( (await ctx.email.bouncesList({ days: 60 })).map(r => r.address) ) for (const u of users) { if (bounced.has(u.email)) continue await ctx.email.send({ to: u.email, ... }) } --- ## Inbox — Inbound Email (inbox) Inbound email is domain-bound. Create addresses on a custom domain that has been added to the project and has inbound email routing enabled (enable it with `domain_enable_email`). This prevents shared-platform-address confusion and stops users from claiming mailboxes on domains they don't own. ## inbox_create_address({ project_id, address, label?, kind?, webhook_url?, forward_to? }) Create an inbox address, for example support@example.com. address must be on a verified project custom domain with inbound email enabled. kind defaults to "admin" — the address is shown in the project's Email tab in the dashboard. Pass kind:"app" when minting per-user mailboxes at runtime from your deployed app, so they don't flood the admin view. sw.inbox.createAddress() from a deployed function defaults to "app" for the same reason. If webhook_url is provided, it must be https://. The create response returns webhook_secret once; store it and verify X-Somewhere-Signature on incoming webhooks. forward_to wires forwarding in the same call — see inbox_forward_set. ## inbox_forward_set({ address_id, forward_to }) Forward a copy of every inbound message to an external mailbox — the inbox the user already reads (you@gmail.com). The project inbox ALWAYS keeps its copy; forwarding is additive, never a redirect. Returns status "active" (live now) or "pending_verification" — the destination mailbox just received a one-time confirmation email. Surface the returned message to the user ("check your inbox and click the confirmation link"); forwarding starts automatically once clicked, no further call needed. Poll inbox_forward_set again or list inbox_addresses (rows carry forward_to + forward_status) to see the status flip. Spam-suspect messages are stored but never forwarded. Idempotent — call again to change the destination. ## inbox_forward_remove({ address_id }) Stop forwarding. Inbound mail keeps landing in the project inbox. ## inbox_addresses({ project_id, kind? }) List inbox addresses on the project. Defaults to kind="admin" (dashboard-managed). Pass kind="app" to enumerate runtime-minted ones, kind="all" to see both. webhook_secret is never echoed back after creation. Rows include forward_to + forward_status ("active" | "pending_verification" | null) when forwarding is configured. ## inbox_delete_address({ id }) Remove the address. New mail to it bounces. Existing messages stay queryable. ## inbox_list({ project_id, address_id?, unread?, q?, limit? }) Most recent messages first. limit defaults to 50, max 200. Pass address_id to scope to one inbox, unread:true to skip messages already marked read, or q to free-text search across subject, body preview, and sender (tokens match as prefixes — "ord" matches "order"). [ { id: "msg_abc", mail_from: "alice@example.com", mail_to: "support@example.com", subject: "Re: order #123", text_preview: "Just confirming this shipped...", has_html: true, attachment_count: 2, read_at: null, received_at: "2026-04-25T...", size_bytes: 4218 } ] ## inbox_get({ id, include_html? }) Single message + raw MIME URL. Pass include_html:true to include the stored html_preview field (capped at ~50KB). Attachment metadata is always returned when present — each entry includes filename, content_type, and size_bytes. Use inbox_attachment to download. ## inbox_mark_read({ id, read? }) Toggle the read flag. read defaults to true. Idempotent. Use to drive "unread badge" UIs and to gate webhook-fanout retries. ## inbox_attachment({ id, index }) Stream one attachment by zero-based index from inbox_get.attachments. Returns binary bytes with original content-type and filename. ## inbox_delete({ id }) Delete the message, raw MIME object, and stored attachment objects. Irreversible. ## inbox_reply({ id, body? | text? | html?, subject? }) Send a threaded reply to an inbound message. Sends from the same address that received the message and sets In-Reply-To / References so Gmail / Outlook / Apple Mail render it inline with the original thread. Subject is auto-prefixed with "Re:" if it isn't already. Requires the receiving domain to be verified as a sender domain on the project (otherwise replies would be unsigned and bounce). Same sender-domain verification as /v1/email/send. await sw.inbox.reply(messageId, { body: 'Thanks, refunding now.' }) The killer use case: an agent that watches the inbox, drafts replies via sw.ai.complete, and ships them with one call. ## inbox_send({ address_id, to, subject, body? | text? | html? }) Start a NEW conversation from one of your inbox addresses (e.g. hello@yourdomain.com). Same sender-domain check as inbox_reply. The platform issues a Message-ID with the outbound so the recipient's reply lands back in the same thread automatically — viewable via inbox_threads / sw.inbox.threads(). await sw.inbox.send(addressId, { to: 'newcustomer@example.com', subject: 'Welcome to our beta', body: 'Thanks for signing up — here\\'s your login.' }) Use inbox_reply for replies and inbox_send for new threads. Both log to the same conversation view. ## inbox_threads({ project_id, address_id?, include_spam?, limit? }) List conversation threads — one entry per unique thread_root, with counts and last-message metadata aggregated across inbound + outbound rows. Default-hides threads whose latest inbound is spam_suspect. [ { thread_root: "", last_at: "2026-05-11T14:22:18Z", first_at: "2026-05-09T09:11:02Z", message_count: 4, unread_count: 1, last_subject: "Re: refund #1421", last_counterparty: "alice@example.com", last_direction: "in" } ] ## inbox_thread_get({ project_id, root }) Full conversation by thread_root — all inbound + outbound messages sorted by time. Use this to render the conversation view in an admin UI or to feed prior context into sw.ai.complete before drafting a reply. const { messages } = await sw.inbox.thread(threadRoot) for (const m of messages) { if (m.direction === 'in') console.log('←', m.mail_from, m.text_preview) else console.log('→', m.to, m.subject) } ## Spam handling Every inbound message is checked against the Authentication-Results header (SPF/DKIM/DMARC verdicts from the receiving relay) and against your project's allow/deny rules. The result is stored on the message as spf_result / dkim_result / dmarc_result / spam_suspect. Default flag logic: - Allow rule match → never spam_suspect (allow wins) - Deny rule match → spam_suspect = true - DMARC fail → spam_suspect = true - SPF fail AND DKIM fail → spam_suspect = true - Otherwise → spam_suspect = false inbox_list and inbox_threads hide spam_suspect by default. Pass include_spam:true to include them (e.g. a "Show spam" view in an admin UI). The flag is also visible on inbox_get so you can render a "Failed authentication" badge. ## inbox_rule_list({ project_id, address_id? }) List allow/deny rules. Returns project-wide rules + rules scoped to the address_id (when provided). ## inbox_rule_create({ project_id, address_id?, pattern, action }) Create an allow or deny rule. pattern: - "alice@example.com" → exact mailbox - "example.com" → domain (and subdomains) action: "allow" or "deny". Omit address_id to apply project-wide. // Whitelist a sender whose DMARC keeps failing await sw.inbox.rules.create({ pattern: 'newsletter@partner.com', action: 'allow' }) // Blacklist a domain await sw.inbox.rules.create({ pattern: 'evil.example', action: 'deny' }) ## inbox_rule_delete({ id }) Remove a rule. Future inbound mail re-evaluates without it. ## From inside a deployed function sw.inbox mirrors the REST surface — all calls scoped to this project: await sw.inbox.list({ unread: true, limit: 25 }) await sw.inbox.list({ q: 'refund' }) // search subject/body/sender await sw.inbox.list({ include_spam: true }) // include flagged messages await sw.inbox.get(id, { include_html: true }) await sw.inbox.reply(id, { body: 'Thanks.' }) // threaded reply await sw.inbox.send(addressId, { // new thread to: 'alice@x.com', subject: 'Hi', body: '...' }) await sw.inbox.threads({ limit: 25 }) // grouped conversations await sw.inbox.thread(rootId) // single conversation await sw.inbox.markRead(id) // or sw.inbox.markRead(id, false) const res = await sw.inbox.attachment(id, 0) // Response — stream/download const raw = await sw.inbox.raw(id) // Response — RFC-822 source await sw.inbox.delete(id) // remove message + attachments await sw.inbox.createAddress({ address, webhook_url }) await sw.inbox.deleteAddress(addressId) await sw.inbox.rules.list() await sw.inbox.rules.create({ pattern: 'evil.com', action: 'deny' }) await sw.inbox.rules.delete(ruleId) ## Common pattern: webhook-first support inbox // 1. Create once during setup await inbox_create_address({ project_id, address: 'support@yourdomain.com', label: 'Support', webhook_url: 'https://yourdomain.com/api/inbox-webhook' }) // 2. In your webhook handler, verify X-Somewhere-Signature using the // webhook_secret returned at creation, then persist/route the event. // The platform retries failed/stale webhook deliveries from cron. // 3. Poll as a fallback/admin view const { messages } = await inbox_list({ project_id, unread: true }) for (const m of messages) { await processSupportTicket(m) await inbox_mark_read({ id: m.id }) } ## Limits | Tier | Addresses | Stored msgs | Retention | |---------|-----------|-------------|-----------| | Free | 1/project | 100/project | 30 days | | Builder | 10/project| 10,000 | 90 days | Past the message cap, FIFO eviction happens at insert time. A daily retention sweep deletes anything older than the window. Both the cap and retention apply per-project, not per-address. ## What NOT to do - Don't create inbox addresses on platform domains like somewhere.tech or somewhere.site — use a custom domain owned by the project. - Don't skip webhook signature verification for sensitive workflows. - Don't delete messages until you've fully processed them; deletion is irreversible. - Don't expect retention beyond the tier window — schedule processing promptly or copy the raw .eml to your own storage. --- ## sw.notifications — unified notify primitive (sw.notifications) One call → fan-out across push, in-app bell, and (when an address is provided) email. Safe for LLM tool-calling — the orchestration lives on the platform, the dev just says "tell user X this thing." ## sw.notifications.send(userId, { title, body?, url?, email?, channels? }) await sw.notifications.send(user.id, { title: 'Northern line: minor delays', body: 'Reported 14:02 GMT. Estimated 10–15 min impact southbound.', url: '/lines/northern', channels: ['bell', 'push'], // optional; default ['bell', 'push'] }) Returns { bell?, push?, email? } with per-channel result. Each entry is { ok: true, ... } or { ok: false, error: '...', code? } so the caller can branch without a try/catch per channel. Channels: - bell — writes a row to _notifications in the project's own database. Auto-creates the table on first use. Render with sw.notifications.list(userId) / unreadCount(userId). Hidden from db_describe / db_browse like every _-prefixed table. - push — proxies to sw.push.send against every active subscription for that userId. Silent no-op if the user hasn't subscribed. - email — only fires when opts.email is set explicitly. We don't auto-look up the address (your user-table shape is yours). Pair with sw.auth.fromRequest(req, { enrichFrom: 'members' }) to pull the email + send in two lines. ## Bell helpers const { notifications } = await sw.notifications.list(user.id, { limit: 50, // default 50, max 200 unread_only: false, // default false }) // notifications[i] = { id, title, body, url, read, created_at } const n = await sw.notifications.unreadCount(user.id) await sw.notifications.markRead(notifId) await sw.notifications.markAllRead(user.id) ## What this is NOT (yet) - No platform-level dedup, rate-limit, or per-user channel prefs. Roll those in your handler if you need them — this is the primitive. - No retries — push / email failures surface immediately. Re-call after fixing the cause. --- ## sw.push — Web Push notifications (sw.push) Available inside any deployed function via the sw argument (ctx still works as an alias). Web Push lets you send a notification to a user's browser even when your tab is closed. The browser registers a push subscription with its push service (FCM for Chrome, Mozilla autopush for Firefox, Apple for Safari), hands you back an opaque endpoint + crypto keys, and you POST encrypted payloads to that endpoint signed with a VAPID JWT. Per-project VAPID keypair: generated lazily on first call to sw.push.vapidPublicKey() or sw.push.send(). The same public key is what the browser must pass to PushManager.subscribe() — fetch it once in your service worker registration code and reuse it. ## sw.push.vapidPublicKey() Returns { vapid_public_key }. base64url uncompressed P-256 (87 chars). Hand this to the browser; the browser hands it to PushManager.subscribe. const { vapid_public_key } = await sw.push.vapidPublicKey() ## sw.push.subscribe(subscription, userId?) Stores a push subscription returned by PushManager.subscribe in the browser. Pass it through verbatim. user_id is optional — pass the app-user id if you want to target this person later. await sw.push.subscribe(subscription, user.id) // subscription = { endpoint, keys: { p256dh, auth } } // returns { id } Re-subscribing the same browser (same endpoint) refreshes the row, doesn't duplicate it. ## sw.push.unsubscribe(endpoint) Idempotent. Pass the endpoint string from subscription.endpoint. await sw.push.unsubscribe(subscription.endpoint) ## sw.push.send(payload, options?) Encrypts and POSTs the payload to one or many subscriptions. Returns { sent, failed, gone, recipients }. Options: user_id? — send to every subscription for one app-user endpoint? — send to one specific endpoint (idempotent retry) ttl? — push service TTL in seconds, default 86400 contact? — VAPID 'sub' claim (mailto: URL), default platform contact If neither user_id nor endpoint is given, broadcasts to every subscription in the project. Use sparingly. Subscriptions returning 404/410 are auto-deleted (browser revoked). // Send to one user await sw.push.send({ title: 'New message', body: 'You have mail.' }, { user_id: user.id }) // Send to a specific endpoint await sw.push.send({ url: '/orders/42' }, { endpoint: subscription.endpoint }) // Broadcast to whole project await sw.push.send({ title: 'Site update', body: 'New version live.' }) ## Browser-side flow 1. Get the public key from your function: const { vapid_public_key } = await fetch('/api/push-key').then(r => r.json()) 2. Register a service worker, then subscribe: const reg = await navigator.serviceWorker.register('/sw.js') const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: vapid_public_key, // base64url string }) 3. POST the subscription back to your function: await fetch('/api/push-subscribe', { method: 'POST', body: JSON.stringify(sub) }) 4. Inside your function call sw.push.subscribe(sub, user.id). 5. In your service worker, listen for 'push': self.addEventListener('push', (e) => { const data = e.data?.json() || {} e.waitUntil(self.registration.showNotification(data.title, { body: data.body })) }) ## Limits Payload max 3000 bytes encrypted. Larger payloads throw. If you need to send more, store it server-side and push a tiny payload with the URL or id to fetch. --- ## sw.ai — AI Models (inside deployed functions) (sw.ai) ## Default model recommendation **For demos and production: `claude-sonnet-4-6` (the default).** It's fast enough and the response quality is what makes a demo land. **For development / testing / scripts: a free model** (`@cf/meta/llama-4-scout-17b-16e-instruct`, on `provider: 'workers-ai'`). No activation needed, included on every plan, fine for smoke tests where quality doesn't matter. When you're showing the app to a customer, use Sonnet. When you're iterating locally or testing a flow, use the free model. ## sw.ai.chat(options) // sw.ai.complete(options) is an alias — same args, same return. // Default pick — paid, activation required (one click, one time). const result = await sw.ai.chat({ provider: 'anthropic', model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: 'Write a haiku' }] }) // result.content is the normalized content-block array (text + tool_use // blocks). result.text is a flattened text convenience. // Free model — for development, smoke tests, scripts. Quality is // noticeably below Sonnet; reach for this only when cost matters // more than response quality. const dev = await sw.ai.chat({ provider: 'workers-ai', model: '@cf/meta/llama-4-scout-17b-16e-instruct', messages: [{ role: 'user', content: 'Summarize this: ...' }], max_tokens: 1024 }) // dev = { content: '...', tokens: { input: 45, output: 200 }, cost: { total_cents: 0 } } // free model → no charge // (Avoid kimi-k2.6 / glm-4.7 / gemma-4 / qwen3-30b at low max_tokens — they // burn 2k+ tokens on chain-of-thought before producing output. See "Free // models" section below.) // Paid Sonnet again, for clarity: const sonnet = await sw.ai.chat({ provider: 'anthropic', model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: 'Write a haiku' }] }) // result.content is the normalized content-block array (text + tool_use // blocks). result.text is a flattened text convenience. // result.stop_reason is 'end_turn' | 'tool_use' | 'max_tokens' | ... // Paid premium model — same activation gate, billed per token at the rate // shown in /v1/pricing. // Models: grok-4 (top quality), grok-4-fast (cheap+fast), // grok-3-mini, grok-code-fast-1. const grokResult = await sw.ai.chat({ provider: 'xai', model: 'grok-4-fast', messages: [{ role: 'user', content: 'Explain quicksort in two sentences.' }], max_tokens: 256 }) // grokResult.text is the reply; grokResult.content is reshaped into the // same normalized content-block array shape so client code is uniform. // Paid model — same activation gate, billed per token at the rate shown // in /v1/pricing. Call ai_catalog for the live model list and categories. const gptResult = await sw.ai.chat({ provider: 'openai', model: 'gpt-5.4-mini', messages: [{ role: 'user', content: 'Explain quicksort in two sentences.' }], max_tokens: 256 }) // gptResult.text is the reply; gptResult.content is reshaped into the // same normalized content-block array shape so client code is uniform. // conversation_id works on every provider — pass it and the platform // replays prior turns from the per-project database. stream is the only // field still gated to anthropic. ## Flex service tier (~50% discount) The flex service tier serves your request from spare capacity. Trade-offs: • Cost: ~half the standard per-token rate (see /v1/pricing). • Latency: noticeably slower than standard. • Reliability: may return 429 resource_unavailable when capacity is tight. Good fit: cron jobs, queue workers, batch enrichment, summarization, nightly reports — anything where a 2–5s delay or a retry is fine. Bad fit: live chat UX, anything a human is waiting on. Opt in by passing service_tier: 'flex'. Default is 'standard'. The field is only valid on provider: 'openai' — sending it on anthropic / xai / workers-ai returns a VALIDATION_ERROR. const flexResult = await sw.ai.chat({ provider: 'openai', model: 'gpt-5.4-mini', service_tier: 'flex', messages: [{ role: 'user', content: 'Summarize this article…' }], max_tokens: 512 }) // flexResult.service_tier echoes 'flex' so you can confirm which rate // billed; flexResult.cost reflects the discounted price. On 429 from flex, the response carries an explicit hint to retry or fall back to service_tier:'standard': try { return await sw.ai.chat({ provider:'openai', model:'gpt-5.4-mini', service_tier:'flex', messages }) } catch (err) { // err.code === 'UPSTREAM_ERROR', status 429 — capacity unavailable. return await sw.ai.chat({ provider:'openai', model:'gpt-5.4-mini', messages }) } Per-model token rates are not hardcoded here — call `ai_catalog` (or `GET /v1/pricing`) for the live per-model input/output rates and which models your tier can use. Flex calls run at roughly half the standard rate. Usage rows tag flex calls as " (flex)" so the cost split is visible in ai_usage / ai_list output. // Tool use (anthropic AND openai) // Pass tool definitions in the standard tool-use shape via `tools`; the platform // translates them to the provider's native format. tool_use blocks come // back in result.content the same way for either provider; stop_reason // is normalized to 'tool_use'. Drive multi-turn loops by appending the // assistant message and a user message with tool_result blocks. const r = await sw.ai.chat({ provider: 'anthropic', // or 'openai' — same tool definition shape model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: 'Find files containing TODO.' }], tools: [{ name: 'fs_search', description: 'Search files for a literal substring.', input_schema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] }, }], }) // If r.stop_reason === 'tool_use', iterate r.content for tool_use blocks. // xai and workers-ai do not yet support tool use through this path. // Streaming const stream = await sw.ai.chat({ provider: 'workers-ai', model: '@cf/meta/llama-4-scout-17b-16e-instruct', messages: [...], stream: true }) return new Response(stream, { headers: { 'Content-Type': 'text/event-stream' } }) ## Conversation history (all providers) Pass conversation_id and the platform stores+replays the chat for you. Prior turns are loaded from this project's database, prepended to your messages, and sent to the model. After the response, your new user message(s) and the assistant reply are saved under the same id. Next call: send only the new user turn. Works on every provider — anthropic, openai, xai, workers-ai. You can even mix providers under the same conversation_id; whichever model you call next sees the full history. (Summarization, when compaction: 'summarize' triggers it, always runs on a fast low-cost summary model — billed normally.) The only field still pinned to anthropic is stream. // First call — pick any id (uuid or your own scheme), up to 128 chars await sw.ai.chat({ provider: 'anthropic', model: 'claude-sonnet-4-6', conversation_id: 'c_abc123', messages: [{ role: 'user', content: "What's the capital of France?" }] }) // → { content: [...], text: 'Paris.', conversation_id: 'c_abc123', ... } // Second call — server loads the prior turn, you only send the new one await sw.ai.chat({ provider: 'anthropic', model: 'claude-sonnet-4-6', conversation_id: 'c_abc123', messages: [{ role: 'user', content: 'And of Spain?' }] }) If the combined input would exceed the model's 200K context window, the oldest history messages are dropped automatically — the response carries conversation_truncated: true. If the conversation_id doesn't exist, it's created on first save. ## History caps (per call) Trim what's loaded with two optional fields. Oldest user/assistant messages drop first; the new turn in messages and your system prompt are never dropped. The smaller cap wins. await sw.ai.chat({ provider: 'anthropic', model: 'claude-sonnet-4-6', conversation_id: 'c_abc', history_max_messages: 20, // default 50 history_max_tokens: 4000, // default 8000 (char/4 estimate) messages: [{ role: 'user', content: 'next question' }] }) Use these to keep a long-running chat cheap and predictable. The model's 200K window still applies on top. ## Compaction (preserve context across overflow) By default, messages that overflow the caps above are dropped — the context is gone. Pass compaction: 'summarize' to fold them into a rolling summary instead. Haiku produces the summary; the platform prepends it to your system prompt on every subsequent call so the model still "knows" what was discussed. await sw.ai.chat({ provider: 'anthropic', model: 'claude-sonnet-4-6', conversation_id: 'c_abc', history_max_messages: 10, compaction: 'summarize', // default: 'truncate' messages: [{ role: 'user', content: 'continue' }] }) The response carries conversation_summarized: true on calls where summarization actually ran. Cost: one extra Haiku request per overflow event (~1¢, billed normally). The summarizer falls back to truncate if the upstream call fails — your call still returns a result. Inspect the rolling summary via ai_conversation_list (pass a conversation_id); it shows in the conversation row's summary field. Manage conversations from outside functions via the MCP tools ai_conversation_list (pass a conversation_id to fetch one) / ai_conversation_delete, or the matching REST endpoints (GET /v1/ai/conversations, GET /v1/ai/conversations/:id, DELETE /v1/ai/conversations/:id — all take ?project_id=). Pass include_summarized=1 on the get endpoint to also see messages already folded into the summary. Not supported with stream: true or with provider: 'workers-ai' / 'xai' / 'openai' yet. ## System prompt — pass either way You can supply the system prompt as a top-level field OR as an inline `role: 'system'` message; the platform normalizes both shapes before dispatch, so the same code works on every provider. // Top-level `system` field await sw.ai.chat({ provider: 'anthropic', system: 'Be terse.', messages: [...] }) // Inline `role: 'system'` message await sw.ai.chat({ provider: 'anthropic', messages: [ { role: 'system', content: 'Be terse.' }, { role: 'user', content: 'Hi' } ] }) Both produce identical behavior — no need to branch on provider. ## Multi-chat — letting end users keep a list of conversations For chat apps that want a Claude.ai-style sidebar of prior threads, list / fetch / delete via `sw.ai.conversations.*`: // Inside a deployed function, scoped to the signed-in user: const user = await sw.auth.requireUser(req); const scoped = sw.ai.scoped(user.id); // also: sw.ai.forUser(user.id) // Sidebar payload — most recent first. const { conversations } = await scoped.conversations.list({ limit: 50 }); // conversations[i] = { id, client_conversation_id, subject_type, // subject_id, created_at, updated_at, // summary_updated_at, message_count, last_message_preview } // Full transcript for one selected thread. const full = await scoped.conversations.get('weather-chat-7'); // full.messages = [{ role, content, created_at }, ...] // \"Delete this chat\" button. await scoped.conversations.delete('weather-chat-7'); // \"New chat\" is just a fresh conversation_id passed to .chat(): await scoped.chat({ provider: 'anthropic', messages: [{ role: 'user', content: text }], conversation_id: 'weather-chat-' + Date.now(), }); // \"Regenerate from this point\" — fork the conversation under a // new id (optionally truncated to message N) so the original stays // intact and the dev can let the user try a different prompt. await scoped.conversations.fork('weather-chat-7', 'weather-chat-7-alt', { upToMessageId: 42, // optional; omit to copy full history }); Pattern: store the active `conversation_id` in app state (URL query param, useState, etc), pass it on every `.chat()` call, render the list from `.conversations.list()`. Apps that only need a single chat (RailTime-style) skip the list and pick one stable id. ## Error codes Every `sw.ai.*` failure rejects with an `Error` whose `.code` and `.status` fields are stable — branch on those instead of regex-matching `.message`. The contract: CONV_HISTORY_UNAVAILABLE 503 conversation persistence load failed. retry without conversation_id to start a fresh thread, or pass a different id. (.retryable: true) SCHEMA_DRIFT 503 conversation table is missing columns the platform expects. Same recovery as CONV_HISTORY_UNAVAILABLE — our team alerted, no caller action. PAID_API_NOT_ACTIVATED 402 caller needs to enable AI completions in the dashboard. .data.activation_url carries the link. INSUFFICIENT_BALANCE 402 monthly cap or pre-paid balance hit. .data has available_dollars + required_dollars + load_url. AI_SPEND_CAP_EXCEEDED 402 thrown only by chatWithTools when the running loop crosses opts.maxSpendCents. .metrics has the partial accounting. AI_MAX_ITERATIONS 422 thrown only by chatWithTools. Loop hit maxIterations without a terminal answer. .metrics.last_response is the final intermediate state. AI_REQUIRED 401 thrown by sw.auth.requireUser when no valid session is on the request. RATE_LIMITED 429 free-tier rate envelope hit or per-IP anonymous-create cap. (.retryable: true) UPSTREAM_ERROR 502/503 the upstream provider rejected. .message is the platform-authored friendly string; raw provider details are server-side only. VALIDATION_ERROR 400 inputs failed shape/range check. PROJECT_NOT_FOUND 404 unknown project or no access. NOT_FOUND 404 resource (conversation, etc) missing. CONFLICT 409 destination id already exists (e.g. conversations.fork to an id that's taken). INTERNAL_ERROR 500 unexpected platform failure — message stays generic, server logs have detail. Database-layer errors that surface through sw.db.* keep their own set documented in platform_help({ topic: 'sw.db' }): SCHEMA_ERROR · SYNTAX_ERROR · TABLE_NOT_FOUND · CONSTRAINT_VIOLATION · WRITE_CONFLICT · DB_BUSY · QUERY_TIMEOUT. Anything marked retryable: true is safe to attempt again after a short backoff. Everything else is a programming/state error — fix the input or wait for the platform team. ## Catalog search tool — `sw.ai.catalogTool` Every chat app with a content catalog (restaurants, products, stations, articles) ends up writing the same SQL-LIKE-over-N-columns tool by hand. `sw.ai.catalogTool` builds the tool definition AND the executor from a small config: const restaurants = sw.ai.catalogTool({ table: 'restaurants', searchColumns: ['name', 'cuisine', 'neighborhood'], resultColumns: ['id', 'name', 'cuisine', 'rating', 'image_url'], urlTemplate: '/restaurant/{id}', // optional; each result gets .url limit: 10, // optional, default 10, capped 50 // optional tool-definition overrides: name: 'search_restaurants', description: 'Search restaurants by name, cuisine, or area.', // optional WHERE constraint (parameterized — never interpolated): where: { sql: 'is_published = ?', params: [1] }, }); // Drop into chatWithTools: const r = await sw.ai.chatWithTools({ model: 'claude-haiku-4-5', messages: [{ role: 'user', content: q }], tools: [restaurants.tool], async executeTools(toolCalls) { return Promise.all(toolCalls.map(async (tc) => { if (tc.name === restaurants.tool.name) { const out = await restaurants.execute(tc.input); return { tool_use_id: tc.id, content: JSON.stringify(out) }; } return { tool_use_id: tc.id, content: 'Unknown tool', is_error: true }; })); }, }); `tool` is a standard tool definition (works on every provider chatWithTools supports). `execute({query, limit?})` runs the SELECT and returns `{ count, query, limit, results: [...] }` with `.url` filled from the template when set. Identifier safety: table / column / tool names are validated against `[a-zA-Z0-9_]+` so the assembled SQL is injection-safe. The user's query string is bound as a parameter — never interpolated. ## Tool-use loop — `sw.ai.chatWithTools` Every chatbot ends up writing the same `while (stop_reason === 'tool_use')` loop with iteration caps, spend caps, and error handling. `chatWithTools` runs that loop for you — give it a tool dispatcher, it returns the final text plus iteration / cost metrics: const r = await sw.ai.chatWithTools({ provider: 'anthropic', model: 'claude-haiku-4-5', conversation_id: 'nibble:' + user.id, messages: [{ role: 'user', content: text }], tools: [ { name: 'lookup', description: '...', input_schema: {...} }, ], async executeTools(toolCalls) { // toolCalls = [{ id, name, input }, ...] return Promise.all(toolCalls.map(async (tc) => { try { const out = await runTool(tc.name, tc.input); return { tool_use_id: tc.id, content: JSON.stringify(out) }; } catch (err) { return { tool_use_id: tc.id, content: String(err), is_error: true }; } })); }, maxIterations: 5, // optional, default 5, max 20 maxSpendCents: 50, // optional, abort if running cost passes cap }); console.log(r.text); // final assistant prose console.log(r.iterations); // 1 = no tool calls; 2+ = tool round-trips console.log(r.tool_calls_made); // total tool_use blocks across all iters console.log(r.total_cost_cents); // sum across all iters (billing-equivalent) Throws on hitting `maxIterations` or `maxSpendCents` — error has `code` (`AI_MAX_ITERATIONS` / `AI_SPEND_CAP_EXCEEDED`) and a `metrics` field with everything spent so far so a logging handler can record the abort. Tool-runner exceptions are NOT fatal — they're fed back to the model as `tool_result` with `is_error: true` so it can recover within the same loop. With `conversation_id` set, the platform's normal history-replay covers each iter's prior turn — pass only the new user message, not the full history. Without `conversation_id`, the helper keeps an in-memory history for the duration of the call. Use `sw.ai.scoped(userId).chatWithTools(...)` to auto-scope the conversation by app-user. ## Per-user memory — `sw.ai.userMemory` Every chat app rolls its own `user_memory` table — Nibble has `nibble_memory`, RailTime would have `railtime_memory`. The platform now provides one: const m = await sw.ai.userMemory.get(user.id); // → {} on first read, or your stored blob await sw.ai.userMemory.update(user.id, { preferred_line: 'Northern' }); // → merges patch into the blob (shallow merge) await sw.ai.userMemory.clear(user.id); Storage lives in the project's own database as `_ai_user_memory` — `_`-prefixed so it's hidden from `db_browse` / `db_describe`. One row per user, free + unlimited. ## Auto-compaction — `sw.ai.userMemory.compact` After N conversation turns, fold the recent history into the structured blob via a single cheap-model call. Pass a JSON Schema and the platform uses `response_schema` to extract the structured output, then merges the result into the blob: await sw.ai.userMemory.compact(user.id, { type: 'object', properties: { preferred_line: { type: 'string' }, commute_time: { type: 'string' }, last_seen_disruptions: { type: 'array', items: { type: 'string' } }, }, }, { conversation_id: 'railtime:' + user.id, // pulls last N turns windowMessages: 20, // optional, default 20 model: 'claude-haiku-4-5', // optional, default Haiku maxTokens: 1024, // optional, default 1024 }); The compaction prompt carries forward existing memory unless the transcript contradicts it — so the call is safe to repeat. One `ai.chat` call's worth of cost per compact; run it on a cron or after every N user turns rather than per-turn. ## Structured output — `response_schema` Pass a JSON Schema object as `response_schema` to get a validated, parsed response. The platform injects a synthetic tool with that schema as its `input_schema` and forces the model to call it — works on both `provider: 'anthropic'` and `provider: 'openai'`. Mutually exclusive with caller-provided `tools` and with `stream`. const r = await sw.ai.chat({ provider: 'anthropic', messages: [{ role: 'user', content: `Extract order: ${text}` }], response_schema: { type: 'object', properties: { order_id: { type: 'string' }, customer: { type: 'string' }, amount: { type: 'number' }, }, required: ['order_id', 'customer', 'amount'], }, }) if (r.parsed) { console.log(r.parsed.order_id, r.parsed.customer, r.parsed.amount) } else { console.warn('parse failed:', r.parse_error) } The response gains two fields: `parsed` (the validated object, or null on failure) and `parse_error` (null on success, otherwise a short reason string). On validation failure the platform retries once silently before giving up — no exceptions are thrown so your handler can branch. ## Free models Default pick — use a fast, non-reasoning model unless you specifically need reasoning; 1024 max_tokens is plenty for most prompts. A free model like @cf/meta/llama-4-scout-17b-16e-instruct is a good start. Call ai_catalog for the live list of models and their categories — don't hardcode a model list, it rots. ⚠️ Reasoning models — read this before picking one. (ai_catalog marks which models are reasoning models.) These models emit a private chain-of-thought before the final answer, which routinely consumes 2000–6000 output tokens BEFORE the visible reply even starts. With the typical 800–1024 max_tokens budget, they exhaust the budget mid-thought and return an UPSTREAM_ERROR that says "the model exhausted its output budget while reasoning." If you use a reasoning model, set max_tokens: 4000 or higher. For simple tasks (summaries, classification, short Q&A, chat replies), do NOT use a reasoning model — pick llama-4-scout or mistral-small instead. They're faster, cheaper on tokens, and don't burn budget on chain-of-thought you can't read anyway. Reach for reasoning models only on multi-step problems (math, code generation with planning, complex tool-use loops) where the extra thinking measurably improves the answer. Rate limits (per user, on free `provider: 'workers-ai'` models): Free tier: 10 req/min, 200 req/day Builder tier: 200 req/min, 10,000 req/day ## Other AI surfaces — also on sw.ai Embeddings, transcription, text-to-speech, image generation, and background removal are first-class on sw.ai inside a deployed function. No API key, no fetch, no project_id juggling — same pattern as sw.ai.chat. // Embeddings — workers-ai (free tier eligible) or a premium model (paid) const r = await sw.ai.embeddings({ provider: 'workers-ai', model: '@cf/baai/bge-base-en-v1.5', input: ['How do I reset my password', 'Billing FAQ'], }) // r.embeddings = [[...], [...]], r.dimensions = 768 // Premium embeddings — text-embedding-3 family (paid; rates at /v1/pricing) // Models: text-embedding-3-large (3072d), // text-embedding-3-small (1536d) // Optional dimensions param truncates the vector. const r2 = await sw.ai.embeddings({ provider: 'openai', model: 'text-embedding-3-large', input: ['...'], dimensions: 1024, // optional, must be <= model native }) // Transcribe (Whisper) — pass base64 audio or a public audio_url const t = await sw.ai.transcribe({ audio_url: 'https://example.com/clip.mp3', }) // t.text, t.duration_seconds // Generate an image — returns a raw Response unless you set storage const img = await sw.ai.generateImage({ provider: 'workers-ai', model: '@cf/black-forest-labs/flux-1-schnell', prompt: 'A serene mountain lake at dawn', }) return img // streams the PNG straight to the browser // …or store it in the project's files and return the path: const stored = await sw.ai.generateImage({ prompt: '...', storage: '/renders/cover.png', }) // stored = { storage_path, size_bytes, content_type } // Remove background — same storage / inline shape as generateImage const cut = await sw.ai.removeBackground({ image_url: 'https://example.com/photo.jpg', storage: '/cutouts/photo.png', }) // Browse the live model catalog const catalog = await sw.ai.catalog() ## Image generation models - @cf/black-forest-labs/flux-1-schnell — provider: 'workers-ai', fast, free - fal-ai/nano-banana — premium quality. Pass provider: 'fal', model: 'fal-ai/nano-banana', prompt, optional width/height. ## Background removal POST /v1/ai/remove-background with { project_id, image_url }. Returns { url, content_type, bytes } — the output PNG is stored under the project's files. Per-image cost is at /v1/pricing. ## Text-to-speech - @cf/myshell-ai/melotts — provider: 'workers-ai', default, free - grok-tts — premium voice (pricing at /v1/pricing). Voice options (voice param): eve, ara, rex, sal, leo. language defaults to 'en'. Max 15,000 chars per call. // Stream the audio straight to the browser const audio = await sw.ai.tts({ model: 'grok-tts', voice: 'eve', text: 'Hello world.', }) return audio // raw audio Response — Content-Type set to the right MIME // Or save to file storage and return its path const stored = await sw.ai.tts({ model: 'grok-tts', voice: 'eve', text: 'Hello world.', storage: '/audio/greeting.mp3', }) // stored = { storage_path, size_bytes, content_type, ... } ## Content moderation sw.ai.moderate(text) classifies text against a safety taxonomy (violence, sexual content, hate, self-harm, illegal advice, etc.). Free — uses the platform's content-safety model, with a secondary moderation fallback if the primary provider is down. const result = await sw.ai.moderate(userMessage) // { // flagged: true, // categories: ['violent_crimes', 'hate'], // scores: { violent_crimes: 1, hate: 1 }, // model: '@cf/meta/llama-guard-3-8b', // provider: 'workers-ai', // } if (result.flagged) { return Response.json({ error: 'Message blocked' }, { status: 422 }) } Categories returned: violent_crimes, non_violent_crimes, sex_crimes, child_exploitation, defamation, specialized_advice, privacy, intellectual_property, indiscriminate_weapons, hate, self_harm, sexual_content, elections, code_interpreter_abuse. Max input: 50,000 chars. Subject to the free-tier AI rate limits above. ## Catalog discovery sw.ai.catalog() (or the ai_catalog MCP tool / GET /v1/ai/catalog externally) returns every model the platform exposes — provider, family (chat / embeddings / tts / image / background-removal), pricing, and default + max steps. Paid-model rates are in /v1/pricing. Use it to render a picker or audit costs without hard-coding model lists. Use sw.ai.* inside any deployed function — the platform binding handles auth and project scoping. The smt_ developer key is only for external clients (CI/CD, server-to-server jobs, webhooks). ## Error envelope (typed catalogue) Every sw.ai.complete / sw.ai.chatWithTools failure surfaces as a structured envelope: ```json { "ok": false, "error": "", "message": "", "retry": true | false, "retry_after_ms": } ``` `retry: true` means the SAME request shape can be sent again after `retry_after_ms` (or immediately when omitted). `retry: false` means something about the request itself has to change before retrying. Match on `error` — the codes below are stable; the `message` text is not. | Code | retry | When you see it | |------------------------------|--------|-----------------| | VALIDATION_ERROR | false | bad input shape (missing project_id, bad messages array, unknown model, mutually-exclusive flags) | | PROJECT_NOT_FOUND | false | project_id doesn't exist or your key can't see it | | PAID_API_NOT_ACTIVATED | false | the paid model requires activation in dashboard settings | | CONV_HISTORY_UNAVAILABLE | true | the project DB couldn't replay prior turns. Retry without conversation_id to start a fresh thread | | CONV_HISTORY_REJECTED | true | the upstream provider rejected the replayed history for this conversation_id. The conversation is poisoned until cleared — call `ai.conversations.delete(id)` then retry, or drop conversation_id on the retry to start fresh | | RATE_LIMITED | true | per-user or per-model rate limit. Honour `retry_after_ms` | | UPSTREAM_RATE_LIMITED | true | upstream provider rate-limited us. Honour `retry_after_ms` | | UPSTREAM_AUTH_FAILED | false | platform credentials with the provider are bad — our team rotates them | | UPSTREAM_BILLING | false | platform has a billing problem with the provider — our team is notified | | UPSTREAM_DOWN | true | provider 5xx. Retry with backoff | | UPSTREAM_ERROR | false | provider 4xx (request rejected) — change your messages/tools | | INTERNAL_ERROR | true | platform glitch — retry once, then escalate via platform_feedback | Default retry behaviour for new codes follows the same rule: 4xx ⇒ `retry: false`, 5xx + 429 + 504 ⇒ `retry: true`. Always read `retry` instead of inferring from `error` — a future code may flip retryability without renaming. --- ## sw.image — Image transformations (sw.image) Available inside any deployed function via the sw argument (ctx still works as an alias). sw.image.resize builds a transformation URL — it doesn't fetch the image, it returns a URL that triggers transformation at the edge when the browser (or your code) fetches it. Stick the URL in for free, no extra hop. ## sw.image.resize(source, options) Returns a string URL. source: '/uploads/foo.png' — relative path on this project's domain 'https://example.com/foo.png' — absolute URL (must be reachable from the project's zone) options (all optional): width number pixel width height number pixel height fit 'cover' | 'contain' | 'scale-down' | 'crop' | 'pad' format 'auto' | 'webp' | 'avif' | 'json' (default: format=auto) quality 1–100 JPEG/WebP/AVIF quality dpr number device-pixel ratio (1, 2, 3) gravity 'auto' | 'left' | 'right' | 'top' | 'bottom' | 'face' | '0.5x0.5' background '#ffffff' fill colour for pad/contain blur 1–250 sharpen 1–10 rotate 90 | 180 | 270 trim '20;30;20;30' edge trim (top;right;bottom;left) metadata 'keep' | 'copyright' | 'none' anim true | false (animated GIF/WebP support) Examples: const thumb = sw.image.resize('/photo.jpg', { width: 200, height: 200, fit: 'cover' }) // → https://your-project.somewhere.tech/cdn-cgi/image/width=200,height=200,fit=cover/photo.jpg const webp = sw.image.resize(uploadedFile.url, { width: 800, format: 'webp', quality: 80 }) return new Response(``, { headers: { 'Content-Type': 'text/html' } }) ## Billing 5,000 unique transformations / month free per project; usage beyond that is at /v1/pricing. Repeated fetches of the same transformed URL are cached at the edge — only the first fetch counts. Cached for 1 year by default. ## Source restrictions The source URL must be on the project's subdomain (default) or on a verified custom domain attached to the project. Cross-origin external URLs are blocked by default and return 403 — if you need to transform images from a domain you don't own, file via the `feedback` tool. For files in sw.fs, point at the project URL with the path: sw.image.resize('/api/files/photo.jpg', { width: 400 }) where /api/files/photo.jpg is a function that does sw.fs.read. ## Errors The transformed URL returns 4xx if Image Transformations isn't enabled on the zone, or 415 if the source isn't an image. The helper itself only validates the inputs and builds the URL — it does not pre-flight. --- ## sw.jobs — Background Jobs (inside deployed functions) (sw.jobs) Queue work that shouldn't block a user request. Platform retries on failure (at-least-once, up to 5 attempts). Make handlers idempotent — they may run more than once. ## Create a job const job = await sw.jobs.create({ handler: '/api/jobs/process-upload', payload: { upload_id: 'u123', user_id: 'u_alice' }, timeout_seconds: 600 }) // job = { job_id: 'j_abc', status: 'queued' } The handler must be a deployed function path. Platform POSTs the payload to your function when the job runs. ## Job handler function // api/jobs/process-upload.ts export default async function(req, sw) { if (!(await sw.jobs.verifyInvocation(req))) { return Response.json({ error: 'forbidden' }, { status: 403 }) } const { upload_id, user_id } = await req.json() // do the heavy work await processUpload(upload_id, sw) return Response.json({ ok: true }) } The platform signs each job delivery with X-Somewhere-Signature, X-Somewhere-Invocation-Timestamp, and X-Somewhere-Body-SHA256. Always verify before trusting the payload on public /api/jobs/* handlers. This blocks a browser from calling the job handler directly with forged payloads. ## Look up a job's status const j = await sw.jobs.status('j_abc') // j = { job_id, status: 'queued' | 'running' | 'succeeded' | 'failed', // attempts, last_error?, started_at?, finished_at? } Use this to poll a long-running job from another request — for example, the browser polls /api/job-status?id=j_abc and your function calls sw.jobs.status to forward the current state. --- ## sw.queue — Fire-and-Forget Background Work (sw.queue) For fire-and-forget tasks where you don't need to inspect status later. Use sw.jobs if you DO need to look up the result. ## Push a message await sw.queue.push({ handler: '/api/queue/log-event', payload: { event: 'signup', user_id: 'u_123' }, delay_seconds: 0 // optional, max 900 (15 min) }) ## Handler function // api/queue/log-event.ts export default async function(req, sw) { if (!(await sw.queue.verifyInvocation(req))) { return Response.json({ error: 'forbidden' }, { status: 403 }) } const { event, user_id } = await req.json() await sw.db.query('INSERT INTO events (type, user_id) VALUES (?, ?)', [event, user_id]) return Response.json({ ok: true }) } Queue deliveries are signed the same way as jobs. Verify the signature before side effects so public queue handler URLs cannot be used as unauthenticated admin endpoints. ## Delivery semantics At-least-once — a consumer MAY receive the same payload twice. Make handlers idempotent (e.g. dedupe key on a unique column). No per-message read API — if you need to look up a job by id, use sw.jobs. --- ## Cron — Scheduled Tasks (cron) Run a handler on a fixed schedule. All times are UTC. ## Create a schedule cron_create({ project_id: "my-app", schedule: "0 9 * * *", // 5-field cron expression handler: "/api/jobs/daily-digest", payload: { segment: "daily" } // optional }) ## Schedule format minute hour day-of-month month day-of-week 0 9 * * * → every day at 09:00 UTC */15 * * * * → every 15 minutes 0 0 1 * * → first of every month The finest granularity is **one minute** — a 5-field expression has no seconds field, so you can't schedule "every 10 seconds." For sub-minute live updates, push via realtime as data arrives instead of scheduling → `platform_help('live-data')`. ## Handler Point handler at a deployed function. Platform POSTs payload when the schedule fires. Same at-least-once semantics as sw.jobs. ## Missed fires If the platform is down across a fire time, the run is NOT replayed. Design schedules to tolerate an occasional skipped run. --- ## Realtime — Channels (publish / subscribe) (realtime) A channel-based publish/subscribe primitive. Use for chat, live dashboards, lightweight notifications — anything message-shaped that needs to reach connected clients in real time. Each (project_id, channel) pair routes to its own isolated session, so projects never share traffic. Idle channels hibernate automatically — you pay essentially nothing while nobody's connected. ## Channel names Match the regex [a-zA-Z0-9][a-zA-Z0-9_\\-:.]{0,127}. Pick a feature-prefixed format: chat:room-42, private:usr_123:notifications, live-scores:match-9 For per-user / private data, name the channel with a private:USERID: prefix (e.g. private:usr_123:notifications) — only that signed-in user can subscribe or publish to it. ANY other channel name (chat:room-42, notifications:u_123) is readable AND writable by every signed-in app user in the project, so never put one user's private data on a non-private channel. ## Publish from a deployed function (the common case) sw.realtime.publish auto-injects project_id and handles auth — no smt_ key, no fetch. await sw.realtime.publish('chat:room-42', { body: 'Hello', author: 'alice' }, { event: 'new_post' }) // → { channel, event, delivered } The 'event' option names the event ('message' by default). The data argument is any JSON value. Payload cap: 64 KB. ## Publish from CI / external (MCP) From an external process (CI, server-to-server worker, MCP agent), use the MCP tool — smt_-authorized: realtime_publish({ project_id: "my-app", channel: "chat:room-42", event: "new_post", data: JSON.stringify({ body: "Hello", author: "alice" }) }) ## Subscribe from a browser Channels are project-scoped. Mint an app-user JWT (sw.auth.login) and pass it as ?token=. Recommended: paste the swRealtime() helper below — it handles reconnect, backoff, and re-subscription on socket loss so you can focus on your event handler instead of WebSocket plumbing. // swRealtime — copy-paste browser helper (~40 lines, no dependencies). // Auto-reconnects with exponential backoff (1s → 30s, full jitter), // re-establishes the subscription on every reconnect, JSON-parses the // envelope, and never throws past your callbacks. Drop-in for both // project channels and the personal system:user channel. function swRealtime({ projectId, channel, token, userId, onMessage, onOpen, onClose, onError }) { let ws = null, stopped = false, attempt = 0 function connect() { if (stopped) return const base = 'wss://api.somewhere.tech/v1/realtime/' const url = userId ? base + 'subscribe-user?user_id=' + encodeURIComponent(userId) + (token ? '&token=' + encodeURIComponent(token) : '') : base + 'subscribe?project_id=' + encodeURIComponent(projectId) + '&channel=' + encodeURIComponent(channel) + (token ? '&token=' + encodeURIComponent(token) : '') ws = new WebSocket(url) ws.onopen = (e) => { attempt = 0; onOpen && onOpen(e) } ws.onmessage = (e) => { try { onMessage(JSON.parse(e.data)) } catch (err) { onError && onError(err) } } ws.onerror = (e) => { onError && onError(e) } ws.onclose = (e) => { onClose && onClose(e) if (stopped) return const cap = Math.min(30000, 1000 * Math.pow(2, attempt++)) setTimeout(connect, Math.floor(Math.random() * cap)) } } connect() return { close() { stopped = true; ws && ws.close() } } } // Usage — project channel (browser, app-user JWT): const sub = swRealtime({ projectId: 'my-app', channel: 'chat:room-42', token: jwt, onMessage: (env) => { // env = { type, event, data, from, at } if (env.event === 'new_post') render(env.data) }, }) // sub.close() // when the user navigates away // Usage — personal system:user channel (platform feedback responses, // cross-project events). Developer smt_ key only. swRealtime({ userId: currentUserId, token: smtKey, onMessage: (env) => { if (env.event === 'feedback_resolved') toast(env.data) }, }) The JWT's project_id is enforced to match the channel's project, so a user can never cross-project. On reconnect the platform does not replay buffered events — refetch state via REST inside onOpen if you can't tolerate gap-time losses. ## Server-side listener (webhook-style between functions) sw.realtime.subscribe long-polls the platform for the next event on a channel. Useful when one function publishes a result and another function (running on a request) wants to wait for it. const env = await sw.realtime.subscribe('jobs:export-done', { timeout_ms: 20000, event: 'done' }) if (env) handle(env.data) else respondPending() ## List active channels sw.realtime.channels() returns rows the platform has touched in the last 10 minutes — current subscriber count + last_publish_at. From an agent: realtime_channels({ project_id }). ## Limits Free: 100,000 publishes / month. Pro+: unlimited. Per-message payload cap: 64 KB. ## Platform-emitted system events (subscribe-only) The platform publishes its own lifecycle events on two reserved channels. You don't publish to these — just subscribe and let the platform push everything. realtime_subscribe_project({ project_id: 'my-app' }) // → { channel: 'system:project', websocket_url, event_types: [...] } realtime_subscribe_user({}) // → { channel: 'system:user', websocket_url, event_types: ['feedback_resolved'] } The 'system:project' channel emits: - 'deployed' | 'patched' | 'restored' | 'rolled_back' { version, by, message, has_functions } — multi-editor conflict prevention. See a version bump, pull before pushing. - 'db_health' { event: 'cpu_exhaust_recovered' | 'cpu_exhaust_failed', query_fingerprint } — fires when the platform retried a slow query for the user. - 'quota_warning' { resource: 'storage' | 'database' | 'email' | 'realtime' | 'ai' | 'inbox', usage_percent, message } — fires once per 80% and 95% crossing per resource per month. - 'auth_event' { event: 'user_deleted' | 'user_banned' | 'user_unbanned' | 'impersonation_started', user_id, actor_id }. The 'system:user' channel emits 'feedback_resolved' { ticket_id, response, resolution_status } when the platform team responds to or resolves a feedback() ticket you submitted. Auth: developer smt_ key only; the channel is bound to your own user id. Platform-emitted events bypass the publish quota — they're free. ## Don't build with this Durable history, replay-on-connect, presence, stateful rooms. Realtime is a fire-and-forget transport. State belongs in sw.db. --- ## Calls — Real-Time Audio/Video Sessions (calls) Build video calling, screen sharing, or live audio rooms. The platform provides session creation; the heavy lifting (SFU mixing, NAT traversal, SDP negotiation) runs on the platform's media backend. Your code only deals with session IDs and SDP payloads — opaque blobs you pass through. ## calls_new_session({ project_id, thirdparty? }) calls_new_session({ project_id: "my-app" }) // → { session_id: "sess_xyz", project_id: "my-app" } session_id: pass to subsequent track operations from the browser. thirdparty: true → the media backend treats the session as pure data (no platform-originated tracks). Default false. Most apps don't need this. Sessions are ephemeral — the platform doesn't persist session_id. Once both peers leave, the session is gone. ## Track operations (REST only — call directly from the browser) After session creation the browser drives the call lifecycle by calling these endpoints with its peer connection's SDP: POST /v1/calls/sessions/:session_id/tracks Body: { sessionDescription: { type, sdp }, tracks: [...] } Add local tracks to the SFU. Returns SDP answer. PUT /v1/calls/sessions/:session_id/renegotiate Body: { sessionDescription: { type, sdp } } Renegotiate when adding/removing tracks mid-call. PUT /v1/calls/sessions/:session_id/tracks/close Body: { tracks: [...], sessionDescription: { type, sdp } } Close specific tracks (mute camera, stop screen share). These are called from the browser with an app-user JWT (the same JWT you use for db_query / fs_read from a logged-in user). The smt_ developer key is never required for the browser path — keep it server-side. SDP payloads are passed straight through to the media backend. ## Common pattern: 1:1 video call // 1. Backend mints a session for each call const session = await calls_new_session({ project_id }) // Send session.session_id to BOTH peers via realtime / WebSocket // 2. Each browser: const pc = new RTCPeerConnection() pc.addTrack(localVideo) const offer = await pc.createOffer() await pc.setLocalDescription(offer) // 3. Push the offer to the SFU const r = await fetch(`/v1/calls/sessions/${sessionId}/tracks`, { method: 'POST', headers: { Authorization: 'Bearer ' + jwt }, body: JSON.stringify({ sessionDescription: { type: 'offer', sdp: offer.sdp }, tracks: [{ location: 'local', mid: '0', trackName: 'video' }] }) }) const { sessionDescription } = await r.json() await pc.setRemoteDescription(sessionDescription) The wire protocol mirrors a standard WebRTC + SFU exchange — any client that speaks the offer/answer SDP dance works. ## When to use calls vs realtime - realtime — text chat, presence, score updates, anything message-shaped - calls — actual audio/video bytes (1:1 calls, group rooms, broadcasts) ## What NOT to do - Don't try to inspect or modify SDP payloads on the server — they're passed through verbatim and are very specific to the peer's hardware. - Don't store session_ids long-term — they're not durable, the SFU drops them as peers leave. - Don't run the call's signaling through your function on every track add — the browser talks to /v1/calls/sessions/* directly, your worker just hands out fresh session_ids. --- ## Payments — Stripe Connect (payments) The platform wires Stripe Connect for you. You get `payments_*` MCP tools and `sw.payments` inside deployed functions. End-customers pay through a Stripe Checkout Session and money settles to the developer's connected account, minus Stripe's standard processing fee and a 0.5% platform fee — each checkout response includes `fee_percent` so you can account for it. ## You DO NOT need to onboard to start building Test-mode checkouts work immediately. Call payments_checkout (or sw.payments.checkout) with env="dev" — when the developer hasn't onboarded yet, the session runs directly on the platform's own Stripe test account (no Connect destination, no application fee), so card 4242 4242 4242 4242 succeeds end-to-end. The response includes `is_stand_in: true` and `platform_fee_cents: 0` so callers know the fee won't apply on those sessions. Useful for dev demos, integration tests, or showing the user the flow before they decide to wire real bank info. Once the developer calls payments_onboard, dev-mode checkouts switch to running against their own test connected account (still no live charges; same 4242 card). ```json // MCP — agent calling the platform payments_checkout({ project_id: "my-store", env: "dev", mode: "payment", line_items: '[{"amount": 4900, "currency": "usd", "name": "Premium plan"}]', success_url: "https://my-store.somewhere.tech/done", cancel_url: "https://my-store.somewhere.tech/cart" }) ``` ```js // Inside a deployed function — env is auto-detected from PROJECT_ENV. // Use env="prod" for live charges (default once payments_onboard is done). export default async function(req, sw) { const user = await sw.auth.fromRequest(req); if (!user) return Response.json({ error: 'unauthorized' }, { status: 401 }); const { url } = await sw.payments.checkoutForUser(user.id, { plan: 'premium', mode: 'payment', line_items: [{ amount: 4900, currency: 'usd', name: 'Premium plan' }], success_url: 'https://my-store.somewhere.tech/done', cancel_url: 'https://my-store.somewhere.tech/cart' }) return Response.redirect(url, 303) } ``` For app-user entitlements, prefer checkoutForUser. The platform creates a checkout-intent row and sends only that intent id through Stripe metadata; webhooks derive app_user_id and plan from the platform database. Do not pass browser-controlled metadata.app_user_id or metadata.plan through a public handler. ## Onboarding for live charges Call payments_onboard when the developer is ready to take REAL money. It's per-account (NOT per-project) — one onboard covers every project the developer owns now or in the future. Returns a Stripe-hosted URL for KYC + bank info. ```json payments_onboard({ return_url: "https://my-store.somewhere.tech/payments/done", refresh_url: "https://my-store.somewhere.tech/payments/retry" }) ``` After the developer finishes onboarding, payments_status reports charges_enabled=true and you can call payments_checkout({ env: "prod" }). ## Collaborators Anyone with access to the project can run checkouts. Funds always go to the project OWNER's connected account. If the owner has not finished live onboarding and a collaborator calls payments_checkout({ env: "prod" }), the platform returns 412 with code OWNER_NOT_ONBOARDED and a message telling the caller the owner needs to onboard first. ## What's NOT done for you The user-facing pricing page, cart UI, and post-success receipts are your code. Stripe Connect handles money + tax + payouts. You handle "what does the customer click before they get to checkout". ## Webhook events The platform's own webhook (at /v1/payments/webhook) keeps the charges_enabled / payouts_enabled flags in sync. You don't need to register your own webhook for that. If you want to react to charge.succeeded / payment_intent.succeeded inside YOUR app, register a SECOND Stripe destination in the developer's connected account and point it at one of your deployed functions. ## Automatic plan tracking on app_users Want user.plan = "premium" to flip automatically when the customer pays — and to flip back when they cancel — without writing a single webhook handler? Use `checkoutForUser` from inside a deployed function. The platform mints a signed intent row server-side and forwards only its id through Stripe metadata, so a browser cannot spoof `app_user_id` or `plan`: const user = await sw.auth.fromRequest(req); if (!user) return Response.json({ error: 'unauthorized' }, { status: 401 }); const { url } = await sw.payments.checkoutForUser(user.id, { plan: "premium", mode: "subscription", env: "prod", line_items: [{ price: "price_PREMIUM_MONTHLY", quantity: 1 }], success_url: "https://my-app.somewhere.tech/billing/success", cancel_url: "https://my-app.somewhere.tech/billing/cancel", customer_email: user.email, }); The platform's own Stripe Connect webhook will: - on checkout.session.completed → app_users.plan = "premium", plan_status = "active", and stamp stripe_customer_id + stripe_subscription_id. - on invoice.payment_failed → plan_status = "past_due" (plan stays the same so you can show "your premium will lapse" copy). - on customer.subscription.deleted → plan_status = "canceled". Read the current plan on the user object: const user = await sw.auth.fromRequest(req); if (user.plan_status !== "active") return Response.redirect("/billing"); if (user.plan === "premium") { /* unlock the feature */ } // Or directly: const { user } = await sw.auth.me(token); console.log(user.plan, user.plan_status); // → "premium", "active" (or "free", null when the user has never paid) Note: plan is always returned (defaults to "free" for users who have never checked out). plan_status is null for free users, and "active" | "past_due" | "canceled" once they've subscribed. ## Refunds, cancels, transactions, portal, events Five more endpoints round out the payments surface so you don't need to drop down to the Stripe SDK or build a custom dashboard. ### Refund a charge ```js // Inside a deployed function. Pass payment_intent_id from the // checkout session (sw.payments.checkout returns it indirectly via // the session object; you typically store it on your order row). const refund = await sw.payments.refund({ payment_intent_id: 'pi_3PqXYZ...', // amount: 1000, // omit for full refund (in cents) // reason: 'requested_by_customer' }); // → { refund_id, status, amount, currency, charge_id, ... } ``` Stripe's processing fees are handled by Stripe's standard refund policy. ### Cancel a subscription ```js await sw.payments.cancelSubscription({ subscription_id: 'sub_...' }); // Default: cancel_at_period_end=true — customer keeps access until period end. await sw.payments.cancelSubscription({ subscription_id: 'sub_...', immediately: true }); // Stops billing now and ends access. ``` `app_users.plan_status` is updated automatically by the webhook on `customer.subscription.deleted` — no extra wiring required. ### List recent transactions ```js const { transactions, next_cursor } = await sw.payments.transactions({ limit: 20 }); // → transactions: [{ id, amount, currency, status, paid, refunded, // created, customer_id, payment_intent_id, ... }] // Paginate with: sw.payments.transactions({ limit: 20, starting_after: next_cursor }) ``` ### Open the Stripe Billing Portal for an end-customer The portal lets customers update their card, switch plans, view invoices, and cancel themselves — no support ticket required. ```js const { url } = await sw.payments.portal({ customer_id: user.stripe_customer_id, return_url: 'https://my-app.somewhere.tech/account/billing', }); return Response.redirect(url, 303); ``` (Stripe auto-creates a default portal configuration on first use. Advanced settings — what plans are switchable, custom branding — need a one-time click in the connected account's Stripe dashboard.) ### Read the webhook event ledger Every Stripe event the platform's webhook receives is persisted in a ledger you can read back. The handler is idempotent — Stripe redeliveries (which happen on transport failures) appear exactly once. Use this to drive an in-product activity feed or to verify delivery during integration. ```js const { events, next_cursor } = await sw.payments.events({ limit: 50, // type: 'checkout.session.completed', // optional filter // before: 1715000000000, // ms-epoch cursor }); // → events: [{ id, type, mode, account_id, project_id, // amount_cents, currency, livemode, received_at }] ``` ### One-time vs subscription `payments_checkout` accepts `mode: 'payment'` (one-time charge) or `mode: 'subscription'` (recurring billing). Same endpoint, same automatic plan-tracking metadata. Default is `'payment'`. ### Gating features after payment Payments collect the money; `sw.billing` turns a paid plan into feature yes/no answers. Pair them: `checkoutForUser(userId, { plan: 'pro' })` sets the user's plan on success, then `sw.billing.has(userId, 'export')` (or `` client-side) gates on it — no webhook code. See `docs({ topic: 'sw.billing' })`. --- ## sw.billing — plans & feature gating for YOUR app's users (sw.billing) Define the plans YOUR app sells and the features each one unlocks, then gate anything with a single check. You gate on a stable feature name (`'export'`), never on a plan name or a Stripe price — so renaming or re-pricing a plan never touches your gate code. This is the entitlement layer that pairs with `sw.payments` (which collects the money): a successful checkout sets the user's plan automatically, and `sw.billing` turns that plan into yes/no answers. (Looking for OUR pricing tiers — what somewhere.tech charges YOU? That's `docs({ topic: 'billing' })`. This topic is about the plans YOUR app offers ITS users.) ## 1. Define your plans (in code, once) ```js // in a deployed function — declarative, safe to run on every deploy await sw.billing.definePlans([ { slug: 'free', name: 'Free', features: [] }, { slug: 'pro', name: 'Pro', price_cents: 2000, interval: 'month', features: ['export', 'api_access', { feature: 'seats', limit: 10 }] }, ]); ``` - The list you pass becomes THE catalog (anything not listed is removed) — idempotent, so just keep it in your code and it converges. - `slug` is what a user's plan is set to. `features` are free-form names you invent and gate on; a feature can carry an optional numeric `limit`. - `price_cents`/`interval` are for display (your pricing page) — they don't charge anyone; `sw.payments` does the charging. - View your live catalog read-only in the dashboard under a project's Auth → Plans tab. The dashboard never edits plans — your code is the source. ## 2. Gate a feature Server side, in a function: ```js if (await sw.billing.has(user.id, 'export')) { // ...do the gated thing } const ent = await sw.billing.entitlements(user.id); // ent = { plan: 'pro', plan_defined: true, features: ['export','api_access','seats'], limits: { seats: 10 } } ``` Client side, with @somewhere-tech/auth (the user's feature list rides their session, so these are instant — no extra request): ```tsx import { useEntitlements, Gate } from '@somewhere-tech/auth/react'; function ExportButton() { const { has } = useEntitlements(); // also: { entitlements, loading } return has('export') ? : ; } // declarative — the feature-gating mirror of }> ``` `auth.billing.plans()` returns the catalog for rendering a pricing page. ## 3. Drop-in pricing + manage UI (React) A whole pricing page and a manage-subscription button, no custom code — both ride the @somewhere-tech/auth session (no extra setup beyond the auth you already mount): ```tsx import { PricingTable, BillingPortal } from '@somewhere-tech/auth/react'; // your catalog plans, current plan highlighted, Subscribe → checkout // one-click "manage/cancel" via the Stripe portal ``` Both are logic-only/unstyled — theme with the `sw-*` class hooks (`sw-pricing-table`, `sw-plan[data-current]`, `sw-plan-name/-price/-features/-cta`, `sw-billing-portal`). Prefer to wire it yourself? The same actions are on the client: `auth.billing.subscribe(planSlug)` and `auth.billing.openBillingPortal()` (both redirect to Stripe; success/cancel/return default to the current page). Subscribe checks out for the SIGNED-IN user (resolved server-side from the session, never a client-supplied id) and the worker resolves the plan's price from your catalog's `stripe_price_id` — so a plan is purchasable once it's `active` and carries a `stripe_price_id`. Set `stripe_price_id` on each paid plan in `definePlans()`; a `$0`/no-price plan can't be checked out. ## 4. How a user gets a plan You don't write webhook code. `sw.payments.checkoutForUser(userId, { plan })` (what `` calls under the hood) starts a Checkout Session; when payment completes, the platform sets that user's plan, and `sw.billing` immediately reflects it. Cancelling (via ``) clears it. (`docs({ topic: 'payments' })` covers the checkout side.) ## Notes - Fully additive. If you already branch on the raw plan name (`user.plan === 'pro'`), that keeps working — adopt `has()` when you want. - `sw.auth.me()` / `fromRequest()` now include `entitlements: string[]` on the user, which is what the client checks read. - REST (if you're not in a function): `POST /v1/entitlements/plans` (define, developer key), `GET /v1/entitlements/plans` (catalog), `GET /v1/entitlements/me`, `GET /v1/entitlements/has?feature=…`. Related topics: `payments`, `auth-client`, `sw.auth`. --- ## sw.env — Environment Variables (inside deployed functions) (sw.env) Set env vars via the env MCP tool or the dashboard Settings page. They are baked into the deployed function bundle at deploy time. ## Access export default async function(req, sw) { const key = sw.env.STRIPE_SECRET_KEY // ... } ## Timing Setting an env var does NOT hot-reload running functions. After env_set, the next project_deploy bakes the new value in. Until you redeploy, old functions continue using the previous value. ## Secrets Env vars are server-side only — they never ship to the browser bundle. Put API keys, database URLs, and third-party secrets here. Rotate secrets by calling env_set with the new value, then project_deploy. --- ## Custom Domains (domains) There are four flavors of custom domain. Pick by what the user is doing: Claimed domain — RECOMMENDED for any domain the user already owns. They delegate nameservers to us once; the domain can then be attached to (and detached from) any project, plus it unlocks inbox routing on the same domain. Custom domain — legacy CNAME-only flow. One project, one hostname, apex requires ALIAS support at the registrar. Use only when the user can't change nameservers. Tenant domain — one hostname per CUSTOMER of your app. Every request on the hostname is dispatched to your prod function with the original Host header preserved; your code branches on req.headers.get('host') to render that tenant's view. Use for white-label / multi-tenant SaaS. Bought domain — registered through the platform via Name.com. After purchase the platform also wires DNS + SSL, so the domain comes online without a separate domain_add call. ## Recommended: claim by delegating nameservers This path covers apex (`example.com`), `www`, and any subdomain in one move. Once active, the domain can be re-attached to a different project without re-verifying ownership. // 1. Claim — returns the nameserver pair to paste at the registrar. domain_claim({ domain: "example.com" }) // → { id: "dom_...", claim_status: "pending_ns", // nameservers: ["", ""], // the pair to paste at your registrar // instructions: [...] } // 2. User updates nameservers at registrar (Namecheap, GoDaddy, ...). // 3. Either wait for the hourly poll, or check on demand: domain_check_ns({ id: "dom_..." }) // → { claim_status: "active" } once propagation is complete. // 4. Attach to a project. host = "apex" (default), "www", or a // single subdomain label like "app". domain_attach({ id: "dom_...", project_id: "my-app", host: "apex" }) // To move the domain to a different project later — no re-verify: domain_detach({ id: "dom_..." }) domain_attach({ id: "dom_...", project_id: "other-app", host: "apex" }) // To list every domain the user has claimed (regardless of attachment): domain_owned({}) ## Inbox on a claimed domain (opt-in) `domain_claim` deliberately leaves MX records alone — the user might already receive email at the domain via Gmail, Workspace, or their own server. Turn on inbox routing only when the user explicitly wants it: domain_enable_email({ id: "dom_..." }) // ⚠ Replaces existing MX records on the domain. // After this, inbox_create_address works on this domain. If the user already has email working, skip this and keep their MX intact. Want mail to ALSO land in the inbox the user already reads (Gmail, Outlook)? After creating the address, wire forwarding with `inbox_forward_set({ address_id, forward_to: "you@gmail.com" })` — the project inbox keeps a copy and a copy is delivered externally. See platform_help("inbox") for the confirmation flow. ## Stuck domain recovery If a claimed domain shows `claim_status: active` and `zone_status: active` in `domain_owned` but `dig ` returns NXDOMAIN or empty answers, the platform-wiring step had a partial failure during claim. Recover with: domain_publish({ id: "dom_..." }) This re-asserts the apex + www records and the routing. Idempotent — safe to call any time. Inbox routing is only re-asserted if it was already enabled, so this never silently overwrites MX records. ## Legacy: CNAME-only (custom_domain) For the rare user who can't change nameservers (e.g. corporate domain they don't own). Apex needs ALIAS / ANAME support at the registrar. domain_add({ project_id: "my-app", domain: "app.yourcompany.com" }) // Returns the CNAME + verification TXT to set at the registrar. domain_verify({ project_id: "my-app", domain: "app.yourcompany.com" }) // Polls DNS + SSL. Once verified, the domain serves prod immediately. A verified custom domain serves your latest deploy — same bytes as the `*.somewhere.tech` subdomain. ## Buy a domain (two-step — non-refundable) Domain purchases charge the user immediately and CANNOT be refunded, so domain_buy through MCP is gated behind a confirmation step. Always show the price to the user verbatim before calling domain_buy_confirm. Step 1 — Get the price + a confirm token: domain_check({ domain: "acmedash.com" }) // → { available: true, price: "/year" } domain_buy({ project_id: "my-app", domain: "acmedash.com" }) // → { ok: false, error: "PURCHASE_CONFIRMATION_REQUIRED", // domain: "acmedash.com", price: "/year", // confirm_token: "tok_abc123", // message: "...non-refundable. Token expires in 10 minutes." } Show the user the price. Get explicit go-ahead. Then: Step 2 — Charge + register + wire DNS/SSL automatically: domain_buy_confirm({ project_id: "my-app", domain: "acmedash.com", confirm_token: "tok_abc123" }) // → { domain: "acmedash.com", status: "active", dns_configured: true } The token expires in 10 minutes. If it lapses, call domain_buy again to mint a fresh one. The first paid domain on a Free account also unlocks 30 days of Builder for free. ## Tenant domains (one hostname per customer) domain_add_tenant({ project_id: "my-app", domain: "customer1.com" }) // → { cname_target: "proxy.somewhere.tech", cf_hostname_id, ssl_status } domain_verify_tenant({ project_id: "my-app", domain: "customer1.com" }) // SSL provisions automatically once DNS resolves. Inside your prod function, branch on the host header to render that tenant: // api/index.ts export default async function(req, sw) { const host = req.headers.get('host') const tenant = await sw.db.query( 'SELECT * FROM tenants WHERE hostname = ?', [host] ) return Response.json({ name: tenant.data[0].name }) } Tenant routes match every path (not just /api/*), unlike custom_domain which only routes /api/* to functions. Pick tenant when the customer should see your full app under their own brand; pick custom domain when you want one branded URL for your own app. ## Domain search // Search for available domains across many TLDs at once // (rate-limited to 10/min and 100/day per user; results are cached // for 5 minutes so the same keyword is instant after first hit) // → returned by the /v1/domains/search REST endpoint or the // somewhere.site /search page. ## Listing + removal domain_list({ project_id: "my-app" }) // custom domains domain_remove({ project_id, domain }) // detach a custom domain --- ## sw.logs — Application Logging (inside deployed functions) (sw.logs) Structured logs written to the project's log stream. Readable via the log MCP tool or dashboard Logs page. ## Write logs sw.logs.debug('loaded config', { keys: Object.keys(config) }) sw.logs.info('user signed up', { user_id: 'u_123' }) sw.logs.warn('slow query', { ms: 812, sql: 'SELECT ...' }) sw.logs.error('payment failed', { error: err.message, user_id: 'u_123' }) All four levels accept (message: string, data?: object). data is JSON-serialized, so keep it under a few KB per call. ## Retention Free: 7 days. Builder: 30 days. A daily cron prunes older entries — there is no archive. ## Reading logs Call the log MCP tool with project_id + optional level + limit. No full-text search across log bodies — log retrieval is sequential (newest first, optionally filtered by level). --- ## Analytics — Track Events, Query Aggregates (analytics) Per-project event tracking. Write events from anywhere (function, browser, MCP), then aggregate them by hour / day / event / user. Backed by an append-only event store. No per-user PII sanitization — treat what you write as eventually visible to anyone with project access. ## analytics_track({ project_id, event, user_id?, properties?, page?, referrer?, user_agent? }) analytics_track({ project_id: "my-app", event: "signup", user_id: "u_alice", properties: JSON.stringify({ plan: "builder", referrer: "blog" }) }) event: required, max 128 chars. Pick a stable name — you'll filter on this. Convention: snake_case verbs (signup, post_published, checkout_started). properties: JSON object, max 8KB total. Up to 20 numeric values are auto-aggregatable in queries (sum / avg). String values are kept as labels for grouping but not summed. page, referrer, user_agent are convenience fields for browser-side tracking; treat them as optional metadata. Returns { ok: true }. Track is fire-and-forget — there is no per-event read API. Aggregate via analytics_query. ## analytics_query({ project_id, event?, from?, to?, group_by?, limit? }) analytics_query({ project_id: "my-app", event: "signup", from: "2026-04-01", to: "2026-04-25", group_by: "day" }) // → [ // { day: "2026-04-01", count: 12, signup_count: 12 }, // { day: "2026-04-02", count: 18, signup_count: 18 }, // ... // ] event: omit to query across all events. from, to: ISO-8601 string ("2026-04-01") or unix-ms integer. group_by: "hour" | "day" | "event" | "user". Omit to get raw rows (most recent first). limit: defaults to 1000, max 10000. When group_by includes a numeric property in the events, the query returns sum_ and avg_ columns alongside count. ## Common patterns Funnel: // Track stages analytics_track({ project_id, event: "checkout_started", user_id }) analytics_track({ project_id, event: "checkout_completed", user_id }) // Daily conversion const started = await analytics_query({ event: "checkout_started", group_by: "day" }) const completed = await analytics_query({ event: "checkout_completed", group_by: "day" }) Per-user activity: analytics_query({ project_id, group_by: "user", limit: 100 }) // → top 100 users by event volume Browser-side tracking from your app (inside a deployed function). Derive user identity from the signed app-user JWT — never trust an ID sent by the browser. export default async function(req, sw) { const user = await sw.auth.fromRequest(req) // null if signed out const body = await req.json() await sw.analytics.track(body.event, { user_id: user?.id, // server-derived, signed properties: body.properties, page: req.headers.get('referer'), user_agent: req.headers.get('user-agent'), }) return Response.json({ ok: true }) } Query inside a function the same way: const today = await sw.analytics.query({ event: 'checkout_completed', group_by: 'day' }) ## What NOT to do - Don't write log lines through analytics — use sw.logs. Analytics is for things you want to count and aggregate, not narrative logs. - Don't put PII in the event NAME (it goes into every grouping). Put it in properties if you must. - Don't expect millisecond-level read-after-write — events are queryable within seconds, not instantly. --- ## sw.web — Read pages from the public web (sw.web) Available inside any deployed function via the sw argument (ctx still works as an alias). sw.web fetches public web pages and returns their content as clean markdown. Use it when an agent or feature needs to ingest content from a URL it doesn't host — docs, blog posts, marketing pages, articles. The platform proxies the call so your function never holds the upstream key and never has to fight CORS. ## sw.web.scrape(url, opts?) Fetch a URL and return its content as markdown by default. Strips nav/footer/sidebar so you get the main article body. const page = await sw.web.scrape('https://docs.somewhere.tech/getting-started') // page = { // url: '...', title: 'Getting started', description: '...', // language: 'en', status_code: 200, // markdown: '# Getting started\\n...' // } opts: formats? — array of: markdown | html | rawHtml | links | screenshot Default: ['markdown']. only_main? — strip nav/footer/sidebar (default true). Set false to keep the full DOM. wait_for? — ms to wait for the page to settle (max 30000). Use for SPAs that hydrate after initial load. ## sw.web.search(query, opts?) Search the public web by keyword. Returns ranked result URLs + titles + snippets — no page bodies. Pair with `sw.web.scrape` when you want the full content of a specific result. const hits = await sw.web.search('react server components streaming', { count: 5 }) // hits = { // query: '...', count: 5, // results: [ // { url: '...', title: '...', description: '...', age: '2 days ago', source: 'blog.example.dev' }, // ... // ] // } opts: count? — 1 to 20, default 10. country? — 2-letter code to bias results, e.g. 'us', 'gb', 'de'. freshness? — 'pd' (past day) | 'pw' (past week) | 'pm' (past month) | 'py' (past year). safesearch? — 'off' | 'moderate' | 'strict'. Default 'moderate'. ## When to use what - Find URLs by keyword → `sw.web.search`. Returns titles + snippets. - One page → `sw.web.scrape`. Returns markdown ready for an LLM prompt. - Search → scrape pattern is the standard agent loop: const hits = await sw.web.search(question, { count: 3 }) const pages = await Promise.all( hits.results.map((r) => sw.web.scrape(r.url)) ) - A whole site → not supported; scrape pages individually. ## Errors UPSTREAM_NOT_CONFIGURED — the platform doesn't have the matching provider wired in this environment. RATE_LIMITED — too many calls; retry with backoff. UPSTREAM_QUOTA — platform-wide quota exhausted; our team tops it up. UPSTREAM_ERROR — provider returned a non-2xx. For scrape, often means the page blocked the request or the URL is wrong. --- ## Search — Two Surfaces (search) The platform has two search systems for two different jobs. ## File search — sw.fs.search (inside functions only) Full-text search across the project's text files. Used by code agents to grep code, search docs, find TODOs. const hits = await sw.fs.search({ path: '/src/', query: 'TODO', limit: 50 }) This is documented in the sw.fs topic. Per-project full-text index, kept in sync automatically on every fs write/delete/move/replace. ## Semantic search — search_index_* MCP tools Vector search for product features (FAQ search, semantic recommendations, RAG retrieval). Embeddings are 768-dim (cosine distance). Each project can hold many named indexes. Indexes can be managed from MCP (search_index_create / upsert / query etc.) and from inside your deployed function via sw.search.* — same operations, no API key juggling. See "From inside a function" below. ## search_index_create({ project_id, name }) Idempotent — returns the existing index if name already exists. Returns { name, project_id, dimensions, distance_metric, created_at }. ## search_index_list({ project_id }) Lists every index on the project: [{ name, item_count, created_at }]. ## search_delete({ project_id, index, ids? }) Delete from a search index. Pass ids (a JSON array of item id strings) to remove specific items; omit ids to drop the entire index AND all items in it (irreversible). ## search_upsert({ project_id, index, items }) Insert or replace items. items is a JSON array of: { id, content, metadata? } search_upsert({ project_id: "my-app", index: "faqs", items: JSON.stringify([ { id: "faq-1", content: "How do I reset my password?", metadata: { category: "account" } }, { id: "faq-2", content: "Where can I see my billing history?", metadata: { category: "billing" } } ]) }) content is embedded server-side — you never compute embeddings yourself. metadata is returned alongside results but NOT searched. Max 100 items per call. ## search_query({ project_id, index, query, limit? }) Returns top N matches by cosine similarity: { results: [{ id, score, content, metadata }] } search_query({ project_id: "my-app", index: "faqs", query: "I forgot my login", limit: 5 }) Score is 0–1. Higher is closer. Limit defaults to 10, max 100. ## From inside a function — sw.search.* Same surface, same args, no API key. Use sw.search inside a deployed function — the platform binding handles auth and project scope. // Index management await sw.search.createIndex('faqs') const idxs = await sw.search.listIndexes() await sw.search.deleteIndex('faqs') // Upsert await sw.search.upsert({ index: 'faqs', items: [ { id: 'faq-1', content: 'How do I reset my password?', metadata: { category: 'account' } }, ], }) // Query const { results } = await sw.search.query({ index: 'faqs', query: 'I forgot my login', limit: 5, }) // Remove specific items await sw.search.remove({ index: 'faqs', ids: ['faq-1'] }) ## When to use which - Searching files (code, markdown, configs in your project) → sw.fs.search - Semantic / "find similar meaning" over your app's content → sw.search.* (or search_index_* MCP) - Searching log bodies or analytics → use sw.logs / sw.analytics.query ## What NOT to do - Don't put PII in metadata if you don't have to — it's stored with the embeddings. - Don't reindex a whole catalog on every write; batch upserts of 100. - Don't expect exact-match — semantic search ranks by meaning, not keyword overlap. --- ## Render — Screenshots and PDFs (render) Render web pages or HTML strings to images or PDFs. Useful for generating social cards, invoices, report exports, OG images. Both endpoints either return the bytes inline (base64) or write straight to your project's file store and return the storage path. > **Screenshots: use `browser` instead.** `render_screenshot` is > DEPRECATED — `browser` covers every case (a quick app shot, an > arbitrary third-party url, and rendering a raw `html` snippet to an > image). See `platform_help({ topic: 'browser' })`. `render_screenshot` > still works for back-compat; the docs below are retained for it. > PDFs have no browser equivalent — keep using `render_pdf`. ## render_screenshot({ url? | html?, width?, height?, format?, quality?, full_page?, wait_for?, project_id?, storage? }) — DEPRECATED, prefer browser render_screenshot({ url: "https://example.com", width: 1200, height: 630, format: "png", full_page: false }) // → { format: "png", bytes: "iVBORw0KGgo...", size_bytes: 87421 } Defaults: 1280x800, PNG. Set full_page: true to capture the entire scrollable page. format: "png" | "jpeg" | "webp". quality (1–100) applies to jpeg/webp. wait_for is a CSS selector to wait for before capture, useful when the page lazy-loads content: wait_for: "#chart-loaded" Direct-to-storage: render_screenshot({ url: "https://...", project_id: "my-app", storage: "/renders/og-2026-04.png" }) // → { storage_path: "/renders/og-2026-04.png", size_bytes: 87421 } // File is now servable at https://my-app.somewhere.tech/storage/renders/og-2026-04.png ## Authed screenshots — session seeding Three optional params seed browser state BEFORE the page loads, so you can screenshot a logged-in view (visual QA of a dashboard, an authed settings page): - `local_storage` — key→value strings written into localStorage before load. The standard auth client keeps its session under `sw_auth`, so seeding that key boots the app signed in. - `cookies` — array of `{ name, value }` set on the target origin. - `headers` — flat object of extra request headers (e.g. `Authorization`). Seeding requires `url` + `project_id` and only works on the project's OWN origins (`{subdomain}.somewhere.tech`, `{subdomain}-dev`, verified custom domains) — credentials are never sent to third-party sites. 8 KB total cap across all three params; values are never logged. **Seed a test user's session, never a real user's.** Create a dedicated test account in your app and log it in to get tokens to seed. Real credentials in tool calls end up in conversation transcripts. ## render_pdf({ url? | html?, format?, landscape?, print_background?, wait_for?, project_id?, storage? }) render_pdf({ html: "

Invoice #123

...", format: "Letter", print_background: true }) // → { format: "pdf", bytes: "JVBERi0...", size_bytes: 14821 } Defaults: Letter, portrait, backgrounds rendered. format: "A4" | "A3" | "Letter" | "Legal" | "Tabloid". landscape: true rotates the page. Same storage option as screenshot — pass project_id + storage to write straight to the file store. ## Common patterns Generate an OG image and serve it: // 1. Render once when content publishes await render_screenshot({ html: `
Post title
`, project_id: "my-app", storage: `/og/${post.slug}.png`, width: 1200, height: 630 }) // 2. Reference in your meta tags Generate a PDF invoice on demand (inside a deployed function): export default async function(req, sw) { const { invoiceId } = await req.json() const html = await renderInvoiceHTML(invoiceId, sw) // sw.render.pdf returns a raw Response when no storage path is set — // pipe it straight to the client. return sw.render.pdf({ html, format: 'Letter' }) } Or write the PDF to file storage and return its URL: export default async function(req, sw) { const { invoiceId } = await req.json() const html = await renderInvoiceHTML(invoiceId, sw) const result = await sw.render.pdf({ html, format: 'Letter', storage: `/invoices/${invoiceId}.pdf`, }) // result = { storage_path: '/invoices/...', size_bytes, content_type } return Response.json({ url: `https://${sw.subdomain}.somewhere.tech/storage${result.storage_path}` }) } ## Limits - 30 second hard timeout per render. Pages that take longer fail. - Inline returns hit JSON size limits — use storage for >5MB outputs. - One render at a time per project (no concurrent burst). ## What NOT to do - Don't pass user-controlled URLs without a domain allowlist — the renderer will fetch any URL you give it. - Don't render on every page load — cache to /storage and reuse. --- ## Browser — your live app's eyes, ears, and hands (browser) `browser` opens your DEPLOYED app in a real headless browser so you can SEE it, INSPECT it, and DRIVE it — one tool for the whole loop of "what does my app look like, is it healthy, and does it actually work?" - **Eyes** — a screenshot of the page. - **Ears** — console errors, page errors, and failed network requests (a backend 500 shows here even when the page looks fine). - **Hands** — click / fill / assert your way through a real flow. `run_code` proves a function's backend; `browser` proves the rendered UI. The report is SIGNALS-FIRST: signals come before the screenshot, and `passed` reflects step outcomes only — ALWAYS read failed_requests too. The principle: **act by selector, verify by signals, screenshot small + last.** You wrote the DOM, so target elements directly — don't guess at pixel coordinates like a computer-use agent staring at a stranger's screen. ## Just look (omit steps) Omit `steps` and you get a small screenshot, the console/network signals, AND the data-testid map + an interactive-element outline (tag, id, testid, text, selector) — the handles you can act on, instead of reverse-engineering selectors from scraped HTML: browser({ project_id: 'my-saas' }) // → { console_errors, page_errors, failed_requests, // screenshots: [{ label: 'page', fs_path: '/_browser_tests/.../00-page.jpg' }], // dom_outline: [{ tag:'button', testid:'submit', text:'Log in', selector:'[data-testid="submit"]' }, ...], // testid_map: { submit: '[data-testid="submit"]', ... }, final_url, passed } For a quick screenshot of your app, this IS the call — a url or project_id with no steps. ## Capture any page, or render raw HTML (the render_screenshot fold) `browser` now covers everything the old `render_screenshot` tool did, so reach for it instead (`render_screenshot` still works for back-compat): // Any public third-party page — pass a url with NO project_id: browser({ url: 'https://example.com' }) // Render a raw HTML snippet straight to an image (e.g. an OG card): browser({ html: '

Hello

', width: 1200, height: 630 }) // → { content_type: 'image/png', size_bytes, base64 } // …or save the snippet straight to the project file store: browser({ html: '
', project_id: 'my-app', storage: '/og/card.png' }) // → { storage_path: '/og/card.png', size_bytes } `html` mode skips navigation/steps/DOM-map — it just returns the picture. `width` / `height` / `wait_for` tune it. When you pass `project_id`, a `url` is scoped to that project's origin; omit `project_id` to hit an arbitrary url. (PDFs have no browser equivalent — use `render_pdf`.) ## Drive a flow (add steps) `steps` is an array of objects, each with an `action` and that action's fields. Copy this shape exactly — NOT `{ click: {...} }` (that 400s): browser({ project_id: 'my-saas', steps: [ { action: 'goto', path: '/login' }, { action: 'fill', selector: '#email', value: 'a@b.co' }, { action: 'fill', selector: '#password', value: 'pw' }, { action: 'click', selector: 'button[type=submit]' }, { action: 'wait_for', selector: '.dashboard' }, { action: 'assert_request', url_contains: '/api/login', status: 200 }, { action: 'assert_text', selector: '.welcome', contains: 'Hi' }, { action: 'screenshot', label: 'after-login' }, ], }) Every action (max 30, ~60s total): goto, click, fill, press, select, hover, wait_for (selector | ms), screenshot (stored as a file path under /_browser_tests/, never inline; opt-in full_page), assert_visible, assert_text, assert_url, assert_request (url_contains + status), click_at (coordinate fallback ONLY). ## Logged-in flows browser({ project_id: 'my-saas', auth: { user_id: 'usr_123' }, steps: [...] }) Mints a 1-hour session for that app user (audited) and injects it before navigation, so authed pages work. Requires a project. ## Reading the result `{ console_errors, page_errors, failed_requests, steps:[{step, ok, error?, duration_ms}], screenshots:[{label, fs_path}], final_url, passed }` `passed` reflects step outcomes only — ALWAYS read failed_requests too: a backend 500 shows there even when every step visually "passed". A failed step aborts the run but the state at failure is still returned; pass `continue_on_failure: true` to run them all. ## Screenshots — small by default Screenshots are tuned for cheap vision tokens: ~800px wide JPEG at quality 70, stored as a file path (never inlined). Need pixel precision? Override per run: browser({ project_id: 'my-saas', screenshot: { width: 1280, format: 'png' } }) Fields: width (default 800, never upscaled past the viewport), format ('jpeg' default | 'png'), quality (1–100, jpeg only, default 70). The override applies to the no-steps page shot and every screenshot step. ## Limits - 30 steps, ~60s total budget, one run at a time per project. - A screenshot needs a project to store the image — pass project_id, or a *.somewhere.tech url that resolves to a project you own. - Look at / test your OWN projects only. --- ## Video — Upload, Stream, Manage (video) Upload video files, get HLS + DASH playback URLs, list and delete. The platform stores videos on a streaming service and gives you direct upload URLs the client uses without ever touching your worker. ## video_upload_url({ project_id, title?, max_duration_seconds?, require_signed_urls? }) Get a one-time upload URL. The client POSTs the video bytes directly to this URL using the TUS resumable upload protocol — your function never sees the bytes. video_upload_url({ project_id: "my-app", title: "Demo walkthrough", max_duration_seconds: 600 }) // → { upload_url: "https:///...", video_id: "vid_abc" } max_duration_seconds: 30–21600 (6 hours), defaults to 3600 (1 hour). Uploads exceeding the limit are rejected at ingest. require_signed_urls: true means playback requires a short-lived signed token. Use for paid content. Default false (public playback). ## video_list({ project_id, limit? }) Returns videos on the project, newest first. limit defaults to 50, max 200. [ { id: "vid_abc", status: "ready" | "queued" | "inprogress" | "error", duration_seconds: 184, size_bytes: 24310291, thumbnail: "https://...thumbnails/thumb.jpg", preview: "https://...preview.mp4", hls_url: "https://...manifest/video.m3u8", dash_url: "https://...manifest/video.mpd", created_at: "2026-04-25T...", ready: true } ] ## video_get({ id }) Same shape as a list item. Useful for polling status after upload — ready: false → ready: true once encoding finishes (typically within minutes). ## video_delete({ id }) Permanently delete the video and all derived files. Irreversible. ## Common patterns Browser upload flow: // 1. Backend mints the upload URL const { upload_url, video_id } = await video_upload_url({ project_id }) // 2. Frontend uploads via TUS (use the tus-js-client library) const upload = new tus.Upload(file, { endpoint: upload_url, onSuccess: () => savedVideoId(video_id) }) upload.start() // 3. Backend polls video_get until ready, then shows the player const v = await video_get({ id: video_id }) if (v.ready) renderPlayer(v.hls_url) Embed in a page: // Use hls.js for cross-browser HLS playback. ## What NOT to do - Don't proxy video bytes through your worker — the upload_url accepts the bytes directly from the client. That's the whole point. - Don't poll video_get every 100ms; once a second is plenty. - Don't issue the same upload_url to multiple clients — each URL is single-use. --- ## sw.rateLimit — Rate limiting (sw.rateLimit) Throttle abusive callers without standing up Redis or a token-bucket library. One method, fixed-window counter, per-second granularity. ## sw.rateLimit.check(key, max, windowSeconds) Returns `{ allowed, remaining, reset, limit, window_seconds }`. On `allowed: false`, return 429 to the caller. `reset` is the epoch seconds when the current window rolls over. ## Per-IP login throttle ```js export async function POST(req, sw) { const ip = req.headers.get('cf-connecting-ip') || 'unknown' const rl = await sw.rateLimit.check(`login:${ip}`, 5, 60) if (!rl.allowed) { return Response.json( { error: 'too_many_attempts', retry_at: rl.reset }, { status: 429 } ) } // …handle login } ``` ## Per-user API throttle ```js const user = await sw.auth.fromRequest(req) const rl = await sw.rateLimit.check(`api:${user.id}`, 100, 60) if (!rl.allowed) return Response.json({ error: 'rate_limited' }, { status: 429 }) ``` ## Per-endpoint global cap ```js const rl = await sw.rateLimit.check('signup', 1000, 3600) // 1k signups/hour if (!rl.allowed) return Response.json({ error: 'paused' }, { status: 429 }) ``` ## Notes - Keys are scoped per project automatically — `login:1.2.3.4` in your app does not collide with the same key in someone else's app. - `max` is a positive integer up to 1,000,000. - `windowSeconds` is between 1 and 86,400 (24h). - Counters are best-effort fixed-window. A small under-count is possible at window boundaries on highly concurrent calls — fine for throttling, not appropriate for hard quotas. --- ## Tasks — Per-project ticketing (tasks) Track work, incidents, or any todo-shaped state per project. Tasks and their comments live in the project's own database (auto-created on first write) so they travel with db_export / db_restore. Free + unlimited. Every mutation publishes a realtime event automatically. ## From a deployed function ```js const t = await sw.tasks.create({ title: 'Investigate latency', priority: 'high', area: 'api', }) await sw.tasks.comment(t.id, 'spike started ~14:02 UTC') await sw.tasks.update(t.id, { status: 'in_progress', assignee: userId }) // Break work into sub-tasks. const sub = await sw.tasks.create({ title: 'Profile the slow endpoint', parent_id: t.id, area: 'api', }) const open = await sw.tasks.list({ status: 'open', limit: 100 }) const apiOnly = await sw.tasks.list({ area: 'api' }) const topLevel = await sw.tasks.list({ parent_id: 'null' }) // no parent const children = await sw.tasks.list({ parent_id: t.id }) const full = await sw.tasks.get(t.id) // includes comments[] await sw.tasks.delete(t.id) ``` ## Fields - `id` (`tsk_…`) - `title` (required), `description` - `status`: `backlog` | `open` | `in_progress` | `blocked` | `needs_review` | `done` | `archived`. `backlog` is for raw, untriaged ideas — devs filter with `status:'open'` so backlog doesn't surface in active queues; promote when ready to work. Use `blocked` when external action is required, `needs_review` when work is done but waiting on sign-off. - `priority`: `low` | `normal` | `high` | `urgent` - `assignee`, `reporter` (defaults to caller) - `labels`: string[] - `due_at`: ms epoch - `area`: free-form subsystem tag (e.g. `billing`, `auth`, `ui`) for filtering related tasks - `parent_id`: optional parent task ID — makes this a sub-task. A task cannot be its own parent. Deleting a parent does NOT cascade — detach or delete sub-tasks first if you want them gone. - `created_at`, `updated_at`, `completed_at` (auto-synced when transitioning to / from `done`) ## Notifications Every mutation publishes `task.created` / `task.updated` / `task.deleted` / `task.commented` on the project's `system:project` realtime channel — no config required. Optionally fan out to a webhook URL and / or email: ```js const { webhook_secret } = await sw.tasks.settings.update({ webhook_url: 'https://example.com/task-webhook', notify_email: 'alerts@yourcompany.com', }) // Store webhook_secret — it's returned exactly once. ``` Webhook signature header: `X-Somewhere-Signature: t=,v1=` — signed body is `${ms}.${rawBody}`. Same scheme as db_webhooks and inbox. Pass an empty string to clear a field. Clearing `webhook_url` also clears the secret. `sw.tasks.settings.get()` never returns the secret — only the first `settings.update` that mints one does. ## MCP tools - `tasks_create` / `tasks_list` (pass `task_id` to read one task with its comments) / `tasks_update` (also accepts `comment` to append a comment) / `tasks_delete` - `tasks_settings_get` / `tasks_settings_update` ## Default ordering `tasks_list` and `sw.tasks.list({})` sort active tasks ahead of `done` ones, then by priority (`urgent` → `high` → `normal` → `low`), then by `created_at DESC` within each priority. Pass an explicit filter (`status`, `priority`, `area`, `parent_id`) to narrow. There's no `order_by` field — if you need a different order, sort client-side. ## Closing a task: always include a resolution_note ```js await sw.tasks.update(id, { status: 'done', resolution_note: 'Root cause: stale CDN cache. Purged + added cache-bust query string. No customer impact.', }) ``` The note is persisted as a comment AND included in the resolution email + webhook payload so the reporter knows what was done. If you omit it on a task with zero prior comments, the API returns the same `{ ok: true }` response with an extra `hint` field nudging you to add one. The warning is non-blocking — bulk triage of stale tickets doesn't need a note on every row. ## Best practices - **Priority honestly.** Reserve `urgent` for things actively breaking customers. Default to `normal`. `high` is for "needs this sprint". - **Use `area` consistently per project.** Pick 4–8 tags (`billing`, `auth`, `api`, `ui`, `infra`) and reuse them. The dashboard can filter on it; agents can list per-area open work. - **Parent + sub-tasks for multi-step work.** Use `parent_id` when a task naturally splits. Don't create a parent for a one-liner. - **Don't delete; close as `done` or `archived`.** The audit trail matters. `delete` is for malformed test rows. --- ## Smoke tests — auto-wired uptime checks (smoke) Every deployed project is probed automatically — no setup. Homepage on the project's `*.somewhere.tech` subdomain plus each claimed custom domain root, every ~20 minutes via cron and once after every deploy. Status per run: `pass` / `partial` / `fail`. A fail-after-pass transition is detected and alerted automatically, debounced by `alert_cooldown_seconds` (default 3600). ## MCP smoke_status({ project_id }) → { latest: SmokeRun, history: SmokeRun[] } smoke_run({ project_id }) → { run: SmokeRun } // synchronous on-demand probe smoke_config_get({ project_id }) smoke_config_update({ project_id, enabled?, test_auth_flow?, alert_cooldown_seconds?, extra_checks?, test_user_email?, canary_enabled? }) `extra_checks` is an array of additional URL probes: [ { name: "users-list", path: "/api/users", expect_status: 200 }, ... ] ## Probe fields Each probe accepts: - `path` (required) — must start with `/`. - `method` — default `GET`. - `name` — label in run results (max 80 chars); auto-generated if omitted. - `expect_status` — exact HTTP status. Without it, anything below 500 passes — useful for "POST /api/login with no body should be 4xx, not 500" and similar contract checks. - `headers` — extra request headers (max 10 per probe). - `body` — request body. An object is sent as JSON (Content-Type set for you); a string is sent raw. Max 8 KB. - `auth: "test_user"` — the probe runs authenticated: the platform mints a fresh session for the project's designated test user at probe time. Set the test user first with `smoke_config_update({ test_user_email })` — it must be an existing app user of the project (create one via signup). No credentials are ever stored in the manifest. When `auth` is set, the Authorization header can't be overridden via `headers`. - `expect_json_path` — dot-path (or array of them, max 10) that must resolve to a non-null value in the JSON response, e.g. `"data.reply"` or `"choices.0.message"`. - `expect_contains` / `expect_not_contains` — substrings the response text must / must not contain (string or array, max 10 each). - `expect_max_ms` — fail the probe if it takes longer than this, even on HTTP 200. ## 15-minute canary (opt-in) smoke_config_update({ project_id, canary_enabled: true }) Runs the full check set (homepage + custom domains + extra_checks + `/_smoke_tests.json`) every 15 minutes, on top of the regular cadence. Off by default. A fail-after-pass transition alerts the project owner (debounced by `alert_cooldown_seconds`). ## Auto-detected: `/_smoke_tests.json` Deploy a JSON file at `/_smoke_tests.json` in your project and the cron picks up the entries automatically — same shape as `extra_checks`, no config call needed. Up to 20 entries per project. Useful when you want the smoke contract to ship with the code, not sit in the dashboard: ```json [ { "name": "homepage", "path": "/", "expect_status": 200 }, { "name": "auth-page", "path": "/login", "expect_status": 200 }, { "name": "api-list-no-auth", "path": "/api/users", "method": "GET", "expect_status": 401 } ] ``` If both `extra_checks` and `/_smoke_tests.json` are present, both run — they're additive. ## Opt out await smoke_config_update({ project_id: '...', enabled: false }) Use for projects whose homepage is intentionally a 401 (private apps) or where the cron is a nuisance during a known outage window. You can still trigger `smoke_run` while disabled — the opt-out only stops the automated cron + deploy hooks. --- ## Portability — getting your data out (portability) The platform doesn't lock you in. Three exit paths, all one-shot: ## Full database dump ```bash # Via MCP (in Claude Code / any MCP client) db_dump({ project_id: "my-app" }) # Via the CLI (when shipped) somewhere db dump > backup.sql # Via curl curl -X POST https://api.somewhere.tech/v1/db/dump \\ -H "Authorization: Bearer smt_..." \\ -H "Content-Type: application/json" \\ -d '{"project_id":"my-app"}' \\ -o backup.sql ``` Returns a single `.sql` file: schema (CREATE TABLE / INDEX / TRIGGER) + every row as INSERT statements + transaction wrapper. The `.sql` dump restores into any SQL database — load it with your SQL client of choice, or convert to Postgres via `pgloader backup.sql postgresql://user:pass@host/db`. The platform never sees the `.sql` after handing it to you. Per-table cap of 1M rows in the in-worker dump; larger tables get a truncation warning appended. Contact support if you need a streaming dump above that. ## Per-table CSV ```bash db_export({ project_id: "my-app", table: "orders" }) ``` Up to 100K rows per call, optional filters. Useful for grabbing one specific table when you don't need the full dump. ## File storage ```bash fs_read({ project_id: "my-app", path: "/" }) # list a directory fs_read({ project_id: "my-app", path: "..." }) # one file # Or browse via the dashboard's Files tab and download via signed URLs. ``` ## Source code Your project's deployed functions live in the platform's file store. Pull them back via: ```bash somewhere pull # CLI clones into ./ ``` The code is standard JS/TS. The `sw.*` API is documented in `platform_help`; replacing it means swapping the binding for direct serverless / Postgres / Stripe / external AI API calls. No proprietary build step, no required framework — what you wrote runs on any JS-compatible serverless runtime. ## What you cannot easily port - The somewhere.tech subdomain (`.somewhere.tech`) doesn't follow you. Custom domains do (you own the DNS). - End-user passwords are stored as standard bcrypt hashes — export them and import to any platform that accepts bcrypt; your users keep their existing passwords, no reset needed (bcrypt is a portable, standard format). - The dashboard, copilot, and security-review surfaces are platform features — they don't migrate, but your data + code does. Related: `sw.db`, `security-model`, `portability`. --- ## Architecture Patterns — How to wire common app shapes (architecture-patterns) Each pattern is a working sketch you can lift and adapt. ## Two ways to use the platform There are two distinct entry points. Pick based on where your code runs. **Outside the platform** (your laptop, CI, a different host): - Auth: `Authorization: Bearer smt_...` developer key (or app-user JWT for end-user calls) - Surface: REST at `https://api.somewhere.tech/v1/*` and MCP at `https://mcp.somewhere.tech/mcp` - Use case: deploy scripts, admin tooling, agent calls, webhooks from third parties **Inside a deployed function** (code running on the platform): - Auth: none — the binding is pre-scoped to your project - Surface: `sw.db`, `sw.fs`, `sw.ai`, `sw.email`, `sw.auth`, `sw.env`, `sw.jobs`, `sw.queue`, `sw.logs` - Use case: API endpoints, server-side logic, AI proxies, file uploads, scheduled work When you write a function, default to `sw.*`. Only fall back to the REST surface when you have a specific reason (calling another project, calling from outside). ## Pattern: Chat app with auth + history Database: ```text users(id, email, password_hash, ...) -- created by sw.auth.signup _conversations(id, project_id, ...) -- platform-managed _conversation_messages(...) -- platform-managed ``` Function (`api/chat.ts`): ```typescript export default async function (req, sw) { const { user } = await sw.auth.me(req.headers.get('Authorization')?.slice(7)) if (!user) return new Response('Unauthorized', { status: 401 }) const { conversation_id, message } = await req.json() const result = await sw.ai.chat({ provider: 'anthropic', model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: message }], conversation_id // platform persists + truncates history }) return Response.json({ conversation_id: result.conversation_id, reply: result.content[0].text }) } ``` Conversation history is stored on the platform — you don't manage it. Pass the same `conversation_id` on subsequent calls and the platform loads + truncates history automatically. ## Pattern: REST API with end-user auth Function (`api/posts.ts`): ```typescript export default async function (req, sw) { const jwt = req.headers.get('Authorization')?.slice(7) const { user } = await sw.auth.me(jwt) if (!user) return Response.json({ error: 'Unauthorized' }, { status: 401 }) if (req.method === 'GET') { const { data } = await sw.db.query( 'SELECT * FROM posts WHERE author_id = ? ORDER BY created_at DESC', [user.id] ) return Response.json(data) } if (req.method === 'POST') { const { title, body } = await req.json() const { data } = await sw.db.query( 'INSERT INTO posts (author_id, title, body) VALUES (?, ?, ?) RETURNING *', [user.id, title, body] ) return Response.json(data[0], { status: 201 }) } } ``` Auth is a single `sw.auth.me(jwt)` call. The platform validates the token, returns the user, and you scope queries by `user.id`. ## Pattern: File upload with public URL Function (`api/upload.ts`): ```typescript export default async function (req, sw) { const { user } = await sw.auth.me(req.headers.get('Authorization')?.slice(7)) if (!user) return new Response('Unauthorized', { status: 401 }) const formData = await req.formData() const file = formData.get('file') as File const buf = await file.arrayBuffer() const path = `/uploads/${user.id}/${crypto.randomUUID()}-${file.name}` await sw.fs.write(path, new Uint8Array(buf), { contentType: file.type }) // Public URL — anyone with the URL can read. const url = `https://${sw.env.PROJECT_SUBDOMAIN}.somewhere.tech/storage${path}` return Response.json({ url }) } ``` Files written under `/uploads/` are served on the project's domain at `/storage/`. For private files, use a separate path prefix and gate downloads through a function. ## Pattern: Background work Inline jobs (return immediately, work later): ```typescript // API endpoint export default async function (req, sw) { const { email } = await req.json() await sw.jobs.create({ handler: 'jobs/send-welcome', payload: { email } }) return Response.json({ queued: true }) } ``` Handler (`jobs/send-welcome.ts`): ```typescript export default async function ({ payload }, sw) { await sw.email.send({ to: payload.email, subject: 'Welcome', html: '

Hi!

' }) } ``` For fire-and-forget side effects use `sw.queue.push({ handler, payload })` — no return value, no status to inspect. For scheduled work use `cron_create` to point a cron expression at a function path. ## Pattern: External API proxy (hide your key) ```typescript // api/openai-proxy.ts — keeps OPENAI_API_KEY off the client export default async function (req, sw) { const { user } = await sw.auth.me(req.headers.get('Authorization')?.slice(7)) if (!user) return new Response('Unauthorized', { status: 401 }) const body = await req.json() const r = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { Authorization: `Bearer ${sw.env.OPENAI_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) return new Response(r.body, { status: r.status, headers: { 'Content-Type': 'application/json' } }) } ``` Set `OPENAI_API_KEY` as an env var on the project. The browser never sees it. Same pattern for Stripe, Twilio, anything. For built-in models (chat, image generation, TTS, embeddings, etc.) prefer `sw.ai.*` — billing is metered through the platform and you don't manage upstream keys at all. --- ## Live data — one poller, fan out to every client (live-data) The golden path for any app that shows the SAME live value to many viewers at once: live scores, a price ticker, a leaderboard, an auction count, a status board. There is a right way and a wrong way, and the wrong way is the one most agents reach for first. **Anti-pattern (don't): per-client polling.** Every viewer hits the source on a timer. A World-Cup-style live-scores page where 5,000 viewers each poll the source every 12 seconds makes ~25,000 calls/minute to that source — your rate limits trip, the source may ban you, and the cost scales with your audience. Worse with every viewer you add. **Pattern (do): one server-side poller, fan out over realtime.** Fetch the source ONCE on the server per tick and push the result to every client through a realtime channel. Load on the source is O(1) — the same single fetch whether one person or a million are watching. ## Step 0 — taking over a project you didn't build Adding live data usually means editing an app you didn't write. Orient first, with the dependable sequence: 1. project_files_list { project_id } — the file tree (paths only). 2. project_grep { project_id, pattern } — find where things live (e.g. the polling code, the page that renders the value). 3. project_file_read { project_id, path } — read the few files that matched. `project_architecture` exists and can be handy, but it can return empty or "pending" on a project that hasn't been analyzed yet — so files + grep is the reliable FIRST move, not the architecture summary. ## The pattern, end to end 1. A scheduled function (cron) fetches the live source ONCE per tick. 2. It writes a snapshot to durable state — a `sw.db` row or a `sw.fs` JSON file — so a client connecting mid-stream reads the current value immediately (realtime does NOT replay past events). 3. It publishes the change on a realtime channel. 4. Each client reads the snapshot ONCE for first paint, then subscribes to the channel. No client ever polls the source. ## Server — the poller (a scheduled function) ```typescript // api/poll-scores.ts — fetch once, snapshot, fan out. export default async function (req, sw) { const scores = await (await fetch('https://example.com/api/scores/live')).json() // 1) snapshot so new joiners read the current value (realtime has no replay) await sw.db.query( `INSERT INTO live_snapshot (key, json) VALUES ('scores', ?) ON CONFLICT(key) DO UPDATE SET json = excluded.json`, [JSON.stringify(scores)] ) // 2) fan out — ONE publish reaches every connected client await sw.realtime.publish('live-scores:match-9', scores, { event: 'update' }) return Response.json({ ok: true }) } ``` Schedule it to refresh once a minute: ```text cron_create({ project_id: "my-app", schedule: "* * * * *", handler: "/api/poll-scores" }) ``` ## Client — read once, then subscribe (never poll) ```js // 1) first paint from the snapshot (a normal endpoint that reads live_snapshot) render(await (await fetch('/api/scores')).json()) // 2) live updates — subscribe; the source is never touched from the browser client.channel('live-scores:match-9') .on('broadcast', { event: 'update' }, ({ data }) => render(data)) .subscribe() ``` The handler receives the realtime envelope; `data` is exactly what the server published. Using a raw WebSocket instead of the SDK? Paste the `swRealtime()` reconnect helper from `platform_help('realtime')` — same channel name, same `event`. ## Sub-minute updates (live sports, finance) **Cron's minimum interval is one minute — a hard floor.** A 5-field cron expression has no seconds field, so you cannot schedule "every 10 seconds." For sub-minute live data you do NOT lean on cron for the cadence: - If you RECEIVE the data (a webhook, an inbound feed, a user action), call `sw.realtime.publish` the instant it arrives — realtime carries every intra-minute change with no schedule at all. - If you must PULL it, run the cron poller at the 60-second floor to keep the snapshot fresh, and let realtime carry the faster changes pushed from wherever new data enters your system. Either way the rule holds: clients read the snapshot once, then live on the channel — the source is hit O(1), never once-per-viewer. ## Reference data: enrich into a table, don't hardcode it Seed/reference data — a squad roster, a product catalog, a venue list — does NOT belong inline in your frontend. Hardcoding it means every correction is a redeploy. Instead, write a RE-RUNNABLE job that builds the dataset into `sw.db` (or a `sw.fs` JSON file), and have the page read it at runtime: ```typescript // jobs/build-rosters.ts — fetch sources, extract to a schema, store. Re-runnable. export default async function (req, sw) { const raw = await (await fetch('https://example.com/teams')).text() const result = await sw.ai.chat({ provider: 'anthropic', model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: `Extract every player from this page as a JSON array of {team, name, number, position}. Return ONLY the JSON.\\n\\n${raw}` }], }) const players = JSON.parse(result.text) for (const p of players) { await sw.db.query( 'INSERT INTO players (team, name, number, position) VALUES (?, ?, ?, ?)', [p.team, p.name, p.number, p.position] ) } return Response.json({ inserted: players.length }) } ``` Run it on demand or on a cron (`cron_create`) to refresh. The frontend fetches `/api/players`, so correcting a roster updates the live app with NO redeploy. Same shape for any "fetch sources → extract to a schema → serve" job. --- ## Common Mistakes — Things real users have hit (common-mistakes) **STOP.** Before writing any code, check if the platform already handles it. Eight rules cover most of the wasted-time tickets: - ❌ Don't build a `messages` or `conversations` table for chat history → use `conversation_id` on `sw.ai.chat()`. The platform loads, persists, and truncates history for you. - ❌ Don't write `getCookie()` / token-parsing helpers → `sw.auth.fromRequest(req)` reads cookie + Bearer header and returns the validated user (or null) in one call. - ❌ Don't write anon-session management → `sw.auth.anonSession(req)` for the cookie + uuid, `sw.auth.migrateAnon({ anonId, userId })` to hand the rows over on signup. - ❌ Don't hand-roll Stripe webhook handling — see the `payments` topic for the supported flow. - ❌ Don't deploy a single-file change via MCP `project_deploy` — that replaces every file in the project. Use `project_patch` for a single file or the `somewhere deploy` CLI for a full deploy. - ❌ Don't write retry / timeout / rate-limit loops around `sw.*` — the platform retries upstream calls already; nested retries make things worse. - ❌ Don't call `https://api.somewhere.tech/v1/...` from inside a deployed function → use `sw.*` (same surface, no network hop, no API key juggling). - ❌ Don't set env vars for platform services (`SOMEWHERE_API_KEY`, database URLs, etc.) → `sw.*` is already authenticated. Env vars are for YOUR external services (e.g. a payment provider, an SMS API, your own AI key, etc). The rest of this topic is the *long* list of failure modes from real support tickets. Skim once. ## 1. Calling api.somewhere.tech from inside a deployed function **Wrong:** ```typescript // inside a deployed function const res = await fetch('https://api.somewhere.tech/v1/db/query', { headers: { Authorization: 'Bearer smt_...' }, ... }) ``` **Right:** ```typescript const res = await sw.db.query('SELECT * FROM users') ``` Inside a deployed function you have direct bindings: `sw.db`, `sw.fs`, `sw.ai`, `sw.email`, `sw.auth`, `sw.env`, `sw.jobs`, `sw.queue`, `sw.logs`. Each one talks to the platform without a network hop and without auth juggling. The REST surface (`api.somewhere.tech`) is for code running OUTSIDE the platform — your laptop, a third-party server, a webhook handler somewhere else. ## 2. Using project_deploy when you meant project_patch `project_deploy` **replaces** every file in the project. If you pass `{ files: { "index.html": "..." } }`, every other file is deleted. For a single-file edit, use `project_patch` — one file per call. Two modes: `{ path, content }` rewrites the file, or `{ path, find, replace }` does a server-side substring swap (~200 bytes on the wire). The platform auto-routes the path to static or function. For multi-file changes, call `project_patch` once per file (the platform preserves everything you don't name). ## 3. Putting tokens or JWTs in URLs Don't do this: ```text https://my-app.somewhere.tech/dashboard?token=eyJhbGc... ``` Tokens in query strings end up in: - Browser history - Server access logs - HTTP referrer headers when the page links out - Anyone watching over your shoulder Use cookies (httpOnly, secure, sameSite) or the `Authorization: Bearer` header. The auth flows the platform ships with already do this — only break the pattern if you have a very specific reason. ## 4. Putting smt_ keys in browser-accessible code The `smt_` developer key is admin. It can read every project, deploy code, change env vars. If it leaks (committed to GitHub, embedded in JavaScript bundle, dropped in browser localStorage) someone can take over the account. Use the smt_ key only: - Server-side, in env vars - In CI/CD secrets - In your local terminal For browser code, mint app-user JWTs through `sw.auth.signup` / `sw.auth.login`. JWTs are scoped to one user, one project — leak blast radius is one account. ## 5. Reasoning models with too-low max_tokens Reasoning models (`kimi-k2.6`, `glm-4.6`, `gemma-3-27b`, `qwen-3-235b-a22b-thinking`) think first, then answer. The `` block consumes tokens. If `max_tokens` is too small, the model burns its budget on reasoning and returns an empty response. **Wrong:** `max_tokens: 500` for a reasoning model. **Right:** `max_tokens: 4000` minimum, `8000+` is safer. For non-reasoning models (Claude, gpt, Llama-Scout) 500 is fine. ## 6. sw.fs.write camelCase vs snake_case The `sw.fs.write` binding accepts both `contentType` (camelCase) and `content_type` (snake_case) for the MIME type. Other fields are camelCase only. Pick one style per project for consistency, but the platform tolerates either on this option. ```typescript await sw.fs.write('/uploads/avatar.png', bytes, { contentType: 'image/png' // or content_type — both work }) ``` ## 7. "My deploy didn't update the live site" Every `project_deploy` and `project_patch` writes to the live serving slot. `https://{subdomain}.somewhere.tech` serves your latest deploy immediately. If the live site looks stale, it's almost always one of: - Browser or CDN cache. Hard-reload (Cmd-Shift-R), or check with `curl -sI https://{subdomain}.somewhere.tech`. - The deploy hit a different project than you think (wrong `project_id`). - Function changes can take 2-4 seconds to propagate; static changes are ~1s. `?env=dev` is preserved as an internal opt-in for testing the same bytes from the dev slot — useful when you want to bypass any intermediate cache layer. ## 8. Auth emails not arriving The platform sends auth emails (signup confirmation, password reset) from `noreply@somewhere.tech` by default. That domain is not on your project — it's the platform's. Some email providers flag it as suspicious because the sending domain doesn't match your app's domain. Fix: add a verified sender domain in dashboard Settings. Once verified, auth emails go out from `noreply@yourdomain.com` and deliverability improves. ## 10. Forgetting to redeploy after rotating an env var `sw.env.MY_KEY` reads the value baked into the running function bundle. When you rotate an env var via the dashboard or `POST /v1/projects/:id/env`, **the running function still sees the old value until the next deploy**. Run `project_deploy` (or `project_patch` with no changes — empty patch is allowed) to refresh. --- ## Troubleshooting — Common errors and what to do (troubleshooting) Pick the row that matches your error and apply the fix. ## "FUNCTION_NOT_FOUND" or 404 on a /api/* route The function file isn't deployed, or the route doesn't match. Check: 1. Did you actually deploy the function? `deploy_status({ project_id })` lists deployed functions. 2. Does the file path match the URL? `api/users/[id].ts` serves `/api/users/123`. `api/users.ts` serves `/api/users`. 3. Was the deploy actually successful? Re-run `project_deploy` and check the response — every deploy goes live; if the route still 404s, the function isn't in the bundle. ## "API key is invalid" inside sw.* calls The function bundle's runtime keys are stale. This happens after the platform rotates internal keys, or after a billing-state change that re-issued credentials. Fix: redeploy the project with any small touch, e.g. `project_patch({ project_id, path: "index.html", find: " ", replace: " " })` (a no-op find/replace bumps the version + reissues runtime keys). Re-running `project_deploy` works too but replaces all files, so `project_patch` is safer. ## "SPENDING_CAP_REACHED" The project hit its monthly AI spending cap. By default each project has a cap to prevent runaway costs. Fix: dashboard → Settings → AI → raise the cap. Or for testing, set the cap to a high value and leave it. Admin accounts bypass this gate automatically. The cap is per-project, not per-account. ## Auth emails (signup, password reset) not arriving Two common causes: **Sender not verified** — The platform sends from `noreply@somewhere.tech` by default. Some inboxes flag this as suspicious. Add a verified sender domain in dashboard Settings → Email, then auth emails come from your own domain. **Email not configured** — Email send goes through the platform email service. If the project's account doesn't have email sending wired up, sends fail silently (logged, not surfaced). Check dashboard Logs for the project to see the actual error. ## "Site not loading after deploy" Every `project_deploy` and `project_patch` writes to the live serving slot. If the live site still shows the old version, it's almost always cache: 1. **Browser cache.** Hard reload (Cmd-Shift-R) or open the URL in a private window. 2. **Edge cache.** `curl -sI https://{subdomain}.somewhere.tech/` shows the response headers — `cache-control` tells you whether it was cached. 3. **Wrong project.** Confirm the `project_id` matches the subdomain you're hitting. If a deploy genuinely failed silently, `deploy_status({ project_id })` returns the most recent timestamp — older than your last deploy means the deploy errored. ## "MIGRATION_BLOCKED" on db_migrate_to The schema diff includes a destructive change (column drop, table drop, type change that loses data). The platform refuses by default to prevent silent data loss. Fix: review the diff, then re-run with `{ allow_destructive: true }` once you've confirmed the data is expendable. Always back up first with `db_query("SELECT * FROM ")` if in doubt. ## "DOMAIN_NOT_VERIFIED" on a custom domain The TXT record hasn't propagated, or doesn't match what the platform expects. Fix: 1. `domain_verify({ domain })` returns the exact TXT name + value you should set. 2. Wait 60-300 seconds after setting DNS for propagation. 3. Use `dig +short TXT _somewhere-challenge.` from terminal to confirm DNS-side before re-running verify. If the value is correct but verify still fails, the most common cause is multiple TXT records on the same name — the older one shadows the new one. Delete the stale record. ## "API_KEY is invalid" from sw.ai If you set a per-project AI provider key (e.g., your own provider key for higher rate limits), the runtime injects it into the function bundle. After rotating that key, you need to redeploy. Same fix as "API key is invalid" above. If you didn't set a custom key, you're using the platform-managed pool — `API_KEY is invalid` from `sw.ai` means the platform's upstream credential rotated and your bundle is stale. Redeploy. ## Deploy succeeds but old code still serves Browser cache, CDN cache, or you're on the wrong environment. In that order, try: 1. Hard reload (Cmd-Shift-R / Ctrl-F5). 2. Open the URL in a private window — bypasses local cache. 3. Check `deploy_status({ project_id })` to see the timestamp of the most recent deploy. If it's older than your last `project_deploy` call, the deploy actually failed and you missed the error. ## Hosted workspace ("/workspace") shows "Disconnected code=4001" The workspace machine is on an outdated agent version. The platform restart-loops it until either the image gets fixed or the machine is replaced. Fix is on our side. If you hit this, post in the support channel with your project_id and account email — it's not user-fixable. ## "Origin not allowed" when calling from a custom subdomain The project's CORS allowlist doesn't include the origin you're calling from. By default the platform allows the project's own `*.somewhere.tech` URL plus any verified custom domains for the project. Fix: dashboard → Settings → CORS, add the origin. Or if you're calling server-to-server, use the smt_ key — CORS doesn't apply to non-browser callers. ## End-users see "Something went wrong" on my live site When the deployed function throws or crashes during a page load, the platform serves a branded fallback page so visitors never see a raw infrastructure error or JSON blob. It looks like: > # Something went wrong > The app ran into a problem. Try again in a moment. Three things to know: 1. **The crash is in your function — read the stack trace.** Every uncaught exception is captured to your project's logs with the full stack: ``` log({ project_id, level: 'error', limit: 20 }) ``` Or open the dashboard → project → Logs tab and filter level=error. Each row has `data.stack`, `data.path`, `data.method`, and `data.request_id` (the `cf-ray` you can correlate with deeper logs if you contact support). 2. **The fallback only triggers for page loads.** `/api/*` requests still return JSON to JS callers — your client error handling stays the same. The branded page only appears when a *page* request itself fails. 3. **You can customize the page.** Upload a `_error.html` file (or `_error/index.html`) in your project. If present, the platform serves that on any unhandled error instead of the default. Plain static HTML — no templating, no function dispatch needed. ## How to read your error logs Every error on your project lands in `app_logs` and is reachable three ways: - **MCP**: `log({ project_id, level: 'error', limit: 50 })` returns the most recent error rows in JSON. Add `since: '1h'` or a unix-ms timestamp to scope the window. Combine with `q:` for a free-text search across message + data. - **Dashboard**: project → Logs tab. Filter by level, source (function | server | client | system), or search. - **From inside a function**: `sw.logs.error(message, data)` writes a row with source='server'. Use this for known-bad branches your code handles deliberately. What you'll see for an uncaught function exception: ```json { "level": "error", "source": "server", "message": "FUNCTION_ERROR: Cannot read properties of undefined (reading 'id')", "data": { "code": "FUNCTION_ERROR", "stack": "TypeError: Cannot read properties of undefined...\\n at handler (api/users.ts:14:18)\\n ...", "request_id": "8f3c1d2a4b5e6789-LHR", "method": "POST", "path": "/api/users", "route": "/api/users", "slot": "prod" }, "created_at": 1715551234567 } ``` The `stack` field is the single most useful field for debugging — it tells you the exact file + line in your code where the throw originated. Page-dispatch errors (worker couldn't start, function timed out, memory limit hit) show up here too with `code: "FUNCTION_DISPATCH_FAILED"`. Note: error capture is best-effort and runs via `ctx.waitUntil` — it never blocks the visitor's response, and the underlying request returns the branded error page either way. ```bash # Quick custom error page (write via fs_write or include in next deploy):

my-app is down

back in a sec

``` The default page is the right call most of the time — only override when your app's design needs a matching aesthetic. ## All API errors include a hint field Every JSON error from the platform now ships with a `hint` field that names the most relevant `platform_help` topic. Read the hint instead of guessing what to call next: ```json { "ok": false, "error": "VERSION_CONFLICT", "message": "Project version is 14, you sent 13.", "hint": "Try platform_help({ topic: 'deploy' }) for usage details..." } ``` If a hint looks misleading, send `platform_feedback({ message })` — you'll get a ticket_id you can check with `platform_feedback({ ticket_id })`. --- ## Pricing Current plans — generated from the platform's pricing config, always in sync with /v1/pricing: | Tier | Price/mo | |---|---| | Free | $0 | | Builder | $20 | | Pro | $50 | | Scale | $100 | | Enterprise | Contact us | Projects and deploys are unlimited on every tier. For live per-tier limits and the full feature list (the authoritative source), call `GET https://api.somewhere.tech/v1/pricing` (public, cached) or open the dashboard Billing page.