> **Sent this link to add the skill?** Install it by following https://withicarus.com/skills/website/llms-install.md
> Already installed, or using it in this chat? Follow the skill below.

---
name: website
description: Builds and publishes a multi-page website for a brand with Icarus, using the brand's own saved products, shots and videos, at a link that stays the same when it is edited. Use when someone asks for a website, landing page, launch page, microsite, product page, lookbook site or link-in-bio page, or a web app, tool or dashboard to open at a link (a CRM, content calendar, calculator, quiz, form, tracker or game), or asks to change, update or take down a site Icarus made, or to add a form, signup, waitlist or a list (products, testimonials) to one, or who filled in a site's form, or to make a site private behind a password.
---

# Website

## Overview

The site is built in your sandbox as a Vite + React + Tailwind + shadcn project, checked in a headless browser, and published with three Icarus tools: `Icarus:upload_site` signs one upload link per file, you PUT the files straight to storage, and `Icarus:publish_site` puts that version live. Three facts shape every step:

- The site is served under a folder, the link the person shares (`/<slug>/`), not at the top of the host. So the build must use **relative asset paths** (Vite `base: "./"`) and **hash routes** (`#/pricing`): an absolute `/assets/app.js` or a `/pricing` path points outside the folder and 404s.
- Visitors keep seeing the old version until `publish_site` succeeds, so a half-uploaded site is never shown. A failed upload costs a retry, never a broken page.
- The link stays the same only when you publish to the same site: pass its `siteId`. A new slug is a new site at a new link.

Every Icarus tool below is on the Icarus connector. Design the pages with the `frontend-design` skill.

## What a site here can't do

- **Never ask visitors for a password, a one-time code, a card number, an ID number or bank details.** No password box, no card form, no "verify your account" page. Every published version is checked, and a site that asks is taken offline (`Icarus:list_sites` then shows `blocked` with the reason; tell the person in those words). Scam pages ask for exactly these, and every site shares one address, so one scam would put every brand's site on a blocklist. A team's private page uses `Icarus:set_site_password`, which the server checks.
- **Never build or offer anything that looks like a sign-in screen**, even one that only asks for a username or sends people elsewhere: a look-alike login is how phishing starts. When someone wants members-only pages, offer `set_site_password`; when they want people to sign in to their own app, offer a plain "Open the app" button that links to it. If they need a login page for their own app, you may give them its code as a download to put on their own website; just never publish it on Icarus.
- **Pages send data only to Icarus.** Forms post to their `formUrl`; a form or a `fetch` to any other website is refused by the browser and takes the site offline. So pictures and videos come from Icarus links, fonts from Google Fonts (`fonts.googleapis.com`, `fonts.gstatic.com`), and the only outside script is Cloudflare's bot check. Anything else from another host (a script, a stylesheet, an embedded video, a map) won't load: build it into the site instead.
- **What the site keeps for people goes in Icarus site data, never in the browser.** Contacts, leads, signups, orders, tasks, notes and anything visitors share live in a collection (`Icarus:set_site_collection`, then the form and record tools; "Forms and lists" below). Never `localStorage`, `sessionStorage`, IndexedDB or cookies: every site shares one address, so any other site can read what one site stored, and browser storage stays on one device and is lost when it is cleared. Browser storage is only for harmless view choices (the theme, a collapsed panel). Never tell the person their data "stays in the browser"; it is in their Icarus workspace.
- **No tokens, keys or passwords in browser storage either.** Another site at the same address can read them.
- **No service workers, web workers or offline mode** (no `vite-plugin-pwa`, no `navigator.serviceWorker.register`). The browser refuses them on these sites: a worker left behind would keep answering after the site is changed or taken down.

## What the person can give you, and what to do with each

