> ## Documentation Index
> Fetch the complete documentation index at: https://docs.goosybear.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# The deploy contract

> pages.deploy.json — the one file a bundle uses to say what it is and what it needs.

Every bundle you send to Goosy is read through one file: **`pages.deploy.json`**. It says
whether the site is static or has a backend, which paths that backend answers, and what it
asks to be wired to.

You never have to write it. If the file is absent we derive one from what the bundle carries
and hand it back in the ingest response, so you can see what we decided and commit it if you
agree.

<Note>
  **What is live today:** the contract is **validated on every upload** — a bundle is
  accepted or refused against the shape below, and the manifest we resolved comes back on the
  response — and the `DB` and `submissions` bindings are **wired**. `env.DB` is real in your
  Worker, your migrations are applied when a revision is staged, and a post to `/__lead` is
  mirrored into the table you name. **`PROJECT_DB` is the exception**: it is declared and
  recorded today, and its binding arrives with the project-database stage.
</Note>

## Where the file goes

| Your bundle is…                                                  | Put the file at                                | What we do                                                                                                                                                                                                                  |
| ---------------------------------------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Already built** — a folder of output, optionally with a Worker | `pages.deploy.json` at the **root of the zip** | The root file is what classifies the upload as prebuilt: we stage exactly the bytes you sent and compile nothing.                                                                                                           |
| **A source project we build** (Astro, and Next.js as it lands)   | `public/pages.deploy.json`                     | Astro copies `public/` verbatim into its output, so the file arrives at the bundle root with no extra build step. A `pages.deploy.json` at the project ROOT would classify the upload as already-built and skip your build. |
| **A plain static folder**                                        | Nothing                                        | We synthesize a static manifest and return it.                                                                                                                                                                              |

Your Worker modules follow the same rule: `_functions/` in an already-built bundle,
`public/_functions/` in a source project. An already-built bundle may also put its entry at
the root (`_worker.js` — what the Cloudflare adapters emit); we relocate it under
`_functions/` when we stage it.

**Four paths are reserved, and that list is closed.** These are the only objects in a staged
bundle a visitor can never fetch:

| Reserved path          | Why                                                               |
| ---------------------- | ----------------------------------------------------------------- |
| `pages.deploy.json`    | Names your secret names, your tables and your entry module.       |
| `pages.functions.json` | The legacy spelling of the same declaration.                      |
| `_functions/`          | Your Worker's source, and the script's identity in our namespace. |
| `_migrations/`         | Your own schema, staged for us to apply.                          |

Everything else in the bundle is servable — including any other `_`-prefixed directory you
ship. We do not reserve a prefix pattern; we reserve those four paths and **relocate into
them**, which is why your migrations move and your pages never do.

**Your declared migrations get the same treatment, on both routes in.** Name the directory
whatever suits your project — `migrations`, `db/sql` — and we move it to `_migrations/` as
we stage the bundle. That is what makes "staged, never served" true whether you uploaded a
built bundle or handed us a source project to build.

**Your own field is never rewritten.** `bindings.database.migrations` comes back normalized —
a trailing slash is removed, so `"migrations/"` reads back as `"migrations"` — and otherwise
untouched. Where the files ended up is recorded separately, in
`bindings.database.stagedMigrationsPrefix` (always `_migrations/`), which the platform writes
after staging and reads when it applies them. Do not set it yourself — a bundle that arrives
carrying it is refused, because it would tell us to read your schema from a directory we
never staged.

**Where we look for it depends on which route you took**, and it is the same place the
route puts everything else. In an already-built bundle the path is relative to the bundle
root. In a **source project** it is relative to `public/` — `"migrations": "migrations"`
means `public/migrations/`, exactly as `entry` means `public/_functions/…`. A directory
outside `public/` is not part of what the toolchain copies into your build output, so it is
neither built nor served, and we do not move it.

Directories you do **not** declare stay exactly where you put them, including an ordinary
`migrations/` page you publish on purpose.

## The shape

```jsonc theme={null}
// pages.deploy.json
{
  "entry": "_worker.js",                 // omit for a static site
  "assets": "dist/client",               // your static files
  "routes": [{ "pattern": "/*" }],       // which paths the Worker answers
  "bindings": {
    "database": { "migrations": "migrations", "tables": ["episodes", "guests"] },
    "projectDatabase": "read",           // none | read | readwrite
    "secrets": ["RESEND_API_KEY"],       // names only, never values
    "submissions": "guest_applications"  // form posts also land here
  },
  "limits": { "cpuMs": 10000, "subRequests": 20 }
}
```

