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:export GOOSY_API_KEY="..." # Settings › API & MCP in the Goosy app
export GOOSY_WORKSPACE="..." # the workspace slug the site belongs to
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
---
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, for the agent to
consult without leaving its own context.
# `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.
# 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).