| Input | What to do | Why |
|---|---|---|
| A brief ("a launch page for our new bag") | Plan the pages and the sections of each, then build | The brief decides the pages; the brand's assets decide how it looks |
| Saved products, characters, assets, earlier shots | Find them with `Icarus:list_products`, `Icarus:list_assets`, `Icarus:list_generations` or `Icarus:get_generations`, and use their links directly in `<img src>` and `<video src>` | Their links are public, served from Icarus's own CDN at full quality. Copying them into the build makes the site heavier and is limited to 25 MB a file |
| Their look ("in our style") | `Icarus:show_taste` | It holds the colours and mood they chose |
| Pictures they attach in chat | `Icarus:save_attached_files` or `Icarus:upload_file`, then use the links | An attached file has no public link until it is saved |
| Copy, prices, contact details | Use them word for word | A site that makes up a price or an address is worse than no site |
| A request to change a site | `Icarus:list_sites`, pick the site, edit the project, publish to its `siteId` | Same `siteId`, same link |

When the brief has no product, no brand name, or no idea of the pages, ask one short question with your best guess ("A one-page launch site for the Weekender bag, with your three studio shots and a buy button. Right?"). Building the wrong site wastes their time; one question does not. If they need new pictures for it, make them first with the usual shot tools, then build.

## Steps

1. **Plan.** Pages, and the sections of each. Name which Icarus links go where. Most brand sites need 2 to 5 pages; a landing page is one. Decide now where everything the site keeps lives: each thing people add or share (signups, contacts, deals, orders, tasks) is a collection you set up before building that page, never browser storage. Deciding later means a rebuild.

2. **Scaffold** in the sandbox:
   ```bash
   npm create vite@latest site -- --template react-ts && cd site
   npm install && npm install tailwindcss @tailwindcss/vite react-router
   npm install -D @types/node
   ```
   Then, as the shadcn Vite guide says: `src/index.css` is `@import "tailwindcss";`; add `"baseUrl": "."` and `"paths": { "@/*": ["./src/*"] }` to `compilerOptions` in `tsconfig.json` and `tsconfig.app.json`; set `vite.config.ts` to:
   ```ts
   import path from "path";
   import tailwindcss from "@tailwindcss/vite";
   import react from "@vitejs/plugin-react";
   import { defineConfig } from "vite";

   export default defineConfig({
     base: "./",
     plugins: [react(), tailwindcss()],
     resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
   });
   ```
   Then `npx shadcn@latest init` and `npx shadcn@latest add` the components you need.

3. **Route with hashes.** `createHashRouter` from `react-router`, made once outside the React tree and passed to `<RouterProvider>`, with each page's route on it (`/`, `/shop`, `/contact`), and `<Link to="/shop">` between them. The visible link then reads `…/acme/#/shop`. A browser router breaks on refresh because the host has no rewrite to `index.html`. Scroll to the top on each route change.

4. **Design and build, phone first.** Lay out for a 390 px screen, then widen: fluid widths, no horizontal scroll, 16 px side gutters, tap targets at least 44 px, text that wraps instead of overflowing. Most visitors open a shared link on a phone. Use the `frontend-design` skill. Put the brand's own pictures front and centre at their real shape. Give every `<img>` a real `alt`, `width` and `height`, and `loading="lazy"` below the fold. Play videos from their Icarus links (`muted playsinline loop` for a background clip). Keep secrets out: everything in the build is public. Run `npm run build`.

5. **Check it in a headless browser, served from a folder like the live one. Every time, before every `publish_site`, first publish included; never skipped.** A layout that breaks on a phone is the first thing the person sees. Copy the build under a two-level folder and serve it, so an absolute path breaks here instead of live:
   ```bash
   rm -rf /tmp/check && mkdir -p /tmp/check/site/v1 && cp -r dist/. /tmp/check/site/v1/
   (cd /tmp/check && python3 -m http.server 4173 >/dev/null 2>&1 &)
   npx -y playwright install chromium
   ```
   With Playwright, open `http://localhost:4173/site/v1/index.html#/` and every route, at 390, 768 and 1280 px wide (390×844, 768×1024, 1280×800), and in light and dark if the site has a dark mode. On each, take a screenshot and check:
   - no console errors and no failed requests
   - every image loaded (`img.complete && img.naturalWidth > 0`)
   - nothing wider than the screen (`document.documentElement.scrollWidth <= innerWidth`), and nothing overlapping or cut off
   - buttons and links at least 44 px tall on the phone width
   - each nav link reaches its page.
   Look at the screenshots yourself. Fix what is wrong, build and check again until it is clean. If the headless browser won't run, say so to the person in plain words ("I couldn't open a browser to check the phone layout this time") and never say it was checked.

