SnapHost
SnapHost turns what your assistant builds into a live website you can send to someone, without making it public and without a hosting setup.
What it can do
- List Workspaces: List the workspaces this token can act in: the token owner’s personal space plus every Pro workspace they belong to. Each entry has a `workspace_id` (pass it as `workspace_id` to any
- Publish Site: Use when the user wants to put a page online, share this with a client or teammate, or get a link they can send. Publishes a document or bundle to SnapHost and returns its stable share U
- Update Site: Use when the user wants to push the new version, ship the changes they just made, or update the page while keeping the same link. Replaces a site's content in place by id. Accepts the sam
What data it sees
Do you need an account
No: the server works without sign-in
SnapHost turns what your assistant builds into a live website you can send to someone, without making it public and without a hosting setup.
Ask Claude to share a report, a dashboard, a notebook or a built web app, and it publishes the page to SnapHost and hands back a link. The link is stable: every later update replaces the content in place, so the person you sent it to always opens the current version, and anyone with the page open sees the new one without reloading.
Access is per person, not a shared password. A private site verifies each viewer's email against an allowlist you control, so you can add a client, remove a contractor, or check who has opened it. You can also set a view password, give the link an expiry date, take the site offline and bring it back, or point your own domain at it. Every publish is versioned, so a bad update is one rollback away.
Published pages can store data too. A contact form, waitlist, RSVP, guestbook, counter or leaderboard works with no backend to wire up, and the submissions appear in your dashboard.
Connecting takes one browser click. SnapHost uses OAuth, so no API key or token is ever pasted into a conversation, and each person who connects reaches only their own sites. Every tool is annotated as read, write or destructive, so your client knows what to confirm before it runs.
Free plan included. Private sharing, custom domains and the data layer are on Pro.
How to connect
How to connect SnapHost to Claude
The server address to paste into Claude settings:
https://snaphost--snaphost.run.tools
- Open Claude (claude.ai or the desktop app).
- Go to Settings → Connectors.
- Click Add custom connector.
- Paste the server address copied below into Remote MCP server URL and click Add.
- In a chat, click + → Connectors and switch the new connector on.
Custom connectors are available on Free, Pro, Max, Team and Enterprise plans (Free is limited to one). On Team and Enterprise an organization Owner adds the connector first under Organization settings → Connectors.
Authorization
After you click Add, a sign-in window for the service opens. Sign in with your own account and approve access. Claude never sees your password.
How to connect SnapHost to ChatGPT
The server address to paste into ChatGPT settings:
https://snaphost--snaphost.run.tools
- Open ChatGPT in a browser (chatgpt.com). A Plus, Pro, Business, Enterprise or Edu plan is required.
- Turn on developer mode once: Settings → Apps → Advanced settings → Developer mode.
- Open Settings → Connectors and click Create.
- Fill in the form: Name (anything), Description (one line about what the service does), MCP server URL (copy it below).
- Under Authentication choose OAuth if the service requires sign-in, otherwise None. Click Create.
- In a new chat open + → Apps/Connectors and enable the connector.
OpenAI has renamed this section before (Connectors → Apps/Plugins). If the label differs, search settings for "developer mode". On Business/Enterprise workspaces an admin must allow custom connectors first.
Authorization
On first use ChatGPT opens the service's sign-in window. Sign in and approve access.
How to connect SnapHost to Cursor
The server address to paste into Cursor settings:
https://snaphost--snaphost.run.tools
Fastest: click Open in Cursor below and confirm the prompt.
Manually:
- In Cursor open Settings → Cursor Settings → MCP and click Add new global MCP server.
- Paste the JSON copied below into
~/.cursor/mcp.json(per project:.cursor/mcp.jsonin the repo root). - Save the file. The server appears in the MCP list; authorize it there if asked.
Authorization
If the service needs sign-in, an authorize button appears next to the server in the MCP list.
Server tool list (51)
Raw names from tools/list. Only developers need these.
| list_workspaces | List the workspaces this token can act in: the token owner’s personal space plus every Pro workspace they belong to. Each entry has a `workspace_id` (pass it as `workspace_id` to any other tool to act in that workspace), a `handle`, and the caller’s `role` (owner, admin, member, or viewer). Omit `workspace_id` on a tool to act in the token owner's own account. |
| publish_site | Use when the user wants to put a page online, share this with a client or teammate, or get a link they can send. Publishes a document or bundle to SnapHost and returns its stable share URL. Provide either `html` (one document) or `zipBase64` (a full bundle; a single Markdown/`.ipynb`/PDF file is also accepted and rendered to a sandboxed site). The content kind (html, markdown, notebook, or pdf) is detected automatically on publish and returned as `content_kind`. Pass a `slug` to make the publish idempotent: the first call creates the site, later calls with the same slug update it in place at the same URL. Optionally set `status` (draft hides it until published) and an `allowlist` (Pro only). The returned read model also reports link expiry (`link_expires_at`) and whether a view password is set (`has_view_password`); manage these with set_link_expiry / set_view_password / clear_view_password. A new site lands in the publisher's own My drive, addressed on the workspace subdomain as /~<handle>/<slug>, unless `folder_id` (from list_folders) or `space` "shared" files it on the Shared drive, where it is addressed by its slug or folder path; a slug is looked up in that same place, so republishing into a folder updates rather than duplicates. `visibility` "public" makes it open to anyone with the link (a landing page must be public). For a large or multi-file app (e.g. a built React site) that is too big to inline, first call create_bundle_upload and pass its `upload_id` instead of `html`/`zipBase64`. Publish also scans the page for `window.SNAPHOST` data usage: a collection the page queries is set up as public_read so its reads work, one it only appends to stays owner-only, and on the marketing site nothing is opened at all (the owner asks by name, and only for records with no personal detail); the result reports what was configured; relay it and confirm the choices with the owner. The page is also scanned for external references (CDN scripts, font stylesheets, embeds) tha |
| update_site | Use when the user wants to push the new version, ship the changes they just made, or update the page while keeping the same link. Replaces a site's content in place by id. Accepts the same inputs as publish_site (HTML, a bundle, or a single Markdown/`.ipynb`/PDF that is re-rendered); the detected `content_kind` may change. Preserves the URL, visibility, allowlist, link expiry, and view password, and records a new version. Open viewers are offered a Refresh to the new content (a landing page, framed in the marketing site, shows it on its next load). Like publish_site, the new content is scanned for `window.SNAPHOST` data usage and any new collections are set up to match how the page uses them (reported in the result), and for external references the site's security policy will block; relay those to the owner and either trust the approved origins (add_external_origins) or vendor the assets. Relative references to files missing from the upload are reported too; include those files and republish. |
| set_site_slug | Change a site's slug, the last segment of its address on the workspace host and, for a landing page, of its snaphost.ai/lp address. The share permalink (nanoId) keeps working; the old slug address and any campaign link to it stop. Normalised to lowercase letters, digits and hyphens; refused when another site on the same drive holds it. Needs a workspace on Pro, which publishes on its own host. |
| rename_site | Rename a site's display title by id. Cosmetic only: the slug, the nanoId permalink, and every existing link keep working; only what viewers and the dashboard call the site changes; the heading and tab of a landing page do not, unless its document declares no <title> of its own. To change the site's link path, use set_site_slug. A landing page's visible title (tab, link preview, heading) is the <title> in its own document: change that with update_site. Returns the updated site. |
| list_sites | List the token owner's non-deleted sites with their ids, slugs, titles, statuses, visibilities (`public` or `allowlist`, i.e. private), share URLs, detected content kind (`content_kind`), SPA fallback (`spa_fallback`), allowed external origins (`external_origins`), link expiry (`link_expires_at`, ISO or null) with its derived `link_status` (`no_expiry`, `active`, `expiring_soon`, or `expired`), and whether a view password is set (`has_view_password`). Narrow the results with the optional `visibility`, `status`, `link_status`, and `expires_within_days` filters; all combine with AND. |
| get_site | Get a single site's status, visibility, current version metadata, detected content kind, SPA fallback, allowed external origins, link expiry (`link_expires_at`, ISO or null) with its derived `link_status`, whether a view password is set (`has_view_password`), view count, and the `allowlist` it is currently shared with (who can view a private site) by id. |
| get_site_content | Fetch a site's current entry document by id so you can edit it and push the result back with update_site: the read half of the round-trip get_site and list_sites omit (they return only metadata, never the body). Returns the live document inline as UTF-8 (`encoding: "utf-8"`) for HTML/Markdown/text/SVG/JSON, or base64 (`encoding: "base64"`) for binary content like PDFs and images, alongside `content_type`, `content_kind`, and `content_hash`. |
| set_render_options | Configure how a site is delivered, without touching its sandbox. `spa_fallback` (free on every plan) serves the entry page for unknown in-app routes so client-side navigation and shared deep links resolve, but real missing files (scripts, images, styles) still 404. `external_origins` extends the site's content-security policy to allow specific https origins it calls at runtime (fonts, images, APIs, embeds); this is a paid capability (Pro) and each entry must be a bare `https://host[:port]` with no path. Pass an empty array to clear the list, or use add_external_origins to add origins without replacing the list. Omit a field to leave it unchanged. Needs edit access to the site (its creator or an editor grant, in a role that can edit); rejected requests (`forbidden`, `plan_limit`, `validation_failed`) leave the stored options unchanged. |
| add_external_origins | Trust one or more external https origins for a site, adding to (never replacing) its CSP allowlist. This is the follow-up when publish_site / update_site reports origins the security policy will block and the owner confirms trusting them. Only pass origins the owner has explicitly approved. Each entry must be a bare `https://host[:port]` with no path. This is a paid capability. To remove or replace origins, use set_render_options instead. Returns the full updated render options. |
| delete_site | Delete a site by id: marks it deleted, purges its content and version history, deletes the records and collections it holds (a waitlist or contact form included), and makes the share link non-viewable. This cannot be undone and nothing can read any of it back afterwards, so read what matters first: list_site_collections for what it holds, then get_site_content and export_site_data. Not a way to fix a broken address: create_share_link puts a page back on its address without destroying any of this. |
| list_versions | List a site's published versions newest-first with their content hash, content kind, entry path, size, file count, and timestamp. |
| rollback_site | Use when the user wants to undo the last update or go back to the previous version. Restores a prior version's content as the site's current content (records a new version). Content only: the slug, folder, visibility and any /lp address stay as they are now, so a rollback after a rename does not restore the old address. The share URL is unchanged and open viewers are offered a Refresh to it (a landing page shows it on its next load). Use `list_versions` to find the `version_id`. |
| list_folders | List the folders in the workspace (the token owner's personal space, or a Pro workspace via `workspace_id`) with their ids, names, parent folder, and drive (`space`: `personal` or `shared`). Use a folder's `folder_id` with move_site_to_folder or publish_site to file a site into it; a site's own `folder_id` (from get_site / list_sites) reports where it currently lives. In the website workspace, the Shared-drive folder slugged `marketing` is the one served at snaphost.ai/lp. |
| move_site_to_folder | File a site into a folder, keeping its share link. Pass the destination `folder_id` from list_folders; omit `folder_id` to move the site back to the drive root. Moving a site between a personal and a shared (workspace-wide) folder reconciles who can see it. Only the site’s creator can move it. Returns the updated site read model with its new `folder_id`. In the website workspace the Shared-drive `marketing` folder is served publicly at snaphost.ai/lp/<slug> (a site filed one folder deeper at /lp/<folder>/<slug>), so filing a site there makes it a landing page and moving a landing page CHANGES its /lp address; the result reports the new one. |
| create_folder | Create a folder to organize sites. `space` is the drive: `personal` (default) or `shared` (Pro workspaces only). Pass `parent_id` (from list_folders) to nest it; omit for a top-level folder. A nested folder must share its parent’s drive. A shared folder is open to the whole workspace by default; pass `open_to_team: false` to start it restricted, addressable at its slug but visible only to the creator and members they invite (refused for the marketing folder and any folder inside it, which are open to the workspace). Returns the new folder, with the slug derived from its name; in the website workspace the landing-page folder must end up slugged `marketing`, so check the returned slug. |
| rename_folder | Rename a folder. Pass its `folder_id` (from list_folders) and the new `name`. The slug, and with it every address under the folder (a landing page's /lp address included), stays as it is; only what the dashboard calls the folder changes. Only someone who can manage the folder may rename it. Returns the updated folder. |
| set_folder_slug | Rename a folder's link slug, its path segment in folder-path addresses like <handle>.snaphost.ai/<folder-slug>/<site-slug>. Shared-drive folders only: personal (My drive) folders are organisational and never appear in links. The slug stays unique among sibling folders. Pro workspaces only (slug addressing rides on the workspace subdomain). In the website workspace the Marketing folder keeps its slug (it is the /lp address space); a folder inside it may change, and the result names every /lp address that ended (set the slug back to restore them) and every one that started. Returns the updated folder. |
| move_folder | Re-parent a folder. A shared-drive folder whose slug was taken where it landed gets the next free one, which the result names, and comes back under it. In the website workspace, a folder moved into the Marketing folder puts every public, published page in it live on snaphost.ai/lp at once (and opens the folder and its pages to the whole workspace as editors, whatever they were), and one moved out or within it ends or changes their addresses; the result names every address that ended (move it back to restore them, then set the slug back if it changed) and every one that started; the Marketing folder itself is never moved. Pass its `folder_id` and the destination `parent_id` (both from list_folders); omit `parent_id` to move it to the drive root. A folder cannot be moved into itself or its own descendant. Returns the updated folder. |
| delete_folder | Delete a folder. Its sites and any sub-folders are kept and moved up to the deleted folder’s parent (the drive root if it was top-level), a sub-folder taking the next free slug there, so no content is lost, though the addresses that carried the folder's slug are. Only someone who can manage the folder may delete it. In the website workspace the Marketing folder is never deleted; a folder inside it may be, and its pages keep serving one level up at shorter /lp addresses, so the result names every address that ended and every one that started. |
| create_bundle_upload | Start a bundle upload for a site too big to inline (a multi-file or media-heavy app, e.g. a built React site). Returns an `upload_id` to send the .zip against, then publish_site or update_site with that `upload_id` instead of `html`/`zipBase64`. Two ways to send it. Send it with upload_bundle_part, one numbered part per call: that is a tool call on this connection, so it works from any client that can call this tool at all, including a sandbox with no network of its own. Or PUT the .zip to `upload_url` (HTTP PUT, Content-Type application/zip; above `max_part_bytes`, to `upload_url`/parts/0, /parts/1, ... in order), which is fewer, larger requests when your shell can reach that host; `storage_url` is the same PUT straight to the storage host, one request at any size. Your tool calls and your shell do not share a network: a PUT that is refused (a 403 naming the host, or a connection that never leaves) says nothing about upload_bundle_part, so switch to it rather than asking the owner to change any network settings. The upload is single-use and expires; request a new one if it lapses. |
| upload_bundle_part | Send one numbered slice of a .zip into an upload opened by create_bundle_upload, encoded as base64 in this call. This is the route that needs no network beyond the one carrying this tool call, so it is how a sandboxed client publishes a multi-file bundle. Split the .zip into slices of at most 1048576 bytes, base64 each slice on its own, and send them as `part` 0, 1, 2, ... with no gaps; part 0 must be the opening bytes of the archive. Nothing is published until publish_site or update_site is called with the `upload_id`, which joins the parts in index order and verifies the archive, so a rejected or damaged part costs only that part: send that index again, the others still stand. Re-sending an index replaces it. |
| create_share_link | Use when a site lost its link and the user wants it back. Gives a site a fresh share link when it has none. Every site gets one when it is published, so this is the way back from a link that was taken away, not an everyday act: it is what puts a landing page back on its /lp address without deleting anything, and the page, its version history and every record it has collected are left untouched. A site that already has an active link keeps it and gets no second one. It puts back whom the last link let in: a page that was open to anyone comes back open and live, and one that was restricted comes back restricted, so the page is NOT live until you also call set_visibility with "public", read the `visibility` that comes back and say which happened. On a workspace with its own subdomain (the marketing site is one) an address is the folder path, not the link id, so the /lp address and the review copy are the same as before and campaign links to them work again; only a bare sites.* link, if one was ever handed out, carries the id and is now a different address. The address to hand out is `landing_url` where there is one; `share_url` is the review copy. The one case this refuses is a link that was restricted on a workspace whose plan has since lapsed: putting it back restricted gives a link nobody can open, and putting it back public would publish a page its owner kept private, so it asks for the plan to be brought back instead of choosing. |
| set_link_status | Use when the user wants to take it down, unpublish it, pause the link, or bring it back. Takes a site offline or brings it back. `enabled: false` unpublishes it (the link returns "not available" for everyone, a landing page's /lp address answers 404 inside the marketing chrome, and any custom domains are suspended) without deleting it; `enabled: true` republishes it. This is the single way to take a site offline; there is no separate unpublish tool. |
| set_visibility | Use when the user says only certain people should see it, make it private, or open it to anyone with the link. Sets a site's visibility. `public` lets anyone with the link view; `allowlist` restricts to verified people on the allowlist (Pro only). |
| list_allowlist | Show who a site is currently shared with: its visibility and the email and/or domain entries on its allowlist. On a `public` site the allowlist is not consulted (anyone with the link can view); entries from before it went public are still listed, and `set_allowlist` with an empty array clears them. Call this before adding or removing people so you can see the existing list rather than overwriting it. |
| add_to_allowlist | Use when the user wants to let one more person in, invite a client, or share it with the team. Adds email and/or domain entries to a private site's allowlist WITHOUT removing anyone already on it; use this to share with more people. Entries already present are left untouched (the call is idempotent), and everyone previously allowed keeps their access. To replace the entire list instead, use `set_allowlist`; to take someone off, use `remove_from_allowlist`. Pro only; on a plan without it the refusal names the plan, and where one lapsed, how to get it back. The site must be `allowlist` (private); make it private first with `set_visibility` if it is public. Each newly-added email receives an invite email; the optional `message` is included in it as a personal note from the owner. |
| remove_from_allowlist | Remove specific email and/or domain entries from a private site's allowlist, leaving everyone else on it. Entries that are not present are ignored. To clear the whole list, use `set_allowlist` with an empty array. |
| set_allowlist | Use when the user wants to set exactly who can view, replacing the current list. REPLACES a site's entire allowlist with the provided email and/or domain entries. This OVERWRITES the existing list, so anyone not in the entries you pass loses access. To add people without dropping the current ones, use `add_to_allowlist` instead; to take specific people off, use `remove_from_allowlist`. Pass an empty array to clear it. Each email that was not already on the list receives an invite email. Pro only, except that clearing the list works on every plan and on a public page. |
| set_link_expiry | Use when the user wants the link to stop working after a date, or to keep it up indefinitely. Sets or clears an expiry on a site's share link. Pass `expires_at` as an ISO 8601 timestamp (e.g. "2026-12-31T23:59:00Z") to make the link stop working after that moment (a landing page's /lp address and campaign links stop with it), or `null` to remove any expiry so the link works indefinitely. Setting an expiry is Pro only and works on a public page as it is; clearing one works on every plan. Needs edit access to the site (its creator or an editor grant, in a role that can edit); rejected requests (`forbidden`, `plan_limit`, `validation_failed`, `not_found`) leave the stored expiry unchanged. |
| set_view_password | Use when the user wants to put a password on the page. Protects a site behind a view password. Anyone with the link must enter this password before they can view the content. The password is hashed at rest; it is never returned, logged, or echoed back. Re-run with a new value to change it. Pro only; on a plan without it the refusal names the plan, and where one lapsed, how to get it back. Needs edit access to the site (its creator or an editor grant, in a role that can edit); rejected requests (`forbidden`, `plan_limit`, `validation_failed`, `not_found`) leave the stored password unchanged. |
| clear_view_password | Remove a site's view password so viewers with the link can open it without a password. Needs edit access to the site (its creator or an editor grant, in a role that can edit); rejected requests (`forbidden`, `not_found`) leave the stored password unchanged. |
| list_access_requests | List pending access requests for an allowlist site: people who asked to view it and are waiting for approval. Each entry has an `id`, the `requester_email`, an optional `message`, its `status`, and when it was `created`. Approve one with approve_access_request (adds them to the allowlist) or reject it with deny_access_request. Admin or owner only; requires a plan with access requests enabled (`plan_limit` otherwise). |
| approve_access_request | Approve a pending access request: the requester is added to the site's allowlist and can then view it. Use list_access_requests to find the `request_id`. Admin or owner only; rejected requests (`forbidden`, `plan_limit`, `not_found`) make no change. |
| deny_access_request | Deny a pending access request: the requester is not added to the allowlist and stays unable to view the site. Use list_access_requests to find the `request_id`. Admin or owner only; rejected requests (`forbidden`, `plan_limit`, `not_found`) make no change. |
| set_site_data | Use when the page should read a setting or value the owner controls (a price, a deadline, a switch). Sets a key-value item in a site's data store (SnapHost Data). KV is mutable config/state the published page reads. `value` is any JSON. Scoped to a site you own. |
| define_collection_schema | Define (or clear, with schema=null) a JSON Schema on a collection in a site's data store. A schema validates every appended record server-side, turning the collection into a typed backend. Defining a schema requires Pro. Scoped to a site you own. |
| list_data_recipes | List ready-made data features (contact form, waitlist, waitlist with a public signup count, RSVP, guestbook, scoreboard, clock-in, leaderboard). Use with apply_data_template to add one to a site end-to-end. `accessMode` is what the recipe asks for; on the marketing site every collection is created append_only whatever it says, so a recipe that reads its own collection shows nothing there until the owner asks for public_read by name. No site needed. |
| apply_data_template | Use when the user wants a contact form, waitlist, RSVP, guestbook, counter, or leaderboard on the page. Adds a ready-made data feature to a site: provisions the recipe's collection (access mode + schema) and returns the HTML block to insert into the page (then publish with update_site). Needs Pro; scoped to a site you own. See list_data_recipes for ids. |
| set_collection_access | Set who can read a collection from the published page and pause/resume writes. `access_mode`: 'public_read' (anyone reads: a scoreboard/guestbook) or 'append_only' (visitors append but only the owner reads: a contact form / waitlist; submissions are never readable by other visitors). On the marketing site public_read means anyone on the internet, so it is refused unless you pass `records_carry_no_personal_detail` true, which you answer with the owner, and the `waitlist` collection stays append_only there whatever is asked. `writes_paused` stops new appends. Needs Pro; scoped to a site you own. |
| list_site_collections | List a site's data collections with their schema, access mode, and paused state. Scoped to a site you own. |
| query_site_records | Use when the user asks who signed up, what was submitted, or wants to see the responses. Queries a collection's append-only records (newest-first by default). Filter with `where` (field/value equality), set `order` (asc|desc) and `limit`. Each record carries its server-stamped actor. Scoped to a site you own. |
| export_site_data | Export the records in a collection, up to 5000 (the store holds far more; a bigger collection comes back cut off, and the result says so with `truncated: true`). It is also the only call that reports a total: `count` is how many came back, so while `truncated` is false it is the whole collection. query_site_records reads at most 200 records and cannot page past them, so it can never total a collection. Use for backup or analysis. Scoped to a site you own. |
| delete_site_record | Delete one record from a collection (moderation). Records are otherwise append-only and immutable. Scoped to a site you own. |
| delete_collection | Delete an entire collection: every record in it plus its access-mode and schema settings. Irreversible. Scoped to a site you own. |
| add_custom_domain | Use when the user wants the site on their own domain. Points a custom domain (e.g. "deck.acme.com") at SnapHost. With `site_id` the host serves exactly that one site (Pro). With `target: "workspace"` (a Pro workspace) the host becomes a full alias of the workspace’s own subdomain: one DNS record, and every site and folder path resolves on it, share.acme.com/<site-slug>, share.acme.com/<folder>/<site>, share.acme.com/~<member>/<site>. Returns the DNS records the owner must add at their registrar; the domain serves once those records resolve and it is verified. |
| set_workspace_domain_root | Choose or clear the ROOT site of a workspace-bound domain: the site served at the domain’s bare / (e.g. share.acme.com/). Omit site_id to clear it, making the root a 404 again. Every other path keeps resolving folder and site slugs. |
| verify_custom_domain | Re-check a custom domain's DNS and activate it once the records resolve. Call after adding the DNS records that add_custom_domain returned. |
| remove_custom_domain | Detach a custom domain from its site and stop serving it. |
| list_custom_domains | List custom domains with their verification status: pass `site_id` for the domains bound to one site, or omit it for the workspace-bound domains (the whole-workspace aliases). |
| get_brand_kit | The active workspace's brand kit, if it has one: design tokens (the `--sh-*` custom properties injected into every page it publishes, e.g. `var(--sh-accent)`), logo and mark URLs, the voice (the one-liner and short pitch to reuse where the page says what SnapHost is, the product truths every page must carry, phrases never to use), the absolute links for calls to action, the current plan names and prices, and the rules a framed page lives by. Call it BEFORE building or restyling a page for the workspace, with the workspace_id you were asked to publish in; compose the page from the page recipes it points to, style through the tokens, quote only the prices it lists, and fix every `brand_warnings` entry a publish returns before reporting done; warnings never block, the page is already published while you fix them. The website workspace’s kit also carries `landing_pages`, the steps that put a page live on snaphost.ai/lp. A workspace with no kit returns `has_brand_kit: false`; then build as the owner asks. |