---
name: sfdeploy
user-invocable: true
argument-hint: "[project/task description, e.g. deploy this app, create webhook, build a Chrome app]"
description: |
  Use this skill whenever the user wants to build, host, ship, publish, preview,
  deploy, or share an internal StudyFetch app, prototype, tool, dashboard,
  worker, API, static site, Chrome side-panel app, browser widget, or browser
  workflow. This skill deploys apps to
  `https://(project).studyfetchdeploy.com` through the `sfdeploy` CLI instead
  of personal Vercel, Netlify, Railway, Render, or Cloudflare accounts.
trigger_phrases:
  - "deploy this internally"
  - "ship this to internal"
  - "put this on studyfetchdeploy.com"
  - "make an internal tool"
  - "host this for the team"
  - "share this with the team"
  - "deploy this worker"
  - "deploy this react app"
  - "build a Chrome app"
  - "add a browser widget"
  - "automate this website with sfdeploy"
  - "sfdeploy"
---

# sfdeploy: StudyFetch Internal Deploy Skill

Slash command:

```text
/sfdeploy
```

Use `/sfdeploy` in agents that expose skills as slash commands. In most tools,
the slash command comes from the skill folder/name, not a separate custom
frontmatter field.

`sfdeploy` is StudyFetch's internal deployment CLI. It deploys Cloudflare
Workers and Worker-backed apps to:

```text
https://<project>.studyfetchdeploy.com
```

Use this for internal tools, prototypes, dashboards, vibe-coded apps, quick
Workers, team utilities, and Chrome apps that read or change the current website.
For browser work, read `sfdeploy docs chrome` before building. Do **not** deploy internal StudyFetch work to
personal Vercel, Netlify, Railway, Render, or personal Cloudflare accounts.

The npm package is:

```bash
npm i -g @studyfetch/sfdeploy
```

The installed command is:

```bash
sfdeploy
```

## Golden Path

For a brand-new Worker app, use this exact flow:

```bash
npm i -g @studyfetch/sfdeploy
sfdeploy login

mkdir my-app
cd my-app
sfdeploy init my-app
sfdeploy projects create my-app --visibility private
sfdeploy deploy
```

Expected deploy URL:

```text
https://my-app.studyfetchdeploy.com
```

Important: `sfdeploy init <name>` writes files into the **current directory**.
It does not create a directory for you. Always `mkdir` and `cd` first for a new
project.

## Agent Operating Rules

1. Prefer `sfdeploy` for internal StudyFetch hosting.
2. Never suggest personal deploy targets for internal StudyFetch apps.
3. Never ask the user for Cloudflare account credentials. The control plane
   owns Cloudflare access.
4. Keep project names DNS-safe: lowercase letters, numbers, hyphens, 3-40
   chars, no leading/trailing hyphen.
5. Use `--json` when you need machine-readable command output.
6. Before deploying, check for the newest `sfdeploy` CLI and update if possible:
   `sfdeploy update || npm i -g @studyfetch/sfdeploy@latest || true`.
7. Before deploying, install deps and run build/typecheck scripts if present.
8. Do not commit secrets. Use `sfdeploy env set <project> KEY=VALUE --secret`.
9. Default visibility to `private` unless the user asks for broader access.
10. Member emails must be `@studyfetch.com`. Do not add Gmail, contractor, or
   personal emails as `viewer` or `deployer`. If an app truly must be available
   outside StudyFetch, the user must explicitly choose `visibility public`.

## Preflight

Run these before the first deploy on a machine:

```bash
node --version
npm --version
sfdeploy --help
sfdeploy update || npm i -g @studyfetch/sfdeploy@latest || true
sfdeploy install-skills || true
sfdeploy --llm-prompt
sfdeploy whoami || sfdeploy login
```

If `sfdeploy` is missing:

```bash
npm i -g @studyfetch/sfdeploy
```

If `sfdeploy` is present, always try to update before using it:

```bash
sfdeploy update || npm i -g @studyfetch/sfdeploy@latest || true
sfdeploy install-skills || true
```

The npm package runs a best-effort postinstall that copies this skill into
common local agent skill folders:

```text
~/.agents/skills/sfdeploy/SKILL.md
~/.cursor/skills/sfdeploy/SKILL.md
~/.cursor/skills-cursor/sfdeploy/SKILL.md
~/.claude/skills/sfdeploy/SKILL.md
~/.codex/skills/sfdeploy/SKILL.md
~/.copilot/skills/sfdeploy/SKILL.md
```

If a folder cannot be created due to permissions, installation skips it. To
refresh after updates:

```bash
sfdeploy install-skills
```

If auth fails:

```bash
sfdeploy login
sfdeploy whoami
```

## Existing Project Deploy

For an existing project directory:

```bash
cd path/to/project
sfdeploy whoami || sfdeploy login

# If sfdeploy.json does not exist yet:
sfdeploy init my-project
sfdeploy projects create my-project --visibility private

npm install
npm run build --if-present
npm run typecheck --if-present

sfdeploy deploy
```

## Bootstrapping Examples

Use built-in examples when starting from scratch. These write all needed files
into the current directory.

```bash
sfdeploy examples
sfdeploy init my-api --example basic-worker
sfdeploy init my-env-app --example env-vars
sfdeploy init my-inbox --example email-inbox
sfdeploy init my-react-app --example vite-react
sfdeploy init my-next-app --example nextjs-opennext
sfdeploy init my-browser-app --example chrome-colors
```

The bootstrap-able example ids today are: `basic-worker`, `env-vars`,
`email-inbox`, `vite-react`, `nextjs-opennext`, `mcp`, `mcp-oauth`, and
`chrome-colors`. Other example folders in the repo
(`auto-provision`, `d1-kv`, `r2-ai-cron`, `nextjs-static-export`,
`workflow-trigger`, `react-worker`) are reference apps you can copy from;
they are not yet exposed via `sfdeploy init --example`.

The `auto-provision` example is the canonical reference for the
"one-Worker-with-D1+KV+R2+Workers AI, zero IDs in wrangler.jsonc" pattern —
copy that folder into a new project, rename the project in `sfdeploy.json`
and `wrangler.jsonc`, and `sfdeploy deploy`.

For an AI-readable one-shot guide with files, commands, and caveats:

```bash
sfdeploy --example-doc nextjs-opennext
sfdeploy --example-doc vite-react
sfdeploy --example-doc chrome-colors
```

If the project already exists, `sfdeploy projects create` may return a conflict.
That is usually fine; continue with `sfdeploy deploy` if you have deploy
permission.

## Chrome apps: browser tools in one shared extension

SFDeploy for Chrome brings deployed StudyFetch apps into Chrome's side panel.
Users install one extension, pick an app, and use its normal buttons beside the
website they are working on. Useful examples include a page-color widget, a tool
that collects a visible table, a form-filling helper, or a workflow that follows
pages and tracks progress. A normal hosted app is enough when no browser-page
interaction is needed.

