# Webly API > The website API for AI agents. Build, preview, publish, roll back, and manage websites, and share files and folders by link, through one REST API or its MCP server. ## Base URL ``` https://api.webly.ai/v1 ``` Machine-readable spec: `GET /openapi.json` (every operation carries `x-capability` and `x-rate-limit-class`). Remote MCP: `POST /v1/mcp`. Human docs rendered from this file: `GET /docs` (guide) and `GET /docs/api` (reference); `GET /docs` without `text/html` in `Accept` returns this file as Markdown. Dashboard for people: `GET /app` (paste an API key). `GET /api` lists the npm packages framework sites may import and their pinned versions. ## Get started with an agent Paste this into Claude Code, Codex, Cursor or any agent that can run shell commands: ``` Set up Webly so you can publish websites and share files and folders for me. If I have npm: npx skills add Webly-AI/webly-plugin --skill webly -g -y -a If not: curl -fsSL https://webly.ai/install.sh | bash Then read https://api.webly.ai/llms.txt and ask me what I'd like to publish or share. ``` That installs the `webly` skill (instructions plus a zero-dependency Node helper, `webly.mjs`) into `~/.claude/skills/webly` and `~/.agents/skills/webly`. No account is needed to publish. Claude Code users can install the plugin instead, which adds the MCP server and a session-start hook (which also reports skill updates): `/plugin marketplace add Webly-AI/webly-plugin` then `/plugin install webly@webly`. **How it works.** The first publish needs no account: the site is live for 24 hours and claimable for 7 days. When the person wants to keep it, the agent connects Webly's MCP server, the person signs in once (OAuth), and the site moves into their workspace. From then on the agent works over MCP. | | Without an account | Signed in (MCP + OAuth) | |---|---|---| | Start | `webly.mjs deploy ./site` | `whoami`, `list_websites` | | Lifetime | Live 24 h, claimable 7 days, then deleted | Permanent | | Sites | One website per machine (per saved token), plus shared files | Per plan (unlimited with billing off) | | Size | 500 MiB per token, website and shared files together; website 25 files, 1 MiB each, 5 MiB total | Full API limits | | File links | As many as fit in the 500 MiB; each link lasts 24 h | Free 1 GB, up to 7 days; paid keeps them | | Editing | Whole-tree update or swap, first 24 h | Drafts, quality gate, publish on approval, rollback | | Extras | none | forms, custom domains, analytics | **What the skill does, depending on what is on the machine.** State lives on disk (`~/.webly/state.json`, `~/.webly/pending`) and at the API, so a new session, `/clear` or a restart picks up where the last one stopped. `webly.mjs doctor` reports it without changing anything. `webly.mjs deploy ` builds a project that has a `package.json` `build` script and deploys its output folder; the site is named with `--name`, else the package or folder name. A meaningless name like `dist` stops the first deploy so the agent can ask the person (it is a label, not the web address). | Token saved | MCP loaded | Meaning | Next | |---|---|---|---| | no | no | New to Webly | Publish anonymously; offer to claim | | yes | no | Published before; MCP never set up or not loaded | Keep publishing while the site's `next.actions` includes `update`; otherwise claim | | no | yes | Signed in | Work over MCP | | yes | yes | Signed in, site not yet claimed | `create_claim_code`, then `webly.mjs claim ` | **Claiming.** `webly.mjs connect claude|codex` registers the MCP server at user scope and records `pending: claim`. If the Webly tools are already callable, the agent calls `authenticate` (Claude Code) or `whoami` (other hosts), opens the sign-in URL, calls `create_claim_code` and runs `webly.mjs claim `: the helper sends the code and the saved secret to the API itself, so the agent never reads the secret. If they aren't (the usual case right after `connect`), the agent doesn't wait for a reload: it runs `webly.mjs claim-link --open`, which opens the browser claim page (the token travels only in the URL fragment and is never printed). MCP loads next session. ## Anonymous deployments (no sign-in required) Anonymous endpoints use the API origin (not the `/v1` base above). One saved credential holds **one live website at a time, plus shared files** (a storage site beside it, see below), and **500 MiB in total** for both. Its website is live and editable for 24 hours, claimable until seven days after the credential's first site, then permanently purged if unclaimed. Neither source updates nor swaps reset those deadlines, and no site's 24 hours run past the seven days (a site created late gets a shorter day). While the server runs invite-only, these endpoints return `503` with `details.reason: "invite_only"`. 1. `POST /public/v1/anonymous/credentials` → `{token, claimPage}`. The local client/agent must save the returned `wa_…` secret atomically in a private file (for example `~/.webly/state.json`, mode `0600`) before deploying. The server cannot write files on your computer. Reuse that file; do not issue a fresh credential for every request. 2. `POST /public/v1/anonymous/sites` with `Authorization: Bearer wa_…` and `{name, kind: "static" | "framework", files}`. Static files are `{path, content, encoding?: "utf8" | "base64", contentType?}`; framework files are `{file: {type, name?}, content}` using the typed file identities below. To start over within the first 24 hours, send the same request with `replace: true`. The new site (new id and URL, any kind) is uploaded and built first; only then does it replace the current one, which goes offline and is purged. It keeps the credential's original deadlines. If the new site fails to build you get `400` with `details.reason: "build_failed"` and the current site is untouched. Without `replace` a second create is `409 site_already_created`. 3. `GET /public/v1/anonymous/sites/{id}` returns versions, deployment status, deadlines and URLs, plus `next: {message, actions}`: what the credential may do now (`update`, `replace`, `claim`, or nothing once claimed), and `live`: a one-line "Live now: version N at URL" (null when offline). Anonymous deploys go live on their own; there is no separate publish step. Use `current` instead of the id to recover a lost creation response. Framework builds may return `202`; poll until `ready` or `failed`. Ready versions are published automatically. Failed builds stay on the same site. 4. `PUT /public/v1/anonymous/sites/{id}` with `{expectedHeadVersion, files}` replaces the complete source tree; omitted files are removed. It requires the same credential and stops at the 24-hour cutoff. No anonymous leases are needed. 5. Open `https://app.webly.ai/claim#token=wa_…` (or paste the secret at `/claim`). Never put the secret in a query string or share/log this link. Sign in and confirm ownership (the page shows the signed-in account, with **Not you? Use a different account** to switch Google accounts). The browser cannot read your local credential file. `POST /api/anonymous/claim` takes `{token}` plus the Better Auth browser session and trusted Origin, or a user OAuth access token, or `{token, code}` where `code` comes from `POST /v1/websites/claim-codes` (MCP `create_claim_code`; a `wc_…` code tied to the signed-in person, valid 10 minutes, `401 claim_code_invalid` otherwise). The code form is how the helper claims (`webly.mjs claim `) without the agent ever holding the secret. An agent connected to Webly's MCP over OAuth should run `webly.mjs doctor` right after connecting and before any deploy, and claim a saved site that way. Without the helper it may read `~/.webly/state.json` and call `claim_anonymous_site` (`POST /v1/websites/claims`). 6. Errors carry `details.reason` when the credential's state matters. Only `credential_consumed` (already claimed) and `credential_expired` (claim deadline passed or site purged) mean the saved secret is permanently useless; delete it and issue a new one for a new site. Keep it on `edit_window_closed` (410, the site can still be claimed; no more swaps), `site_already_created` (409, send `replace: true` to swap) and `network_limit` (409, too many live anonymous sites on this network; `details.retryAfterSeconds`). A credential that has not created a site yet gets `404` from status and claim. API keys alone cannot claim. The account is activated and the website moves to its personal workspace. Same-user retries return `alreadyClaimed`. After claim, the secret no longer grants anonymous access and cannot create or swap to another site. Continue with normal authenticated APIs/OAuth. Free claims retain only the original 24-hour preview; afterwards the saved website is held offline. When the server runs without billing (`GET /v1/billing` → `enabled: false`) there are no plans: a claim is an ordinary active site and nothing is held. Base/Max claims activate at the same URL when a slot is available (5/25 sites by default). Held sites do not use an active slot. The owner can edit and publish them through the normal API until the original 24-hour preview ends; after that they are read-only (`402 claim_held`) until activated. An upgrade, a comped plan or `POST /v1/websites/{id}/activate` releases held sites. **Sharing files without an account.** Sharing files doesn't use up the website: create the credential's storage site with `{name, kind: "storage"}` (no `files`) whether or not it already has a website (`409 site_already_created` if it already has a live one; `GET /public/v1/anonymous/sites/storage` finds it, and `current` stays the website when there is one). Then `POST /public/v1/anonymous/sites/{id}/objects/uploads` and `POST /public/v1/anonymous/sites/{id}/objects/finalize` with the same bodies as the signed-in storage endpoints and the `wa_` secret as the bearer, and `DELETE /public/v1/anonymous/sites/{id}/objects/{objectId}` to cancel an upload that failed. Each upload is its own folder, so links are unlimited: one per file and one per folder. The credential's 500 MiB covers the website's uploads (every version) and finished files together (`413 anonymous_storage_limit`; status shows `storage: {usedBytes, limitBytes}`), 100 MiB per file (uploads in progress don't spend it, and have their own 1 GB allowance; deletes don't refund it). The storage site's files are live **and claimable for 24 hours** from its creation, then deleted; after that, creating it again starts a new 24 hours (the budget isn't renewed). Claiming the credential moves the website and its live shared files together into the person's workspace (it must have room in its storage quota, else `402 storage_limit`), the files with the plan's default expiry, counted from the claim; the claim response has the website in `website` and the files in `storage`. Helper: `webly.mjs upload … [--folder name]` does all of it and prints each file's link, the folder link and the zip link. Limits: 25 files, 1 MiB/file, 5 MiB decoded tree, 8 MiB JSON body; uploads spend the credential's 500 MiB (updates and swaps included). A network (one IPv4 address, or one IPv6 /64) holds at most 50 live anonymous sites, so an office or event can share one connection. Rate limits (burst, then refill): per credential 5 creates/swaps then 10/day, 20 edits then 20/hour; per network 60 credentials then 120/day, 100 creates then 200/day, 200 edits then 10/minute, 60 claims then 60/hour. Signed-in requests use the normal API limits instead. Observe `Retry-After` on 429. Terminal/expired credentials are never reusable. A different credential is a different anonymous identity; these limits cannot identify a human who changes credentials/devices/IPs. See `docs/anonymous-deployments.md` for rollout, security and testing details. Helper: `node scripts/webly.mjs` (shipped in the `webly` skill). ## Authentication and access Bearer token in the Authorization header: ``` Authorization: Bearer wb_your_api_key ``` `GET /v1/me` tells you who you are: organization, access scope, and the exact capabilities the key holds. **Keys carry a scope and a role.** - Scope: `{ kind: "organization", role }` (every website in the org) or `{ kind: "websites", websiteIds: [...], role }` (only those websites). - Roles are strictly additive: - `content_editor` — read websites; read schema; create/update/publish CMS items; upload and manage assets; read pages. - `full_editor` — everything above plus source files, deploys, publish/rollback/unpublish, CMS schema, pages, domains, and reading the workspace's plan. - `admin` — everything plus create/rename/delete websites, manage API keys, read the audit log. Organization scope only. (Upgrading or changing the plan needs a signed-in admin in the dashboard.) - `owner` is a human account role and is never grantable to a key. **Two different failures, deliberately:** - `404 not_found` — the website (or anything under it) is outside the key's scope. Ids never confirm their existence across tenants or to keys without a grant. - `403 forbidden` — the key can see the website but its role lacks the capability. `details.capability` names what is missing. **Taken-down sites.** Webly can take a site down for breaking the acceptable-use policy. The website then has `suspendedAt` and `suspendedReason` set, nothing serves on any hostname (live, draft or custom domain), and publishing fails with `403` and `details.reason: "suspended"`. Nothing you do can lift it: tell the owner the reason and that they can write to abuse@webly.ai. Anonymous sites report `status: "suspended"`. ### API keys (admin) - POST /v1/api-keys — `{ name?, access: { kind, role, websiteIds? }, expiresAt? }` → the `secret` is returned exactly once - GET /v1/api-keys (`status=all` to include revoked, `websiteId=` to filter) · GET /v1/api-keys/{keyId} - PATCH /v1/api-keys/{keyId} — change `name`, `expiresAt`, or `access` (takes effect immediately) - POST /v1/api-keys/{keyId}/rotate — new `secret`; the old one stops working instantly - DELETE /v1/api-keys/{keyId} — revoke (permanent) Give each agent session the narrowest key that works: a website-scoped `full_editor` for building a specific site; a `content_editor` for content-only tasks. ### Audit log (admin) - GET /v1/audit-log — `eventType=`, `resourceType=`, `websiteId=`, `actorId=`, `actorKind=` (`api_key`, `oauth`, `user`; comma-separated for several), cursor-paginated. Records website create/update/delete/restore, publish, rollback, unpublish, subdomain renames, domain add/activate/primary/remove, and API key create/update/rotate/revoke, each with the acting key or person. Events by an app signed in over OAuth carry its name in `metadata.agent`. ## Conventions - **Errors** always have the shape `{ kind, message, details? }`. `kind` is one of `validation` (400), `unauthorized` (401), `payment_required` (402), `forbidden` (403), `not_found` (404), `conflict` (409), `payload_too_large` (413), `rate_limited` (429), `upstream` (502), `internal` (500). - **Lists** always return `{ items, nextCursor }`. Pass `nextCursor` back as `?cursor=` until it is `null`. `limit` is 1-100 (default 20). Cursors are opaque. - **Rate limits** are per key, token-bucket. Standard: 120 burst, ~600/min. Expensive (deploys, publishes, rollbacks, builds, head/check): 20 burst, ~30/min. `429` responses carry `Retry-After`; every response carries `X-RateLimit-Limit` / `X-RateLimit-Remaining`. ## Mental model - An **organization** owns **websites**. - A website is either **framework** (default: React source in typed files, built through a quality gate, edited with the `/source` tools below) or **static** (raw files sent with `/deploy`). Pick `kind` at creation; it cannot change. - Every website has a **draft head version** (`headVersion`) and a **published version** (`publishedVersion`, `null` until the first publish). - Deploying files creates a **new head version** (append-only, versions are never rewritten). Nothing goes live until you **publish**. - The draft head is always viewable at `https://draft--{subdomain}.webly.site` (noindex, no-store). - The published version is served at `https://{subdomain}.webly.site` and on every active custom domain. - Publishing fails safe: if the build is not ready or the quality gate fails, the live site does not change. **Rollback** re-publishes any earlier version; **unpublish** takes the site offline without touching history. - Framework sites: every commit starts a **background preview build**; the draft host moves to the new version once it is ready. Publishing waits for the build and returns `400` with `details.diagnostics` / `details.renderFailures` when it failed. - **Where content lives.** Everything is **source files**: pages, layout, styles, and repeating content such as blog posts, products, team members, events and FAQs. The CMS and managed blog (`/collections`, `/blog/posts`, MCP `create_collection`, `add_blog_post`, …) are **deprecated**: the owner's dashboard no longer shows that content, so don't create new collections or posts. Existing sites that already read collections or a managed blog keep working: check with `GET /collections` / `GET /blog` before changing a site, edit that content through those endpoints, and move it into source files only when the owner asks. - **Forms** (contact, quote request, signup, booking) post to Webly with `formAction('name')`; replies land in the owner's dashboard (Forms tab) where they can read, reply and export. Never use a `mailto:` link or a third-party form service — that sends the owner's leads somewhere they don't control. Include a hidden `_honey` input (spam trap) and a hidden `_redirect` to a thank-you page. ## Quick Start (framework website) 1. Create a website (starts as an empty draft) and take the edit lease: ```bash curl -X POST /v1/websites -d '{"name": "My Blog"}' # { id: "ws_...", kind: "framework", subdomain: "my-blog", headVersion: 1, publishedVersion: null, # urls: { draft: "https://draft--my-blog.webly.site", published: null } } curl -X POST /v1/websites/{id}/source/lease # { leaseToken: "...", headVersion: 1 } ``` 2. Write a page (every write needs `leaseToken`; each write is one new head version): ```bash curl -X PUT /v1/websites/{id}/source/file -d '{ "leaseToken": "...", "file": { "type": "page", "name": "home" }, "content": "import { definePage } from \"@webly\"\nexport default definePage({\n route: \"/\",\n metadata: { title: \"Home\" },\n render: () =>