6. **List the files.** Every file in `dist`, its path inside `dist` and its size in bytes:
   ```bash
   node -e 'const fs=require("fs"),p=require("path");const walk=d=>fs.readdirSync(d,{withFileTypes:true}).flatMap(e=>e.isDirectory()?walk(p.join(d,e.name)):[p.join(d,e.name)]);console.log(JSON.stringify(walk("dist").map(f=>({path:p.relative("dist",f).split(p.sep).join("/"),size:fs.statSync(f).size}))))' > files.json
   ```
   Hidden files (a leading dot) are refused, so leave them out of `dist`.

7. **Get the upload links.** `Icarus:upload_site` with `files`, a fresh `versionKey` (save it; it makes a retried call return the same version), and either `slug` and `title` for a new site or `siteId` for an existing one. If the slug is taken, pick another and tell the person. Over 200 files: call it again with the same files, key and `page: 2` and so on, for the rest of the links.

8. **Upload every file in parallel, with retries.** PUT each file's bytes to its `uploadUrl` with exactly its `headers`, 8 at a time. Retry a network error or a 5xx up to 3 times. A 403 means the link expired (they last 10 minutes) or a header differs: call `upload_site` again with the same files and key for fresh links, and PUT again.
   ```js
   // upload.mjs <upload_site-result.json>
   import fs from "node:fs";
   const { uploads } = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
   const put = async (u, tries = 3) => {
     const res = await fetch(u.uploadUrl, { method: "PUT", headers: u.headers, body: fs.readFileSync(`dist/${u.path}`) }).catch(() => null);
     if (res?.ok) return;
     if (tries > 1 && (!res || res.status >= 500)) return put(u, tries - 1);
     throw new Error(`${u.path}: ${res ? res.status : "network error"}`);
   };
   const queue = [...uploads];
   await Promise.all(Array.from({ length: 8 }, async () => { while (queue.length) await put(queue.shift()); }));
   console.log(`uploaded ${uploads.length}`);
   ```

9. **Publish.** `Icarus:publish_site` with `siteId` and `version`. If it names files that didn't arrive, PUT those again and call it again; repeating it is safe.

10. **Reply** with the `url` it returned and one line on what is on the site. In the Icarus chat a preview card appears from the tool's result on its own, so don't paste the link twice.

**Editing later:** keep the project in the sandbox. For a change, `Icarus:list_sites` to get the `siteId`, edit, build, check (step 5), and publish with that `siteId` and a new `versionKey`. If the project is gone from the sandbox, rebuild it, using the live site in the headless browser as the reference, and say so. `Icarus:delete_site` takes a site down for good; do it only when they ask.

## Forms and lists

A site can take form answers (a signup, a contact form, a waitlist) and show lists you keep for it (products, testimonials, team members). Both live in a site's **collections**. Set one up before you build the part of the page that uses it.