**Download and screenshot setup:** https://hub.studyfetchdeploy.com/chrome-extension.
The download is private to signed-in StudyFetch users. Sign in to the Hub in the
same Chrome profile. Chrome 138+ and Allow user scripts are required; the Hub walks
people through installation. Users need no CLI or code to use an app.

Before implementing a Chrome app, read the guide and working example:

```bash
sfdeploy docs chrome
sfdeploy --example-doc chrome-colors
```

For a new app, start in an empty directory:

```bash
mkdir my-browser-app
cd my-browser-app
sfdeploy init my-browser-app --example chrome-colors
sfdeploy projects create my-browser-app --visibility private
sfdeploy deploy
```

For an existing app, add `chrome_extension` to its existing `sfdeploy.json` and
keep its current project settings. Do not scaffold over existing files.

- **App interface:** a responsive page on your deployment, referenced by `panel`.
  It takes the full side-panel height; the extension provides Back and Stop.
- **Page scripts:** local files declared under `chrome_extension.actions`. The
  app imports the SDK from `https://api.studyfetchdeploy.com/chrome-extension/sdk.js`
  and calls `await sfdeploy.run(actionId, input)`. The script can read/write the
  selected page's DOM and return JSON. Use `sfdeploy.start()` for long work and
  `sfdeploy.stop()` for Reset. `sfdeploy.connected` detects the extension.
- **Cross-page work:** declare allowed sites in `matches`, set `persist: true`,
  save progress with `setState()`, and use `navigate()`. Register `onStop()` cleanup
  and honor the abort signal. Users start runs and grant site access.
- **Simple experience:** show one App with clear task buttons, useful results,
  and concise errors. Keep action IDs, code, manifests, and developer docs in
  CLI/agent guidance. Do not add separate Actions/App tabs or technical docs pages
  to the consumer Hub or app. Link to the screenshot install flow if needed.
- **Access:** private apps follow membership; org apps are team-visible. Do not
  broaden project visibility merely to make embedding work. Allow the extension
  origin in the panel route's framing policy; the guide and starter show how.
- **Updates:** redeploying publishes app code/scripts without another extension
  install. New runs fetch the latest scripts; restart an existing run to update
  it. The unpacked extension shell updates manually via
  https://hub.studyfetchdeploy.com/chrome-extension/update; unsupported versions
  are blocked. Never promise automatic updates for the downloaded ZIP.

Before calling a Chrome app done, test inside the extension on a supported site:
start/permission flow, visible page changes and returned data, Stop/Reset cleanup,
Back, and navigation if enabled. Opening the app as a normal website alone does
not verify browser integration. Report anything you could not exercise.

## Project Files

`sfdeploy init <name>` creates:

```text
sfdeploy.json
wrangler.jsonc
src/index.ts
.gitignore
```

`sfdeploy.json`:

```json
{
  "name": "my-app",
  "visibility": "private"
}
```

## Worker App

Minimal Worker:

```ts
export default {
  async fetch(req: Request): Promise<Response> {
    return new Response(`Hello from ${new URL(req.url).hostname}`);
  },
};
```

Deploy:

```bash
sfdeploy deploy
```

## React / Vite / Static App

