# chorus.host full reference > Web hosting for AI agents: HTML reports, dashboards, mini apps and static sites at https://.chorus.host and Cloudflare Workers at https://.worker.chorus.host. This file is the complete reference. The short version is https://chorus.host/skill.md. OpenAPI: https://chorus.host/openapi.json Publish a file or folder, get back `https://.chorus.host`. Base URL: `https://chorus.host`. The first part below is the same as skill.md; the endpoint reference follows. ## When to use The user wants something online with a link: an HTML page, a static build (`dist/`, `build/`, `out/`, `public/`), a report, dashboard, slide deck, game, PDF or image. Or a temporary preview that expires on its own, or a page behind a password. Or they need a backend a static page can call: an API, a form handler, a GitHub or Stripe webhook receiver, a Hono or Workers app. If you can't make network requests from where you run (a sandbox without internet), don't guess: tell the user to open https://chorus.host/html-to-url and drop or paste the file there. ## Requirements `curl` and network access to `chorus.host`. The three-call API for big sites also needs `*.r2.cloudflarestorage.com` (file bytes go straight to storage through presigned URLs). Python 3 or the `beacon` CLI are optional shortcuts. ## Publish a file: one call, no account ```bash curl -sS https://chorus.host/v1/publish -H 'X-Chorus-Client: claude-code/2.0' -F file=@index.html ``` The response is JSON: `url`, `claimUrl`, `claimToken`, `expiresAt`, and `fileUrl` (the direct link when you sent one file). A single `.html` file becomes the home page whatever it's called. More files: repeat `-F file=@path` (use `-F "file=@app.js;filename=assets/app.js"` to keep a folder path), or zip the folder and send `-F archive=@site.zip`. Add `-F slug=my-site` to pick the subdomain, `-H 'Accept: text/plain'` to get only the link. Up to 4 MB of files per call without an API key, 10 MB with one; bigger sites use the three calls below. Private link? Add `-F username=client -F password=...` and the site is behind a browser login prompt before it goes live, also without an account. Give the user the URL, username and password, and send the password separately from the link. Shorter life? Add `-F expires=2h` (`90m`, `2h`, `7d` or RFC 3339). Without an account a site can only expire sooner than 24 hours. ## Big files and folders: three calls Create the site with a file list, upload each file, go live. Files up to 250 MB each without an account. ```bash F=index.html # file to publish CLIENT='X-Chorus-Client: claude-code/2.0' # your harness/version, see below HASH=$( (sha256sum "$F" 2>/dev/null || shasum -a 256 "$F") | cut -d' ' -f1) SIZE=$(wc -c < "$F" | tr -d ' ') curl -s https://chorus.host/v1/sites -H "$CLIENT" -H 'Content-Type: application/json' \ -d "{\"files\":[{\"path\":\"index.html\",\"size\":$SIZE,\"contentType\":\"text/html; charset=utf-8\",\"hash\":\"sha256:$HASH\"}]}" > site.json curl -s -X PUT "$(jq -r '.uploads.pending[0].uploadUrl' site.json)" \ -H 'Content-Type: text/html; charset=utf-8' --data-binary @"$F" curl -s -X POST "https://chorus.host$(jq -r .version.finalizeUrl site.json)" \ -H "$CLIENT" -H "X-Claim-Token: $(jq -r .claimToken site.json)" jq -r '.site.url, .claimUrl' site.json ``` No `jq`? Run the calls one at a time and copy the values out of the JSON yourself. Rules: - `hash` is `sha256:` plus 64 lowercase hex characters of the file bytes. `size` is in bytes. - PUT each file in `uploads.pending` to its `uploadUrl` with exactly the `Content-Type` you declared. Files the server already has come back in `uploads.skipped`; don't upload those. - Finalize an anonymous site with `X-Claim-Token: `. Without it you get `401`. - More files: list them all in `files` (nested paths like `assets/app.js` are fine) and PUT each one. `/` serves `index.html`. ## Publish a folder ```bash curl -fsSLO https://chorus.host/publish.py # download once python3 publish.py ./dist # folder or single file python3 publish.py ./dist my-slug # pick the subdomain ``` Standard-library Python. Prints JSON with `url`, `slug`, `expiresAt`, `claimUrl` and, for one file, `fileUrl`, and saves the slug and claim token in `.beacon/` so running it again updates the same site. `--expires 2h` makes the link expire sooner. Set `CHORUS_CLIENT=/` to identify yourself. Or the CLI (sites and workers): `curl -fsSL https://chorus.host/install.sh -o install-beacon.sh && sh install-beacon.sh`, then `beacon deploy --pretty`. Windows: `iwr https://chorus.host/install.ps1 -useb | iex`. ## Share a single file (PDF, image, HTML) ```bash curl -sS https://chorus.host/v1/upload -F file=@report.pdf # prints https://.chorus.host/report.pdf ``` Files keep their content type, so HTML renders and PDFs and images open in the browser instead of downloading. Give the user the direct file link (`fileUrl`); chat apps such as Slack preview images from it. The site root shows a small viewer page. `/v1/upload` prints just the link and puts the claim details in the `X-Claim-Token`, `X-Claim-Url` and `X-Expires-At` response headers; `/v1/publish` returns them as JSON. ## What to tell the user 1. Put the site URL on a line by itself, with nothing else on that line. 2. If the site is anonymous, say it expires in 24 hours, and give them the `claimUrl` so they can keep it. Copy it exactly as returned, character for character. Don't shorten, wrap or reformat it; the code after `#` is 47 characters and the link breaks if any are lost. 3. Never show an API key in the chat. The `claimUrl` already carries the claim code, so don't paste the `claimToken` separately. 4. If they ask how keeping the site works, link https://chorus.host/guides/host-static-site-no-signup#claim-the-site-later ``` Your site is live: https://quiet-river-42.chorus.host It will be deleted in 24 hours. To keep it, open this link and sign in with your email: ``` Keep the `claimToken` (`ctk_...`) with the project. It is the only way to update, delete or claim an anonymous site. ## Sign up or sign in (email code) There's no password. Signing up and signing in are the same two calls: ```bash curl -s https://chorus.host/v1/auth/send-otp -H "$CLIENT" -H 'Content-Type: application/json' \ -d '{"email":"user@example.com"}' ``` Ask the user: "I sent a 6-digit code to user@example.com. What is it?" Then: ```bash curl -s https://chorus.host/v1/auth/verify-otp -H "$CLIENT" -H 'Content-Type: application/json' \ -d '{"email":"user@example.com","code":"123456"}' # -> {"apiKey":"chk_...","account":{"id":"...","email":"..."}} ``` Codes expire after 10 minutes, and 5 wrong tries cancel a code. ## Save the API key yourself Write it to `~/.config/beacon/config.json` as `{"apiKey":"chk_..."}` and `chmod 600` the file. `publish.py` and the `beacon` CLI read it from there (or from the `BEACON_API_KEY` environment variable). Do this yourself; don't ask the user to, and don't show the key in the chat. Send it as `Authorization: Bearer chk_...`. With a key, sites last until you delete them or until an `expiresAt` you set, workers are available, `GET /v1/sites` lists sites, and `GET /v1/account` shows the account and its usage. Move an anonymous site into the account: ``` POST /v1/sites//claim Authorization: Bearer chk_... {"claimToken": "ctk_..."} ``` ## Identify your client Send `X-Chorus-Client: /` on every request, for example `claude-code/2.0`, `cursor/1.7`, `codex/0.40`, `hermes/1.2` or `my-script/1`. It's optional. It tells us which agents use chorus.host so we can fix what breaks for them. ## Update a site ``` POST /v1/sites//versions {"files": [...same manifest format...]} X-Claim-Token: ctk_... (anonymous site) or Authorization: Bearer chk_... (owned site) ``` Upload the `pending` files, then POST the returned `finalizeUrl` with the same header. `POST /v1/sites` with a slug that exists returns `409`; use the versions endpoint instead. `GET /v1/sites/` (same headers) shows the site and its live version. Shortcut: `curl -sS "https://chorus.host/v1/publish?slug=" -H 'X-Claim-Token: ctk_...' -F file=@index.html` sends a new version in one call. ## Temporary links Anonymous sites last 24 hours from the first publish and updates don't reset the clock; they can be made to expire sooner (`expires` on `/v1/publish`, `--expires` on publish.py, or `PATCH /v1/sites//metadata` `{"expiresAt":""}`), to any time up to 24 hours after the first publish, never later. With an API key any future time works, or `null` for none. At expiry the URL returns 404 and the site is deleted. More: https://chorus.host/guides/temporary-website-hosting ## Workers (API backends) Serverless JavaScript/TypeScript (Hono works) on Cloudflare at `https://.worker.chorus.host`. Needs a chorus.host API key, not a Cloudflare account, and the URL stays up until you delete it. Sign-in works without a browser: POST `/v1/auth/send-otp` with the user's email, ask the user for the 6-digit code, then POST `/v1/auth/verify-otp`. Hono: `beacon init my-api --template hono && cd my-api && npm install && beacon deploy --pretty`. Replace `chk_...` with the key: ```bash curl -s -X POST https://chorus.host/v1/workers -H "Authorization: Bearer chk_..." \ -H 'Content-Type: application/json' -d '{"slug":"my-api"}' curl -s -X POST https://chorus.host/v1/workers/my-api/deploy -H "Authorization: Bearer chk_..." \ -F 'metadata={"entryPoint":"index.js","compatibilityDate":"2024-09-23","compatibilityFlags":["nodejs_compat"]}' \ -F 'file=@index.js' ``` Upload a JavaScript ES module (`export default { fetch }`); compile TypeScript to JavaScript first. `beacon deploy` bundles npm imports with esbuild. Secrets: `PUT /v1/workers//secrets/` with `{"value":"..."}`; set them after the first deploy and they stay set across redeploys and rollbacks. Scheduled (cron) runs are not available yet. Workers have no built-in database or KV; keep state in an outside service and put its key in a secret. Form submissions are received and forwarded (email, a spreadsheet, your database), not stored. A site and a worker can't share a slug, so pair them with two: `my-app.chorus.host` (frontend) calls `my-app-api.worker.chorus.host` (API). Webhook receiver: read the raw body with `await request.text()`, verify the sender's HMAC signature against a Worker secret, return 2xx within 10 s (GitHub does not retry), forward elsewhere for storage. Walkthrough with GitHub and Stripe code: https://chorus.host/guides/deploy-webhook-from-agent. Workers page: https://chorus.host/workers. Full worker reference: https://chorus.host/llms-full.txt ## Other site operations | Do this | Call | |---|---| | Password-protect (anonymous sites too) | `PUT /v1/sites//password` `{"username":"u","password":"p"}` (max 72 chars) | | Remove password | `DELETE /v1/sites//password` | | Title / description / OG image / expiry | `PATCH /v1/sites//metadata` `{"title":"...","description":"...","ogImagePath":"og.png","expiresAt":"..."}` | | List versions | `GET /v1/sites//versions` | | Roll back | `POST /v1/sites//versions//rollback` | | Delete | `DELETE /v1/sites/` | | Expired upload URLs (after 1 hour) | `POST /v1/sites//versions//uploads/refresh` `{"paths":["index.html"]}` | All of these take `X-Claim-Token` (anonymous) or `Authorization: Bearer` (owned). ## Errors | Status | Meaning | Fix | |---|---|---| | 401 on finalize or update | Missing credentials | Send `X-Claim-Token` from the create response, or your Bearer key for owned sites | | 404 on a site | Not your site, wrong key, or expired | Check `GET /v1/sites` | | 405 | Wrong method | The `Allow` header lists the right one | | 409 on create | Slug taken | Use another slug, or `POST /v1/sites//versions` if it's yours | | 400 on finalize | `file "x": not uploaded`, or size/hash mismatch | PUT the named file to its `uploadUrl` (the exact bytes you hashed), then finalize again | | 409 on finalize | `version is not pending` | Already live; create a new version to change it | | 429 | Rate limit | Anonymous: 5 publishes an hour per IP (new sites and updates). Sign in for 60 an hour | Errors are JSON: `{"error":"..."}`, often with a `hint` and a `docs` link. ## Limits Anonymous: sites last 24 hours, 250 MB per file, 5 publishes an hour. Signed in: permanent sites, 5 GB per file, 60 publishes an hour. Both: 10 GB per site and 10,000 files per version. Workers: 3 MB compressed bundle, 100 files, 20 deploys an hour. `GET /v1/limits` returns the current numbers. If these docs and the live API disagree, trust the API: its error messages say what to fix. ## MCP server Clients that speak MCP can publish without curl. The remote server is `https://chorus.host/mcp` (Streamable HTTP, no OAuth) with three tools: `publish_site` (send file contents, get the URL and claim link back), `get_site` and `get_docs`. Anonymous by default; send `Authorization: Bearer chk_...` to publish into an account. ```bash claude mcp add --transport http chorus https://chorus.host/mcp ``` Setup for Codex, Cursor, VS Code, claude.ai and ChatGPT: https://chorus.host/mcp. `publish_site` takes up to 4 MB of file content per call without an API key and 10 MB with one, and optional `password` and `expires_at`; use the HTTP API above for anything bigger. Guides: host a static site with no signup (https://chorus.host/guides/host-static-site-no-signup), share an HTML file as a link (https://chorus.host/guides/share-html-file-as-link), temporary links (https://chorus.host/guides/temporary-website-hosting), password-protect an HTML page (https://chorus.host/guides/password-protect-html-page), deploy a webhook receiver (https://chorus.host/guides/deploy-webhook-from-agent). # Endpoint reference ## Static Sites API ### Publish in one request {#one-request-publish} ``` POST /v1/publish Authorization: Bearer (optional; omit for anonymous) X-Claim-Token: ctk_... (optional; with ?slug=, updates that anonymous site) ``` The body is the files themselves, in any of these forms: - `multipart/form-data`: one or more `file` fields (the part's filename is the path; a lone `.html` file becomes `index.html`), or an `archive` field with a `.zip` or `.tar.gz` (a single top-level folder is stripped). Optional fields: `slug`, `title`, `expires` (`90m`, `2h`, `7d` or RFC 3339), `username` and `password` (HTTP Basic Auth on the site). - `application/json`: `{"files":[{"path":"index.html","content":"...","encoding":"utf8|base64"}],"slug":"...","title":"...","expires_at":"2h","password":{"username":"u","password":"p"}}` (the MCP `publish_site` shape). - `application/zip`, `application/x-tar` or `application/gzip`: an archive, unpacked as above. - Anything else: one file. Name it with `?name=report.pdf`; without a name, HTML becomes `index.html` and other types get a generic name with the right extension. Query parameters: `slug`, `title`, `expires`. Limits: 4 MB of files per request without an API key, 10 MB with one, 1,000 files; bigger sites get `413` and should use the three-call flow below. Same rate limits as `POST /v1/sites` (5 an hour per IP without a key). Response `201` (new site) or `200` (update): ```json { "url": "https://bright-river-42.chorus.host", "pageUrl": "https://bright-river-42.chorus.host/", "fileUrl": "https://bright-river-42.chorus.host/report.pdf", "slug": "bright-river-42", "updated": false, "anonymous": true, "expiresAt": "2026-10-02T09:14:03Z", "claimUrl": "https://chorus.host/claim/bright-river-42#ctk_...", "claimToken": "ctk_...", "passwordProtected": false, "versionId": "...", "fileCount": 1, "totalBytes": 18342 } ``` `fileUrl` is set when one file was published; `fileUrls` lists up to 20 files otherwise. Send `Accept: text/plain` to get only the link (`fileUrl`, else `pageUrl`) as the body, with `X-Claim-Token`, `X-Claim-Url` and `X-Expires-At` response headers. `POST /v1/upload` is the same endpoint with plain-text output by default (send `Accept: application/json` for JSON), and `PUT /v1/upload/` takes the raw body (`curl -T report.pdf https://chorus.host/v1/upload/report.pdf`). ### Create a site ``` POST /v1/sites Authorization: Bearer (optional — omit for anonymous) Idempotency-Key: (optional — prevents duplicate creates on retry) Content-Type: application/json { "slug": "my-site", "files": [ { "path": "index.html", "size": 1024, "contentType": "text/html", "hash": "sha256:<64 lowercase hex chars>" } ] } ``` - `slug` is optional (auto-generated if omitted) - `hash` is the SHA-256 hex digest of the file contents, prefixed with `sha256:` - Files with matching hashes across any site are deduplicated (skip upload) Response: ```json { "site": { "id": "01JQXYZ...", "slug": "my-site", "url": "https://my-site.chorus.host", "expiresAt": "2026-03-21T15:04:05Z", "createdAt": "2026-03-20T15:04:05Z" }, "version": { "id": "01JQABC...", "finalizeUrl": "/v1/sites/my-site/versions/01JQABC.../finalize" }, "uploads": { "pending": [ { "path": "index.html", "uploadUrl": "https://r2.cloudflarestorage.com/...", "uploadMethod": "put" } ], "skipped": [] }, "claimToken": "ctk_...", "claimUrl": "https://chorus.host/claim/my-site#ctk_..." } ``` `claimToken` and `claimUrl` are only returned for anonymous creates. `expiresAt` is null for authenticated creates. ### Upload files PUT each file to its presigned URL with the correct Content-Type: ``` PUT Content-Type: text/html ``` Upload URLs expire after 1 hour. Use the refresh endpoint to get new ones if needed. ### Finalize a version ``` POST /v1/sites/:slug/versions/:versionId/finalize Authorization: Bearer ``` Or for anonymous sites: ``` POST /v1/sites/:slug/versions/:versionId/finalize X-Claim-Token: ctk_... ``` Site is live immediately after finalize succeeds. ### Create a new version (update existing site) ``` POST /v1/sites/:slug/versions Authorization: Bearer Content-Type: application/json {"files": [...]} ``` Same flow: upload files, then finalize. ### List sites ``` GET /v1/sites?limit=20&offset=0 Authorization: Bearer ``` Pagination: `limit` (1-100, default 20), `offset` (default 0). ### Update metadata ``` PATCH /v1/sites/:slug/metadata Authorization: Bearer (or X-Claim-Token: ctk_... for an anonymous site) Content-Type: application/json { "title": "My Site", "description": "A demo", "ogImagePath": "preview.png", "expiresAt": null } ``` `expiresAt` is an RFC 3339 time, or `null` for no expiry (account-owned sites only). Anonymous sites may only shorten their expiry, to at most 24 hours after creation; a later time gets `400`. Claim the site to keep it longer. ### Set password protection ``` PUT /v1/sites/:slug/password Authorization: Bearer Content-Type: application/json {"username": "demo", "password": "s3cret"} ``` Anonymous sites can be locked before anyone claims them, with the claim token: ``` PUT /v1/sites/:slug/password X-Claim-Token: ctk_... Content-Type: application/json {"username": "demo", "password": "s3cret"} ``` Username 1-128 characters; password max length: 72 characters (bcrypt limit). Visitors get the browser's Basic Auth prompt; responses are `Cache-Control: private, no-store`. Guide: https://chorus.host/guides/password-protect-html-page ### Remove password protection ``` DELETE /v1/sites/:slug/password Authorization: Bearer ``` ### Delete a site ``` DELETE /v1/sites/:slug Authorization: Bearer (or X-Claim-Token: ctk_... for anonymous sites) ``` Returns `200` with `{"success": true}`. ### Claim an anonymous site Requires both Bearer auth (to identify the account) and the claim token: ``` POST /v1/sites/:slug/claim Authorization: Bearer Content-Type: application/json {"claimToken": "ctk_..."} ``` ### List versions ``` GET /v1/sites/:slug/versions?limit=20&offset=0 Authorization: Bearer ``` ### Rollback to a version ``` POST /v1/sites/:slug/versions/:versionId/rollback Authorization: Bearer ``` ### Refresh upload URLs If presigned URLs have expired (after 1 hour): ``` POST /v1/sites/:slug/versions/:versionId/uploads/refresh Authorization: Bearer Content-Type: application/json {"paths": ["index.html"]} ``` ### Complete multipart upload For files >= 250 MB that use multipart upload: ``` POST /v1/sites/:slug/versions/:versionId/uploads/complete Authorization: Bearer Content-Type: application/json { "path": "large-file.mp4", "uploadId": "mpu_abc123", "parts": [ {"partNumber": 1, "etag": "\"abc123...\""}, {"partNumber": 2, "etag": "\"def456...\""} ] } ``` --- ## Workers API Workers are serverless JavaScript/TypeScript functions on Cloudflare's edge network. Ideal as API backends for static sites. All worker endpoints require `Authorization: Bearer `. ### Create a worker ``` POST /v1/workers Authorization: Bearer Idempotency-Key: (optional — prevents duplicate creates on retry) Content-Type: application/json {"slug": "my-api"} ``` Response: ```json { "slug": "my-api", "url": "https://my-api.worker.chorus.host", "createdAt": "2026-03-20T15:04:05Z" } ``` ### List workers ``` GET /v1/workers Authorization: Bearer ``` ### Get worker details ``` GET /v1/workers/:slug Authorization: Bearer ``` Response: ```json { "slug": "my-api", "url": "https://my-api.worker.chorus.host", "currentDeployment": { "id": "01JQDEP...", "version": 3, "entryPoint": "index.js", "sizeBytes": 1234, "deployedAt": "2026-03-20T15:10:00Z" }, "createdAt": "2026-03-20T15:04:05Z" } ``` ### Deploy a worker Uses `multipart/form-data` with a `metadata` JSON field and one or more `file` fields. ``` POST /v1/workers/:slug/deploy Authorization: Bearer Content-Type: multipart/form-data Form fields: metadata = {"entryPoint": "index.js", "compatibilityDate": "2024-09-23", "compatibilityFlags": ["nodejs_compat"]} file = @index.js file = @utils.js ``` Files are deployed as-is: Beacon doesn't compile TypeScript or bundle npm imports, so upload JavaScript ES modules (`export default { fetch }`). curl sends only a file's base name, so keep extra modules next to `index.js` and import them as `./utils.js`. Compile TypeScript first (for example `npx esbuild index.ts --format=esm --outfile=index.js`). `beacon deploy` runs your package.json `build` script, or bundles npm imports with esbuild, before it uploads. curl example: ```bash curl -X POST https://chorus.host/v1/workers/my-api/deploy \ -H "Authorization: Bearer your-api-key" \ -F 'metadata={"entryPoint":"index.js","compatibilityDate":"2024-09-23","compatibilityFlags":["nodejs_compat"]}' \ -F "file=@index.js" ``` Metadata fields: - `entryPoint` (required) — main module filename - `compatibilityDate` (required) — Cloudflare compatibility date - `compatibilityFlags` — e.g. `["nodejs_compat"]` - `crons` — cron expressions, e.g. `["0 * * * *"]`. Accepted and stored, but scheduled runs are not available yet; don't rely on them. Response: ```json { "slug": "my-api", "url": "https://my-api.worker.chorus.host", "deploymentId": "01JQDEP...", "version": 3, "sizeBytes": 1234, "fileCount": 2, "entryPoint": "index.js", "deployedAt": "2026-03-20T15:10:00Z" } ``` Deploy is idempotent — if the script hash and metadata match the current deployment, the existing deployment is returned. ### Delete a worker ``` DELETE /v1/workers/:slug Authorization: Bearer ``` Returns `204 No Content`. ### Set a secret ``` PUT /v1/workers/:slug/secrets/:name Authorization: Bearer Content-Type: application/json {"value": "postgres://user:pass@host/db"} ``` Secret name must match `[A-Z][A-Z0-9_]{0,63}`. Value max size: 5 KB. Secrets are encrypted by Cloudflare and take effect immediately. Response: ```json {"name": "DATABASE_URL", "createdAt": "2026-03-20T15:04:05Z"} ``` ### Delete a secret ``` DELETE /v1/workers/:slug/secrets/:name Authorization: Bearer ``` Returns `204 No Content`. ### List secret names ``` GET /v1/workers/:slug/secrets Authorization: Bearer ``` Returns names and creation dates (values are never exposed): ```json { "data": [ {"name": "DATABASE_URL", "createdAt": "2026-03-20T15:04:05Z"}, {"name": "API_KEY", "createdAt": "2026-03-20T15:04:05Z"} ], "totalCount": 2, "limit": 20, "offset": 0 } ``` ### List deployment history ``` GET /v1/workers/:slug/deployments Authorization: Bearer ``` ### Rollback to previous deployment ``` POST /v1/workers/:slug/rollback Authorization: Bearer Content-Type: application/json {"deploymentId": "01JQDEP..."} ``` `deploymentId` is optional — defaults to the previous deployment. Response includes `rolledBackFrom` field: ```json { "slug": "my-api", "url": "https://my-api.worker.chorus.host", "deploymentId": "01JQNEW...", "version": 4, "sizeBytes": 1234, "fileCount": 2, "entryPoint": "index.js", "deployedAt": "2026-03-20T16:00:00Z", "rolledBackFrom": "01JQDEP..." } ``` ### Real-time logs ``` GET /v1/workers/:slug/logs/tail Authorization: Bearer ``` Returns a WebSocket URL for live log streaming: ```json { "url": "wss://tail.developers.workers.dev/..." } ``` --- ## Example: Full Publish Flow (Node.js) ```javascript const crypto = require('crypto'); const fs = require('fs'); const BASE = 'https://chorus.host'; // 1. Compute file hash const content = fs.readFileSync('index.html'); const hash = 'sha256:' + crypto.createHash('sha256').update(content).digest('hex'); // 2. Create site (anonymous — no auth needed) const createRes = await fetch(`${BASE}/v1/sites`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ files: [{ path: 'index.html', size: content.length, contentType: 'text/html', hash }] }) }); const { site, version, uploads, claimToken } = await createRes.json(); // IMPORTANT: save claimToken — you need it for all subsequent requests // 3. Upload files for (const upload of uploads.pending) { const fileContent = fs.readFileSync(upload.path); await fetch(upload.uploadUrl, { method: 'PUT', body: fileContent }); } // 4. Finalize (use claim token for anonymous sites) await fetch(`${BASE}${version.finalizeUrl}`, { method: 'POST', headers: { 'X-Claim-Token': claimToken } }); console.log(`Live at: ${site.url}`); ``` ## Example: Hono Backend Worker Hono is an npm package, so deploy this with `beacon deploy`, which bundles it with esbuild. The raw deploy endpoint takes the files as-is and can't resolve `import ... from 'hono'`. ```typescript import { Hono } from 'hono' const app = new Hono() app.get('/api/hello', (c) => { return c.json({ message: 'Hello from chorus.host!' }) }) app.post('/api/contact', async (c) => { const body = await c.req.json() return c.json({ ok: true }) }) export default app ``` Deploy: `my-app.chorus.host` serves the frontend, `my-app-api.worker.chorus.host` serves the API (a site and a worker can't share a slug). --- ## Limits | Limit | Anonymous | Authenticated | |-------|-----------|---------------| | Site lifetime | 24 hours | Permanent | | Max file size | 250 MB | 5 GB | | Total site size | 10 GB | 10 GB | | Publish rate | 5/hour per IP | 60/hour per key | | Worker deploy rate | N/A | 20/hour per key | | Max files per version | 10,000 | 10,000 | | Worker script bundle | N/A | 3 MB compressed | | Max worker files | N/A | 100 | | Secret value size | N/A | 5 KB | | Upload URL expiry | 1 hour | 1 hour | | Pagination max limit | 100 | 100 | ## Error Format All errors return JSON: ```json {"error": "slug is already taken"} ``` Common status codes: 400 (bad request), 401 (unauthorized), 404 (not found), 409 (conflict/idempotency mismatch), 413 (too large), 422 (unprocessable), 429 (rate limited), 502 (Cloudflare API failure), 503 (workers not configured). ## Endpoint Reference | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/v1` | None | Entry points and docs links | | POST | `/v1/auth/send-otp` | None | Send OTP code to email (aliases: `/v1/auth/login`, `/v1/auth/signup`, `/v1/signup`, `/v1/login`) | | POST | `/v1/auth/verify-otp` | None | Verify OTP, get API key (aliases: `/v1/auth/verify`, `/v1/auth/otp/verify`) | | GET | `/v1/account` | Bearer | Your account and usage (alias: `/v1/me`) | | GET | `/v1/limits` | None | Rate limits and size caps as JSON | | GET | `/v1/openapi.json` | None | OpenAPI 3.1 spec (same as `/openapi.json`) | | POST | `/v1/publish` | Optional | Publish files in one request (multipart, raw body, zip or JSON) | | POST, PUT | `/v1/upload`, `/v1/upload/:name` | Optional | Same as `/v1/publish`, prints just the link | | POST | `/v1/sites` | Optional | Create site with file manifest | | GET | `/v1/sites` | Bearer | List your sites | | GET | `/v1/usage` | Bearer | Account usage: site/worker counts, bytes, recent activity | | GET | `/v1/sites/:slug` | Bearer or Claim | Site details and live version (404 without credentials) | | PATCH | `/v1/sites/:slug/metadata` | Bearer or Claim | Update title, description, OG image, expiry (anonymous: shorten only) | | PUT | `/v1/sites/:slug/password` | Bearer or Claim | Set password protection | | DELETE | `/v1/sites/:slug/password` | Bearer or Claim | Remove password protection | | DELETE | `/v1/sites/:slug` | Bearer or Claim | Delete site (200 `{"success":true}`) | | POST | `/v1/sites/:slug/claim` | Bearer + Claim body | Claim anonymous site | | POST | `/v1/sites/:slug/versions` | Bearer or Claim | Create new version | | GET | `/v1/sites/:slug/versions` | Bearer or Claim | List versions | | POST | `/v1/sites/:slug/versions/:id/finalize` | Bearer or Claim | Verify uploads, go live | | POST | `/v1/sites/:slug/versions/:id/uploads/refresh` | Bearer or Claim | Refresh presigned URLs | | POST | `/v1/sites/:slug/versions/:id/uploads/complete` | Bearer or Claim | Complete multipart upload | | POST | `/v1/sites/:slug/versions/:id/rollback` | Bearer or Claim | Rollback to this version | | POST | `/v1/workers` | Bearer | Create worker | | GET | `/v1/workers` | Bearer | List workers | | GET | `/v1/workers/:slug` | Bearer | Get worker details | | POST | `/v1/workers/:slug/deploy` | Bearer | Deploy worker (3 MB bundle limit) | | DELETE | `/v1/workers/:slug` | Bearer | Delete worker (204) | | PUT | `/v1/workers/:slug/secrets/:name` | Bearer | Set secret | | DELETE | `/v1/workers/:slug/secrets/:name` | Bearer | Delete secret (204) | | GET | `/v1/workers/:slug/secrets` | Bearer | List secret names | | GET | `/v1/workers/:slug/deployments` | Bearer | List deployments | | POST | `/v1/workers/:slug/rollback` | Bearer | Rollback worker | | GET | `/v1/workers/:slug/logs/tail` | Bearer | Get log stream URL |