1. **`Icarus:set_site_collection`** with the site's `siteId` (publish the site once first if it is new), a `collection` name (`signups`, `products`), and:
   - a form (a signup, a waitlist, a contact form): `publicWrite: true` and `fields` naming what the form sends (`["name", "email"]`). Leave `publicRead` off: answers with emails or phone numbers must never be public.
   - a list the site shows: `publicRead: true` and `fields` naming what visitors may see (`["name", "price", "image"]`). Any other field you save stays private.
   - a form whose answers the site shows (reviews, a guestbook): `publicWrite: true`, `publicRead: true`, `fields` for what visitors send, and `showFields` for what everyone sees (`["name", "review"]`). Never put an email or a phone number in `showFields`. A visitor's answer stays hidden until the person picks it, so a stranger can't post to the page: tell them, and when they say which, call `Icarus:show_site_records` with those ids (`Icarus:list_site_records` with `pending: true` lists the hidden ones).
   - a private list (a team's deals): neither; only you see it, in chat.

   It returns `formUrl` and `dataUrl`, and `turnstileSiteKey` when the bot check is on. Fill a list with `Icarus:save_site_records` (50 at a time; add a `position` field if the order matters); what you save shows at once, no approving needed.
2. **In the build**, one helper, `src/lib/site-data.ts`. The form posts as `text/plain` so the browser sends one request, not two; the list file sits in the site's own folder, so its path is relative. No key or secret goes in the build; none is needed.
   ```ts
   // Each form's formUrl, exactly as set_site_collection returned it.
   export const SIGNUP_FORM = "<formUrl>";

   export async function sendForm(formUrl: string, answers: Record<string, string>): Promise<boolean> {
     const res = await fetch(formUrl, { method: "POST", headers: { "Content-Type": "text/plain" }, body: JSON.stringify(answers) }).catch(() => null);
     return res?.status === 201;
   }

   export type SiteRecord<T> = { id: string; createdAt: string; data: T };
   export async function readList<T>(collection: string): Promise<SiteRecord<T>[]> {
     const res = await fetch(`_data/${collection}.json`, { cache: "no-cache" }).catch(() => null);
     if (!res?.ok) return [];
     const body = (await res.json().catch(() => null)) as { records?: SiteRecord<T>[] } | null;
     return body?.records ?? [];
   }
   ```
3. **A form** has a hidden honeypot input, `<input name="_gotcha" tabIndex={-1} autoComplete="off" className="hidden" aria-hidden="true" />`, sent with the answers: people leave it empty, bots fill it, and their post is dropped. While sending, disable the button. On `true`, show a thank-you line in place of the form; on `false`, keep what they typed and show "That didn't send. Try again in a moment." A person sends 5 answers a minute at most.

   **When `turnstileSiteKey` came back**, every post must carry Cloudflare's invisible bot check, or it is refused. Load its script once in `index.html`'s `<head>`, `<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" defer></script>`, and render the widget from code (Cloudflare's way for single-page apps: a form on a later page would be missed by the automatic scan). Put `<div ref={check.box} />` inside the form, send `{ ...answers, _turnstile: check.token() }`, and call `check.reset()` after every send, sent or not: a token works once and lasts 5 minutes.
   ```ts
   // src/lib/turnstile.ts. TURNSTILE_KEY is turnstileSiteKey, exactly as returned; it is public, unlike a secret.
   import { useEffect, useRef } from "react";
   type Turnstile = { render(el: HTMLElement, o: { sitekey: string }): string; getResponse(id: string): string | undefined; reset(id: string): void; remove(id: string): void };
   const ts = () => (window as unknown as { turnstile?: Turnstile }).turnstile;

   export function useTurnstile(siteKey: string) {
     const box = useRef<HTMLDivElement>(null);
     const id = useRef<string | null>(null);
     useEffect(() => {
       const start = () => { if (!id.current && box.current && ts()) id.current = ts()!.render(box.current, { sitekey: siteKey }); return !!id.current; };
       const wait = start() ? undefined : setInterval(() => start() && clearInterval(wait), 200);
       return () => { clearInterval(wait); if (id.current) ts()?.remove(id.current); id.current = null; };
     }, [siteKey]);
     return { box, token: () => (id.current && ts()?.getResponse(id.current)) || "", reset: () => { if (id.current) ts()?.reset(id.current); } };
   }
   ```
4. **A list** reads `readList` once on load and shows a quiet empty state while it is empty or loading. Records are newest first, the newest 500. The file updates a few seconds after each change.
5. **Show what visitors sent as text, always.** Every site shares one address, so one unsafe page would let a stranger's code run beside every other site:
   - Put values in as React text (`{record.data.name}`) or `textContent`, never `dangerouslySetInnerHTML`, `innerHTML` or `document.write`.
   - A value used as a link is checked first: `new URL(value)` inside `try`, and used only when its `protocol` is `http:` or `https:`. A `javascript:` link runs code when clicked.
   - Never put a visitor's text in a `style`, a `<script>`, or an `onclick`-style attribute.
   - Lists everyone sees never show emails or phone numbers.
6. **In chat later:** "who signed up today?" is `Icarus:list_site_records` with the collection and `since` set to the day's start. "Add a testimonial", "mark Acme won", "remove the red bag": `save_site_records` (an `id` changes that record, only the fields sent) or `delete_site_records`. "Show the new reviews": `list_site_records` with `pending: true`, then `show_site_records` with the ids they pick. No rebuild needed: the site reads the new list on its own. `list_sites` names each site's collections.

The headless check in step 5 has no form address to post to, no `_data/` file and no bot check (it runs only on the live address): check the form's error state and the list's empty state there, then check the live link once after publishing.

## Private sites

For a team's CRM or dashboard, a client preview or a page before launch, password-protect it with `Icarus:set_site_password` (the site's `siteId` and a `password`). Visitors get a page that asks for the password, and the site opens for 7 days on that device. Its forms and lists keep working, and its list files are private too. `password: null` makes it public again; a new password signs everyone out.

- Use the password the person gives, or pick one of 15 characters or more made of a few words (`blue-river-summer`).
- Never put the password in the build, a page or a list: everything in the build is sent to every visitor.
- Tell the person the link (`url`, which is now the password page) and the password once, in that reply.
- Publishing a new version keeps the site private.

### The team edits a private CRM

On a private site, everyone who opened it with the password is the team. The signed link they landed on is their proof: the page sends its own `location.pathname` as `_unlock`, and our server checks it. No login, no key in the build.

- Set the collection up as a form and a list: `publicWrite: true`, `publicRead: true`, `fields` for everything the team types (`["name", "company", "stage", "notes"]`) and `showFields` for what the team sees. The list file is served only behind the password, so here it may show the team's own fields; a public site's lists still never show emails or phone numbers.
- A post that carries `_unlock` shows at once; one without it waits for approval like a stranger's.
- Change or delete an entry with `PATCH` or `DELETE` on `formUrl` plus `/` and the record's `id` (from `readList`). A change sends only the fields that changed; the others stay. Only the private host may send these, and a public site refuses them.
  ```ts
  // In src/lib/site-data.ts, beside sendForm. Every call carries the signed link the page was opened with.
  const proof = () => ({ _unlock: location.pathname });
  export const sendTeamForm = (formUrl: string, answers: Record<string, string>) => sendForm(formUrl, { ...answers, ...proof() });
  async function teamCall(method: "PATCH" | "DELETE", url: string, body: object): Promise<boolean> {
    const res = await fetch(url, { method, headers: { "Content-Type": "text/plain" }, body: JSON.stringify(body) }).catch(() => null);
    return res?.ok === true;
  }
  export const changeEntry = (formUrl: string, id: string, fields: Record<string, string>) => teamCall("PATCH", `${formUrl}/${encodeURIComponent(id)}`, { ...fields, ...proof() });
  export const deleteEntry = (formUrl: string, id: string) => teamCall("DELETE", `${formUrl}/${encodeURIComponent(id)}`, proof());
  ```
- On `true`, change or remove the row on screen right away; the list file catches up within seconds. On `false`, put the row back and say "That didn't save. Try again in a moment." A `403` means the link expired or the password changed: ask them to open the site with the password again. One address sends 5 posts and changes a minute at most, so a delete-many button deletes one at a time with a short pause.
- Ask before a delete, in the page's own design (never `window.confirm`): a deleted entry is gone for good.

## Examples

<example>
Request: "Make a landing page for our Weekender bag. Use the studio shots we made last week."
Do: `Icarus:list_products` finds "Weekender bag"; `Icarus:list_generations` finds last week's three studio shots. Plan one page: hero with the best shot, three detail sections, a buy button to the link they give. Ask for the buy link if they haven't given one. Build with `base: "./"`, check at both widths from `/tmp/check/site/v1/`, list `dist`, call `upload_site` with `slug: "weekender"` and `title: "Weekender"`, send the files, `publish_site`, and reply with the link.
</example>

<example>
Request: "We need a site for the brand: home, shop with all our saved products, about, and contact. Put the campaign video on the home page."
Do: `Icarus:list_products` for the shop grid (each card uses the product's own picture link), `Icarus:list_generations` for the campaign video's link, `Icarus:show_taste` for the look. Four hash routes. The video plays from its Icarus link, muted and looped behind the hero. With 140 files, `upload_site` returns 1 page of links; at 230 it returns 2, so call it again with `page: 2` for the rest.
</example>

<example>
Request: "Change the headline on our website to 'Made to travel'."
Do: `Icarus:list_sites`. One site: edit the headline in the project, build, check, and publish with its `siteId` and a new `versionKey`; the link stays the same. Two sites ("weekender" and "brand"): ask which one before changing either.
</example>

## Common mistakes

| Mistake | What happens | Fix |
|---|---|---|
| Vite's default `base: "/"` | Every script and style 404s live; the page is blank | `base: "./"`, and check from the two-level folder in step 5 |
| A browser router (`/shop`) | The page works when clicked to, then 404s on refresh or when shared | `createHashRouter` (`#/shop`) |
| A new slug for an edit | A second site at a second link; the old link keeps the old site | Publish with the site's `siteId` |
| Reusing a versionKey for a changed build | Refused as different files | A new key for each build; the same key only for retries of that build |
| Changing or dropping a header on the PUT | 403 from storage | Send exactly the `headers` `upload_site` returned |
| Copying every Icarus picture into `public/` | A heavy site, and files over 25 MB are refused | Use the Icarus links in the page |
| Publishing without the headless check, even the first time | A broken phone layout or a missing image goes live | Step 5 on every page at 390, 768 and 1280 px, then publish; if the browser won't run, tell the person |
| A desktop layout squeezed onto a phone | Text runs off the screen, buttons too small to tap | Phone first: fluid widths, 16 px gutters, 44 px tap targets |
| Contacts, leads, orders or tasks kept in `localStorage`, `sessionStorage`, IndexedDB or cookies ("your data stays in this browser") | Other sites at the same address can read it; it lives on one device and goes when the browser is cleared | A collection with `set_site_collection` and the record tools, decided in step 1 |
| A token or key saved in browser storage | Any other site at the same address can read it | Never store one; site data needs none |
| A service worker or offline mode (`vite-plugin-pwa`) | The browser refuses it; the build may fail to load its worker | Leave it out |
| A list's records written into the build, or a key put in it to reach data | The list goes stale and any key is public | `readList` from `_data/`, and edit the list with `save_site_records` |
| A form's answers collection made `publicRead`, or an email in its `showFields` | Everyone can read every email sent | A waitlist or contact form is `publicWrite` only; a shown form names safe `showFields` |
| A visitor's value put in with `dangerouslySetInnerHTML`, or as an unchecked `href` | A stranger's code runs on the site, next to every other site at the same address | React text, and links only after `new URL()` says `http:` or `https:` |
| A form left without the Turnstile widget when `turnstileSiteKey` came back | Every answer is refused | The widget in the form, its token as `_turnstile`, `reset()` after each send |
| A password checked in the site's own code to hide a private site | Anyone can read the password, and the pages, in the files | `set_site_password`, which the server checks |
| A login page, a password box, or a card or code field, even as a demo | The site is taken offline at its first check | Never ask for them; `set_site_password` for a private page |
| A picture, font, script or embed from another website | It doesn't load live; a form or `fetch` to another website takes the site offline | Icarus links for pictures and videos, Google Fonts for fonts, everything else built into the site |
| Reading `localStorage` or `sessionStorage` for a view choice (a theme switch) without `try`/`catch` | The site goes blank in the Icarus chat preview, which runs it with no storage, and in some private windows | Wrap each read and write in `try`/`catch` and fall back to a default |

---

Page: https://withicarus.com/skills/website
Download: https://icarus.b-cdn.net/skills/website/v1-fdd7c5c3/website.zip
This skill makes photos and videos with Icarus. Connect Icarus to your AI app: https://withicarus.com/mcp