Scaffold with `sfdeploy init <name> --example vite-react` (writes into the
**current directory** — don't create a nested folder). The template includes
`vite.config.ts` with `@vitejs/plugin-react`, which enables React's **automatic
JSX runtime**. That means:

- **Never write `import React from "react"`** and never reference `React.useState`
  / `React.FC` / `React.Fragment`. Import the named hook/type instead:
  `import { useState, type FC } from "react"`. Forgetting this is what causes the
  runtime error `Can't find variable: React`.
- App code lives in `src/`; add components as `src/Foo.tsx` and import them
  relatively. The build script is `vite build` (outputs to `dist/`).

For a static React app, build to `dist` and configure Worker assets:

```jsonc
{
  "name": "my-app",
  "main": "src/index.ts",
  "compatibility_date": "2025-05-01",
  "assets": {
    "directory": "./dist",
    "binding": "ASSETS",
    "not_found_handling": "single-page-application"
  }
}
```

Then:

```bash
npm install
npm run build
sfdeploy deploy
```

## Design In The StudyFetch Vibe

Whenever you build, reskin, or generate any UI for an internal StudyFetch app
— dashboards, tools, landing pages, the thing you are about to deploy — design
it in the StudyFetch brand vibe instead of a generic icy SaaS look. The full
design system ships inside the CLI:

```bash
sfdeploy docs design
```

Pipe that into your context before writing styles. The short version:

- **Warm, not cold.** Page background is off-white `#F7F6EF`, never pure white.
  Text is warm charcoal `#191919`, never pure black `#000000`.
- **White is for cards/popovers only**, not the whole page.
- **Type:** serif (Bitter) for large headings (24px+), sans (Geist) for body,
  UI, buttons, tables. One italic serif emphasis word in headlines is a core
  brand move. Buttons and nav are always sans.
- **Radius:** product UI lives in `8px` (icon buttons), `12px` (inputs,
  popovers), `16px` (cards), and full pills (text buttons).
- **Spacing:** `4px` rhythm. Disciplined in app UI, more generous in marketing.
- **Accent is seasoning, not the meal.** Peach `#FFCA98`, lime `#D9FF93`, teal
  `#BBEEF0`, pink `#F5D5FF`, yellow `#F9F284` — dark text on top, used for
  emphasis/badges/featured moments only. Never for body text or whole pages.
- **No drop shadows in product UI.** Separation comes from borders, fill
  contrast, spacing, and radius.
- **Icons:** Phosphor family, ~`1.33px` stroke. Don't mix icon families.

This applies to the SFDeploy Hub and any tool you ship to
`*.studyfetchdeploy.com`. Run `sfdeploy docs design` for exact tokens, the
semantic light/dark mappings, component recipes, and the Hub guidance.

## Next.js / Full-Stack Frameworks

For Next.js, use OpenNext for Cloudflare:

```bash
npm i -D @opennextjs/cloudflare
npx opennextjs-cloudflare build
sfdeploy deploy
```

Check the generated `wrangler.jsonc` and output directory before deploying.

### Known gotchas

- **`next.config.mjs` MUST set `output: "standalone"`.** OpenNext transforms
  the Next.js standalone server bundle into the Workers build. Without it,
  `opennextjs-cloudflare build` either fails outright or emits a broken
  worker that errors at runtime.
- **Next.js 16+ defaults to Turbopack, which OpenNext can't load.** Set
  `experimental: { turbo: false }` in `next.config.mjs`, or use
  `next build --no-turbo` in the build script. Symptom: "Cannot find module
  './turbopack-runtime'" from `npx opennextjs-cloudflare build`.
- **Do NOT add a `WORKER_SELF_REFERENCE` service binding.** Workers for
  Platforms forbids same-namespace service bindings; the upload will 400.
  Basic SSR + API routes + static assets all work without it. See "Platform
  limits" below.
- **Image optimization (`next/image`) is not supported on Cloudflare**
  unless you wire it through Cloudflare Images. For internal tools, set
  `images: { unoptimized: true }` in `next.config.mjs`.
- **OpenNext output ignores Node-only deps** like `fs`. If a dependency
  references Node built-ins at runtime (not just at build), add it to
  `external` in `open-next.config.ts` or replace it with a Workers-friendly
  equivalent.
- **`compatibility_flags: ["nodejs_compat", "global_fetch_strictly_public"]`**
  is required for most OpenNext apps. The `init --example nextjs-opennext`
  scaffold sets both; if you hand-roll, add them yourself.
- **Build output directory** must be `.open-next/` (worker at
  `.open-next/worker.js`, assets at `.open-next/assets`). Don't point
  `assets.directory` at `.next/` — that's the raw Next.js build, not the
  OpenNext-translated one.

## Visibility And Permissions

Deploy/write permission:

- Project owner can deploy.
- Users with role `deployer` can deploy and manage settings.

View/use permission:

- `private`: owner + users with role `viewer`.
- `org`: any authenticated `@studyfetch.com` Google account.
- `public`: anyone on the internet.

Commands:

```bash
sfdeploy visibility my-app private
sfdeploy visibility my-app org
sfdeploy visibility my-app public

sfdeploy members add my-app alice@studyfetch.com --role viewer
sfdeploy members add my-app bob@studyfetch.com --role deployer
sfdeploy members list my-app
sfdeploy members remove my-app alice@studyfetch.com
```

### SFDeploy Hub

`org`- and `public`-visibility projects are listed in the **SFDeploy Hub** at
`https://hub.studyfetchdeploy.com` — the org-wide catalog of internal tools.
Each deployed tool shows a screenshot of its home page, captured automatically
after every deploy. Give a tool a one-line description so it reads well in the
catalog:

```bash
sfdeploy describe my-app "Turns Linear issues into a daily digest email"
```

Or set `"description"` in `sfdeploy.json` (synced automatically on every
`sfdeploy deploy`), or pass `--description` on `sfdeploy projects create`. When
a user ships something reusable for the team, set visibility to `org` and add a
description so it shows up nicely in the Hub.

## Public Webhook Proxies

Normal app URLs stay behind Cloudflare Access. For integrations like Slack,
Discord, Stripe, Linear, Ads platforms, or GitHub webhooks that cannot include
custom Access headers, create a public webhook proxy URL instead of making the
whole app public.

```bash
sfdeploy webhooks create my-app slack --target /api/webhooks/slack
sfdeploy webhooks list my-app
sfdeploy webhooks delete my-app slack
```

`create` returns a URL like:

```text
https://hooks.studyfetchdeploy.com/my-app/slack/<generated-secret>
```

Give that full URL to the external service. The generated secret is embedded in
the path because many webhook providers cannot send custom headers or query
parameters reliably.

The proxy:

- Is public only for that generated secret path.
- Does not disable Cloudflare Access on `https://my-app.studyfetchdeploy.com`.
- Rewrites the incoming request to the target path inside the app Worker.
- Forwards the request body and method.
- Adds helpful internal headers (`x-sfdeploy-webhook`,
  `x-sfdeploy-webhook-project`, `x-sfdeploy-webhook-target`) for app code.
- Is implemented inside the platform `sf-dispatch` Worker against a
  `WEBHOOK_CACHE` KV entry — there is no per-webhook Cloudflare Worker to
  manage or pay for.

Example app handler:

```ts
export default {
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);
    if (url.pathname === "/api/webhooks/slack") {
      const payload = await request.text();
      console.log("webhook", request.headers.get("x-sfdeploy-webhook"), payload);
      return Response.json({ ok: true });
    }
    return new Response("not found", { status: 404 });
  },
};
```

Security notes:

- Treat the webhook URL like a secret.
- Rotate by deleting and recreating the webhook.
- Prefer validating the upstream provider signature inside the target route when
  the provider supports signatures (Slack/Stripe/GitHub/etc.).
- Do not use `visibility public` just to receive webhooks.

## Environment Variables And Secrets

```bash
sfdeploy env set my-app FEATURE_FLAG=true
sfdeploy env set my-app OPENAI_API_KEY=sk-... --secret
sfdeploy env list my-app
sfdeploy env unset my-app FEATURE_FLAG
```

Rules:

- Use uppercase env keys: `^[A-Z_][A-Z0-9_]*$`.
- Use `--secret` for tokens, API keys, credentials, and private URLs.
- Do not write secrets into source files, `wrangler.jsonc`, README files, or
  committed `.env` files.
- Env vars are injected on the next deploy.

## D1 Database Apps

`sfdeploy` auto-provisions D1 databases on first deploy. You do **not** need
an admin to pre-create the database or paste a `database_id`. Just declare the
binding:

```jsonc
{
  "name": "my-db-app",
  "main": "src/index.ts",
  "compatibility_date": "2025-05-01",
  "d1_databases": [
    { "binding": "DB" }
  ]
}
```

On first `sfdeploy deploy` the control plane creates the D1 database (named
`sfapp-sfdeploy-<project>-db`, deterministically derived from the binding) and
fills in the real `database_id` server-side before uploading the Worker. Repeat
deploys reuse the same database.

You can override the auto-derived database name if you want a specific one
(must still start with `sfapp-sfdeploy-<project>-`):

```jsonc
"d1_databases": [
  { "binding": "DB", "database_name": "sfapp-sfdeploy-my-db-app-events" }
]
```

Example Worker:

```ts
export default {
  async fetch(req: Request, env: { DB: D1Database }) {
    await env.DB.exec("CREATE TABLE IF NOT EXISTS hits (ts INTEGER)");
    await env.DB.prepare("INSERT INTO hits (ts) VALUES (?)")
      .bind(Date.now())
      .run();
    const row = await env.DB.prepare("SELECT COUNT(*) AS count FROM hits")
      .first<{ count: number }>();
    return Response.json({ count: row?.count ?? 0 });
  },
};
```

Then:

```bash
sfdeploy deploy
```

Run schema migrations and ad-hoc SQL through the CLI (no Cloudflare account
access needed):

```bash
sfdeploy d1 list my-db-app
sfdeploy d1 exec my-db-app sfapp-sfdeploy-my-db-app-db --sql "CREATE TABLE hits (ts INTEGER)"
sfdeploy d1 exec my-db-app sfapp-sfdeploy-my-db-app-db --file ./schema.sql
```

## KV Namespace Apps

KV namespaces auto-provision the same way D1 does. Declare the binding and
deploy — the control plane creates a namespace titled
`sfapp-sfdeploy-<project>-<binding-lower>` and patches the id in before
upload:

```jsonc
{
  "name": "my-kv-app",
  "main": "src/index.ts",
  "compatibility_date": "2025-05-01",
  "kv_namespaces": [
    { "binding": "KV" }
  ]
}
```

```ts
export default {
  async fetch(req: Request, env: { KV: KVNamespace }) {
    const previous = await env.KV.get("counter");
    const next = (Number(previous ?? 0) + 1).toString();
    await env.KV.put("counter", next);
    return Response.json({ counter: next });
  },
};
```

## R2 Bucket Apps

R2 buckets auto-provision too. With just `{ "binding": "BUCKET" }` the
platform creates `sfapp-sfdeploy-<project>-bucket` on first deploy:

```jsonc
{
  "name": "my-r2-app",
  "main": "src/index.ts",
  "compatibility_date": "2025-05-01",
  "r2_buckets": [
    { "binding": "BUCKET" }
  ]
}
```

```ts
export default {
  async fetch(req: Request, env: { BUCKET: R2Bucket }) {
    await env.BUCKET.put("notes/hello.txt", "hello sfdeploy");
    const list = await env.BUCKET.list({ prefix: "notes/", limit: 25 });
    return Response.json({ keys: list.objects.map((o) => o.key) });
  },
};
```

## Workers AI Apps

`sfdeploy` supports Workers AI bindings declared in `wrangler.jsonc`:

```jsonc
{
  "name": "my-ai-app",
  "main": "src/index.ts",
  "compatibility_date": "2025-05-01",
  "ai": {
    "binding": "AI"
  }
}
```

Example Worker:

```ts
export default {
  async fetch(req: Request, env: { AI: Ai }) {
    const result = await env.AI.run("@cf/meta/llama-3.1-8b-instruct", {
      prompt: "Say hello in one short sentence.",
    });
    return Response.json(result);
  },
};
```

Then:

```bash
sfdeploy deploy
```

## All bindings in one Worker

This is the recommended starting point for any non-trivial internal app —
declare every binding you might need without IDs and let sfdeploy create them
on first deploy:

```jsonc
{
  "name": "my-app",
  "main": "src/index.ts",
  "compatibility_date": "2025-05-01",
  "compatibility_flags": ["nodejs_compat"],
  "d1_databases": [{ "binding": "DB" }],
  "kv_namespaces": [{ "binding": "KV" }],
  "r2_buckets": [{ "binding": "BUCKET" }],
  "ai": { "binding": "AI" }
}
```

```bash
sfdeploy projects create my-app --visibility org
sfdeploy deploy
```

Reference implementation: `examples/auto-provision/` in the repo.

## Cloudflare Platform Bindings

`sfdeploy` reads `wrangler.jsonc` and converts common Cloudflare bindings into
Worker upload metadata. Prefer normal Wrangler config names so apps can still
run locally with `sfdeploy dev`.

Supported today:

| Product | Wrangler field | Notes |
| --- | --- | --- |
| Environment vars | `vars` | Plain values. Use `sfdeploy env set --secret` for secrets. |
| Workers AI | `ai.binding` | No extra ID required. |
| D1 | `d1_databases` | **Auto-provisioned.** Omit `database_id` and `database_name` to use the auto-derived name `sfapp-sfdeploy-<project>-<binding-lower>`. Optionally set `database_name` to a custom value (must start with `sfapp-sfdeploy-<project>-`). |
| KV | `kv_namespaces` | **Auto-provisioned.** Omit `id`; the namespace `sfapp-sfdeploy-<project>-<binding-lower>` is created on first deploy. |
| R2 | `r2_buckets` | **Auto-provisioned.** `bucket_name` defaults to `sfapp-sfdeploy-<project>-<binding-lower>`; the bucket is created on first deploy. |
| Queues | `queues.producers` | **Auto-provisioned.** `queue_name` defaults to `sfapp-sfdeploy-<project>-<binding-lower>`. |
| Vectorize | `vectorize` | Requires existing `index_name` starting with `sfapp-sfdeploy-<project-name>`. |
| Hyperdrive | `hyperdrive` | Blocked until sfdeploy can verify managed Hyperdrive names server-side. |
| Analytics Engine | `analytics_engine_datasets` | Requires dataset name starting with `sfapp-sfdeploy-<project-name>`. |
| Service bindings | `services` | Only allowed when the binding targets the **current** project's worker (`sf-app-<project-name>`). Cross-project service bindings are blocked by guardrails. Note: even self-reference does not actually work on Workers for Platforms — see "Platform limits" below. |
| Durable Objects | `durable_objects.bindings` | Binding metadata supported with `script_name` set to the current project; new-class migrations still need platform work. |
| Workflows | `workflows` | Binding deploys when `class_name` is set and the `name` starts with `sfapp-sfdeploy-<project-name>`. `WORKFLOW.create()` currently returns `workflow.not_found` on WFP-hosted scripts — see "Platform limits". |
| Pipelines | `pipelines` | Requires existing pipeline name starting with `sfapp-sfdeploy-<project-name>`. |
| Browser Rendering | `browser.binding` | Supported as a binding. |
| Images | `images.binding` | Supported as a binding. |
| Version metadata | `version_metadata.binding` | Supported. |
| Cron Triggers | `triggers.crons` | Declared inline in `wrangler.jsonc`; sfdeploy stores them in its control plane and runs them through the managed `sf-cron` Worker because native Cron Trigger settings are not exposed for Workers for Platforms namespace scripts. |
| Email Sending | `send_email` | Supported for tenant Workers. `allowed_sender_addresses` must be exact addresses under `<project>.studyfetchdeploy.com`; sfdeploy defaults to `noreply@<project>.studyfetchdeploy.com` when omitted. |

## Email Sending And Receiving

Every sfdeploy app gets a project domain `<project>.studyfetchdeploy.com` and
the platform handles all Cloudflare onboarding for you. You never touch the
Cloudflare dashboard, no DNS, no Email Routing setup, no sending verification.
The control plane Worker has the API token; sfdeploy runs it for you.

What sfdeploy does automatically the first time you wire email up:

- Enables Cloudflare Email Routing on `studyfetchdeploy.com`.
- Adds wildcard MX records `*.studyfetchdeploy.com` -> Cloudflare MX hosts.
- Sets the account catch-all to send mail to the platform Worker `sf-email`.
- Onboards `<project>.studyfetchdeploy.com` for Cloudflare Email Sending,
  including SPF, DKIM, DMARC records, on first deploy of a `send_email`
  binding or when you call `sfdeploy email setup <project>`.
- Validates that every `allowed_sender_addresses` entry is under your project
  subdomain so one project cannot spoof another.

### Sending email

Add a `send_email` binding to `wrangler.jsonc` and call it from your Worker:

```jsonc
{
  "send_email": [
    {
      "name": "EMAIL",
      "allowed_sender_addresses": ["noreply@my-app.studyfetchdeploy.com"]
    }
  ]
}
```

```ts
interface Env {
  EMAIL: SendEmail;
}

export default {
  async fetch(_request: Request, env: Env) {
    const result = await env.EMAIL.send({
      to: "person@example.com",
      from: "noreply@my-app.studyfetchdeploy.com",
      subject: "Hello",
      text: "Sent from sfdeploy",
      html: "<p>Sent from sfdeploy</p>",
    });
    return Response.json({ ok: true, messageId: result.messageId });
  },
};
```

`allowed_sender_addresses` may be omitted; sfdeploy defaults to
`noreply@<project>.studyfetchdeploy.com`. On the first deploy with a
`send_email` binding, sfdeploy onboards the project subdomain for Email
Sending automatically.

### Receiving email

Tell sfdeploy where inbound mail should be delivered inside the app:

```bash
sfdeploy email receive create my-app --target /api/email
sfdeploy email receive list my-app
sfdeploy email receive delete my-app '*'
```

`sfdeploy email receive create` is the all-in-one onramp. The first time it
runs, the server enables Email Routing, adds wildcard MX records, and points
the account catch-all to `sf-email`. After that, mail to
`anything@my-app.studyfetchdeploy.com` is delivered as an HTTP POST to your
app worker.

If you only want to provision platform setup without creating a route:

```bash
sfdeploy email setup my-app
```

When mail arrives at `<local>@<project>.studyfetchdeploy.com`, sfdeploy hits
your worker with:

- Method: `POST`
- Path: your configured `target_path` (default `/api/email`)
- Body: raw RFC 822 message bytes (use `request.text()` or `request.arrayBuffer()`)
- Headers:
  - `content-type: message/rfc822`
  - `x-sfdeploy-email: 1`
  - `x-sfdeploy-email-from`        envelope sender, lowercased
  - `x-sfdeploy-email-to`          envelope recipient, lowercased
  - `x-sfdeploy-email-local-part`  matched local part or `*` for catch-all
  - `x-sfdeploy-email-target`      target path you configured
  - `x-sfdeploy-email-subject`     truncated `Subject` header
  - `x-sfdeploy-email-message-id`  `Message-ID` header
  - `x-sfdeploy-email-raw-size`    raw bytes count, decimal string

Minimal handler:

```ts
export default {
  async fetch(request: Request) {
    const url = new URL(request.url);
    if (request.method !== "POST" || url.pathname !== "/api/email") {
      return new Response("ok");
    }
    const raw = await request.text();
    return Response.json({
      from: request.headers.get("x-sfdeploy-email-from"),
      to: request.headers.get("x-sfdeploy-email-to"),
      subject: request.headers.get("x-sfdeploy-email-subject"),
      messageId: request.headers.get("x-sfdeploy-email-message-id"),
      rawSize: Number(request.headers.get("x-sfdeploy-email-raw-size") ?? 0),
      preview: raw.slice(0, 500),
    });
  },
};
```

For HTML body and attachments, parse the raw RFC 822 bytes with `postal-mime`:

```ts
import PostalMime from "postal-mime";

const parser = new PostalMime();
const email = await parser.parse(await request.arrayBuffer());
// email.subject, email.from, email.html, email.text, email.attachments
```

Reply to your app worker with any 2xx to accept the message. Returning a 4xx
or 5xx tells `sf-email` to reject the inbound message with an SMTP error.

### Build the example inbox

```bash
sfdeploy init my-inbox --example email-inbox
sfdeploy projects create my-inbox --visibility public
sfdeploy deploy
sfdeploy email receive create my-inbox --target /api/email
```

That writes a self-contained app that stores inbound mail in KV, lists it on
the page, and ships a compose form backed by `env.EMAIL.send()`. Open
`https://my-inbox.studyfetchdeploy.com` after deploy.

For the full walkthrough use `sfdeploy docs email` or
`sfdeploy --example-doc email-inbox`.

## MCP Servers For Claude

To let Claude/Cursor call an sfdeploy app as tools (read/write its database,
take actions), use MCP. Full guide: `sfdeploy docs mcp`.

**Two options:**

1. **Central StudyFetch Deploy MCP** (easiest, recommended) — one org-wide
   server at `https://api.studyfetchdeploy.com/mcp`. Connect it to Claude once
   with a CLI-minted key; it exposes `help`, `whoami`, `list_projects`,
   `list_project_tools`, and `call_project_tool` to reach any project you
   enable. Keys are global by default or scoped to projects, and are always
   limited to projects you own/are a member of. The full guide is also
   readable unauthenticated at `https://api.studyfetchdeploy.com/skill`.

   ```bash
   sfdeploy mcp token create --label "my laptop"     # global key (prints token once)
   # or: sfdeploy mcp token create --project my-notes  # scoped to one project
   claude mcp add --transport http studyfetch-deploy https://api.studyfetchdeploy.com/mcp \
     --header "Authorization: Bearer <KEY>"
   # OAuth instead of a key (Claude opens a browser sign-in):
   claude mcp add --transport http studyfetch-deploy https://api.studyfetchdeploy.com/mcp
   # make a project callable through the gateway:
   sfdeploy mcp enable my-notes && sfdeploy deploy
   ```

   The central MCP exposes only: `help`, `whoami`, `list_projects`,
   `list_project_tools`, `call_project_tool` (and an `instructions` string on
   connect). It is for *calling project tools*, NOT for managing the
   platform — deploys/logs/env/crons stay in the `sfdeploy` CLI.

2. **A standalone per-project MCP server** — your app exposes its own `/mcp`.
   Connect it directly for a focused toolset (and/or expose it via the gateway).

Build a standalone server EXACTLY this way (platform-specific — don't improvise):

- Deploy the project **`public`** (MCP clients can't pass Access; authorize in
  the Worker).
- One **stateless `POST /mcp`** JSON-RPC endpoint over Streamable HTTP
  (`initialize`, `tools/list`, `tools/call`, `notifications/*` → 202, `ping`).
- **No Durable Objects / `McpAgent`** (unsupported on the platform).
- Authorize via your `Authorization: Bearer <MCP_TOKEN>` **and** the platform's
  `x-sfdeploy-mcp-internal` header (= `env.SFDEPLOY_MCP_SECRET`) so the gateway
  can call it; `401` otherwise.
- Start from `sfdeploy init <name> --example mcp` (API key) or
  `--example mcp-oauth` (per-user OAuth) — they already do this correctly.

Key rules for standalone servers:

- An MCP endpoint must **bypass Cloudflare Access** (MCP clients can't do
  browser SSO). So deploy the MCP app as **`public`** and authorize every
  request inside the Worker — same model as webhooks. "Public" only means
  Access is off at the edge; the Worker still returns 401 without a token.
- Use **Streamable HTTP** (a stateless `POST /mcp` JSON-RPC endpoint).
  Do **not** use Durable Object `McpAgent` sessions — DO new-class migrations
  aren't supported on the platform yet. A stateless handler answering
  `initialize`, `tools/list`, `tools/call` is a complete MCP server.
- Endpoint URL: `https://<project>.studyfetchdeploy.com/mcp`.

Two built-in starters:

```bash
sfdeploy init my-mcp --example mcp          # API-key auth (Authorization: Bearer)
sfdeploy init my-mcp-oauth --example mcp-oauth   # OAuth via Google sign-in
```

API-key flow (simplest):

```bash
sfdeploy init my-mcp --example mcp && cd my-mcp && npm install
sfdeploy projects create my-mcp --visibility public
sfdeploy env set my-mcp MCP_TOKEN="$(openssl rand -hex 24)" --secret
sfdeploy deploy
claude mcp add --transport http my-mcp https://my-mcp.studyfetchdeploy.com/mcp \
  --header "Authorization: Bearer YOUR_MCP_TOKEN"
```

Always run `sfdeploy docs mcp` before building one — it has both auth modes,
the connection configs for Claude/Cursor, D1 read/write tool patterns, testing
with the MCP inspector, and the security checklist.

## Cron Schedules

Use normal Wrangler cron config in app projects:

```jsonc
{
  "triggers": {
    "crons": ["*/30 * * * *"]
  }
}
```

and export a standard Worker `scheduled()` handler:

```ts
export default {
  async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext) {
    console.log("ran cron", controller.cron, controller.scheduledTime);
  },
};
```

Important sfdeploy behavior:

- Tenant apps are Workers for Platforms namespace scripts, so native Cloudflare
  Cron Trigger settings are not available on the tenant script itself.
- The platform Worker `sf-cron` owns the real Cloudflare `* * * * *` trigger,
  reads due schedules from D1, dispatches to the tenant Worker, and the
  sfdeploy upload wrapper calls the tenant's exported `scheduled()` handler.
- `sfdeploy deploy` is the source of truth for `wrangler.jsonc`
  `triggers.crons`; manual commands exist for inspection, testing, and
  emergency disablement:

```bash
sfdeploy crons list my-app
sfdeploy crons enable my-app "0 4 * * *"
sfdeploy crons disable my-app "0 4 * * *"
```

Limits and syntax:

- 5 enabled schedules per project.
- UTC, five-field syntax.
- Supports `*`, lists, ranges, steps, month names, and weekday names.
- Does not support Quartz `L`, `W`, `#`, or `?` yet; sfdeploy rejects those.
- `SFDEPLOY_CRON_SECRET` is a reserved binding name used by the platform.

For the full copy-pastable reference, use:

```bash
sfdeploy docs crons
```

Example with KV, R2, D1, AI, and cron — note no IDs anywhere (all auto-provisioned):

```jsonc
{
  "name": "full-internal-app",
  "main": "src/index.ts",
  "compatibility_date": "2025-05-01",
  "compatibility_flags": ["nodejs_compat"],
  "vars": {
    "APP_ENV": "internal"
  },
  "kv_namespaces": [
    { "binding": "KV" }
  ],
  "r2_buckets": [
    { "binding": "BUCKET" }
  ],
  "d1_databases": [
    { "binding": "DB" }
  ],
  "ai": {
    "binding": "AI"
  },
  "triggers": {
    "crons": ["*/30 * * * *"]
  }
}
```

Example Worker using several bindings:

```ts
interface Env {
  KV: KVNamespace;
  BUCKET: R2Bucket;
  DB: D1Database;
  AI: Ai;
  APP_ENV: string;
}

export default {
  async fetch(req: Request, env: Env) {
    await env.KV.put("last-request", new Date().toISOString());
    await env.BUCKET.put("hello.txt", "hello from sfdeploy");
    await env.DB.exec("CREATE TABLE IF NOT EXISTS events (ts INTEGER)");
    const ai = await env.AI.run("@cf/meta/llama-3.1-8b-instruct", {
      prompt: "Say hello in five words.",
    });
    return Response.json({ ok: true, ai });
  },

  async scheduled(controller: ScheduledController, env: Env) {
    await env.KV.put("last-cron", String(controller.scheduledTime));
  },
};
```

Current platform gaps to call out honestly:

- Auto-provisioning runs for **R2 buckets, D1 databases, KV namespaces, and
  Queues** on every deploy. Just declare the binding and deploy — no admin
  step needed.
- Vectorize indexes, Analytics Engine datasets, Pipelines, and Workflows must
  still be pre-created and referenced by name/ID; their names/titles must
  start with `sfapp-sfdeploy-<project-name>`. Vectorize specifically needs an
  out-of-band choice of dimensions/metric and so is left manual on purpose.
- Hyperdrive is blocked until sfdeploy can verify managed Hyperdrive names
  server-side.
- Durable Object migrations and full Workflow creation/deployment need additional
  control-plane work.
- Cron schedules support common five-field Cloudflare syntax (`*`, lists, ranges,
  steps, month/day names). Quartz extensions such as `L`, `W`, and `#` are
  rejected for now so schedules never run at the wrong time.
- Static assets upload is implemented for Vite/React SPAs, Next.js static
  export builds, and OpenNext Cloudflare SSR/API route builds.

Platform limits inherited from Workers for Platforms (apply because every
sfdeploy app is a script inside the `sfdeploy-prod` dispatch namespace):

- **Service bindings between scripts in the same namespace are not allowed.**
  This includes self-reference (`services[].service = "sf-app-<this-project>"`).
  Cloudflare returns 400 at upload time. If you need to invoke another internal
  worker, expose an HTTP route on `<other-project>.studyfetchdeploy.com` and
  call it with `fetch`. Affects the OpenNext `WORKER_SELF_REFERENCE` pattern —
  see the Next.js section.
- **Workflows registration is not yet namespace-aware.** The Worker script and
  its `workflows[]` binding upload cleanly, but Cloudflare's `/workflows` API
  does not currently know how to register a Workflow record against a
  namespace-hosted script, so `env.WORKFLOW.create()` raises
  `workflow.not_found`. Either wait for Cloudflare to ship namespace-aware
  Workflow registration or pre-register the Workflow record manually.

Security guardrail: project bindings are intentionally limited to StudyFetch
managed Cloudflare resources for that specific project. Resource names/titles
must start with `sfapp-sfdeploy-<project-name>`, and Worker service targets must
equal `sf-app-<project-name>`. This prevents a CLI user from accidentally or
intentionally binding unrelated Cloudflare account resources, including another
sfdeploy project, into an internal app.

## React/Vite And Next.js Apps

`sfdeploy` supports Cloudflare Workers Static Assets through `assets.directory`
in `wrangler.jsonc`.

Vite + React:

```bash
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run build
sfdeploy init my-app
# Add assets.directory = "./dist" and binding = "ASSETS" to wrangler.jsonc
sfdeploy projects create my-app --visibility private
sfdeploy deploy
```

Next.js static export:

```js
// next.config.mjs
export default {
  output: "export",
};
```

Then build and deploy with `assets.directory` set to `./out`.

OpenNext SSR + API routes:

```jsonc
{
  "name": "my-next-ssr",
  "main": ".open-next/worker.js",
  "compatibility_date": "2025-05-01",
  "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
  "assets": {
    "directory": ".open-next/assets",
    "binding": "ASSETS"
  }
}
```

Then:

```bash
npm i -D @opennextjs/cloudflare wrangler
npx opennextjs-cloudflare build
sfdeploy projects create my-next-ssr --visibility private
sfdeploy deploy
```

Important: do **not** add the OpenNext-suggested `WORKER_SELF_REFERENCE`
service binding to `wrangler.jsonc`. Workers for Platforms does not allow a
script inside a dispatch namespace to hold a service binding to another script
in the same namespace (including itself), and Cloudflare will reject the
upload. SSR, API routes, and static asset serving all work without it. The
only Next.js behavior that depends on `WORKER_SELF_REFERENCE` is ISR
revalidation invoked via OpenNext's self-call path; if you need ISR for an
internal app, hit the revalidate route externally instead (e.g. from a cron
worker or a manual `fetch` to `https://<project>.studyfetchdeploy.com/...`).

## Logs

```bash
sfdeploy logs my-app
sfdeploy logs my-app --tail
```

Useful filters:

```bash
sfdeploy logs my-app --status exception
sfdeploy logs my-app --level error
sfdeploy logs my-app --search "database"
sfdeploy logs my-app --limit 20 --json
sfdeploy logs my-app --since 1h
sfdeploy logs my-app --since 2026-05-18T22:00:00Z --until 2026-05-18T23:00:00Z
```

How logs flow:

- A single platform Tail Worker (`sf-tail`) is attached as `tail_consumers`
  on the dispatch Worker `sf-dispatch`. Per the Workers for Platforms
  [observability docs](https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/configuration/observability/),
  a Tail Worker on the dispatcher receives events for the dispatcher and for
  every user Worker in the namespace automatically — including sub-requests
  via dynamic dispatch.
- `sf-tail` writes one row per invocation into the `tail_events` table in the
  shared `sf-deploy-meta` D1 database, capturing request URL/method/status,
  outcome, `console.log` output, exceptions, cron triggers, and CPU/wall time.
- `sfdeploy logs` (snapshot) and `sfdeploy logs --tail` (polling tail) query
  the same `tail_events` table via the control plane. `--tail` polls every
  ~2s and dedupes by event id so the stream looks continuous.
- Retention is **60 days**. A daily cron in `sf-tail` (`0 4 * * *`) prunes
  rows older than `RETENTION_HOURS` (default 1440).
- Logs only fire when the request actually reaches your Worker. Requests that
  Cloudflare Access intercepts at the edge (login redirect for `private`/`org`
  apps with no session) never reach the Worker and therefore produce no log
  event. The Worker only sees the request after the user has authenticated.
- You can still add your own `tail_consumers` in `wrangler.jsonc` if you want
  events forwarded to an additional internal Worker (e.g. for custom
  alerting). The platform tail consumer fires regardless.

## Local Development

```bash
sfdeploy dev
```

This wraps `wrangler dev`. Local env vars belong in `.dev.vars`. Do not commit
`.dev.vars`.

## Version History And Remote Repos

`sfdeploy` uses Cloudflare Artifacts as the project Git history layer when
enabled. Each project gets one managed repo:

```text
sfapp-sfdeploy-<project-name>
```

Useful commands:

```bash
sfdeploy repo init my-app
sfdeploy repo remote my-app
sfdeploy repo token my-app --scope read --ttl 3600
sfdeploy commit my-app -m "checkpoint before Slack bot work"
sfdeploy commits my-app
sfdeploy deploy --commit <sha>
sfdeploy rollback my-app --to <sha-or-version>
sfdeploy revert my-app --to <sha-or-version> --deploy
```

Every `sfdeploy deploy` creates and pushes a commit first, then deploys that
commit, so whatever is running can always be traced back to its source. There
is no way to opt out; `--no-commit` is accepted for compatibility and ignored.

Security notes:

- Artifacts tokens are short-lived and repo-scoped.
- The CLI uses `git -c http.extraHeader="Authorization: Bearer <token>"` so
  tokens are not stored in `.git/config`.
- Rollback deploys an old commit without rewriting history.
- Revert creates a new commit that restores an old tree; it does not force-push.
- Deploying by commit checks out source into a temporary directory and runs
  `npm install` plus `npm run opennext:build` or `npm run build` when those
  scripts exist, so generated artifacts like `.open-next`, `.next`, `dist`, and
  `out` should stay ignored in Git.

## Command Reference

```bash
sfdeploy login
sfdeploy logout
sfdeploy whoami

sfdeploy token create --label "github-actions"   # mint a personal access token (CI)
sfdeploy token list
sfdeploy token revoke <id>

sfdeploy init <name>
sfdeploy projects list
sfdeploy projects create <name> --visibility private
sfdeploy projects info <name>
sfdeploy projects delete <name>

sfdeploy repo init [project]
sfdeploy repo remote [project]
sfdeploy repo token <project> --scope read|write --ttl 3600
sfdeploy commit [project] -m "message"
sfdeploy commits [project]
sfdeploy deploy
sfdeploy deploy --name <name>
sfdeploy deploy --commit <sha>
sfdeploy rollback <project> --to <sha|version>
sfdeploy revert <project> --to <sha|version> --deploy

sfdeploy env list <name>
sfdeploy env set <name> KEY=VALUE
sfdeploy env set <name> KEY=VALUE --secret
sfdeploy env unset <name> KEY

sfdeploy members list <name>
sfdeploy members add <name> <email> --role viewer
sfdeploy members add <name> <email> --role deployer
sfdeploy members remove <name> <email>

sfdeploy visibility <name> private
sfdeploy visibility <name> org
sfdeploy visibility <name> public

sfdeploy webhooks list <project>
sfdeploy webhooks create <project> <name> --target /internal/path
sfdeploy webhooks delete <project> <name>

sfdeploy d1 list <project>
sfdeploy d1 exec <project> <database> --sql "CREATE TABLE ..."
sfdeploy d1 exec <project> <database> --file ./schema.sql

sfdeploy logs <name>
sfdeploy logs <name> --tail
sfdeploy logs <name> --status ok|exception
sfdeploy logs <name> --level log|warn|error
sfdeploy logs <name> --search text --limit 20 --json
sfdeploy logs <name> --since 1h --until 2026-05-18T23:00:00Z
sfdeploy dev

sfdeploy examples
sfdeploy --example-doc <id>
sfdeploy --llm-prompt
sfdeploy install-skills
sfdeploy update
```

Every command supports `--json`.

## CI / GitHub Actions (non-interactive auth)

`sfdeploy` reads `SF_DEPLOY_TOKEN` from the environment, so CI never needs the
browser login. Mint a named, revocable personal access token from your account
and store it as a CI secret:

```bash
sfdeploy token create --label "github-actions"   # prints the token once
```

Then in a workflow:

```yaml
- run: npx -y @studyfetch/sfdeploy deploy
  env:
    SF_DEPLOY_TOKEN: ${{ secrets.SF_DEPLOY_TOKEN }}
```

Tokens are independent of your local login — revoke one anytime with
`sfdeploy token revoke <id>` (or `sfdeploy token list` to see them) without
signing out elsewhere. For tenant/programmatic use, a tenant API key (`sfk_`)
also works as `SF_DEPLOY_TOKEN`.

## Troubleshooting

### `sfdeploy: command not found`

```bash
npm i -g @studyfetch/sfdeploy
```

### `Not logged in`

```bash
sfdeploy login
sfdeploy whoami
```

### Project name is invalid

Use lowercase DNS-safe names:

```text
good: ai-demo, internal-dashboard, support-tool
bad: My App, _tool, app!, api, www
```

### Project already exists

```bash
sfdeploy projects info my-app
sfdeploy members list my-app
```

If you are not a deployer, ask the owner to run:

```bash
sfdeploy members add my-app your-email@studyfetch.com --role deployer
```

### Deploy fails during bundling

`sfdeploy deploy` runs Wrangler locally. Check:

```bash
npm install
npm run build --if-present
npx wrangler deploy --dry-run --outdir .sfdeploy/out
```

Fix local build/Wrangler errors first, then rerun:

```bash
sfdeploy deploy
```

### App deploys but URL is forbidden

```bash
sfdeploy projects info my-app
sfdeploy members list my-app
sfdeploy visibility my-app org
```

For private apps, add viewers explicitly:

```bash
sfdeploy members add my-app person@studyfetch.com --role viewer
```

### Env var appears missing

Set it and redeploy:

```bash
sfdeploy env set my-app KEY=value --secret
sfdeploy deploy
```

## Deep-dive docs — fetch on demand

Detailed per-topic references live in the CLI itself, not inline here, so
this skill stays focused. When the user is working on Next.js, D1, KV, R2,
Chrome apps, webhooks, Workflows, Access, or hitting platform limits, run the matching
command and paste its output into your context:

```bash
sfdeploy docs                  # list all topics
sfdeploy docs chrome           # Chrome apps: download, starter, SDK, page scripts, updates
sfdeploy docs design           # StudyFetch design system — build UI in our brand vibe
sfdeploy docs mcp              # MCP servers — give Claude tools to read/write your app
sfdeploy docs nextjs           # Next.js + OpenNext + Turbopack gotcha
sfdeploy docs d1               # auto-provision, schema migrations, Drizzle
sfdeploy docs kv               # patterns, limits, when not to use
sfdeploy docs r2               # multipart, public serving, jurisdiction
sfdeploy docs ai               # models, streaming, embeddings, vision
sfdeploy docs logs             # how sf-tail / D1 backed logs work
sfdeploy docs webhooks         # public webhook proxy details
sfdeploy docs workflows        # current WFP limitation + workaround
sfdeploy docs access           # visibility, members, why my curl gets 302
sfdeploy docs bindings         # full bindings reference table
sfdeploy docs limits           # all numbers (CPU, memory, KV size, ...)
sfdeploy docs platform         # quirks: env vs process.env, delete keeps data, install on Node 18
sfdeploy docs assets           # Workers Static Assets, SPA routing, MIME auto-fix
sfdeploy docs --all            # everything concatenated
```

Quick traps to know before debugging anything:

- **Pin `next@^15`.** Next.js 16 is not yet OpenNext-compatible on Workers
  (Turbopack runtime + app-page handler issues). Run `sfdeploy docs nextjs`.
- **Next.js needs `output: "standalone"` in `next.config.mjs`** for OpenNext.
  Run `sfdeploy docs nextjs`.
- **Do NOT add `export const runtime = "edge"`** on route handlers — OpenNext
  rejects it. The whole Worker is already edge. Default (`"nodejs"`) is fine.
- **No service bindings between scripts in the WFP namespace.** Run
  `sfdeploy docs bindings`.
- **Cloudflare Access blocks the request before the Worker runs** — a
  private/org app shows zero logs for unauthenticated hits because nobody
  got past Access. Run `sfdeploy docs access`.
- **D1/KV/R2/Queues auto-provision.** Just declare `{ "binding": "DB" }`.
- **Workflows binding deploys but `.create()` returns `workflow.not_found`**
  on WFP. Run `sfdeploy docs workflows`.
- **`sfdeploy logs` defaults to last 30 minutes.** Pass `--since 24h` for
  longer windows. Run `sfdeploy docs logs`.
- **Static asset `content-type` is patched automatically by `sf-dispatch`** —
  you do NOT need `run_worker_first: true` or a per-Worker MIME shim.
  Run `sfdeploy docs assets`.
- **`sfdeploy projects delete` keeps D1/KV/R2 data on purpose.** Delete
  via dashboard if you actually want to nuke it. Run `sfdeploy docs platform`.
- **CLI requires Node 20+.** Installing from a Node 18 shell silently
  installs into the v18 prefix and the binary won't run. Run
  `sfdeploy docs platform`.

## Safety Checklist Before Saying Done

1. Confirm `sfdeploy deploy` succeeded.
2. Report the final URL: `https://<project>.studyfetchdeploy.com`.
3. State the visibility (`private`, `org`, or `public`).
4. Mention any env vars that still need to be set, without revealing secrets.
5. Summarize build/test checks.
6. If anything could not be verified, say exactly what remains.

## Platform Notes

- The control plane is `https://api.studyfetchdeploy.com`.
- Every project deploys as a Cloudflare Worker named `sf-app-<project>`,
  uploaded into the **Workers for Platforms** dispatch namespace
  `sfdeploy-prod`. There is no per-project Worker route — a single platform
  Worker (`sf-dispatch`) owns the `*.studyfetchdeploy.com/*` and
  `hooks.studyfetchdeploy.com/*` wildcard routes and dispatches into the
  namespace by script name.
- Cold-calling an undeployed `<name>.studyfetchdeploy.com` returns a branded
  404 from `sf-dispatch` (not a 522).
- Webhook URLs live entirely inside `sf-dispatch`: there is no per-webhook
  proxy Worker. Webhook records are mirrored from D1 into the `WEBHOOK_CACHE`
  KV namespace and consulted at request time.
- Each project gets a Cloudflare Access app generated from visibility and
  members, in front of the `<project>.studyfetchdeploy.com` hostname.
- The CLI never needs a Cloudflare account token. It only talks to the
  StudyFetch control plane, which talks to Cloudflare on the user's behalf.