The machine-readable schema is `PagesDeployManifest` in
[the OpenAPI document](https://app.revastack.ai/api/v1/openapi.json). It is generated from
the same definition the platform validates against, so it is never out of date.

| Field                                      | Meaning                                                                                                                                                                                                                                                                                                 | Status                                                                                                                       |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `entry`                                    | The Worker module that answers `routes`, relative to the bundle root. **Omit it and the site is static.**                                                                                                                                                                                               | Validated and staged today.                                                                                                  |
| `assets`                                   | The directory holding your static files.                                                                                                                                                                                                                                                                | Validated today.                                                                                                             |
| `routes[].pattern`                         | A site-root-relative path, at most 512 characters. `*` is allowed only as a trailing `/*`. Everything not matched is served as a static file.                                                                                                                                                           | Validated today.                                                                                                             |
| `bindings.database.tables`                 | The tables your site's own code will own.                                                                                                                                                                                                                                                               | **Live** — the database is created and bound as `env.DB`.                                                                    |
| `bindings.database.migrations`             | A directory of `.sql` files, to be applied in filename order. Name it whatever you like; we **move the files to `_migrations/`** when we stage the bundle — one of the four reserved paths (above) we never serve — and leave this field as you wrote it, normalized only by removing a trailing slash. | **Live** — applied in filename order when a revision is staged, once each; the database's own ledger table records what ran. |
| `bindings.database.stagedMigrationsPrefix` | **Platform-written, never yours.** Where we actually staged the files (always `_migrations/`). It appears in the manifest we hand back; a bundle that arrives carrying it is refused.                                                                                                                   | Written at staging today.                                                                                                    |
| `bindings.projectDatabase`                 | `none` (default), `read`, or `readwrite` access to the database shared by the sites in a project.                                                                                                                                                                                                       | **Declared only** — validated and recorded; the binding arrives with the project-database stage.                             |
| `bindings.secrets`                         | Secret **names** your Worker will read. Set the values on the site; never put a value in this file.                                                                                                                                                                                                     | **Live** — a name whose value is set on the site is bound into your Worker.                                                  |
| `bindings.submissions`                     | A table in your site's database that form posts to `/__lead` are **also** written to, so your own code reads your leads. You create the table; see below for the columns and the exact SQL.                                                                                                             | **Live** — every lead is mirrored.                                                                                           |
| `limits.cpuMs`                             | CPU milliseconds per invocation. Default `10000`, ceiling `30000`.                                                                                                                                                                                                                                      | Validated and applied at dispatch today.                                                                                     |
| `limits.subRequests`                       | Outbound sub-requests per invocation. Default `20`, ceiling `50`.                                                                                                                                                                                                                                       | Validated and applied at dispatch today.                                                                                     |

## What the platform decides, and will not let you set

Three things are ours, and a manifest that sets one is refused by name rather than ignored:

* **the script name** — derived from your site id, so one site can never claim another's
  name;
* **the compatibility date** — pinned by the platform, so the Workers runtime cannot change
  your site's behaviour without a rebuild;
* **the ceilings** — `limits` is a request; asking for more than the ceiling is refused
  **with the number**, never quietly clamped to it.

`mainModule` is the old spelling of `entry`. A file that still uses it is accepted as the
legacy `pages.functions.json`; in `pages.deploy.json`, use `entry`.

## A static site

The smallest honest manifest. You can also ship no manifest at all and we will synthesize
exactly this.

```json theme={null}
{
  "routes": [],
  "bindings": { "projectDatabase": "none", "secrets": [] },
  "limits": { "cpuMs": 10000, "subRequests": 20 }
}
```

Your bundle is a folder of files with `index.html` at its top level. Zip the folder's
**contents**, not the folder.

## A Worker bundle

```json theme={null}
{
  "entry": "_worker.js",
  "assets": "assets",
  "routes": [{ "pattern": "/*" }],
  "bindings": {
    "database": {
      "migrations": "migrations",
      "tables": ["episodes", "guests"]
    },
    "secrets": ["RESEND_API_KEY"],
    "submissions": "guest_applications"
  },
  "limits": { "cpuMs": 20000, "subRequests": 30 }
}
```

Zip that tree — `pages.deploy.json`, `_worker.js`, `assets/`, `migrations/` — and send it:

```bash theme={null}
curl -X POST https://app.revastack.ai/api/pages/ingest \
  -H "Authorization: Bearer $GOOSY_API_KEY" \
  -F "file=@site.zip" \
  -F "siteId=$GOOSY_SITE_ID"
```

A bundle shaped this way needs no `index.html` at its root: your Worker answers `/`.

The manifest above is validated, recorded and staged, and the bindings it declares are
injected: `env.DB` is your database, a declared secret whose value is set on the site is
there, and form posts are mirrored into your `submissions` table. **`env.PROJECT_DB` is the
one exception** — declare it now if you will want it; it is bound by the project-database
stage.

## Bring your own app

You do not have to build anything yourself. Send us the **source project** and we run its
framework's own Cloudflare build for you, then map the output onto the contract above.

| Your project                                                     | What we run                                      | Where its Worker ends up               |
| ---------------------------------------------------------------- | ------------------------------------------------ | -------------------------------------- |
| **Astro** with server routes, middleware, or a declared database | `@astrojs/cloudflare` with `output: "server"`    | `_functions/server/entry.mjs`          |
| **Astro** with none of those                                     | Your ordinary `astro build` — unchanged          | Nowhere; it is a static site           |
| **Next.js**                                                      | `@opennextjs/cloudflare` (Node compatibility on) | `_functions/worker.js`                 |
| **Vite** (React, Svelte, Vue, or none)                           | Your ordinary `vite build`                       | Nowhere; it is a static site           |
| **Already ships its own Worker** under `public/_functions/`      | Your own build, untouched                        | Wherever your `pages.deploy.json` says |

In every case your static files land at the bundle root and your server code lands under
`_functions/`, which is never served to a visitor.

### What we change, and how you see it

If your project needs a framework adapter and does not have one configured, we add it —
**deterministically, with no model involved** — and the build result lists every file we
touched:

```json theme={null}
{
  "conform": {
    "recipe": "astro-cloudflare",
    "changes": [
      { "file": "astro.config.base.mjs", "change": "Your Astro config, copied here unchanged so the adapter can be layered on top of it." },
      { "file": "astro.config.mjs", "change": "Replaced with a wrapper that imports your config and adds `output: \"server\"` and the Cloudflare adapter, so your server routes run on a Worker." }
    ],
    "requiredDependencies": []
  }
}
```

**Your own configuration is never rewritten.** For Astro it is copied verbatim to
`astro.config.base.mjs` and the file at `astro.config.mjs` becomes a small wrapper that
imports it and sets exactly two things — `output` and `adapter`. Everything else you
configured — integrations, Vite plugins, `site`, `base`, image settings — passes through
untouched. Delete the wrapper and rename your file back to undo it.

For Next.js we add `open-next.config.ts` and `wrangler.jsonc`, and point your `build` script
at `opennextjs-cloudflare build`. Nothing else in your `package.json` changes. Your own
`next build` is not lost — the adapter runs it, because `open-next.config.ts` carries
`buildCommand: "next build"`. That split is deliberate and load-bearing: the adapter builds
your app by running your package's `build` script, so a `build` script that also named the
adapter would re-enter it forever.

If you had already wired the adapter yourself, we change nothing and `changes` comes back
empty. The same is true of a second build of a project we already conformed.

### The one thing you have to do yourself

**Declare the adapter as a dependency.** We install from your lockfile alone and never
resolve versions off the registry at build time, so we cannot add a package your
`pnpm-lock.yaml` does not corroborate. A project missing its adapter is refused **before**
anything is installed, naming exactly what to add:

```
This project needs "@astrojs/cloudflare": "^14.3.0" (in dependencies) to build for
Cloudflare, and the build installs from your lockfile alone — it never resolves versions off
the registry. Add it to `package.json`, run `pnpm install` to refresh `pnpm-lock.yaml`,
include both in the upload, and import it again.
```

The adapters, and the versions we build against:

| Framework | Package                  | Range     |
| --------- | ------------------------ | --------- |
| Astro     | `@astrojs/cloudflare`    | `^14.3.0` |
| Next.js   | `@opennextjs/cloudflare` | `^1.20.0` |

A source project also needs a `pnpm-lock.yaml`. A `package-lock.json` or a `yarn.lock` is
not one, and a project shipping only those is refused by name.

### If both configs are present

A project carrying `astro.config.*` **and** `next.config.*` at its top level is refused
naming both files. We will not guess which one you meant and spend a build finding out.

### A Vite app with an external backend

The Lovable and Bolt shape — a React app in the browser talking to Supabase or your own API.
It ingests as an ordinary static site, your backend stays exactly where it is, and your
secrets stay yours. We add one note to the build result:

```json theme={null}
{
  "advisories": [
    {
      "code": "seo.client-rendered",
      "message": "This site loads its content in the browser from an external backend (VITE_SUPABASE_URL), so a search engine or an AI crawler that does not run JavaScript sees an empty page.",
      "nextStep": "It will work for visitors exactly as it does today. To be found in search, move the pages that matter onto a framework that renders them on the server — Goosy can do that conversion for you."
    }
  ]
}
```

It is a **note, not a refusal** — the site publishes and works. We read the *names* of your
`VITE_*` variables to notice the pattern; we never read or store their values.

## Your form entries in your own database — `bindings.submissions`

Name a table in `bindings.submissions` and every post to `/__lead` lands twice: once in
Goosy, where the leads list and the CRM sync read it, and once as a row in **your** table,
which nothing but your own code touches. You write no code to make that happen — and you do
create the table, because your site's migrations own your schema, not us.

The platform writes exactly six columns, always these and only these:

| Column       | What it holds                                                                                        |
| ------------ | ---------------------------------------------------------------------------------------------------- |
| `id`         | The lead's Goosy id — the same value the leads API reports, so the two records join.                 |
| `form`       | Which form was submitted — see below — or `null` when the post did not name one.                     |
| `email`      | The submitted email address, or `null`.                                                              |
| `name`       | The submitted name, or `null`.                                                                       |
| `payload`    | The whole sanitized submission as a JSON string — every field, including ones we have no column for. |
| `created_at` | When the visitor submitted, as an ISO-8601 string.                                                   |

Put this in your own `migrations/` directory, changing only the table name:

```sql theme={null}
create table if not exists submissions (
  id text primary key,
  form text,
  email text,
  name text,
  payload text,
  created_at text
);
```

### Telling your forms apart — `form` (or `form_type`)

One `/__lead` serves every form on your site, so a hidden field is what tells a newsletter
signup from an application. Post it as **`form`**; **`form_type` is accepted as an alias**, so
a site already sending that spelling needs no change. If a page sends both, `form` wins.

If `form` is present but its value is refused, we do **not** fall back to `form_type` — we
know which field your page meant, so the lead is stored with `form` empty rather than under a
name from the other field. An empty `form` is treated as absent, so the alias behind it still
answers.

```html theme={null}
<input type="hidden" name="form" value="guest_applications" />
```

The value must be a short identifier — lowercase letters, digits, `_` and `-`, up to 64
characters. Anything else is **refused to `null`** rather than tidied into something
acceptable, because a value we rewrote would be a form name you never declared. Case and
surrounding spaces are the one exception: `Contact` and `contact` are the same form. A refused
value never costs you the lead — the submission is stored either way, with `form` empty.

**`id text primary key` is required.** The insert is `on conflict(id) do nothing`, so a
redelivered submission adds nothing and overwrites nothing — and SQLite refuses the statement
outright if `id` is not a primary key or unique.

A column we do not write may exist, but it has to accept a row that does not set it: a `not
null` column with no default makes every insert fail. Add columns of your own as nullable, or
give them a default.

The mirror is **best-effort and never costs you a lead.** If the table is missing, has the
wrong shape, or the site has no database yet, the lead is still stored by Goosy and the form
still answers the visitor normally; the attempt is recorded against the lead with the reason.
Nothing is retried, so a fix to your schema applies to the next submission, not to past ones —
the leads API is where past leads live either way.

## What the response tells you

Every successful ingest answers with the contract it resolved to:

```json theme={null}
{
  "kind": "prebuilt",
  "deploySource": "declared",
  "deploy": { "entry": "_functions/_worker.js", "routes": [{ "pattern": "/*" }], "...": "..." }
}
```

* `kind` is `prebuilt` (a bundle with a manifest), `bundle` (a plain static folder) or
  `source` (a project we build).
* `deploySource` is `declared` (your file), `legacy` (your `pages.functions.json`,
  translated) or `synthesized` (ours).
* `deploy` is the manifest **as we resolved it** — defaults filled in, `entry` rewritten to
  where it was actually staged (under `_functions/`), and
  `bindings.database.stagedMigrationsPrefix` added when you declared migrations. Your own
  `bindings.database.migrations` comes back normalized (trailing slash removed) and otherwise unchanged. Read the response rather than assuming
  your file was taken verbatim.

## When a bundle is refused

Every refusal names the field, and a ceiling refusal names the number. Nothing is written
when a bundle is refused — fix the file and send it again.

| What we say                                                         | What to change                                                                                            |
| ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `limits.cpuMs` … asks for 60000, over the platform ceiling of 30000 | Lower the number.                                                                                         |
| `scriptName` … is decided by the platform                           | Delete the field.                                                                                         |
| … declares 1 route(s) but no `entry`                                | Name the Worker module, or drop `routes` for a static site.                                               |
| … names an `entry` but declares no `routes`                         | Add `{ "pattern": "/*" }`.                                                                                |
| Route pattern … is not one the serving Worker can honour            | Site-root-relative, no `?`/`#`/`..`, at most 512 characters, `*` only as a trailing `/*`.                 |
| `bindings.submissions` … is not a table name                        | Letters, digits and underscores, starting with a letter or underscore.                                    |
| `entry` … is not a file inside the bundle                           | A bundle-relative path — no leading slash, no `..`.                                                       |
| … is not valid: Unrecognized key(s) in object                       | A typo'd field. The contract is closed on purpose: a field we do not know is a binding you would not get. |

## Related

* [Pages API](/api/pages) — creating sites, building, publishing, domains.
* [Recipes](/api/recipes) — a full run from an empty directory to a live site.
* [Errors and refusals](/api/errors-and-refusals) — the shared error envelope.