Hello

,\n})\n" }' # { version: 2, changed: true, file: { type: "page", name: "home" } } ``` 3. Run the quality gate, then preview at `urls.draft` and publish: ```bash curl -X POST /v1/websites/{id}/head/check # { version: 2, ok: true, diagnostics: [], renderFailures: [], pages: [{ name: "home", route: "/", renders: 1 }] } curl -X POST /v1/websites/{id}/publish ``` ## Quick Start (static website) 1. Create with `kind: "static"`: ```bash curl -X POST /v1/websites -d '{"name": "My Blog", "kind": "static"}' ``` 2. Deploy files into a new draft version: ```bash curl -X POST /v1/websites/{id}/deploy/json -d '{ "files": [ {"path": "index.html", "content": "

Hello

"}, {"path": "style.css", "content": "body { font-family: sans-serif; }"} ], "message": "First draft" }' # { version: 2, changed: true, urls: { draft: "https://draft--my-blog.webly.site", published: null }, next: "Preview ... then POST /publish" } ``` 3. Preview the draft at `urls.draft`, show it to the user, then publish: ```bash curl -X POST /v1/websites/{id}/publish # { website: { publishedVersion: 2, urls: { published: "https://my-blog.webly.site" } }, deployment: { status: "ready" } } ``` 4. Something wrong? Roll back instantly: ```bash curl -X POST /v1/websites/{id}/rollback -d '{"version": 2}' ``` ## Endpoints ### Websites - POST /v1/websites — Create website `{ name, kind?: "framework"|"static"|"storage", slug?, subdomain?, description? }`. `storage` is file sharing, not a website (see Storage websites below): live at once, no source, build or publish, and it takes no plan website slot. Without `slug`/`subdomain` both come from `name`, and a taken one gets a random suffix (`portfolio-x7k2p9`, generated labels stay within 40 chars). A `slug` or `subdomain` you pass is used exactly or fails `409`. - GET /v1/websites — List websites the key can see (`limit`, `cursor`; `scope=trash` for soft-deleted) - GET /v1/websites/{id} — Get website (includes `headVersion`, `publishedVersion`, `hasUnpublishedChanges`, `urls`) - PATCH /v1/websites/{id} — Update name / slug / description (does not affect hostnames) - PATCH /v1/websites/{id}/subdomain — Rename the platform subdomain `{ label }` (1-56 chars, lowercase, digits, inner hyphens; names we generate stay within 40) - DELETE /v1/websites/{id} — Soft-delete and take offline on every hostname - POST /v1/websites/{id}/restore — Restore a soft-deleted website - GET /v1/websites/{id}/analytics/query?startAt=&endAt=[&dimension=&limit=&filters] — Engagement totals for a range (Unix ms) `→ { pageviews, visitors, visits, bounces, totaltime }`. Add `dimension` (one of `path, entry, exit, title, query, referrer, channel, domain, country, region, city, browser, os, device, language, screen, event, hostname, tag, distinctId`) to get `rows: [{ name, pageviews, … }]` ranked by page views (`limit` default 25, max 1000). Optional filters as query params: `path, referrer, title, query, browser, os, device, country, region, city, language, hostname, tag, event, distinctId, utmSource, utmMedium, utmCampaign, utmContent, utmTerm, segment, cohort`. - GET /v1/websites/{id}/analytics/timeseries?startAt=&endAt=&series=pageviews|events&unit=year|month|day|hour|minute[&timezone=&maxPoints=&compare=prev|yoy] — `series=pageviews` `→ { pageviews: [{ x, y }], sessions: [{ x, y }], compare? }`; `series=events` `→ { events: [{ x, t, y }] }` (custom events tracked with `umami.track('name')` in page code). `maxPoints` (default 30, max 500) keeps the most recent buckets and sets `truncated: { shown, total, hint }` when it cut. - GET /v1/websites/{id}/analytics/realtime — `→ { views, visitors, events, countries, timestamp }` for the trailing 30 minutes. - GET /v1/websites/{id}/analytics — Server-side beacon overview (`period=24h|7d|30d|90d` or `from`/`to`): totals, series, top pages/referrers/countries/devices. Counts every published HTML response the edge served, including non-JS clients (crawlers, agents), so it is the number to compare against the tracker-based endpoints above. Analytics comes from two sources: a first-party tracker (`/_webly/a.js`, proxied on the site's own hostname; sessions, duration, devices, custom events — powers `query`/`timeseries`/`realtime`) and an edge beacon (every published HTML response). Draft previews are never tracked. `website.analytics` reports `{ provider: "umami"|"beacon", tracking, dashboardUrl }`; `dashboardUrl` is a read-only shareable dashboard. Disable the tracker for a site with `defineWebsiteConfig({ analytics: { enabled: false } })`. Static websites include the tracker themselves if wanted: `` (id from `website.analytics`). When the tracker backend is not configured the three endpoints answer from the beacon with `source: "beacon"` and a reduced dimension set (`path, referrer, country, device, hostname`). - POST /v1/websites/{id}/transfer — Start moving the website to another organization `{ toOrganizationId? }` `→ { id, status: "pending", expiresAt, code }`. The `code` is shown once and expires in 7 days; give it to an admin of the receiving organization. `409` while another transfer is pending. - GET /v1/websites/{id}/transfer · DELETE /v1/websites/{id}/transfer — Inspect or cancel the pending transfer - POST /v1/websites/claim-codes — returns `{ code, expiresAt }`, a `wc_…` code valid for 10 minutes that claims into the signed-in person's personal workspace when sent with the secret to `POST /api/anonymous/claim` as `{token, code}`. Lets the local helper (`webly.mjs claim `) claim without the agent reading the secret. Needs an OAuth token or signed-in session. MCP: `create_claim_code`. - POST /v1/websites/claims — `{ token }` claims a website created without an account (`wa_` secret, usually in `~/.webly/state.json`) into the signed-in person's **personal** workspace, whichever workspace the caller is connected to. Needs an OAuth token or signed-in session; API keys get `403` with `details.reason: "user_required"`. MCP: `claim_anonymous_site`. Returns `{ website, workspace, dashboardUrl }`. - POST /v1/websites/transfers/accept —`{ code }` (admin of the receiving org) moves the website, its history, CMS, assets, domains and published build. Website-scoped keys of the previous organization lose access; any edit lease is dropped. The old organization then sees `404`. ### Deploy (static websites only; creates a new draft head version; nothing goes live) - POST /v1/websites/{id}/deploy — Multipart: repeat field `files`, filename = path. Optional fields `replace`, `expectedHeadVersion`, `message` - POST /v1/websites/{id}/deploy/json — `{ files: [{ path, content, contentType?, encoding?: "utf8"|"base64" }], deletes?: [path], replace?: bool, expectedHeadVersion?: n, message? }` Give every static HTML page share-preview tags in `` (`og:title`, `og:description`, `og:image` as an absolute URL, `twitter:card`, and a favicon): Webly adds `og:` tags only for framework sites, so a static page's link previews show exactly what you uploaded. Files are merged into the current head by default; `replace: true` makes the new version contain only the files you send. Identical content is a no-op (`changed: false`, version does not move). `expectedHeadVersion` fails with `409` if another session moved head. Limits: 1 MiB per file, 2,000 files per website. Paths may not contain `..`. ### Source code and versions - GET /v1/websites/{id}/source/summary — `headVersion`, `publishedVersion`, `hasUnpublishedChanges`, file count, urls - GET /v1/websites/{id}/source/files — List files (`target=head|published` or `version=n`, optional `prefix`) - GET /v1/websites/{id}/source/files/read?path= — Read one file (text as utf8, binary as base64) - POST /v1/websites/{id}/source/files/read — `{ paths: [...≤50], target?|version? }` - DELETE /v1/websites/{id}/source/files — `{ paths: [...], expectedHeadVersion?, message? }` → new version - GET /v1/websites/{id}/source/versions — History, newest first (`limit`, `cursor`); entries flag `isHead` / `isPublished` - GET /v1/websites/{id}/source/changes — Added/removed/modified files between `from` and `to` (default `published` → `head`) ### Framework source (framework websites only) Source is a set of **typed files**, not a file tree. Named types: `page`, `component`, `module` (name is path-like: 1–255 chars, no leading `/`, no `.`/`..` segments, none of `< > : " | ? * \ #`; not an asset extension). Singletons (no name): `global_css`, `not_found`, `website_config`, `custom_head_start`, `custom_head_end`, `custom_body_start`, `custom_body_end`. Assets (images, files) live in the asset library, never in source. - POST /v1/websites/{id}/source/lease — Acquire the **single edit lease** `→ { leaseToken, headVersion }`. Acquiring replaces any previous lease; writes with a replaced token fail `409 Invalid edit lease` (re-acquire and re-read what you changed). - GET /v1/websites/{id}/source/tree — List typed files (`type=`, `nameGlob=` e.g. `blog/*`, `lines=true`, `target=head|published` or `version=n`) - GET /v1/websites/{id}/source/file?type=&name= — Read one file `→ { content, bytes, lines, hash }` - POST /v1/websites/{id}/source/file/read — `{ files: [{ type, name? }...≤50], target?|version? }` - PUT /v1/websites/{id}/source/file — `{ leaseToken, file, content, message? }` create/replace one file → new head version (`201`), or `200 changed:false` if identical - DELETE /v1/websites/{id}/source/file — `{ leaseToken, file }` - POST /v1/websites/{id}/source/str-replace — `{ leaseToken, files: [{ file, edits: [{ oldStr, newStr, replaceAll? }] }], dryRun? }` — atomic: ≤50 files / ≤200 edits, each `oldStr` must match exactly once unless `replaceAll`; an `oldStr` with no exact match still applies if it matches exactly once when whitespace is ignored (so text that Prettier re-indented still works); any failing edit fails the whole call (`400` with `details.problems[]`) and nothing is written. `dryRun: true` validates without a lease. Prefer this over rewriting whole files. - PATCH /v1/websites/{id}/source/pages/route — `{ leaseToken, page, route }` change the `route:` literal (normalized; `/_webly/*` reserved) - PATCH /v1/websites/{id}/source/metadata — `{ leaseToken, page?, metadata }` replace a page's `metadata` object, or the site defaults in `website_config` when `page` is omitted - POST /v1/websites/{id}/source/search — `{ pattern, isRegex?, caseSensitive?, nameGlob?, type?, contextLines?≤3, maxMatchesPerFile? (10, max 50), filesOnly?, target?|version? }` `→ { matches: [{ file, line, column, text, before?, after? }], files, truncated }` - GET /v1/websites/{id}/source/file/diff?type=&name=&from=&to= — Unified diff of one file (defaults `published` → `head`) - POST /v1/websites/{id}/head/check — **Quality gate, read-only**: lint → typecheck against the generated `@webly` types → bundle → server-render every page with placeholder CMS content `→ { version, ok, message?, diagnostics: [{ step, severity, file?, line?, column?, message }], renderFailures: [{ pageId, route, message }], pages, timings }`. Publishing runs the same gate (with live content) and fails safe. - POST /v1/websites/{id}/rebuild — `{ target?: "published"|"head", version? }` re-render a built version with current CMS content. Usually unnecessary: item changes (create, update, delete, publish, scheduled `publishAt`) automatically re-render the published and head builds within a few seconds. - GET /v1/websites/{id}/deployments/status — includes `diagnostics` (pages, timings, or the failure details) and the served `routes` Every `PUT`/`str-replace` write is formatted with Prettier before it is stored (single quotes, no semicolons, 100 columns), so head is always canonical: read a file back before composing exact-match edits against it. Responses carry `formatted: true` when formatting changed your text; unparseable code is stored as sent (`formatError`) and the gate reports the syntax error. Raw HTML slots (`custom_*`) are stored verbatim. #### Writing pages ```tsx import { definePage, cms } from '@webly' import Hero from '@components/Hero' export default definePage({ route: '/blog/:slug', // static segments and :params (one dynamic segment per route) metadata: { title: '{{ data.post.title }}', description: '{{ data.post.excerpt }}' }, queries: ({ params }) => ({ // declare CMS data; the platform resolves it before render post: cms.blogPosts.bySlug(params.slug), // binding a route param enumerates one page per published item recent: cms.blogPosts.list({ limit: 5, sort: { field: 'createdAt', direction: 'desc' } }), }), render: ({ data, params }) => data.post ?
:

Not found

, }) ``` - `@webly` is generated from the website's CMS schema: `cms..list(opts) | bySlug(slug) | byId(id)`; items are `{ id, slug, publishedAt, createdAt, updatedAt, position, ...fields }`, with `image`/`file`/`gallery` fields resolved to `{ id, url, filename, contentType, altText, kind }`. Field keys and option values are typed — typos fail the typecheck. - `reference` / `multi_reference` fields are resolved **one level deep**: `post.author` is the published author item (`AuthorItemRef`, its own reference fields stay ids) or `null` if unpublished/missing; `multi_reference` is always an array. Going deeper (`post.author.company.name`) is a type error — declare a second query instead. - `list` options: `limit` (≤100), `offset`, `sort: { field, direction }`, `filter: { [field]: { eq | neq | contains | startsWith | endsWith | gt | gte | lt | lte | in | nin | exists } }`, `filterLogic: 'and'|'or'`, `search`. Only published items are returned. - Imports allowed: `@webly`, `@components/Name`, `@modules/name`, `@pages/name`, `@modules` (registry), `react`, and allowlisted npm packages pinned to an exact version in the specifier (`clsx@2.1.1`). Not allowed: relative imports, assets, Node built-ins, unpinned packages, `react-dom/*`, two versions of one package. #### Packages available to framework sites `GET /api` → `packages` is the source of truth (name → installed version). Currently: `clsx@2.1.1` (class names), `date-fns@4.4.0` (dates), `marked@18.0.12` (Markdown → HTML string; render with `dangerouslySetInnerHTML`), `slugify@1.6.9`, `lucide-react@1.44.0` (icons; server-rendered SVG), `zod@4.6.2`. The pin must equal the installed version exactly — `import clsx from 'clsx@2.1.1'`; a different pin fails lint with the message `"clsx" is provided at version X; pin "clsx@X"`. Anything else is a lint error: implement it inline or ask for the package to be allowlisted. #### Site helpers from `@webly` - `imageUrl(asset | assetId, { width?, height?, fit?: 'cover'|'contain'|'inside', format?: 'webp'|'avif'|'jpeg'|'png', quality? })` → same-origin `/_webly/img/{assetId}?w=…` URL that the edge resizes on demand and caches forever. `srcSet(asset, [480, 960, 1440], opts)` builds a `srcSet` string. Image assets carry `width`/`height` for aspect-ratio boxes. - `formAction('contact')` → `/_webly/forms/contact`. Use it as `
`; the edge stores submissions (see Forms below). Add `` as a honeypot and optionally ``. - `website_config`: `export default defineWebsiteConfig({ metadata: { title, titleTemplate: '%s | Site', description, language, openGraph, twitter }, redirects: [{ from, to, status? }], headers: { ... }, forms: { contact: { redirect?: '/thanks', webhook?: 'https://…', fields?: ['email', 'message'] } } })` — a literal object (no spreads/computed keys); metadata strings accept `{{ data.x.y }}` and `{{ params.name }}` templates. `headers` are added to HTML responses on top of the platform defaults (`X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, HSTS on published hosts). - `not_found` exports a React component (or a `definePage`) rendered as `404.html`. `global_css` is served at `/global.css`. `custom_head_*` / `custom_body_*` are raw HTML injected into the document shell. - **Tailwind CSS v4** is built in: start `global_css` with `@import "tailwindcss";` and use utility classes in any file; the build compiles exactly the classes it finds in your source. Configure with CSS (`@theme { --color-brand: #ff4400; }`, `@layer`, `@utility`); `@plugin`/`@config` JavaScript plugins are not supported and fail the gate with a `css` diagnostic. Plain CSS without the import passes through untouched. - Render runtime: pages are server-rendered in a dedicated V8 isolate with ECMAScript + `TextEncoder` only — no DOM, no `fetch`, no timers that fire, no Node APIs — then hydrated in the browser. Each render has a 5 s CPU budget and the isolate a 256 MB heap; failures land in `renderFailures` with the page and route and never block other pages. - Loop: `str-replace`/`PUT` → `head/check` → fix diagnostics → preview `urls.draft` → `publish`. Never publish without a green check. ### Publishing - POST /v1/websites/{id}/publish — Publish head. `409` if the build is still running, `400` if it failed or head is empty - POST /v1/websites/{id}/unpublish — Take the site offline; head and history untouched - POST /v1/websites/{id}/rollback — `{ version }` Re-publish an earlier version (commits it as a new head, then publishes) - POST /v1/websites/{id}/head/restore — `{ to: "published" | n }` Copy a state into a new head without publishing (discard a draft) - GET /v1/websites/{id}/deployments/status — `status: building|ready|failed`, `canPublish`, `routes`, `createdBy`/`apiKeyId` (who triggered the build; null for anonymous or background builds) (`target=head|published` or `version=n`) - POST /v1/websites/{id}/deployments/ensure — Build a version ahead of time `{ target?|version? }` ### Pages (legacy DB-backed content records, readable via the public API) - POST /v1/websites/{id}/pages · GET /v1/websites/{id}/pages · GET/PATCH/DELETE /v1/websites/{id}/pages/{pageId} - These records are not rendered by the site and do not appear in the owner's dashboard. Prefer source files for pages and collections for content. ### Blog (managed; any site kind) > **Deprecated.** Kept working for existing sites; don't use it for new content. Put content in source files. One call turns "add a blog post" into a post the owner can edit in their dashboard and pages on the live site. - POST /v1/websites/{id}/blog/posts — `{ title, body (markdown), excerpt?, date? (YYYY-MM-DD), tags?: string[], cover?: assetId, slug?, publish? (default true), publishAt? }` `→ 201 { post, collection: { created, addedFields }, rendering: { kind, connected, scaffolded, indexPath, postPathPattern }, notes: string[] }`. First use creates the `blog-posts` collection (fields `title`, `date`, `excerpt`, `body`, `tags`, `cover`; an existing `blog-posts` collection is reused and missing fields added as optional) and, on framework sites, scaffolds pages `blog` (route `/blog`) and `blog-post` (route `/blog/:slug`) that query `cms.blogPosts` — restyle them freely but keep the queries. Static sites need no files: every build generates `/blog/index.html` and `/blog//index.html` from the collection, reusing the ``, `
` and `