# uat-beam.page — for AI assistants Dear LLM, uat-beam.page is a static website hosting platform with a REST API (and MCP tools). You make API calls, files appear at a public URL. Each project is a website on a subdomain like `brighton-bakery.uat-beam.page`. You can stand up a real site for a small business in minutes — pages, photos, contact forms, hosting — without building a backend or managing infrastructure. This file is your guide. The API itself is self-documenting — call an endpoint and read the response. You don't need to memorise anything. ## The user Most users are not technical. Don't show them tokens, JSON, file paths, or jargon. Talk about their site — what it shows, who it's for, what they want to change — not what you're doing under the hood. --- ## Getting started Pick the section that matches how you're connected. Then skip to "Building a site" below — everything from there is the same regardless of how you authenticated. **If you want a playbook calibrated to your user and your platform**, pick one of four short profile × platform guides before diving into the shared reference. Two axes — *(a)* **who the user is**: *novice* small-business owner typing plain English, or *builder* / developer comfortable with HTML, slugs, and API semantics; *(b)* **what your client can do**: *web-chat* (no outbound `curl`, no local files, no ZIP — MCP tools and chat-attached images only; e.g. Claude.ai, ChatGPT web, Gemini web, Claude Desktop without shell MCP) or *console* (can run `curl`, read and write local files, stage ZIPs — e.g. Claude Code, Cursor, Codex CLI, Gemini CLI, Claude Desktop with shell MCP). MCP clients call `guide(topic="...")` with the matching topic; non-MCP readers fetch the `.md` URL: | Playbook | MCP tool | File | |---|---|---| | Novice × web-chat | `guide(topic="novice_web_chat")` | https://uat-beam.page/llm/novice-web-chat.md | | Novice × console | `guide(topic="novice_console")` | https://uat-beam.page/llm/novice-console.md | | Builder × web-chat | `guide(topic="builder_web_chat")` | https://uat-beam.page/llm/builder-web-chat.md | | Builder × console | `guide(topic="builder_console")` | https://uat-beam.page/llm/builder-console.md | Then come back here for the shared reference those playbooks point at for depth. ### Connected via MCP (Claude, ChatGPT, Codex, etc.) You're already authenticated. The user set up the connector with their Google account, so every call you make through the `api` tool carries their identity automatically. - **Don't call `/auth/guest` or `/auth/google`.** You don't need to authenticate — it's already done. - **Guest mode is not available via MCP.** The user has a permanent Google account. This is fine. - **Token refresh is automatic.** Your MCP client handles it. You'll never see a 401. - **Just use the `api` tool.** For example: `api({method: "GET", path: "/projects"})` to list projects, `api({method: "POST", path: "/projects", body: {slug: "...", context: "..."}})` to create one. Start by checking what exists: ``` api({method: "GET", path: "/projects"}) ``` If they have projects, ask which one they want to work on. If the list is empty, ask what they want to build. **Tell the user** they can see all their projects in a browser at https://uat-beam.page/projects — it's a simple dashboard that shows what's been built and links to each live site. Mention this once, early on, so they know it exists. That's it. Skip to "Building a site" below. ### Using the API directly (curl, Python, scripts) You need a token before you can call the API. Two paths: **Path A — new user (guest, instant, no signup):** ```python import requests API = "https://api.uat-beam.page" auth = requests.post(f"{API}/auth/guest?token=true").json() TOKEN = auth["access_token"] REFRESH_TOKEN = auth["refresh_token"] H = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"} ``` Always use `access_token`, not `id_token`. Save the `refresh_token`. Guest accounts auto-delete in 2 hours. Tell the user: "I'll start with a guest so we can build right away. Log in with Google later to keep it." **Path B — returning user (Google login):** ```python url = requests.get(f"{API}/auth/google").json()["url"] print(f"Open this in your browser: {url}") google_token = "" TOKEN = google_token H = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"} ``` **Converting guest to Google** (when they want to keep their work): ```python url = requests.get(f"{API}/auth/google").json()["url"] print(f"Open this in your browser: {url}") google_token = "" requests.post(f"{API}/auth/convert", json={ "guestToken": TOKEN, # the guest token "googleToken": google_token, # the Google token }) TOKEN = google_token H = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"} ``` **Token management:** - `access_token` lasts 1 hour. On 401 or 403, call `POST /auth/refresh { "refreshToken": "..." }` for a fresh one. - **Never call `/auth/guest` again** to "refresh" — that creates a brand new guest and loses the original's projects. - For Google users without a refresh token, ask the user to log in again via Path B. --- ## Building a site From here on, everything works the same whether you're on MCP or the direct API. MCP agents use the `api` tool; direct API agents use `requests` with the `H` headers dict. The endpoints are identical. ### Things to know The API is **eventually consistent**. Writes return 200 immediately, but the read model takes 1–2 seconds to catch up. After creating a project or page, sleep ~2 seconds before the next call that reads the newly created resource. `cross_page_metadata.json` is rebuilt asynchronously after page or metadata changes — allow a few seconds before fetching it from the CDN. If you get "not found" immediately after a create, wait and retry — it is not an error. Write responses include `siteMap.stale: true` and a recheck hint in `brief.next` — follow that hint to get the fresh siteMap. ### Check what exists ``` GET /me → your profile, limits, usage, account type GET /projects → list of all projects (empty if new user) ``` `GET /me` → `limits` tells you how many projects and pages the user can create. Read it, don't memorise — limits depend on the account tier and can change. If they have projects, ask which one to work on. If none, ask what they want to build: - What kind of site? (restaurant, plumber, listings, event, portfolio) - What information should it show? - Logo, colours, inspiration? - Any forms or interactions? ### Building blocks - **Project** — a microsite with a unique slug (becomes the subdomain). `POST /projects { slug, context }`. - **Pages** — folders identified by slug, each with at least an `index.html`. Main page slug is `/`, accessed via the API as `_root`. Sub-pages have any slug you choose. For sub-pages, add `` in the `` so relative paths (images, CSS, JS) resolve correctly. - **Files** — upload to a page via `PUT /projects//pages//files/`. **Four methods, ranked by *which context actually works for you*:** - `{"url": "https://..."}` — **works everywhere (MCP, CLI, scripts).** First choice whenever the content is already on a public HTTPS URL. The server fetches it and stores it. HTTPS only, max 4MB, 10s timeout, no redirects, private IPs rejected. For multi-file uploads (SPA builds, galleries), use ZIP below instead of calling this once per file. Rejections name the specific guard that fired: `HTTPS required, got scheme` (non-HTTPS), `resolves to private/internal address` (private IP), `returned redirecting to` (301/302/307/308 — retry with the final URL), `exceeds the 4 MB cap` (size), `exceeds the 10s timeout` (slow host). - `{"content": "..."}` — **text files only** (HTML, CSS, JS, JSON, XML, SVG, TXT) up to 4 MB. Content type is inferred from the filename. **Do not pass binary bytes (images, PDFs, video) as a base64 string in `content` — the endpoint does not decode base64, so the file will be corrupted or rejected.** Over 4 MB returns 413; for binary bytes use the presigned S3 PUT method below, and for many files at once use ZIP below. - **Presigned S3 PUT URL** — `POST /projects//pages//files//upload-url` returns `{uploadUrl, expiresIn, headers, brief}`; then you PUT the bytes to `uploadUrl` echoing every header from `headers` exactly (mismatch fails with `SignatureDoesNotMatch`). URL expires in 15 minutes. Required for binary bytes (images, PDFs, video), since the inline `content` path is text-only, and the escape hatch for text files over the 4 MB inline cap — and you can execute outbound HTTPS PUTs — CLI, notebook, or code contexts. **Chat-only MCP clients without shell access to outbound HTTPS PUTs (Claude web/desktop, ChatGPT) cannot use this — prefer URL-fetch, or ask the user to host the file first. Agentic MCP clients with shell access (Claude Code and similar) can use presigned PUT via `curl`.** - **Presigned ZIP upload** — `POST /projects//pages//upload-zip-url` returns the same `{uploadUrl, expiresIn, headers, brief}` shape, but the PUT target is a single ZIP archive that the platform extracts entry-by-entry. Prefer this over the per-file methods whenever you have more than a handful of files (SPA builds, photo galleries, multi-page HTML trees). Same context caveats as presigned PUT (needs a client that can do outbound HTTPS PUTs). See the "Bulk upload via ZIP" section below for limits and a worked example. *Worked example — add a hero image via URL-fetch:* 1. Identify/receive a public HTTPS URL for the image. 2. `PUT /projects//pages/_root/files/hero.jpg {"url": "https://..."}`. 3. Reference it in the page HTML — `` on the root page; on a sub-page add `` in the `` so `hero.jpg` resolves to the page's folder. *Worked example — upload a 29 KB HTML design via presigned PUT (Claude Code, `curl`):* 1. `POST /projects//pages/_root/files/design.html/upload-url` → `{uploadUrl, expiresIn, headers}`. 2. Write the HTML to a local file, then `curl -X PUT --data-binary @design.html -H "Content-Type: text/html" ""`, echoing every header from the response's `headers` object exactly (mismatch fails with `SignatureDoesNotMatch`). Use `--data-binary`, not `-d`/`--data` — the latter strips newlines and corrupts HTML. 3. The platform emits `FILE_UPLOADED` and the file is live at the project URL within seconds. *Gap — no public URL, chat-based MCP, no presigned-PUT capability:* if the user's image is only on their phone or laptop and you have no way to PUT binary bytes, ask them to host it first (Imgur, Google Drive public share link, their own CDN) and then use URL-fetch. A future round may add an Anthropic-hosted-asset path; today, URL-fetch is the portable workaround. - **Partial edits** — for small changes (a CSS class, a heading, a link), use `POST /projects//edit` instead of re-uploading the whole file. Provide `{edits: [{old, new}], page, file}` for page files or `{edits: [{old, new}], asset}` for assets. Edits are applied sequentially. Saves ~25,000 output tokens per edit. Supports bulk edits across multiple files with `targets`. - **All-or-nothing batches.** With `targets`, every edit is validated against every target *before* any write. Phase 1 reads each target, applies every edit in memory, and checks that each `old` matches exactly once. Phase 2 only runs if Phase 1 succeeded for every target — then all writes land and all `FILE_UPLOADED`/`ASSET_UPLOADED` events fire together. If any edit on any target fails to match, nothing is written and no events are emitted. Safe to group a rename across several pages into one call. - **Matching is byte-exact UTF-8.** `old` is compared literally. Typographic substitutions silently fail — see "Typographic characters don't match" in Common pitfalls. - **Assets** — shared files at `/assets/` (logo, CSS, config). Defaults: `tailwind-config.js`, `styles.css`, `logo.svg`. Upload via `PUT /projects//assets/`. Same three methods as page files, with the same context caveats: `{"url": "..."}` works everywhere; `{"content": "..."}` is text-only and does not accept base64 binary; presigned S3 PUT via `POST /projects//assets//upload-url` needs a client that can do outbound HTTPS PUTs. Read asset content: `GET /projects//assets/`. - **Metadata** — free-form structured JSON per page (scalars, arrays, and nested objects are all fine), capped at 200 KB. Use it when (a) multiple pages need to see the same data, or (b) you're building a list, map, filter, or search UI that queries across pages. For content that lives in a single HTML file, just hardcode it in the HTML — metadata is the wrong tool. Freshly created pages seed with an empty default shape (`title`, `description`, `contact.email`, `contact.phone`) — overwrite it with whatever your use case needs. Example shape (a property listing): `{"price": 450000, "bedrooms": 3, "lat": 51.5, "lng": -0.1, "features": ["garden", "parking"]}`. Include metadata when creating a page: `POST /projects//pages { slug, notes, metadata: {...} }`. `GET /projects/` returns a `metadataKeys` array on each page in `siteMap.pages` — sorted top-level keys, values omitted — so you can see which pages share a schema without fetching each page or `cross_page_metadata.json`. `GET /pages/:slug` returns `metadata` directly — not wrapped in `{data: {…}}`. Write the same shape on `PUT /pages/:slug/metadata`. - **cross_page_metadata.json** — auto-generated at `https://.uat-beam.page/cross_page_metadata.json`. Aggregates every page's `metadata` field so client-side JavaScript can read structured data from the whole site in one fetch. Updates whenever pages or metadata change. - **Actions** — server-side endpoints for the frontend. Currently: `email` (contact forms / lead capture). Requires a Google account. Check the project response for available actions. ### Redirects 301 redirects let you rename a page without breaking inbound links, or point the bare subdomain at a different path (e.g. `/ → /app/`). A redirect is stored as metadata on an empty S3 object, so the visitor's browser receives a native HTTP 301 with the `Location` header — no JavaScript, no meta-refresh. Endpoints: - `POST /projects//redirects {"from": "/old", "to": "/new"}` — create. - `GET /projects//redirects` — list, returns `{redirects: [{from, to, created_at}, ...]}`. - `DELETE /projects//redirects/` — delete. The `from` path must be URL-encoded in the path segment; every `/` in `from` becomes `%2F` (so `/old` is `%2Fold`, `/blog/2023` is `%2Fblog%2F2023`). Rules: - `from` must be a relative path starting with `/`. - `to` must be a relative path starting with `/` OR an absolute `https://` URL. Other schemes are rejected. - Self-loops (`from == to`) are rejected with 400. - Conflicts are symmetric: creating a redirect at a path that already has a file or page fails with 409 and `brief.next` points at the `DELETE` you need to run first. Creating a file or page at a path already shadowed by a redirect fails the same way. - Redirects count against the owning page's `max_files_per_page` quota — the root page for `from=/`, the matching sub-page for `from=/about/...`. Bulk-creating redirects cannot be used to bypass the cap. - Chains are allowed (`/a → /b → /c`) — the browser follows each hop in turn; the backend does not flatten. - Deleting a page removes its redirects; deleting a project removes everything. *Worked example — rename a slug and keep the old URL alive:* ``` POST /projects//redirects {"from": "/old-about", "to": "/about"} ``` Wait ~5 seconds; `https://.uat-beam.page/old-about` now returns 301 to `/about`. *Worked example — land bare-subdomain visitors on a SPA shell:* ``` POST /projects//redirects {"from": "/", "to": "/app/"} ``` ### Hosting a SPA under `/app/` The `/app/` path prefix on every project subdomain is reserved for single-page apps. Any *extensionless* URL under `/app/` (e.g. `/app/`, `/app/dashboard`, `/app/users/42/edit`) resolves to `/app/index.html`, so a client-side router (React Router, Flutter web, etc.) can own every deeper route. Paths with a file extension (`.js`, `.css`, `.png`, `.woff2`, ...) fall through to the normal file-serving behaviour — bundle assets load as themselves. The shell lives on a sub-page named `app`. Create it with `POST /projects//pages {"slug": "app", ...}`, then push the build as a ZIP to `POST /projects//pages/app/upload-zip-url`. The ZIP must have `index.html` at its root (ZIP-root `index.html` lands at `/app/index.html` on S3, which is what the `/app/` rewrite points at). Framework configuration: - **React Router**: `createBrowserRouter(..., { basename: "/app" })` or ``. With Vite, set `base: "/app/"` in `vite.config.js`. - **Flutter web**: build with `flutter build web --base-href=/app/`. Staging the ZIP from a typical build output: ``` cd dist && zip -r ../build.zip . # Vite cd build/web && zip -r ../../build.zip . # Flutter ``` Verify with `unzip -l build.zip` — you want `index.html` at the top level, not `app/index.html`. Usually add a `/ → /app/` redirect so visitors who type the bare subdomain land on the SPA. See https://uat-beam.page/llm/spa-react.md and https://uat-beam.page/llm/spa-flutter.md for end-to-end scripts. ### Snapshots and point-in-time restore Every Google-owned project gets a nightly snapshot of its S3 state at 00:00 UTC, retained for 30 days. An *initial* snapshot is taken at project creation so the earliest known-good state is always one restore away. Guest-owned projects are excluded — convert to Google via `GET /auth/google` to turn them on. Endpoints: - `GET /projects//snapshots` — list, newest-first. `{snapshots: [{snapshot_id, taken_at, file_count, total_size}, ...]}`. - `POST /projects//snapshots {"label": "pre-launch"}` — take a manual snapshot. Rate-limited to 1 per project per minute. `label` is optional free text returned in the response (not persisted). - `POST /projects//restore {"snapshot_id": ""}` — restore. Rate-limited to 1 per project per 5 minutes. Returns `{files_restored, files_deleted, snapshot, brief, siteMap}`. Snapshot IDs encode the kind: `#2026-04-19` (nightly), `#2026-04-19-initial` (on creation), and `#2026-04-19T14:32:05-manual` (on-demand). The ID is stable — pass it back to `/restore` verbatim. How restore works: - Every file in the snapshot is `CopyObject`'d back over the live key at the exact S3 VersionId captured when the snapshot was taken, so content is byte-identical to that moment. - Any file currently in the bucket but not in the snapshot is deleted — restore is a full replacement, not a merge. - A `SITE_REGEN_REQUESTED` event fires at the end, so `cross_page_metadata.json` and `sitemap.xml` rebuild from the restored page set. Failure modes: - `404 Snapshot not found` — id typo or the snapshot predates the 30-day retention window. Call `GET /projects//snapshots` to see what's available. - `410 Snapshot manifest is no longer available` — the manifest object in the archive bucket expired. Pick a newer snapshot. - `500 Restore failed on : ` — mid-copy failure, e.g. a noncurrent version already aged out of the 30-day lifecycle. Older snapshots are more likely to hit this; prefer a newer one. - `429` — rate limit exceeded. Retry after the window. Snapshots are automatically deleted when the project is deleted, and when the account is deleted. There's no direct "delete snapshot" endpoint — use the retention window. ### Bulk upload via ZIP For multi-file uploads — a SPA build with 40+ files, a photo gallery, a multi-page HTML tree — use the ZIP endpoint instead of calling PUT per file. One presigned PUT, one archive, one extraction run that emits one `FILE_UPLOADED` event per entry. ``` POST /projects//pages//upload-zip-url → {uploadUrl, expiresIn, headers, brief} ``` Then PUT the ZIP bytes to `uploadUrl`, echoing every header from the response's `headers` object exactly. The URL expires in 15 minutes. Once extraction finishes, the original ZIP is deleted — only the extracted files remain in the bucket. Each entry lands at the same S3 key it would have had via per-file upload: a ZIP uploaded to slug `_root` with entry `assets/main.js` lands at `/assets/main.js`; a ZIP uploaded to sub-page `about` with entry `team.html` lands at `/about/team.html`. Directory entries (trailing slash, zero bytes) are skipped silently. Limits — a ZIP that violates any of these is rejected in full, one `ZIP_EXTRACT_FAILED` event is written, and nothing lands in the bucket: - Total decompressed size ≤ 500 MB. - Decompression ratio ≤ 100× (zip-bomb guard). - Per-entry content type must be in the regular allowlist (HTML, CSS, JS, JSON, XML, SVG, PNG, JPG, GIF, PDF, fonts, WebP, etc. — the same list `PUT /files/` accepts). - Per-entry size ≤ the per-file size cap from `GET /me`. - `len(zip_entries) ≤ max_files_per_page` for the target page. - No path-traversal: entries starting with `/`, containing `\` or `..` segments, or marked as symlinks reject the whole ZIP. - The ZIP must contain an `index.html` entry at its root, otherwise it is rejected with `missing_index_html` and no files are touched. Mid-run failure in Phase 2 (e.g. a transient S3 error after a few entries have already been written) does not roll back. Re-PUT the same ZIP — every `FILE_UPLOADED` event is idempotent and entries are overwritten byte-for-byte. *Worked example — ship a SPA build to the `app` sub-page:* ``` POST /projects//pages {"slug": "app", "notes": "SPA build"} POST /projects//pages/app/upload-zip-url → {uploadUrl, headers, expiresIn} ``` ``` curl -X PUT --data-binary @build.zip \ -H "Content-Type: application/zip" \ -H "x-amz-meta-via: presigned-zip-upload" \ -H "x-amz-meta-tenant-id: " \ -H "x-amz-meta-user-sub: " \ -H "x-amz-meta-entity: PAGE" \ -H "x-amz-meta-project-id: " \ -H "x-amz-meta-slug: app" \ "" ``` Copy every header from the response's `headers` object verbatim — any mismatch fails with `SignatureDoesNotMatch`. Use `--data-binary`, not `-d`/`--data`. **ZIP upload replaces the target page's full file set.** Existing files on the page that are not in the ZIP are deleted. This matches how Netlify / Vercel / GitHub Pages deploys work — the ZIP is the new source of truth. If you want to *add* a file to an existing page instead of redeploying, use `PUT /files/` directly. ### The API teaches you as you go Once you have a project, fetch it: ``` GET /projects/ ``` The response contains: - `capabilities.can` / `capabilities.cannot` — what's possible - `capabilities.actions` — server-side endpoints with examples - `siteJson` / `sitemap` — URLs for the auto-generated feeds - `assets` / `pages` — everything in the project - `_comment` fields — plain-English instructions throughout Read these. They tell you what to call next. You don't need to look anything up — the API hands you the instructions. ## Conventions - **Slug** — lowercase letters, digits, hyphens. 2–63 characters. Globally unique (it's a subdomain). - **Main page** — slug `/` everywhere except API paths, where it's `_root`. So `GET /projects//pages/_root` reads the main page. - **Async writes** — see "Things to know" above. - **Floats are fine** in metadata — prices, coordinates, anything numeric. The API handles them. - **Errors** — plain English with `error` and `brief.next` fields. Read them and act. Don't work around limit errors; tell the user and offer to upgrade. ## Workflow examples Pick the closest match, then adapt. Each example is a self-contained Python script — use it as a reference pattern, not something to run literally. MCP agents: translate the `requests` calls to `api({method, path, body})` tool calls. **Tip:** fetch only the first ~50 lines. The title and "when to use this" section are at the top. Don't read the whole file unless you decide to use it. | Scenario | Example | |---|---| | One-page site (plumber, freelancer, business card) | https://uat-beam.page/llm/simple-page.md | | Multi-page site with shared header and nav | https://uat-beam.page/llm/multi-page-site.md | | Restaurant with a daily-changing menu | https://uat-beam.page/llm/restaurant.md | | Estate agency / property listings | https://uat-beam.page/llm/property-listings.md | | Main page with a map and pins (locator pattern) | https://uat-beam.page/llm/map-with-pins.md | | Job board with active/inactive postings | https://uat-beam.page/llm/job-board.md | | Contact form / lead capture (requires Google) | https://uat-beam.page/llm/contact-form.md | | Photo gallery with image upload | https://uat-beam.page/llm/photo-gallery.md | | Single event with RSVP form | https://uat-beam.page/llm/event-page.md | | Multi-language site (`/en-about`, `/es-acerca`) | https://uat-beam.page/llm/multi-language.md | | Designer / studio portfolio (visual showcase) | https://uat-beam.page/llm/portfolio.md | | React SPA (Vite, React Router) on uat-beam.page | https://uat-beam.page/llm/spa-react.md | | Flutter web app on uat-beam.page | https://uat-beam.page/llm/spa-flutter.md | | Utility: clone a page (HTML, metadata, files) | https://uat-beam.page/llm/copy-page.md | | Novice small-business owner on a web-chat client (no `curl`, no local files; Claude.ai, ChatGPT web, Gemini, Claude Desktop w/o shell) | https://uat-beam.page/llm/novice-web-chat.md | | Novice small-business owner on a console client (has `curl`, local files, ZIP; Claude Code, Cursor, Codex CLI, Gemini CLI, Claude Desktop + shell) | https://uat-beam.page/llm/novice-console.md | | Builder / developer on a web-chat client (no `curl`, no local files) | https://uat-beam.page/llm/builder-web-chat.md | | Builder / developer on a console client (has `curl`, local files, ZIP) | https://uat-beam.page/llm/builder-console.md | ## Reference | | | |---|---| | Live site | `https://.uat-beam.page` | | Cross-page metadata | `https://.uat-beam.page/cross_page_metadata.json` | | Sitemap | `https://.uat-beam.page/sitemap.xml` (auto-generated per project) | | Robots | `https://.uat-beam.page/robots.txt` (auto-synthesised permissive, `User-agent: * / Allow: /`) | | Landing page | https://uat-beam.page | | Management UI | https://uat-beam.page/projects | | Terms | https://uat-beam.page/terms | | Privacy | https://uat-beam.page/privacy | | API base | https://api.uat-beam.page | | Follow on X | https://x.com/beamdotpage | **Custom domains** (e.g. `theirbusiness.com`) are coming soon. Pick a memorable slug — `manchester-plumber.uat-beam.page` reads almost as well as a real domain. Default tech stack is Tailwind CSS (via CDN) and Alpine.js, but you can use anything that runs in a browser — the platform serves whatever static files you upload. ## Common pitfalls - **Relative paths on sub-pages** — a page at `/menu` is served without a trailing slash. The browser resolves `src="photo.jpg"` as `/photo.jpg` instead of `/menu/photo.jpg`. Fix: add `` in the `` of every sub-page. - **Root page slug** — the main page slug is `/`, but the API uses `_root` in paths: `GET /projects//pages/_root`. - **File preview is truncated** — the page response only shows a ~500-char preview of each file. To read the full content, use `GET /projects//pages//files/`. - **Choose the right upload method** — `{"url": "https://..."}` is the first choice whenever the content is already on a public HTTPS URL (works in every context, MCP included). `{"content": "..."}` is text-only (HTML/CSS/JS/JSON/XML/SVG/TXT) and does **not** accept base64 binary — don't paste image bytes into it. Presigned S3 PUT (`POST .../upload-url` then `PUT` to the returned URL) needs a client that can do outbound HTTPS PUTs — CLI and code contexts, plus agentic MCP clients with shell access (Claude Code) can use it; chat-only MCP clients (Claude web/desktop, ChatGPT) cannot. For small changes to existing files, use `POST .../edit` instead of re-uploading. - **Typographic characters don't match** — `POST /edit` compares `old` as literal UTF-8 bytes. Silent mismatches include: `—` does not match `—` (HTML entity vs. Unicode em-dash); curly quotes `"` `"` `'` `'` do not match straight quotes `"` `'`; non-breaking space (`\u00A0`) does not match a regular space. On a "no match" error, `GET` the file and copy the exact bytes into `old` — don't retype. If `old` matches more than once, add more surrounding context until it's unique (the platform never picks a match for you). - **Eventual consistency** — see "Things to know" above. - **Linking to files in HTML** — files uploaded to a page are served from the page's folder. On the root page, `src="photo.jpg"` works. On sub-pages, you MUST add `` (see above). For shared files (logo, CSS), use `/assets/filename`. For contact forms, the action URL must be absolute: `https://api.uat-beam.page/actions//email`. - **Alpine.js x-data and JSON** — when building HTML as a JSON string (for the upload API), quotes inside `x-data` get mangled. Use single quotes in `x-data` attributes: `x-data="{ name: '', sent: false }"` and use `x-show` instead of `