## Websites (Static Site Hosting)

### Deploy flow (recommended — single call)
1. Create a website: `POST /websites` → get `websiteId`
2. Deploy files: MCP `deploy_website_file({ websiteId, fileName: "index.html", content: "<html>..." })`
3. Site is live at `https://www.easybits.cloud/s/<slug>/`

`deploy_website_file` uploads content directly (max 1MB, text or base64). No presigned URL or status update needed.
For large binary files (>1MB), use `upload_website_file` → PUT to presigned URL → `update_file(status: "DONE")`.

### A site is a folder, not a single page
`fileName` accepts a **path**, so deploy every file at its real path and the site keeps its structure:

```ts
await eb.deployWebsiteFile(websiteId, { fileName: "index.html",      content: html });
await eb.deployWebsiteFile(websiteId, { fileName: "producto.html",   content: producto });
await eb.deployWebsiteFile(websiteId, { fileName: "css/estilos.css", content: css, contentType: "text/css" });
await eb.deployWebsiteFile(websiteId, { fileName: "js/main.js",      content: js,  contentType: "application/javascript" });
```

`/s/<slug>/` resolves **relative links between the site's own files**, so the HTML goes as-is:

```html
<link rel="stylesheet" href="css/estilos.css">
<script src="js/main.js"></script>
<a href="producto.html">Ver producto</a>
```

Do **not** rewrite a site's own pages/CSS/JS to absolute storage URLs — the deployed copies end up orphaned while the page loads a duplicate from a random key. Absolute public URLs are only for **binary assets** you uploaded (`upload_website_file`), and for those you must embed the `url` returned verbatim.

Routing details: `/s/<slug>/` serves `index.html`; a path with no extension falls back to `<path>/index.html`.

Images: download third-party images and upload them (`upload_website_file`, or `deploy_website_file` with `encoding: "base64"` if <1MB). Never hotlink Flickr/Unsplash from a published site.

### Custom domains (your own domain, automatic HTTPS)
Two ways to serve a website on a domain you own:

- **Subdomain**: `<slug>.midominio.com` — every website of the account is reachable by its slug, no linking needed.
- **Root domain**: `midominio.com` (and `www.midominio.com`, which 301-redirects to the root) — exactly one website per domain, assigned with `set_domain_website`.

Flow: `add_domain` → add DNS → `verify_domain` → (optional) `set_domain_website`.

| DNS record | Name | Value | Needed for |
|---|---|---|---|
| TXT | `_easybits-verify.midominio.com` | the `txtToken` returned by `add_domain` | verification |
| CNAME | `*.midominio.com` | `cname.sandboxes.easybits.cloud` | subdomains |
| A | `midominio.com` | `54.38.94.14` | root domain |
| CNAME | `www.midominio.com` | `cname.sandboxes.easybits.cloud` | root domain |

Certificates are issued automatically on the first HTTPS request (wildcard registered on verify; root + www when a site is assigned). They can take a few minutes after DNS propagates.

**DNS hosted in Mailmask?** Skip the copy-paste. Connect Mailmask once (OAuth consent in the browser, `/dash/integrations/mailmask/connect`) and EasyBits writes these records into your Mailmask zone and verifies: `POST /api/v2/domains/:domainId/dns-auto` · MCP `configure_domain_dns({ domainId })`. It saves what was there before, so `DELETE …/dns-auto` / `undo_domain_dns` restores it (records you changed afterwards are left alone), and deleting the domain undoes them too. Without a connection the call returns `MailmaskNotConnected` with a `connectUrl`.

REST: `GET/POST/DELETE /domains` (each domain carries `dns`: the records above, ready to create), `POST /domains/:domainId/verify`, `POST /domains/:domainId/apex` `{ websiteId | null }`.
MCP: `list_domains`, `add_domain`, `verify_domain`, `set_domain_website`, `delete_domain`, `configure_domain_dns`, `undo_domain_dns`.

### List site-builder sites
`GET /sites`
The sites of the site builder (/dash/sitios), as the dashboard shows them: static sites and web apps, with the engine that builds each one. Different from `GET /websites`, which lists every static website of the account (documents and agent-published pages included).
Returns: `{ items: Site[], nextCursor, hasMore, total }` — each `{ id, name, kind: "static" | "webapp", engine, stack, url, websiteId, sandboxId, createdAt }`. A static site's files live in its website (`websiteId`); a web app's code lives in its machine (`sandboxId`).
SDK: `eb.listSites()` · MCP: `list_sites` · CLI: `easybits sites ls`

