> ## 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 Goosy deploy skill

> An installable skill that teaches Claude Code, Codex or Gemini the whole Pages deploy contract and API.

If you build with a coding agent, you can hand it this skill once and then say
"ship this to Goosy". It teaches the deploy contract, the import call, build
polling, preview, the two-call publish, domains and rollback — using only calls
the [OpenAPI document](https://app.goosybear.ai/api/v1/openapi.json) publishes.

It is plain Markdown with YAML frontmatter. Claude Code, Codex and Gemini all
read that shape.

## Install it

Create the folder and save the three files below into it.

**Claude Code** — for one project, `.claude/skills/goosy-deploy/`; for every
project, `~/.claude/skills/goosy-deploy/`. Restart the session and ask it to
deploy; it loads the skill by its description.

**Codex** — save the same folder under your agent skills directory and reference
`SKILL.md` from your project instructions.

**Gemini** — the same folder works; point your agent configuration at
`SKILL.md`.

```
goosy-deploy/
  SKILL.md
  reference/
    deploy-contract.md
    api-calls.md
```

## Give it a key

The skill expects two environment variables and will ask for them if they are
missing:

```bash theme={null}
export GOOSY_API_KEY="..."     # Settings › API & MCP in the Goosy app
export GOOSY_WORKSPACE="..."   # the workspace slug the site belongs to
```

Use a key narrowed to one workspace for unattended work. An account-wide key
works too — the skill passes `workspace` on every call.

## What it will and will not do

It is written to **stop** at the two places that matter. It will not publish, roll
back, or connect a domain without you saying so, and after a domain connect it
relays the DNS records and tells you the domain is **not live** until they
resolve. It will not invent an endpoint: every call in it is one the published
OpenAPI document carries.

## `SKILL.md`

````markdown theme={null}
---
name: goosy-deploy
description: Build a site or small web app locally and deploy it to Goosy Bear Pages through the public API. Use when the user asks to ship a site to Goosy, deploy to Goosy Pages, push a build to Goosy, connect a domain to a Goosy page, roll a Goosy page back, or wire `pages.deploy.json`. Covers the deploy contract, the import call, build polling, preview, publish, domains and rollback.
---

# Deploy to Goosy Bear Pages

You are shipping a site the user built locally — static output, an Astro or
Next.js project, or a prebuilt Worker bundle — to Goosy Bear Pages. Everything
happens over the public API with one API key. You never need the Goosy
repository, a Cloudflare account, or any platform credential.

## Before you start

Ask the user for two things and put them in the environment:

```bash
export GOOSY_API_KEY="..."     # Settings › API & MCP in the Goosy app
export GOOSY_WORKSPACE="..."   # the workspace slug the site belongs to
```

If the user does not know their workspace slug, run any workspace-scoped call
without `workspace` and read the `workspaces` list back from the refusal.

**Do not put the key in a file you commit.** If you see one in the repo, say so
and move it to the environment before doing anything else.

## The one rule that decides everything

Every request is `Authorization: Bearer $GOOSY_API_KEY`, and the answer's `ok`
field is the outcome — **not** the HTTP status. A call that was accepted and
declined is `200` with `"ok": false` and a typed `code`. Branch on `ok`.

## Step 1 — write `pages.deploy.json`

One file describes the bundle. **Where it goes decides how the upload is
classified**, so get this right before anything else:

| The bundle is… | Put the file at | What happens |
| --- | --- | --- |
| Already built output (optionally with a Worker) | `pages.deploy.json` at the **zip root** | Staged exactly as sent; nothing is compiled |
| A source project Goosy builds (Astro, Next.js) | **`public/pages.deploy.json`** | Copied to the output root by the build; your build still runs |
| A plain static folder | nothing | A static manifest is synthesized and returned to you |

A root-level `pages.deploy.json` in a SOURCE project is the mistake to avoid: it
classifies the upload as already-built and **skips the build**.

```jsonc
{
  "entry": "_worker.js",                 // omit entirely for a static site
  "assets": "dist/client",               // the folder of static files
  "routes": [{ "pattern": "/*" }],       // which paths the Worker answers
  "bindings": {
    "database": { "migrations": "migrations/", "tables": ["episodes"] },
    "projectDatabase": "read",           // none | read | readwrite
    "secrets": ["RESEND_API_KEY"],       // NAMES only — never values
    "submissions": "guest_applications"
  },
  "limits": { "cpuMs": 10000, "subRequests": 20 }
}
```

Omit any key you do not need. `bindings.secrets` carries **names only** — the
values are set in the Goosy app and are never in the bundle.

**Tell the user what is live before they write code against it.** The manifest is
**validated on every upload**, the resolved manifest comes back on the response,
and the `DB`, `secrets` and `submissions` bindings are **wired** — `env.DB` is
real, declared migrations are applied when a revision is staged, and a post to
`/__lead` is mirrored into the named table. **`PROJECT_DB` is the one exception**:
declared and recorded today, bound by the project-database stage. Declaring it now
is safe; reading it is not yet.

**An Astro project reads its bindings with
`import { env } from "cloudflare:workers"`** — it has no `fetch(request, env)` of
its own. `Astro.locals.runtime.env` was removed in Astro 6 and **throws** on
Astro 7, so a page reaching for it answers 500 on every request. Never suggest it.

**A lead form names itself with a hidden `form` field** (`form_type` is accepted
as an alias; `form` wins if both are sent). The value is a short identifier —
lowercase letters, digits, `_` and `-`, up to 64 characters — and anything else is
refused to `null` rather than tidied, though the lead is still captured. A
`form` value that is REFUSED does **not** fall back to `form_type` — the lead is
stored with `form` empty rather than under the other field's name; an EMPTY
`form` (no value sent) is treated as absent, so the alias still answers.

Every field, with its exact constraints and its current status, is at
[docs.goosybear.ai/api/deploy-contract](https://docs.goosybear.ai/api/deploy-contract), and the machine-readable schema
is `PagesDeployManifest` in [docs.goosybear.ai/openapi.json](https://docs.goosybear.ai/openapi.json). Read one
of those before inventing a field; do not guess.

## Step 2 — import the bundle

Zip the project and send it. `workspace` is the workspace slug or id; send it
whenever the key is account-wide.

`workspace` rides the QUERY STRING here, not the form. This endpoint's body is
your archive, and reading a field out of it would mean decoding up to 40 MB
before the key was even checked.

```bash
zip -r ../site.zip . -x '.git/*' 'node_modules/*'

curl --silent --request POST \
  --url "https://app.goosybear.ai/api/pages/ingest?workspace=$GOOSY_WORKSPACE" \
  --header "Authorization: Bearer $GOOSY_API_KEY" \
  --form "file=@../site.zip"
```

Add `--form "siteId=THE-SITE-UUID"` to REPLACE an existing site instead of creating
one. Omit it to create; `--form "name=My site"` then names the new site.

Read `data.kind` from the answer:

- `bundle` / `prebuilt` — already staged. `data.revision` is the build id.
- `source` — queued. `data.buildRunId` is what you poll in step 3.

Always read `data.findings` and `data.report` back to the user. An empty
`findings` array means "nothing to fix", not "we did not check". `data.deploy`
is the manifest that was actually used and `data.deploySource` says whether it
was yours (`declared`), translated from a legacy file (`legacy`), or ours
(`synthesized`) — if it is `synthesized`, show it to the user and offer to
commit it.

## Step 3 — follow the build

Only for a `source` import. Poll with ordinary backoff; **never** in a tight
loop.

```bash
curl --silent --request POST \
  --url "https://app.goosybear.ai/api/v1/tools/page.build_status" \
  --header "Authorization: Bearer $GOOSY_API_KEY" \
  --header "Content-Type: application/json" \
  --data "{\"site_id\":\"$SITE_ID\",\"build_run_id\":\"$BUILD_RUN_ID\",\"workspace\":\"$GOOSY_WORKSPACE\"}"
```

`status` is `pending`, `running`, `retrying`, `succeeded` or `failed`. A
`pending` immediately after the import can simply mean the durable record has
not appeared yet. A `succeeded` carries `revision`.

## Step 4 — look at it before anyone else does

```bash
curl --silent --request POST \
  --url "https://app.goosybear.ai/api/pages/$SITE_ID/preview-links" \
  --header "Authorization: Bearer $GOOSY_API_KEY" \
  --header "Content-Type: application/json" \
  --data "{\"ttlMinutes\":60,\"workspace\":\"$GOOSY_WORKSPACE\"}"
```

The returned `token` is a bearer capability: treat it as a secret, keep it out
of logs, and share the URL only with the person reviewing. Creating a preview
publishes nothing.

## Step 5 — publish, in two calls

**Never publish without the user saying to.** The first call publishes nothing;
it reads the latest build and returns a confirmation to show them.

```bash
# 1 · propose
curl --silent --request POST \
  --url "https://app.goosybear.ai/api/v1/tools/page.publish" \
  --header "Authorization: Bearer $GOOSY_API_KEY" \
  --header "Content-Type: application/json" \
  --data "{\"site_id\":\"$SITE_ID\",\"workspace\":\"$GOOSY_WORKSPACE\"}"

# 2 · after they approve, repeat with the confirmation_id you got back
```

Show the user the `site_id` and `revision` from the first answer and wait. The
confirmation expires and is bound to that exact revision — if a newer build
lands first, the call answers `pages.publish_revision_stale` and you start over
so the newer bytes get reviewed.

## The rest of the lifecycle

| You want to… | Call | Notes |
| --- | --- | --- |
| See what has been built | `page.revisions` | Newest first; the live one is flagged `published: true` |
| Put a previous build back | `page.rollback` | Same two-call confirmation as publish; takes `revision` |
| List the site's images and files | `page.assets` | Identities only — no download URLs are issued |
| Read where the site answers | `page.domains` | Every hostname, its state, and the DNS records still needed |
| Connect the user's own domain | `page.domain_connect` | Reserves the name and starts the certificate |
| Read one site's current facts | `page.read` | `latest_revision` vs `published_revision` |

All of them are `POST https://app.goosybear.ai/api/v1/tools/{tool}` with a JSON
body carrying `site_id` and `workspace`.

**Where `workspace` goes, by door.** JSON bodies carry it as a field — every
tool above, and the preview-link endpoint. The two MULTIPART doors take it as a
query parameter instead (`?workspace=…`): `POST /api/pages/ingest` and
`POST /api/pages/{siteId}/assets`. One name everywhere; each transport's natural
place for it.

**After `page.domain_connect`, always relay the returned `records` to the
user.** The domain is not live until they add those at whoever manages their
DNS — say so plainly, and use `page.domains` to check rather than claiming it
resolved.

## When a call declines

Read `code`, then act:

| `code` | What to do |
| --- | --- |
| `workspace_ambiguous` | The answer lists the slugs. Ask the user which one; never pick. |
| `workspace_unreachable` | The name is not one this key reaches — or the key is pinned elsewhere. |
| `pages.publish_revision_stale` | A newer build landed. Start the two-call publish again. |
| `pages.revision_not_found` | That revision is not one of this site's builds. |
| `pages.domain_taken` | The hostname is already connected somewhere. |
| `invalid_arguments` | `issues` names the fields. Fix and retry. |
| `tenant_mcp.rate_limited` (`429`) | Wait for `Retry-After`. Do not retry sooner. |

The full list is at [docs.goosybear.ai/api/errors-and-refusals](https://docs.goosybear.ai/api/errors-and-refusals).

## Things not to do

- Do not invent an endpoint. Every call you make must be one the published
  OpenAPI document at [docs.goosybear.ai/openapi.json](https://docs.goosybear.ai/openapi.json) carries.
- Do not publish, roll back, or connect a domain without the user's explicit go.
- Do not put a secret value in `pages.deploy.json` — names only.
- Do not poll in a tight loop; use backoff and stop at a terminal state.
- Do not tell the user a domain is live because the connect call succeeded.

## Reference files

- `reference/deploy-contract.md` — every `pages.deploy.json` field.
- `reference/api-calls.md` — copyable request bodies for the whole lifecycle.
````

## `reference/deploy-contract.md`

The short form of [the deploy contract](/api/deploy-contract), for the agent to
consult without leaving its own context.

````markdown theme={null}
# `pages.deploy.json`, in one page

**The authority is [docs.goosybear.ai/api/deploy-contract](https://docs.goosybear.ai/api/deploy-contract), and the
machine-readable schema is `PagesDeployManifest` in
[docs.goosybear.ai/openapi.json](https://docs.goosybear.ai/openapi.json).** Both are generated from the same
definition the platform validates against, so they are never out of date. This
file is the short version — when it disagrees with either of those, they win.

## Where the file goes

| Your bundle is… | Put the file at |
| --- | --- |
| Already built output | `pages.deploy.json` at the **zip root** |
| A source project we build | **`public/pages.deploy.json`** |
| A plain static folder | nothing — one is synthesized and returned |

A root-level file in a source project classifies the upload as already-built and
**skips your build**. This is the most common mistake.

## The fields

| Field | Meaning |
| --- | --- |
| `entry` | The Worker module that answers `routes`. **Omit it and the site is static.** |
| `assets` | The directory holding your static files. |
| `routes[].pattern` | A site-root-relative path, ≤ 512 characters. `*` only as a trailing `/*`. Anything unmatched is served as a static file. |
| `bindings.database.tables` | Tables your site's own code owns. |
| `bindings.database.migrations` | A directory of `.sql` files applied in filename order. Name it what you like. |
| `bindings.projectDatabase` | `none` (default) · `read` · `readwrite` — the database shared by a project's sites. |
| `bindings.secrets` | Secret **names** your Worker reads. Values are set on the site, never here. |
| `bindings.submissions` | A table platform form posts are also written to. |
| `limits.cpuMs` | CPU ms per invocation. Default `10000`, ceiling `30000`. |
| `limits.subRequests` | Outbound sub-requests per invocation. Default `20`, ceiling `50`. |

## Reading the bindings

`DB`, `secrets` and `submissions` are **wired**. `projectDatabase` is validated and
recorded only — its `env.PROJECT_DB` binding arrives with the project-database stage.

```js
// A Worker of your own:
export default { async fetch(request, env) { await env.DB.prepare("…").all(); } };
```

```astro
---
// An Astro page or endpoint — there is no `env` argument to read from:
import { env } from "cloudflare:workers";
---
```

**`Astro.locals.runtime.env` was removed in Astro 6 and THROWS on Astro 7** — a page
using it answers 500 on every request. `cloudflare:workers` is the supported access.

## Naming a lead form

One `/__lead` serves every form, so a hidden field tells them apart. Post `form`;
**`form_type` is accepted as an alias**, and `form` wins when both are sent.

```html
<input type="hidden" name="form" value="guest_applications" />
```

Lowercase letters, digits, `_` and `-`, up to 64 characters. Anything else is refused
to `null` rather than tidied — the lead is captured either way.

A `form` value that is refused does **not** fall back to `form_type` — the lead is
stored with `form` empty rather than under the other field's name. An empty `form`
(no value sent) is treated as absent, so the alias still answers.

## What you may NOT set

Three things belong to the platform, and a manifest that sets one is refused **by
name** rather than ignored:

- the **script name** — derived from the site id, so one site cannot claim another's;
- the **compatibility date** — pinned, so the runtime cannot change a site's behaviour;
- the **ceilings** — `limits` is a request; over the ceiling is refused **with the
  number**, never quietly clamped.

`bindings.database.stagedMigrationsPrefix` is platform-written too. A bundle that
arrives carrying it is refused.

## Reserved paths

Four objects in a staged bundle are never served, and the list is closed:
`pages.deploy.json`, `pages.functions.json`, `_functions/`, `_migrations/`.
Everything else in your bundle is servable, including other `_`-prefixed
directories. Declared migrations are **relocated** into `_migrations/`; your own
field is left as you wrote it.

## The smallest honest static manifest

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

You can also ship no manifest at all and this is what comes back.

## Legacy

`pages.functions.json` is still accepted, and `mainModule` is the old spelling of
`entry`. In `pages.deploy.json`, use `entry`.
````

## `reference/api-calls.md`

A copyable body for every call on [the Pages API page](/api/pages).

````markdown theme={null}
# Every call in the Pages lifecycle

Base URL `https://app.goosybear.ai`. Every request carries
`Authorization: Bearer $GOOSY_API_KEY`. **Branch on the body's `ok`, never on
the HTTP status** — an accepted call that declined is `200` with `"ok": false`
and a typed `code`.

Tool calls are `POST /api/v1/tools/{tool}` with a JSON body. Every
workspace-scoped body takes the same optional `workspace` field — a slug or an
id — which is required in practice whenever the key is account-wide. The two
MULTIPART doors (import, asset upload) take that same name as a QUERY parameter
instead, so the credential and the rate limiter run before anything decodes the
body.

```bash
export GOOSY_API_KEY="..."
export GOOSY_WORKSPACE="..."
export SITE_ID="..."
```

## Import or replace — `POST /api/pages/ingest?workspace=<slug>`

`multipart/form-data`. `file` is the only required field. **`workspace` is a
QUERY parameter here**, not a form field — the body is your archive, and reading
a field out of it would decode up to 40 MB before the key was checked.

| Where | Field | Meaning |
| --- | --- | --- |
| query | `workspace` | Which workspace to import into — slug or id. |
| form | `file` | One ZIP archive. |
| form | `siteId` | Replace this existing site. Omit to create one. |
| form | `name` | Display name for a NEW site. Ignored when `siteId` is sent. |

```bash
curl --silent --request POST \
  --url "https://app.goosybear.ai/api/pages/ingest?workspace=$GOOSY_WORKSPACE" \
  --header "Authorization: Bearer $GOOSY_API_KEY" \
  --form "file=@./site.zip"
```

Read `data.kind`: `bundle` / `prebuilt` are staged now and carry `revision`;
`source` is queued and carries `buildRunId`. `data.deploy` is the manifest that
was used and `data.deploySource` says whose it was.

## Read a site — `page.read`

```json
{ "site_id": "…", "workspace": "…" }
```

`latest_revision` is what is built. `published_revision` is what the internet is
being served. They are different questions.

## Follow a build — `page.build_status`

```json
{ "site_id": "…", "build_run_id": "…", "workspace": "…" }
```

`pending` · `running` · `retrying` · `succeeded` · `failed`. Poll with backoff;
stop at a terminal state.

## List builds — `page.revisions`

```json
{ "site_id": "…", "limit": 20, "workspace": "…" }
```

Newest first. Each row: `revision`, `origin`, `created_at`, `published`,
`has_functions`. Exactly one row can have `published: true`.

## Preview — `POST /api/pages/{siteId}/preview-links`

```json
{ "ttlMinutes": 60, "workspace": "…" }
```

Whole minutes, 5 – 43,200; the default is 10,080 (seven days). The returned
`token` is a bearer capability — keep it out of logs. `DELETE` on the same path
with `{"token":"…","workspace":"…"}` revokes it. Creating a preview publishes
nothing.

## Publish — `page.publish`, always two calls

```json
{ "site_id": "…", "workspace": "…" }
```

returns `state: "confirmation_required"` with `revision` and `confirmation_id`.
Show those to the person approving. Then repeat with `confirmation_id` added.
The confirmation is bound to that exact revision and expires; a newer build
answers `pages.publish_revision_stale` and you start over.

## Roll back — `page.rollback`, also two calls

```json
{ "site_id": "…", "revision": "A-PRIOR-BUILD-REVISION", "workspace": "…" }
```

Same confirmation shape, bound to **both** the site and the revision. The first
call also returns `previous_revision` — what is live now — so the approver can
see both ends of the swap. `pages.revision_already_published` means that
revision is already the live one.

## List assets — `page.assets`

```json
{ "site_id": "…", "limit": 50, "workspace": "…" }
```

Identity, name, type, size and time per asset. **No download URL is issued.**
Upload one with `POST /api/pages/{siteId}/assets?workspace=<slug>` as multipart
`file` — PNG, JPEG, WEBP or GIF, up to 40 MB, no SVG. Like the import door,
`workspace` is a QUERY parameter on this one because the body is the upload.

## Addresses — `page.domains`

```json
{ "site_id": "…", "workspace": "…" }
```

Every hostname the site answers on, each with a `state`
(`waiting_dns` · `validating` · `active` · `failed`), the three progress `steps`
and every `record` still required. The free platform address carries no records
— it is ours end to end.

## Connect a domain — `page.domain_connect`

```json
{ "site_id": "…", "hostname": "offers.example.com", "workspace": "…" }
```

Reserves the name and starts the certificate, then returns the `records` the
customer must add at whoever manages that domain's DNS. **The domain is not live
until those resolve.** Never describe it as connected or live; use
`page.domains` to check. `pages.domain_taken` means the hostname is already
connected somewhere.

## Refusal codes worth branching on

| `code` | Meaning |
| --- | --- |
| `workspace_ambiguous` | More than one reachable workspace and none selected. The answer lists them. |
| `workspace_unreachable` | Not a workspace this key reaches, or the key is pinned elsewhere. |
| `invalid_arguments` | `issues` names the fields that failed. |
| `pages.publish_revision_stale` | A newer build is ready than the one approved. |
| `pages.revision_not_found` | Not one of this site's builds. |
| `pages.nothing_to_publish` | Nothing has been built yet. |
| `pages.domain_taken` | That hostname is already connected. |
| `tenant_mcp.rate_limited` | `429`. Wait for `Retry-After`. |

The full list is [docs.goosybear.ai/api/errors-and-refusals](https://docs.goosybear.ai/api/errors-and-refusals).
````

All three files say the same thing the pages here do — when they disagree, these
pages win, because they are generated from the same definitions the platform
enforces.
