Porchsite · All guides · Ask for something

Install guide (for assistants)

# Installing Porchsite (for the assistant)

You are an AI assistant installing Porchsite on your person's own Cloudflare
account. Follow these steps in order. Every step is one Cloudflare API call;
nothing needs git, a terminal or a build tool. When a step says "tell your
person", say it in plain words and wait for them.

Read this file whole, word for word: the steps depend on exact values. It
may be copied in full (MIT licence). If your fetch tool only returns a
summary, or can't reach this address, the same file is at
https://raw.githubusercontent.com/iXanadu/porchsite/main/cloudflare/INSTALL.md

Is your person a developer who'd rather run it themselves? Read
[self-hosting](https://get.porchsite.dev/self-hosting.md) first.

(The site's file is `sitekeeper.js` and its layout table `_sitekeeper_migrations`:
Sitekeeper is the engine's internal name, kept in those names so every site
stays updatable. Everything else is Porchsite.)

**Three rules for the whole job.**
- Only ever upload a release's own `sitekeeper.js`, its hash checked. Never
  change the site's code, add a logging or helper Worker to it, or put a
  temporary page on it, not even for a minute to debug: your person
  approves what the site says, and that rests on the site being the
  release.
- Codes are secrets while they live (the setup code, an OAuth code, a PKCE
  verifier, a token). Never type one into another site, a search box or a
  form that isn't the one it belongs to.
- When you write content later, finish the whole job and send it for
  approval as one request, not a piece at a time; and tell your person
  each time there's something waiting at `/review`, with the link. Drafts
  are invisible to them until you do.
- Content goes in only through the site's own tools at `/mcp` (step 9),
  never by writing its database, which skips the checks the site runs on
  every change (secrets, private names, field rules, copying in images).
  The only things you ever write to the database are the release's
  migration files and their `_sitekeeper_migrations` rows (steps 3 and
  Updating). Never copy code, data or pictures from another site, even
  one on the same account: install from the release's files.

Read this guide in full, as plain text, from
`https://get.porchsite.dev/install.md`. A fetch tool that summarises pages
drops steps; if yours does, ask your person to paste the guide in, or use
the copy in the repository (`cloudflare/INSTALL.md` at
https://github.com/iXanadu/porchsite).

Get the files from `https://get.porchsite.dev/latest.json`. It lists
the current `version` and its `files`, each
`{"path": "sitekeeper.js", "url": "…", "bytes": 1635400, "sha256": "…"}`:
`sitekeeper.js` (the site) and `migrations/*.sql` (the database layout).
Download each one and check its sha256 (hex, of the file's bytes) before you
use it. Tell your person which version you installed. If your network can't
reach get.porchsite.dev, every file also has a `mirror`: the same bytes on
the project's GitHub releases (check the same sha256). Each past release's
files and hashes are at its `files_url` in `releases`.

**Moving the files.** `sitekeeper.js` is about 1.6 MB, and step 6 sends it to
Cloudflare. Fetch it with your own web or shell tool, then send it with an
HTTP tool that can carry a file or a long body. Cloudflare's MCP `execute`
sandbox can't fetch outside addresses itself (it refuses get.porchsite.dev),
so if that's your route you
must pass the file's contents into the call, inlined in the request body
(step 6 shows the exact shape; some apps let you reference a local file in a
tool's arguments). If you can do neither, ask your person for an API token
(step 0) and use your own HTTP tool for steps 3 and 6.

List calls (`GET` of databases, namespaces, zones) return a page at a time:
add `?per_page=50` and check `result_info` for more.

## 0. Access

**No Cloudflare access of your own?** Don't improvise one. Send your person
to `https://get.porchsite.dev/install/`: they make a token there from a
pre-filled link, paste it with the site's name, and the page does steps
2 to 7 and gives them the claim link. Then pick up at step 9 (connect
yourself). Skip the rest of this section.

Calls go to `https://api.cloudflare.com/client/v4` with your Cloudflare
access: either Cloudflare's own API MCP server, `https://mcp.cloudflare.com/mcp`
(its `search` and `execute` tools reach the whole API), or an API token
your person made. Not the "Cloudflare Developer Platform" connector some
apps list in their directory: that one manages databases and storage but
can't upload a Worker, so the install stops at step 6. If your person has
to add the right one, it's a custom connector with that address. If it's a token, collect it through your app's
secret or password field, never in the chat, and never write it into a
memory, a note or a file. The access must be able to **edit** Workers scripts,
D1 and Workers KV.

For a token, send your person this pre-filled link (signed in to Cloudflare;
if the form opens empty, open it again):
https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22workers_scripts%22%2C%22type%22%3A%22edit%22%7D%2C%7B%22key%22%3A%22d1%22%2C%22type%22%3A%22edit%22%7D%2C%7B%22key%22%3A%22workers_kv_storage%22%2C%22type%22%3A%22edit%22%7D%2C%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%5D&name=Porchsite
It asks for four account permissions: Workers Scripts Edit, D1 Edit,
Workers KV Storage Edit, Account Settings Read. Not the "Edit Cloudflare
Workers" template: it adds a zone row that a new account, with no domain,
can't fill, and it leaves out D1. If you connected through Cloudflare's MCP server, your
person had to choose write access on its consent screen; read-only is the
default and will fail at step 2.

Find the account: `GET /accounts`. If there is more than one, ask your person
which. Call its id `ACCOUNT` below. Note the time now, in UTC (`START`): the
last check lists what changed on the account since.

Through Cloudflare's MCP server, `execute` can also take an account id. If
your session has only one account, leave it out: passing it has sometimes
failed with error 7403 ("account not valid or not authorized") where the
same call without it worked. If you get 7403, retry once without it.

## 1. Pick a name, and the address, with your person

Ask before you create anything: what is the site for, and where should it
live? Either a free address, `https://<name>.<subdomain>.workers.dev`, or
their own domain (step 7b; one they have, or about $10 a year for a new
one). Suggest a name that fits the site (a reunion: the school and the
year), not `porchsite`. Call it `NAME`. Settle it now: the passkey your
person makes in step 8 works only at the address where they make it, so a
later move means a re-link (see "Moving to a new address later").

## 2. Database

`POST /accounts/ACCOUNT/d1/database` with `{"name": "NAME"}`.
Keep `result.uuid` as `DB`.

Already exists (error code 7502)? `GET /accounts/ACCOUNT/d1/database?name=NAME`
and use that one's uuid. Never delete a database to start over: it holds the
site.

## 3. Database layout

Porchsite records which layout files it has applied, so this step is safe to
repeat. First:

`POST /accounts/ACCOUNT/d1/database/DB/query` with
`{"sql": "CREATE TABLE IF NOT EXISTS _sitekeeper_migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)"}`

Then `{"sql": "SELECT name FROM _sitekeeper_migrations"}`; the names are at
`result[0].results[].name`. For each file in
`migrations/` (latest.json's `all_migrations`, in that order) that is not in
that list: send the file's whole
text as `sql` to the same endpoint, and when it succeeds, record it:
`{"sql": "INSERT INTO _sitekeeper_migrations VALUES (?, datetime('now'))", "params": ["0001_init.sql"]}`.
If a file fails, stop and show your person the error; do not record it.
A file may hold several statements; one call runs them all.

## 4. Sign-in storage

`POST /accounts/ACCOUNT/storage/kv/namespaces` with `{"title": "NAME-oauth"}`.
Keep `result.id` as `KV`. Already exists? List them
(`GET /accounts/ACCOUNT/storage/kv/namespaces`) and use the one with that title.

## 5. Setup code

Make a random code of at least 16 letters and digits. Call it `CODE`. Your
person never types it: in step 8 you send them a link with it inside, and
they claim the site from that link. Don't put it anywhere but
step 6 and your message in step 8. If you must keep it in a file meanwhile,
delete the file once your person has claimed the site, and delete any file
you built for step 6's upload too: it contains the code.

## 6. Upload the site

`PUT /accounts/ACCOUNT/workers/scripts/NAME` as `multipart/form-data` with
two parts:

- `metadata` (`application/json`):
  ```json
  {
    "main_module": "sitekeeper.js",
    "compatibility_date": "2026-08-15",
    "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
    "bindings": [
      {"type": "d1", "name": "DB", "id": "DB"},
      {"type": "kv_namespace", "name": "OAUTH_KV", "namespace_id": "KV"},
      {"type": "secret_text", "name": "SETUP_CODE", "text": "CODE"}
    ],
    "observability": {"enabled": true}
  }
  ```
  with your `DB`, `KV` and `CODE` values in place of the quoted names.
- `sitekeeper.js` (`application/javascript+module`): the file's contents,
  with that part name and filename.

If your tool can't build `multipart/form-data` itself, write the body as
text: pick a boundary (e.g. `----porchsite`), then for each part a line
`------porchsite`, its headers (`Content-Disposition: form-data;
name="metadata"` or `name="sitekeeper.js"; filename="sitekeeper.js"`, and
`Content-Type`), a blank line and the content; end with `------porchsite--`.
Send it raw (not JSON-encoded) with the header `Content-Type:
multipart/form-data; boundary=----porchsite`. Use CRLF (`\r\n`) line ends.

**If you call the Cloudflare API through Cloudflare's MCP server**, its
`execute` tool runs your code, and the upload must go through its request
helper as that raw multipart text: the whole of `sitekeeper.js` inlined in
the body, `rawBody: true` so the helper sends the body as is instead of
encoding it as JSON, and `contentType` carrying the boundary. The exact
shape:

```js
async () => {
  const B = "----porchsite";
  const METADATA = { /* step 6's metadata, with your values */ };
  const BUNDLE = "…"; // sitekeeper.js's whole text, as a JSON string literal
  const body = [
    `--${B}`,
    'Content-Disposition: form-data; name="metadata"',
    "Content-Type: application/json",
    "",
    JSON.stringify(METADATA),
    `--${B}`,
    'Content-Disposition: form-data; name="sitekeeper.js"; filename="sitekeeper.js"',
    "Content-Type: application/javascript+module",
    "",
    BUNDLE,
    `--${B}--`,
    "",
  ].join("\r\n");
  return cloudflare.request({
    method: "PUT",
    path: "/accounts/ACCOUNT/workers/scripts/NAME",
    contentType: `multipart/form-data; boundary=${B}`,
    rawBody: true,
    body,
  });
}
```

Inline the bundle as a JSON string literal (escape it as JSON would), never
in a template literal: the bundle contains backticks and `${`. Without
`rawBody: true`, or with a `contentType` that lacks the boundary, Cloudflare
can't read the parts and refuses the upload. Other calls (JSON bodies, GETs,
DELETEs) go through the same helper with `method`, `path` and, for JSON,
`body` as an object.

Updating later is the same call; see "Updating" at the end.

## 7. Turn on the address

Every account has one workers.dev name, shared by all its Workers: the site's
address is `https://NAME.<that name>.workers.dev`. Most people use their own
name or nickname, e.g. `sam` gives `https://porchsite.sam.workers.dev`.

1. `GET /accounts/ACCOUNT/workers/subdomain`. If it returns a `subdomain`,
   keep it. On a new account it fails with HTTP 404, error code 10007 ("You
   do not have a workers.dev subdomain"). That is expected, not a problem,
   and you don't need the dashboard it mentions: ask your person to pick a
   short name, then `PUT /accounts/ACCOUNT/workers/subdomain` with
   `{"subdomain": "<their pick>"}`. Taken? Ask for another.
2. `POST /accounts/ACCOUNT/workers/scripts/NAME/subdomain` with
   `{"enabled": true, "previews_enabled": false}` (preview addresses for
   each upload are on by default; the site doesn't need them).

The site is `https://NAME.<subdomain>.workers.dev`. A brand-new address
takes a minute or two to answer: request `https://…/setup` every 20 seconds,
treat connection or TLS errors as "not ready yet", and go on once it answers
200. Don't send your person there before that.

## 7b. A free address, or their own domain

Ask your person now, before step 8, in plain words:

> "Your site can live at the free address `https://NAME.<subdomain>.workers.dev`,
> or at a domain you own, like `yourname.com`. A domain costs about $10 to $15
> a year. Which would you like? You can add a domain later, but it's simpler now."

Settle it here. A passkey works only at the address it was made on, so your
person must claim the site at its final address in step 8.

**Free address:** nothing to do; go to step 8.

**Their own domain:** find out which case it is, then follow that path.

**A. The domain is already on this Cloudflare account.**
`GET /zones?name=<domain>` returns it. Keep `result[0].id` as `ZONE` and
attach it (below).

**B. They own the domain, but it's registered somewhere else** (GoDaddy,
Namecheap, Squarespace, …).
1. Add it to Cloudflare: `POST /zones` with
   `{"name": "<domain>", "account": {"id": "ACCOUNT"}, "type": "full"}`. Keep
   `result.id` as `ZONE` and `result.name_servers` (two names).
2. Tell your person: "Sign in where you bought the domain, find its
   nameservers, and replace them with these two: …". Offer to walk them
   through it step by step for their registrar. If the domain already runs
   email or another site, warn them first: Cloudflare copies the existing DNS
   records it finds, but they should check those records before switching.
3. Wait. `GET /zones/ZONE` until `status` is `active`. It usually takes
   minutes, but can take up to a day. `PUT /zones/ZONE/activation_check` asks
   Cloudflare to look again (once an hour at most). Tell your person how long
   it's been, and stay with step 7b until it's active.

**C. They don't have a domain yet.** Cloudflare sells domains at cost. Tell
your person: "In the Cloudflare dashboard, open Domain Registration, then
Register Domains, search for the name you want, and buy it. I can't buy it
for you." Once it shows in `GET /zones?name=<domain>`, carry on as in case A.

**Attach it.** The site lives at the bare domain (`sam.com`, not
`www.sam.com`). `PUT /accounts/ACCOUNT/workers/domains` with
`{"hostname": "<domain>", "service": "NAME", "zone_id": "ZONE"}`, then the
same call with `"hostname": "www.<domain>"`, so people who type www still
arrive: the site sends them on to the bare domain. Cloudflare
creates the DNS record and the certificate itself. If the hostname already
has a DNS record (an old site, a parking page), the call fails. Show your
person the error, and don't delete their record without asking.

A new domain takes a few minutes to answer. Request `https://<domain>/setup`
every 30 seconds, treat connection or TLS errors as "not ready yet", and go on
once it answers 200. From here on, the site's address is `https://<domain>`;
use it in every step below. `www.<domain>` can take a few minutes longer
than the bare domain to get its certificate; that's normal.

Access for this step: adding and attaching a domain needs **Zone: Edit**,
**DNS: Edit** and **Workers Routes: Edit** on top of step 0's access. If a
call is refused for permissions, tell your person which one to add to the
token (or to choose on Cloudflare's consent screen), and wait.

## 8. Your person claims it

Send your person the link `https://…/setup?code=CODE` and tell them: "Your
site is ready. Open this link, add your name, and tap Create my passkey. That
passkey is how you approve what I write." The page fills the code in; they
need no terminal and no code to type. Wait until they say it's done. (If the
link is lost, the same link works until the site is claimed; once it is
claimed, the link only leads to sign-in.)

Then, at once:

**Delete the SETUP_CODE secret.** `DELETE
/accounts/ACCOUNT/workers/scripts/NAME/secrets/SETUP_CODE`. The site no
longer needs it. It stays stored until you delete it: uploading again
without the `SETUP_CODE` binding does **not** remove it. Deleting it is the
only way.

Check: `GET /accounts/ACCOUNT/workers/scripts/NAME/secrets` lists the
script's secrets (`result[].name`). None may be named `SETUP_CODE`. If one
is, delete it again and check again.

## 9. Connect yourself

Tell your person to add a connector in their assistant settings with the
address `https://…/mcp`, then approve it on the page that opens. Their
assistant app may ask them to confirm twice: once in the app, once on the
site's own page with their passkey. Both are expected. After that,
use the tools it gives you; start with the one that describes the site.

If your app can't add a connector that signs in (or its sign-in fails),
use an assistant key instead: ask your person to open
`https://…/review/assistants`, make a key named for you, and put it in your
app's secret or API-key field, never in the chat. Send it to
`https://…/mcp` as `Authorization: Bearer <key>` (JSON-RPC over HTTP, MCP's
streamable transport; it answers plain JSON if you accept only
`application/json`). The tools and limits are the same. From your own
code, send a User-Agent naming your app: Cloudflare refuses some library
defaults (Python's urllib) with "error code: 1010" before the site sees
them.

If you can't reach the site at all, write the content as one file and give
it to your person to upload at `https://…/review/import`:
`{"porchsite_import": 1, "from": "<your name>", "note": "<for your person>",
"items": [{"kind": "entry", "data": {...}}, ...], "images": {"hall.jpg":
"<base64>"}}`. Use `image:hall.jpg` wherever a picture goes (a cover, or
`![what it shows](image:hall.jpg)` in a body). Each item's `data` is what
`item_create` takes; a `site` item changes only the fields it names. It's
checked like your drafts and waits for their approval.

The import is a last resort for a first delivery. Keeping the site up is
your job, through `/mcp`: connect as above (sign-in or an assistant key)
before you call the work done. Never ask your person to edit content, fill
in fields or upload corrections; they approve or reject, and you do the
rest.

## 10. Pick a look

A new site shows the built-in look: plain on purpose, and the fallback if a
look ever goes missing. Don't leave it there. The look and its colours are
your person's to choose, so show them the choice, one way or the other:

- **Ask**, if they're happy to be asked: what the site is for (a personal
  page, a product, a local business, an event, …), then `theme_list` and
  two or three looks that fit, each with its demo link, and the palettes
  `theme_needs` lists for the one they pick.
- **Pick, then tell**, if they asked you not to stop with questions: choose
  the best fit and a palette yourself, and say in your approval note (and
  to them) which you chose and why, with the other looks and palettes on
  offer and their demo links, and that switching is one more approval.

The bundled looks are ready to use: `theme_list` marks them `source: built-in`
in `installed`. No `theme_install` is needed for those looks. Use it only
for a catalog look missing from `installed`, or to update an installed look.

Either way: set the site item's `theme` (and `palette`) to the chosen look,
call `theme_needs` and fill what it asks for (words and pictures), then ask
for approval. Nothing changes for visitors until they approve.

## Check

- `GET https://…/` answers with the site.
- `GET https://…/setup` after step 8 sends you to `/login`: the site is claimed.
- `GET /accounts/ACCOUNT/workers/scripts/NAME/secrets` lists no secret named
  `SETUP_CODE` (step 8).
- Nothing you write shows on the site until your person approves it at
  `https://…/review`. That is on purpose.
- **What changed on the account.** `GET
  /accounts/ACCOUNT/audit_logs?since=START&per_page=100` lists every change
  since you began (each with `when`, `action.type`, `resource.type` and
  `resource.id`, and `actor`). Match each one to a step you ran: the
  database, the sign-in storage, the upload, the secret and its removal,
  the address. Tell your person, in plain words, about any change you
  didn't make; don't undo it yourself. The log can lag by 15 minutes or
  more, so a step of yours missing from it isn't a problem; what matters is
  a change you didn't make. If the call is refused (your access can't read
  audit logs), say you couldn't check this, and go on.

## Images

Shrink pictures before sending: web size, about 2400 pixels on the long side, usually under 1 MB (JPEG or WebP). Sending the original is possible, but tell your person first, plainly: bigger files use more of the free plan's limits, make pages slower and take more storage.

Prepare the web-size copy yourself before posting a file to an upload link;
a direct file upload does not resize it. On the phone page, use the options
shown by the installed site. If resizing is unavailable, choose a copy you
have already shrunk. This advice also applies to older releases.

Image fields (the site's `hero`, an entry's `cover`, a card's `image`) and
Markdown images in an entry's body take a full https:// link to a JPEG, PNG
or WebP, up to 8 MB. The site copies the image in when you save, and the item
then holds a `/media/...` path instead; reuse that path freely. Every image
needs a few words saying what it shows (`hero_alt`, `cover_alt`, `image_alt`,
or the text in `![...]`).

A photo your person sent you: call `photo_upload_link`, then POST the file to
the `url` it returns as `multipart/form-data`, with fields `photo` (the file)
and `alt` (what it shows), and header `Accept: application/json`. The answer
holds `image`, the path to use. The link works once, for 30 minutes. If you
can't send files, give the link to your person instead: the page lets them
pick the photo on their phone, and
`photo_upload_status` tells you when it's done.

## Moving to a new address later

If the site was claimed at one address and later gets a domain, your person's
passkey won't work at the new one. Attach the domain as in step 7b, then tell
your person: "Sign in at the old address, open Passkeys, and make a link.
Open it at the new address and save a passkey there." Then they reconnect you
at `https://<domain>/mcp`.

Keep the old address working until your person has signed in at the new one
with the new passkey. Taking it away first locks them out of their own site.

## Updating

Porchsite never updates itself, and you never update it on your own: your
person decides. Every release stays downloadable, and every release is
checked before it is published so that the one before it runs on its
database too. That makes an update safe to try and a rollback one upload.

**Knowing there is one.** `site_state` returns `update`. When
`update.available` is true it also has `latest` (the newest release),
`security`, `changes`, `migrations`, `reconnect` and `how` (this section).
Your person's review page shows a line too. You can also read
`https://get.porchsite.dev/latest.json` yourself: `version`, `changes`,
`security`, `migrations` (the database files new in that release only),
`all_migrations` (every database file, in the order to apply them, each
`{"name", "url", "sha256"}`), `reconnect`, `files`, and `releases` (every
release, newest first). The whole history in plain words is at
`https://get.porchsite.dev/changelog/`.

**A security release** has `security: true` (in `update`, in latest.json
and in its changelog entry; `update.security` is true when any release
since the one running is). Say so first, in those words, and suggest doing
it soon. It is still your person's call.

### Ask

Tell your person, in plain words: which release they run (`release` in
`site_state`) and which is new, the `changes` lines (put them in your own
words if that helps), whether it's a security release, whether you'll need
to be reconnected afterwards (`reconnect`), and that nothing on the site
changes until you do it. Then ask. No yes, no update.

### Update (on a yes)

Note first: `RUNNING`, the release running now (`site_state.release`, or the
`X-Porchsite-Release` header). That is where a rollback goes back to.

1. **Download** `sitekeeper.js` from latest.json's `files`, and the
   database files you need (step 2). Each entry in `files` is
   `{"path", "url", "bytes", "sha256"}`, the same shape as
   `https://get.porchsite.dev/v/<release>/files.json`
   (`{"version": "<release>", "files": [...]}`). Check each file's `sha256`
   (hex, of the file's bytes). Any mismatch: stop and tell your person;
   don't upload anything.
2. **Database files first.** Don't rely on `migrations`: it lists only the
   newest release's new files, and your person may have skipped releases.
   Read what the site has applied, from its D1 database:
   `POST /accounts/ACCOUNT/d1/database/DB/query` with
   `{"sql": "SELECT name FROM _sitekeeper_migrations ORDER BY name"}`
   (the names are at `result[0].results[].name`). Then for **every** file
   in latest.json's `all_migrations` that is not in that list, in the
   order listed: download it from its `url`, check its `sha256`, send its
   whole text as `sql` to the same endpoint, and when it succeeds record it
   (`{"sql": "INSERT INTO _sitekeeper_migrations VALUES (?, datetime('now'))", "params": ["<name>"]}`).
   They only add tables and columns, so the release still running keeps
   working while you do this. If one fails, stop: don't record it, don't
   apply the later ones and don't upload. Show your person the error; the
   site is unchanged and still running `RUNNING`.
3. **Upload** as in step 6, with the same `DB` and `KV`, and without the
   `SETUP_CODE` binding (the site is claimed). The content stays as it is.
4. **Delete the SETUP_CODE secret** if it is still there. Leaving the
   binding out of the upload does **not** remove it. List the secrets:
   `GET /accounts/ACCOUNT/workers/scripts/NAME/secrets`. If one is named
   `SETUP_CODE`: `DELETE
   /accounts/ACCOUNT/workers/scripts/NAME/secrets/SETUP_CODE`.
5. **Verify.** `GET https://…/` answers 200 with an `X-Porchsite-Release`
   header naming the new release. The old release's header can show for a
   few seconds after an upload while it reaches everywhere: retry for up to
   30 seconds before you call it a failure. `GET
   /accounts/ACCOUNT/workers/scripts/NAME/secrets` lists no secret named
   `SETUP_CODE`. Then call `site_state`: `release` is the new id. It can
   still name the old one for a few seconds after the header has changed,
   so retry it for up to 30 seconds too. `update.available` is false (that
   part can lag a day: the site checks once a day). The owner's `/review` page still opens for them.
6. **Reconnect** if `reconnect` was true: tell your person to remove the
   site's connector in their assistant app and add it again (same address,
   `https://…/mcp`), so you see the new tools.
7. **Tell your person** it's done, and which release is running.

### Roll back

If the new release misbehaves, put the previous one back. There is no
database step: the database files only ever add, and every release is
checked against the next one's database before it's published.

1. Download the previous release's bundle from
   `https://get.porchsite.dev/v/RUNNING/sitekeeper.js` (`RUNNING` from
   above) and check its sha256 against
   `https://get.porchsite.dev/v/RUNNING/files.json`, which lists every
   file of that release with its hash:
   `{"version": "RUNNING", "files": [{"path": "sitekeeper.js", "url": "…", "bytes": …, "sha256": "…"}, …]}`.
   Use the entry whose `path` is `sitekeeper.js`. Every release is there,
   for good.
2. Upload it as in step 6 (same bindings). Do **not** remove database files
   or tables, and don't run step 3.
3. Verify as above: `X-Porchsite-Release` names `RUNNING` again (retry for
   up to 30 seconds).
4. Reconnect if the release you're leaving had `reconnect: true`.
5. Tell your person what happened, and report the problem at
   [porchsite.dev/ask](https://porchsite.dev/ask) if they agree.

The plain file