# Publishing an app

> Two ways in. Your coding agent calls the reachpad MCP server and hands over the files, or you upload one file and reachpad publishes it for you. A server app can also store data: it has a database of its own, a place for files, and the secrets your organization has set.

Part of the reachpad docs: https://reachpad.dev/docs. Rendered page: https://reachpad.dev/docs/publishing. Last checked 2026-09-05.

## Through your coding agent

Connect the agent to `https://reachpad.dev/mcp` once, which is [one URL and four steps](https://reachpad.dev/docs/mcp), then ask it to publish. It calls `create_app` and gets the link back in the answer.

- `name` is what your list shows.
- `files` is an array of `{ path, content }`, with the content written as text rather than encoded.
- `slug` picks the subdomain. Leave it out and it comes from the name.
- `manifest` says how the version runs: `{ "kind": "page", "entry": "index.html" }`, or `{ "kind": "function", "entry": "server.js" }` for a server, which can also declare the `services` and `secrets` it uses. Omit the manifest for a page, or read [the manifest](https://reachpad.dev/docs/publishing#the-manifest) for every field.
- `access` sets who can open the link. Leave it out and it is anyone signed in to your organization.

> Note: Version 1 is live the moment `create_app` answers. There is no staging step and no draft, so ask your agent to show you the content and the destination before it calls.

## Publishing a change

Have the agent call `read_app` first. It answers with `base_version_id`, the version the next edit builds on, and that version's file listing. Pass it to `update_app` as `base_version_id`.

If somebody else published in between, `update_app` refuses with `stale_base` rather than dropping their version. The move after that refusal is to read the app again and pass the `base_version_id` it returns.

A publish replaces the whole file set. A file that was in the last version and is not in this call is not in the app any more.

To send only what changed, pass `mode: "merge"`. The new version keeps every file of the version `base_version_id` names that the call does not name, and `remove` takes the paths to drop. The 2 MB limit on a publish counts the carried files too.

`validate: true` on either call answers with the file set it would publish, its size and its entry file, and writes nothing. Use it to find a missing `index.html` before the link serves a 404 instead of after.

`publish: false` on `update_app` builds the version and leaves the link where it is. The staged version has its own URL to look at, and `publish_app_version` puts it live. The same call takes an older number, which is how an app is rolled back.

New versions go live as they are published, up to 60 an hour for one app.

## Uploading a file

[Add an app](https://reachpad.dev/apps/new) takes one file and publishes it as version 1 of a new app.

- A Markdown file is rendered to a page, and the Markdown travels with it.
- An HTML file is served as it is.
- Any other file gets a page naming it and its size, with the file itself behind that page.

The same screen writes a build prompt you can paste into a coding agent that has your project folder open, for the times when the thing you want published does not exist yet.

## The manifest

The manifest is one object on a publish call, and it decides how the version runs. Leave it out and the version is a page.

| Field | What it is |
| --- | --- |
| `kind` | `page` serves the files. `function` runs one module for every request. |
| `entry` | For a page, the file served at the root of the link, usually `index.html`. For a function, the module that exports `default { fetch }`. |
| `env` | Plain string values the function reads as `env.NAME`. They are stored as written and readable by anyone who can read the version, so a key belongs in `secrets` instead. |
| `services` | What the function may use: `db`, `files`, or both. A function only. A name outside that pair is refused when you publish rather than at the first call. |
| `secrets` | The names of organization secrets to bind, each arriving as `env.NAME`. A function only. |

```json
{ "kind": "function", "entry": "server.js",
  "env": { "SITE_NAME": "Notes" },
  "services": ["db", "files"],
  "secrets": ["STRIPE_KEY"] }
```

Over MCP the manifest is an argument on the publish call. From the command line it is the same object, kept in `reachpad.json` beside the source, which is the file the refusals name.

On `update_app`, leaving the manifest out keeps the previous version's. Sending one replaces the whole of it, so a manifest that omits `services` takes `env.reachpad` away from code that still calls it.

A function is handed whoever opened the link. `request.reachpad` is `{ user, name, org, role, app, version }`, so an app that its readers sign in for can key its rows on `user` without asking them to sign in twice.

> Note: On a public link nobody has to sign in, and an anonymous reader arrives with `user` and `name` set to `null` and `role` set to `anonymous`. `org` is the organization that owns the app either way. Check `role` before you trust `user` to be there.

## What a function should do in a request

A function answers one request and returns one answer. It gets about 10 seconds of computing per request, and 90 seconds before the link stops waiting. Computing is time your code spends working: waiting on the database, on a file, or on a service you call costs none of the 10 seconds, which is why an app that streams a model's answer for a minute is well inside both.

Almost nothing an app does in a request comes close. A page put together from a few queries is a handful of milliseconds of computing.

Work that runs for minutes belongs somewhere else: a long render, a large export, a job that walks a whole dataset. Hand it to a service built to run it, keep a row for the job in the app's database, answer straight away with its id, and let a later request report on it. An app that tries to finish that work inside the request fails in front of whoever opened the link.

## Storing data in a function

`env.reachpad` is on every function. What `services` decides is whether a call through it works: a version that has not declared `db` still has `env.reachpad.db.query`, and the call fails when it is made. There is nothing to install and no connection string to hold.

```js
const { rows } = await env.reachpad.db.query(
  "SELECT id, title FROM notes WHERE owner = ? ORDER BY id DESC LIMIT 50",
  [request.reachpad.user],
);

const id = crypto.randomUUID();
await env.reachpad.files.put(id, await request.arrayBuffer(), "image/png");
const stored = await env.reachpad.files.get(id);
```

- `db.query(sql, params)` answers `{ rows, changes, lastInsertRowid }`, with each row an object keyed by column name.
- `db.batch([{ sql, params }])` runs 1 to 20 statements in one transaction and answers an array of the same `{ rows, changes, lastInsertRowid }`, one per statement, in the order they were sent. A statement that fails takes the whole batch back with it.
- A parameter is a string, a number, a boolean, `null`, or `{ "$blob": "<base64>" }` for bytes. A `BLOB` column reads back in that same shape, so what you wrote is what you get.
- Each app has one database, shared by every version of that app. A version staged for review reads and writes the same rows the live one does.
- `files.put(id, body, contentType)` takes an id your app picks, a lowercase UUID, and a body that is a string, an ArrayBuffer, a view or a Blob. `files.get(id)` answers a response you can return as it is, `files.head(id)` answers `{ bytes, contentType, etag }`, and `files.delete(id)` removes it. `get` and `head` answer `null` for an id that is not there.
- There is no call that lists files. Your app chose the ids, so your own table is the index.
- `usage()` answers `{ db_bytes, files, file_bytes }`, which is what to read before storing something large.

| Limit | Value |
| --- | --- |
| One app's database | 10 GB, the storage platform's own ceiling |
| Rows one query or batch returns | 5,000, or 4 MB of JSON |
| Statements in one batch | 20 |
| One stored file | 100 MB |
| Files in one app | 10,000, and 1 GB in total |
| Migration files in one version | 200, each at most 100 KB |
| Secrets in one organization | 100 |

> Note: A query that would return more than 5,000 rows or 4 MB is refused rather than truncated: `The result is over 5,000 rows or 4 MB. Add a LIMIT.`

From the command line, `reachpad db "SELECT count(*) FROM notes"` runs one statement against the same database, with `--params '[...]'` for its bound values. It refuses a schema change: those go in a migration file.

## Secrets

A secret belongs to your organization, not to one app. Set `STRIPE_KEY` once and every function app in the organization can bind it by naming it in `secrets`.

Set one in [Settings](https://reachpad.dev/apps/settings/secrets), or with `reachpad secrets set STRIPE_KEY`, which reads the value from a hidden prompt or from what you pipe into it and never from the command line. A name is capital letters, digits and underscores, starting with a letter, at most 64 characters, and cannot start with `RP_`.

Set the secret before you publish the app that names it. A name your organization has not set fails the publish with `STRIPE_KEY is not set. Run reachpad secrets set STRIPE_KEY.`, and a name written into both `env` and `secrets` fails with `STRIPE_KEY is both an env var and a secret. Keep one.`

`reachpad secrets list` shows the names and which apps bind them, never the values. Nothing reads a value back out, including you. `reachpad secrets remove` clears the value from the running versions that bind it, within seconds, so removing one takes it away from apps published months ago. It reports any version it could not update, and the name stays listed until they are all done, so the next remove retries exactly those.

## Changing the schema

Schema changes live in the app, in `migrations/NNNN_name.sql`: four or more digits, an underscore, then lowercase letters, digits, hyphens or underscores. `migrations/0001_notes.sql` and `migrations/0002_add_pinned.sql` run in that order.

They run when the version goes live, not when it is built. That is the point of the split: an app has one database, so a version staged with `publish: false` would otherwise rewrite the schema under the version still serving.

- Only a function that lists `db` in `services` may carry the folder. Anything else fails the publish with `Migrations need the db service.`
- Never edit a file that has already been applied. Each file's hash is recorded, and a changed one is refused: `migrations/0002_x.sql changed after it was applied. Add a new file instead.`
- One file is all or nothing, and a set of them is not. A version carrying three files can stop at the second, and the first one stays applied.
- A file that fails fails the publish, with the message SQLite gave, and the app's link keeps serving the version it was serving.
- A file takes no bound values, and 200 files of at most 100 KB each is the ceiling for one version.

The record of what ran is a table in your own database, `_rp_migrations`. Read it with `reachpad db "SELECT name, applied_at, version_id FROM _rp_migrations"`, fix the file that failed, and publish again: anything already applied is skipped.

## What the link serves after a publish

The app's own link always serves whichever version is live. Each version also keeps a link of its own that never changes what it serves: [versions and their links](https://reachpad.dev/docs/versions).