### Site theme
`GET /sites/:id/theme` · `POST /sites/:id/theme` `{ themeId }` | `{ colors }`
Change a site-builder site's colors without a model turn (same as the «Tema» button). Sites use color tokens (`primary`, `primary-light`, `primary-dark`, `secondary`, `accent`, `surface`, `surface-alt`, `surface-deep`, `on-surface`, `on-surface-muted`, `on-surface-deep`, `on-primary`, `on-secondary`, `on-accent`) as Tailwind classes; the theme file inside the site is the source of truth (web app: `eb-theme.css`, hot-reloads in the preview; static: the `eb-theme` block of `index.html`). `themeId` is a palette (`minimal`, `calido`, `oceano`, `noche`, `dracula`, `nord`, `tokyo-night`, `github-light`…); `colors` must carry every token as `#rrggbb`.
Returns: GET `{ supported, colors }` · POST `{ colors }`. `409 no_tokens` = the site predates themes (literal colors): ask the builder to adapt it first. Remix and Elysia web apps don't support themes yet.
SDK: `eb.getSiteTheme(id)` · `eb.setSiteTheme(id, { themeId })` · MCP: `site_set_theme`

### Site engine and model
`GET /sites/engines` · `POST /sites/:id/engine` `{ engine?, model?, secretValue? }`
What builds a site-builder site. The engine belongs to the site (`claude` = the user's Claude, `easybits` = included); the model belongs to the account's builder for that engine, so changing it applies to EVERY site with that engine, from the next turn. `POST /sites` also accepts `engine` and `model`. If the engine needs a key the account lacks (`needsSecret`), send it as `secretValue`.
Returns: GET `{ items: [{ id, label, included, needsSecret, models, defaultModel, currentModel }] }` · POST `{ siteId, engine, label, model }`.
SDK: `eb.siteEngines()` · `eb.setSiteEngine(id, { model })` · MCP: `site_set_engine` · CLI: `easybits sites model <site> [<model>]`

### List websites
`GET /websites`
Returns: `{ items: Website[] }`
SDK: `eb.listWebsites()`

### Create website
`POST /websites`
Body: `{ name: string, slug?: string }`
Returns: `{ website }` with id, slug, url. `slug` is optional (generated if omitted).
SDK: `eb.createWebsite(name, { slug? })`

### Get website
`GET /websites/:websiteId`
Returns: Website with stats (fileCount, totalSize).
SDK: `eb.getWebsite(websiteId)`

### Update website
`PATCH /websites/:websiteId`
Body: `{ name?, slug?, status? }`
Returns: `{ ok: true, website }` with slug and url.
SDK: `eb.updateWebsite(websiteId, { name?, slug?, status? })`

Slug rules (create and update): lowercase a-z, 0-9 and single hyphens, 3–60 chars, no leading/trailing hyphen, not reserved (api, admin, s, www, app, assets, static, dashboard, login…), unique among live sites. Errors: 400 `{ error: "slug_invalid" }`, 409 `{ error: "slug_taken" }`. Files are stored by id, so renaming moves nothing; the old `/s/<old>/` answers **301** to the new slug (up to 10 previous slugs remembered) until another site takes it. With subdomain masking on, the certificate moves to the new `<slug>.easybits.cloud` and the old subdomain stops working.

### List website files
`GET /websites/:websiteId/files?limit=&cursor=`
Returns: `{ items: File[], nextCursor? }`
SDK: `eb.listWebsiteFiles(websiteId, { limit?, cursor? })`
MCP: `list_website_files({ websiteId, limit?, cursor? })`

### Delete website
`DELETE /websites/:websiteId`
Soft-deletes all associated files (recoverable 7 days).
SDK: `eb.deleteWebsite(websiteId)`

### Website object
```json
{
  "id": "web123",
  "name": "My Site",
  "slug": "my-site",
  "status": "ACTIVE",
  "url": "https://www.easybits.cloud/s/my-site/",
  "fileCount": 3,
  "totalSize": 51200,
  "createdAt": "2026-01-15T..."
}
```
