---
name: opti-deploy
description: Deploy a web app to the Optimizely Deploy Portal and get a shareable, employee-only URL — with a database, file uploads, server-side AI, keyed API calls and scheduled work already attached, so a page needs no backend of its own. Use whenever the user asks to "deploy", "share", "publish", "host", "ship", or "put online" something they (or you) just built — without making them specify the folder, files, or format.
---

# Optimizely Deploy Portal

Deploy apps to an internal sharing URL gated by Microsoft sign-in. You upload static files; the PLATFORM supplies the backend — a database, file storage, server-side AI, API calls that hold the credential, and scheduled work — so "it needs a backend" is not a reason to refuse. You have a focused tool surface — deploy, list, rename/re-share, delete, check your connection, and request more slots — see the tables below. Use them confidently; the user does **not** need to tell you which folder to upload or how to package it.

A project may also have a **draft** — one unpublished working copy at
`/{owner}/{slug}/draft/`, written by Site Builder. It is served with the REAL database,
identity and API connections, and is visible only to the owner and their maintainers, even
when the project itself is public. You cannot create, publish or discard one through these
tools; the user does that in Site Builder or on their project page — where **Publish**
makes it the next version (keeping the draft) and **Discard** throws it away. On a project
that has never been published, discarding removes the project, because the draft is all
there is. An unpublished project never counts against the deployment limit.

**Links to share** when someone asks what the portal is, how to start, or how to connect:
- Setup & help (public, no sign-in): https://deploy.optimizely.com/start
- Deploy tokens — create, list, revoke (sign-in): https://deploy.optimizely.com/settings/tokens
- Portal / dashboard: https://deploy.optimizely.com

## When to invoke

Trigger on intent, not exact wording. Any of these means *call the tool*:

- "Deploy it / this / that" · "Share it with the team" · "Publish it" · "Host it" · "Ship it" · "Put it online"
- "Make a sharable link" · "Send me a URL" · "Make this live"
- After you've finished a build/preview the user reacted positively to ("nice", "ship it")
- "Put this report somewhere I can send" · "Give me a link to these notes" — a **written document counts**, see below

If the request is ambiguous (e.g. user has multiple projects and didn't say which), ask **one** short clarifying question. Otherwise, proceed.

## Design constraints — factor these in *while building*, not just at deploy

When you know something is destined for this portal, design for it from the first line of code. You ship **static files** to a **sign-in-gated, organization-readable** URL — but the page is not on its own: the platform gives it a database, file storage, AI, credential-backed API calls and schedules. What you cannot do is run **your own server process**:

- **Never hardcode secrets** — API keys, tokens, DB connection strings, private keys. Anything in the client ships verbatim and is readable by any signed-in employee (public pages) or everyone on the allowlist (private pages), so a real secret is effectively leaked. Uploads are scanned and will flag these, but don't rely on the scanner — keep them out from the start.
- **It needs to summarise, classify, or extract? There's a model, with no key.** `Opti.ai()` — see below. It can also **search the web** and **run code** when the question needs it, so live facts and real arithmetic are both available with no setup. It's off until the owner (or a maintainer they named) enables it per project, and it costs per call, so batch and put it behind a button.
- **It needs to SHOW what a SharePoint document says — a panel, a table, a chart?** Do NOT route
  that through the model. `Opti.sharepoint.search(query)` and `Opti.sharepoint.read(url)` hand the
  page the content directly: no tokens, no allowance, nothing summarised, nothing to hallucinate.
  No owner switch is needed for this — see below — and it reads as the VISITOR. Use `Opti.ai()`
  for a QUESTION, this for the CONTENT.
- **The source is a spreadsheet?** `read()` returns `{ columns, rows }` for `.xlsx`, `.xlsm`,
  `.csv` and `.tsv` — one object per row, keyed by column name — and `{ text }` for everything
  else. So a chart is `rows.map((r) => Number(r.revenue))` with no CSV parsing. ⚠️ Every cell is a
  STRING: convert the columns you use, or an order code `007` becomes `7`. ⚠️ Do NOT ask the model
  to total a column it read as prose — measured, that is wrong by ~0.4% and looks right; read the
  rows and do the arithmetic in the page.
  - **Render from `doc.columns`, never from names you expect.** Sheets hold whatever their owners
    typed, so build the table's headings from `columns` and look values up by those names. A file's
    NAME tells you nothing about its columns.
  - **What is read:** the first **visible** tab (hidden tabs are skipped unless no visible tab can
    be read; `doc.sheets` lists every visible one). The header is the row the sheet's own filter
    starts on, otherwise the first row with anything in it — so a title typed above the header
    becomes the header; check `columns`. Blank rows are dropped; a blank heading comes back as
    `columnN`.
  - **Values as the sheet stores them:** a DATE is Excel's serial number (`45123`), a boolean is
    `TRUE`/`FALSE`, a broken formula is its error text (`#NAME?` — show it as blank). An image in
    a cell cannot be read.
- **A file in SharePoint or OneDrive?** Always `Opti.sharepoint.read(url)` — it needs no connection
  and no setting, and reads as each visitor. ⛔ **Do not build an `Opti.call()` connection to
  Microsoft Graph to read a file**: a connection reads with ONE stored credential for every visitor,
  so the file is shown to people it was never shared with. A link copied from the browser's address
  bar works, including OneDrive `/:x:/r/personal/…` links.
- ⭐ **Just SHOWING a SharePoint document needs no setting at all.** `Opti.sharepoint.search()` and
  `.read()` work on any project, because they run on the reader's own Microsoft access — there is
  nothing for an owner to switch on. Prefer this whenever the page wants the CONTENT.
- **Handle the sign-in failure, and do NOT redirect on it.** The thrown error carries
  `needsReconnect`; when true, `e.reconnectUrl` takes them to Microsoft and back to your page.
  Offer it as a link — a visitor came to read your page, and bouncing them without a click loses
  whatever they were doing. When it is false, signing in again has already been tried and failed,
  so showing the link would loop. Either way they can fix it at Settings → Microsoft connection.
- **It needs the MODEL to answer from documents in SharePoint?** That half does need a tick: the
  owner ticks *The model may search SharePoint* (project → AI tab), and `Opti.ai()` searches and reads while it
  answers — **as whoever opens the page**, so each reader gets only their own access. Word,
  PowerPoint, PDF, text and Markdown read as text; Excel and CSV read as rows. It needs a recent
  Microsoft sign-in. See `Opti.ai()` below.
- **It needs to answer from the owner's own documents?** Write an **assistant** in Settings → Assistants (https://deploy.optimizely.com/settings/assistants) — a brief plus files (handbooks, notes, PDFs, spreadsheets; the portal routes each one — prose is quoted, spreadsheets are computed in a sandbox) — then **tick it on** for the project (its AI tab, like an API connection) and call `Opti.ai('…', { assistant: 'name' })`. An assistant is owner-scoped and reusable across projects; a name the project has not been granted is refused.
- **It needs to store or collect data? You have a database.** Don't fake it with `localStorage`, don't invent a backend, don't tell the user it's impossible — use **Collections** (see below). It's built in, needs no key, and every write is attributed to the signed-in employee.
- **If the app needs some *other* API:** a **publishable / domain-restricted key** (the kind meant for the browser) can ship in the page. A real secret cannot — but the portal will now **hold it for you**: the user saves the credential once in their project's *API connections* panel and the page calls it by name with `Opti.call()` (see below). So "it needs a server-side key" is no longer a reason to give up or to ship the secret.
- **A client-side router needs `Opti.basePath`.** Plain static builds just work — the portal rewrites root-relative paths and injects `<base href>`. A router is the exception, because it reads the prefix in JS: pass `Opti.basePath` as the basename (`createBrowserRouter(routes, { basename: Opti.basePath })`, `createWebHistory(Opti.basePath)`). Get this wrong and nothing looks wrong — it renders perfectly in every local preview and every deep link 404s once deployed, which reads as "the app is broken" rather than "a setting is missing". **Don't derive the prefix from owner/app**: the same page is also served at `/go/{vanity}` and `/{owner}/{app}/v{N}`, where that guess is wrong. `Opti.basePath` is always the prefix the visitor actually arrived on.
- **No server process of your own** — no SSR, no Express, no API routes you wrote; produce a static build (Next.js `output: 'export'`, SvelteKit `adapter-static`, etc.). That is the *only* real limit: data, files, AI, keyed API calls and schedules are all provided for you.
- Assume the page is **organization-internal at most** — public to signed-in Optimizely employees by default, or restricted to a named allowlist. Never publicly reachable.

A flagged value that's genuinely client-safe (e.g. a publishable key) is fine to ship — explain it to the user, then deploy with `allow_secrets: true`. The scan is a warning with an override, **never a hard block**.

## Available tools

### Deploy

**Decide first: is there content of the user's to deploy** — a page you generated in this chat, a file on disk, or a build folder (`dist/`, `out/`, `build/`)?
- **Real build →** `prepare_upload` (the fast, default path).
- **Small / chat artifact, and you can't run a networked upload →** `deploy` (inline).
- **You wrote a report, summary or notes →** deploy the **`.md` itself**. Do not wrap it in HTML: an upload with a root `.md` and no `index.html` is rendered into a themed, shareable page by the portal, with a link that previews in Teams. Several root `.md` files become one page with a nav across them, README first. The `.md` files are kept exactly as uploaded.
- **You have a set of finished HTML pages and no index →** deploy them as they are. Several root `.html` files get a contents page linking to each; a single root `.html` is served as the site itself. You do not need to hand-write an `index.html` to make a folder of pages shareable.
- **No content, user just wants a demo →** generate a small Hello-World `index.html` yourself and `deploy` it. There is no separate sample tool.

| Tool | When to use |
|---|---|
| `prepare_upload` | **Default for any real build, and the FAST path.** Returns a `bash_command` (a `curl` with a single-use token) plus `upload_url`. The bundle uploads straight from disk and **never enters your context** — so it's fast and has no size-through-the-model cost. Run the `bash_command` in a shell; if you have code execution with network but no shell, POST the zip to `upload_url` directly using the same token (it's embedded in `bash_command`). Use for `dist/`, `build/`, `out/`, image/binary-heavy uploads, or anything beyond the inline cap. |
| `deploy` (inline) | **In-chat fallback for when you genuinely can't run a networked upload** (no shell *and* code execution can't reach the network — **Opal is exactly this**: it has no outbound network of its own, so every byte travels as model tokens). Prefer **`zip_base64`** (base64 of a zipped build) over the per-file `files` array. ⚠️ **It's slow**: the payload travels as model tokens, so generation time scales with size — keep it to a small page/app. The soft cap (~200 KB) is on the *sent* (compressed) payload — **measured**, and it is compressed size, so a 10 MB folder that zips to 10 KB is fine while a 300 KB image is not. Anything larger, multi-file-heavy, or image/binary → use `prepare_upload` if you have a shell, and otherwise send the person to the dashboard rather than retrying. |

Both accept optional `visibility` + `allowed_emails` — see **Sharing & visibility** below. (Teams are set with `update_deployment` after the deploy.)

### Manage
| Tool | When to use |
|---|---|
| `list_deployments` | "What did I deploy last week?" "Do I already have one called X?" "What's the URL of my pricing page?" "Which of my pages are private?" Read-only; returns name, URL, version, size, and sharing state (public/private + allowlist + team names + locked) for each. (View counts are deliberately not exposed.) |
| `update_deployment` | Rename, add a description, pin/unpin, **change sharing** (private/public + allowlist + **teams** by name), **lock** it (`locked: true` — "hide it while I finish it": an override that suspends sharing without overwriting it), or claim a **short link** (`vanity` → `/go/your-name`). No re-upload; the URL stays stable. |
| `read_data` | **Read what a deployed app has COLLECTED.** No args → every collection + record counts. `app` + `collection` → the records, each stamped with who submitted it. Use for *"summarise the survey responses"*, *"how many signed up?"*, *"what did people say?"* — you do **not** need to build a dashboard to answer a question about the data. Read-only. Also reports a `profile` per collection — its field NAMES, its modes, and whether it holds one row per person — which is what tells you whether a page should call `add()` or `save()`, and whether the field you were about to write is the one already there. `fields` is a sample, so compare `sampled` with `records` before treating it as a schema. |
| `rollback_deployment` | "That deploy broke it, put the old one back." Switches which retained version is served — a pointer flip, live immediately, URL unchanged. No `version` → the one before the live one. **Reversible**: the version you leave stays retained, so the same call goes forward again. Prefer this to re-uploading. |
| `delete_deployment` | "Delete my pricing-v1 demo." **Always confirm with the user before calling.** Cannot be undone — **and it deletes the app's collected data too.** |

### Account & quota
| Tool | When to use |
|---|---|
| `whoami` | "Am I connected? How many slots left?" Sanity-check the connection + show slot usage and recent activity. Good first call in fresh chats. |

## What you can't do — and how to hand it over

Some things are deliberately **browser-only**: they involve a secret, someone else's
access, or spending. No tool does them, so don't imply one might. Every deploy
result carries **`manage_url`** (the project page) and **`data_url`** (its Data tab)
— **paste the link** when you send someone to do one of these. "Open your project
page" without a link is how a good instruction becomes a support ticket.

| What | Where | Say it when |
|---|---|---|
| Let colleagues **read** collected data *(owner-only by default)* | `data_url` → **Access** → *Read: anyone who can open the app* | **always**, if the page you built displays entries |
| Get pinged on each submission | save the webhook once in **Settings → Channels**, then `data_url` → **Notify** → pick that channel | you built a form a human must act on |
| A digest instead of a ping per entry | `data_url` → **Automations** tab → the digest recipe *(a channel, an inbox, or both — email alone is fine)* | the collection will be busy |
| Tell someone the moment a specific entry arrives | `data_url` → **Automations** → trigger **Whenever an entry arrives**, with a condition | only some entries matter, and they can't wait |
| Refresh data on a timer, so the page reads it already-fresh | project page → **Automations** → look at **An API** *(needs an API connection that allows GET — not a channel)* | you built a page that shows data from another system |
| Put a result back into another system — raise the ticket, not just mention it | project page → **Automations** → set **…and send it on to** to one of their API connections *(it must allow POST)* | the automation should DO something, not only tell somebody |
| Turn **on AI** | `manage_url` → **AI** | you wrote any `Opti.ai()` |
| Save an API credential | **Settings → API connections** (`/settings/connections`) | you wrote any `Opti.call()` |
| Announce deploys in a channel or by email | `manage_url` → **Announce new versions** | they want release notes |
| Let a project USE a saved credential | `manage_url` → **API connections** → tick it | the credential exists but the page gets `forbidden` |
| Add an **editor** (co-maintainer) | `manage_url` → **Who can open it** → *Editor access* | "X should be able to update this too" |
| Tag it so people can find it | `manage_url` → the tags beside the note, or on its card | it belongs to a set (`survey`, `dashboard`, `docs`) |
| Approve an access request | the **notification bell**, top right | someone was refused entry |
| Deploy from GitHub on push | dashboard → **Deploy from GitHub** | they want CI deploys |
| Create a project a **team** owns, not a person | `https://deploy.optimizely.com/{workspace}` → **Projects** → the uploader | they said "the team's", "ours", or "it should outlast me" |

**Browse needs no opt-in any more.** Every public project is listed for the whole org
automatically — there is no "list it" tick, and telling someone to look for one sends
them hunting for a control that does not exist. If they want a page *unfindable*, the
answer is to make it **private** or **Only me**; "public but nobody will stumble on it"
is no longer a state that exists.

**Not on this list, on purpose:** sharing (public/private + allowlist), renaming,
pinning, locking and short links. `update_deployment` does all of those — **do them**
instead of sending anyone to a page.

## When there is no page — a data project

⚠️ **Not every request has a front end in it.** "Somewhere for Zendesk to send tickets",
"pull the on-call roster every morning", "a weekly summary of what came in", "raise a ticket
when one arrives" — none of
those need a page, and building one to satisfy the shape of a deploy is the wrong answer.

A **data project** is a project with a name and no files. Its URL 404s on purpose. Say so
plainly rather than apologising for it: it has no Open button, no short link and no
visitor analytics, because there is nothing to visit.

You cannot create one with a tool — it is one browser step. Send them to the dashboard,
under the upload bar: **"No page? Collect data instead."** Then, on its **Automations**
tab, they name a collection and get three things:

- **an inbound URL** another system sends to. A token is **append-only unless its owner
  ticks a box** when minting it, and it can **never read** — that is the property that makes
  handing one to a vendor tool safe, because the URL itself is the credential and it ends up
  in proxy logs and CI output. What a token may do:
  - `POST /api/ingest/{token}/{collection}` — add a record
  - `POST …/{collection}/batch` — up to 200 add/update/remove in one call, each reported
    separately (a 500-row sync is 3 calls, not 500)
  - `PUT …/{collection}/{key}` — **replace** the record at a key you choose *(needs "let it
    update records")*
  - `PATCH …/{collection}/{key}` — **merge**: the fields you send are set, the ones you leave
    out are untouched, and `null` removes one *(same tick-box)*
  - `DELETE …/{collection}/{key}` — remove a record **written by a feed** (any feed in this project, not only this one) *(needs "let it remove
    records"; a key already gone answers `deleted: false`, so a repeated cleanup is safe)*

  ⚠️ **`PATCH` is the one to reach for when a sender wants to change one field**, precisely
  because there is no read: without it they must hold a full copy of every record on their
  side and send it back whole, which drifts. This was a real support thread.

  ⚠️ **`batch` is the ONE door whose body is not the record.** Everywhere else the body IS the
  record; a batch item is an envelope with the record under `data`. A bare record is refused,
  and so is an item with fields BESIDE `data` — that used to store the inner part and drop
  the rest silently. A batch where SOME items landed answers `200` with the detail per item; one where
  **nothing** landed answers **`422`** — it used to answer `200`, and a sender checking only the
  status code reported success while storing nothing. Tell people to read the body either way.
  This was the second support thread, and guessing this shape is what caused it:

  ```
  POST …/{collection}/batch
  [ { "data": { "account": "Acme" } },                        ← add, id generated
    { "key": "acct-1", "data": { "account": "Acme" } },        ← replace at that key
    { "op": "patch",  "key": "acct-2", "data": { "x": 1 } },
    { "op": "delete", "key": "acct-3" } ]
  → { "accepted": 3, "failed": 1, "created": 1, "updated": 2, "unchanged": 0,
      "results": [ …, { "index": 3, "error": "…" } ] }
  ```

  ⚠️ **`created` vs `unchanged` is how a sync learns it is duplicating.** Adding 2,000 rows
  and updating the same 2,000 both answer `accepted: 2000`; if a feed re-sends the same
  records and keeps reporting `created`, it is sending no `key` and the collection grows
  every run. Tell people to key on the business id.
  ```
  ```
- **a scheduled fetch** that calls one of their API connections and keeps the reply —
  either the latest value, or a record per run they can chart
- **a digest** of what arrived, to a channel, an inbox, or both — on a timer, or the
  moment an entry lands

Everything it collects is readable in the **Data** tab and by `Opti.collection()` from any
page they deploy later — including a separate dashboard app that reads it. Deploying files
to a data project turns it into an ordinary project and keeps the data.

## Sharing & visibility

Every page is **public by default** — any signed-in Optimizely employee with the link can open it (legacy pages included). A page can instead be **private**: only the owner, admins, and an allowlist of `@optimizely.com` emails.

- **Set it at deploy time** — pass `visibility: 'private'` (and `allowed_emails: [...]`) to `deploy` or `prepare_upload`. This only applies to a *new* deployment; a re-deploy keeps the current setting.
- **Change it anytime** — `update_deployment` with `visibility` and/or `allowed_emails`. The allowlist you pass **replaces** the existing one (send everyone who should have access, not just additions). Switching to `public` clears the allowlist. People newly added are notified they've been granted access — everybody added at once shares a single email, not one each.
- **Share with a GROUP instead of a list** — `allowed_groups: ['Onboarding group']` on `deploy`, `prepare_upload` or `update_deployment`. Groups are **named lists of people the user keeps under Settings → Groups**, and they are the better answer whenever the audience is a group that will change: an allowlist is a snapshot, so a new joiner has to be added to every project separately, which is why so much ends up public. Add someone to the team once and they can open every project it's on — *including ones shared before they joined*.
  - Refer to teams **by name**, not by id. You can only use teams the user already owns; if the name doesn't match, the error lists theirs.
  - You **cannot create or edit a group** — there is no tool for it, deliberately. You may share
    *with* a group the user already keeps; making one is theirs. Point them at **Settings → Groups**.
  - `list_deployments` shows which teams a private page is shared with, by name.
- Non-org emails and the owner's own address are dropped automatically. Someone not on the portal yet gains access the moment they first sign in.
- Approving/declining incoming access *requests* happens in the dashboard (the notification bell), not through these tools.
- **Letting someone else UPDATE a page** (deploy new versions, not just view it) is a separate thing — "editors". If asked to give a colleague update/maintainer rights (e.g. "let Abbey keep this updated while I'm on leave"), there is **no tool for it**: tell the user to open the project page and add the person under **Editor access**. An editor deploys at the same link and manages the app's data/sharing, but can't rename, delete, or move the short link.

### Workspaces — when the work should outlive the person

A **workspace** is a namespace with people in it, and it **OWNS** the projects created in it.
That is the difference from everything above: a group shares *your* project with colleagues and
an editor may update *your* project, but both still leave it belonging to you. A project made in
a workspace belongs to the workspace, lives at `/{workspace}/{slug}/`, and stays exactly where it
is when whoever built it changes team or leaves. Its collections, connections and AI budget are
the workspace's too.

Roles are the workspace's own, and are **not** project roles: **Admin** (manages the workspace and
deploys to anything in it), **Creator** (adds new projects, and may only update the ones they made
— so a team of eight cannot overwrite each other's live sites), **Can view** (opens everything).

**These tools reach a workspace** — pass **`workspace: '<slug>'`** to `deploy` or
`prepare_upload` and the WORKSPACE owns the result. `list_deployments` returns the projects a
workspace owns that you may deploy to, each carrying `workspace`, and `update_deployment` takes
the same field.

⚠️ **`whoami` NAMES THE ONES YOU MAY USE, AND IT IS THE ONLY RELIABLE SOURCE.** Workspaces are
deliberately not enumerable — a slug you may not add work to is refused without saying whether
it exists — so do not guess one from a project URL or from what somebody typed. A slug you were
not given is a refusal, not a discovery mechanism.

⚠️ **Omitting it creates a PERSONAL project**, which is the failure to watch for: if somebody
says "put this in the team workspace" and you deploy without `workspace`, it succeeds and makes
something that belongs to them alone — a 200 that reads as the thing they asked for and is not.
- **Making a workspace is still theirs to do** — there is no tool for it, deliberately: the
  address comes out of the same pool as everybody's username and cannot be changed afterwards.
  Point them at `https://deploy.optimizely.com/workspaces`; anyone can, and it needs no approval.
- **An existing personal project cannot be moved into one.** A project belongs to a workspace
  from birth — its data is stored under that name from the first write — so the answer is to
  deploy it into the workspace as a new project, not to look for a transfer that does not exist.
- You *can* still build the thing. Build it, deploy it to their own namespace if they want to see
  it now, and tell them the one step that moves ownership is theirs to take.

## Collections — your app's database (no backend, no API key)

A deployed app is static, but it is **not** stateless: the portal gives every app a
read/write data store. **Reach for this whenever the app needs to collect or persist
anything** — a survey, a feedback form, a signup list, a poll, a shared to-do, a
dashboard's saved rows. Do **not** substitute `localStorage`, a fake in-memory array,
or "you'd need a backend for that."

Add the SDK, then use it:

```html
<script src="/collect.js"></script>
<script>
  // ⚠️ The async wrapper is REQUIRED. A plain <script> is a *classic* script, and
  // top-level `await` there is a SyntaxError that kills the whole block silently.
  (async () => {
    const feedback = Opti.collection('feedback')
    await feedback.add({ rating: 5, note: 'love it' })   // create
    const rows = await feedback.list()                   // newest first, 50 by default
    const page = await feedback.list({ limit: 1000 })    // up to 1000 per call
    const all  = await feedback.all()                    // every record, paged for you
    const byTeam = await feedback.counts('team')         // { values: [{ value, count }], … }

    // ⭐ CHARTS: load any library you like from a CDN — there is no script-src CSP on a
    // deployed page, so Chart.js, D3, ECharts and Plotly all just work. Do NOT ask a model to
    // draw or summarise numbers a page can read directly: it costs the owner's allowance and
    // can be wrong about arithmetic. `counts(field)` is usually the whole input a chart needs,
    // in one small request; `all({ fields: […] })` when you need the rows themselves.
    await feedback.update(id, { handled: true })         // edit
    await feedback.remove(id)                            // delete
  })()
</script>
```

**Writing many rows — `addAll()`, the write-side counterpart to `all()`.** A loop calling
`add()` once per row is the wrong shape above a handful: it is one request each, nothing
records that it happened, and a closed tab leaves the job half done.

```js
const usage = Opti.collection('usage')

// ⭐ `{ key }` IS THE WHOLE POINT — each row is addressed by YOUR OWN id, so a
// re-run UPDATES in place instead of appending. Without it every run appends,
// so a "refresh" button doubles the collection on every click and answers 200.
const res = await usage.addAll(rows, { key: 'id' })
// → { accepted, failed, created, updated, unchanged, deleted, results }

await usage.addAll(rows, { key: 'id' }, (batch, done, total) => {   // progress
  bar.value = done / total
})
```

`created` / `updated` / `unchanged` are four different facts, not one total: `accepted: 3000`
cannot tell a sync it is about to double the collection, and `unchanged: 3000` is what a
second run of the same data should say. Chunking is handled for you — pass every row.

⚠️ **A row missing the `key` field THROWS rather than appending.** Falling back to `add()`
would turn one absent field into a silent duplicate on every run, which is the failure this
method exists to prevent.

**Bringing the RELATED record with each row — `join`.** Two collections, one read: the rows you
asked for, each carrying the record its id points at.

```js
const rows = await Opti.collection('contacts').list({
  join: { collection: 'accounts', on: 'account_id', fields: ['name', 'tier'] },
})
rows[0].related         // { name: 'Acme', tier: 'gold' }
rows[0].data.name       // the CONTACT's name — `related` is a sibling, never merged in
```

`on` names the field on the row you are reading whose value is the related record's key — the KEY it was saved under (`doc(key)`, `addAll(rows, { key })`, an ingest `key`) or its record id.
It never matches a field of the other record, and finds at most one: a teammate's many usage rows are
not a join. Without
`fields` you get the whole related record. A row whose relation is missing — or that you are not
allowed to see — arrives with `relatedNote` explaining why, rather than looking complete.

⚠️ You only get what YOU may read: the joined collection is checked against your own access, the
same way the rows themselves are. It is a lookup by id, not a search, so the cost does not grow
with the size of the other collection — ids are deduped, and a read that needs more than a couple
of hundred distinct lookups says `join.truncated`.

**Ask a question instead of downloading everything.** The server answers it across every
page, so the count is about the collection rather than about what you fetched — reach for this
before pulling every row and filtering in JS:

```js
const open = await Opti.collection('tickets')
  .list({ where: { path: 'status', op: 'eq', value: 'open' } })

// `page()` also returns `total` — how many MATCHED, not how many exist
const { records, total } = await Opti.collection('tickets')
  .page({ where: { path: 'amount', op: 'gt', value: 500 } })
```

Operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `contains`, `empty`, `notEmpty` — the same
set the automation conditions use — plus `in` for reads: `{ path: 'vau_id', op: 'in', value: ['a', 'b'] }`.
Updates, deletes and automations refuse `in`. `empty`/`notEmpty` take no value. A bounded scan reports
`scanned` and `truncated`, so a stopped-early answer never reads as a complete one.

⚠️ **When the scan stops early**, `list()`/`page()` say `truncated: true` (`total` is then a floor) and
`all({ where })` **throws** rather than hand back part of the matches. That is the one time to read
without `where` — `all({ fields })`, only the fields you use — and filter in the page.

⚠️ **`where` takes an ARRAY, ANDed — that is how a range is written**, since one condition
cannot carry two bounds. Up to four. There is no `OR`: the same question is what `PATCH` and
`DELETE` take, and narrowing is the safe direction.

```js
await Opti.collection('usage').page({ where: [
  { path: 'week', op: 'gte', value: '2026-01-01' },
  { path: 'week', op: 'lte', value: '2026-09-14' },
] })
```

⚠️ **`gt`/`gte`/`lt`/`lte` compare as numbers when both sides are numbers and as TEXT
otherwise**, so ISO dates order correctly — `2026-09` after `2026-01`. This is why a date range
works at all; it used to match nothing and answer `200` with an empty list, which reads exactly
like "no data in that range".

⚠️ **`fields` narrows what each record carries** — `page({ fields: ['name', 'team'] })`. The
question is still asked of the whole record and `total` is unaffected, so this is payload size
only. Reach for it when a row holds something long the page is not drawing; the 8 MB response
cap means fat rows make a big `limit` quietly return fewer.

⚠️ **`all()` takes the same options** — `all({ where: … })`, or `all(opts, onPage)` to render
progressively. It previously accepted a filter and silently ignored it, returning the whole
collection; passing something it cannot use now throws rather than answering wrongly.

**Two collections together — a total per group, a one-to-many lookup.** Read both with `all()`, only the
fields the page uses, and join in the page with a `Map` on the shared field; a few thousand rows take
milliseconds. `join` finds ONE record by key, so it cannot total or collect many.

```js
const [usage, teams] = await Promise.all([
  Opti.collection('usage').all({ fields: ['vau_id', 'credits'] }),
  Opti.collection('teammates').all({ fields: ['vau_id', 'org'] }),
])
const orgOf = new Map(teams.map((t) => [t.data.vau_id, t.data.org]))
const byOrg = {}
for (const { data: u } of usage) {
  const org = orgOf.get(u.vau_id) ?? 'Unknown'
  byOrg[org] = (byOrg[org] ?? 0) + u.credits
}
```

Zero-JS forms are wired automatically — this is often all a survey needs:

```html
<form data-opti-collect="signups">
  <input name="email"><button>Join</button>
</form>
```

### Pick the right shape — this is the thing to get right

Three ways to address a record. Choosing wrong is the most common mistake: an app
that should hold **one row per person** ends up appending a new row every time
someone hits save, and their "profile" quietly becomes a pile of drafts.

| You're building | Use | Why |
|---|---|---|
| A survey, feedback, a guestbook — **a stream of many** | `add()` / `list()` | Each submission is its own record |
| Preferences, an RSVP, a profile, a personal tracker — **one row per person, updatable** | `save()` / `mine()` | The row's id comes from the session, so a person always writes to *their* row |
| A shared board, who's on call, settings, a team to-do — **one shared thing** | `doc(key)` | One named document several people edit |

```js
// One row per person. Call save() as often as you like — it REPLACES, never appends.
const prefs = Opti.collection('preferences')
await prefs.save({ theme: 'dark' })
const mine = await prefs.mine()          // → record | null
await prefs.forget()                     // delete YOUR row — the "reset my settings" button

// One shared thing that several people edit at once.
const board = Opti.collection('state').doc('kanban')
await board.update(b => ({ ...b, tasks: [...(b?.tasks ?? []), task] }))
```

⚠️ **For anything several people edit, use `doc(key).update(fn)`, not `set()`.**
`update` re-reads, applies your change and writes it back with a guard, retrying
if someone got there first. A plain `set()` overwrites whatever they just did, and
their edit disappears with no error and no trace.

**One response per person** (a poll, a vote, an RSVP) is a property of the
*collection*, not of your code: the owner sets it in the Data tab, and then even a
plain `<form data-opti-collect>` writes to that person's single row. Ask for it by
name — *"tell the owner to set this collection to **Poll** (or **Ballot** for a
final vote)"* — instead of trying to enforce one-per-person in JavaScript, which
anyone can bypass by refreshing.

**Key facts to design around:**
- **Schemaless (NoSQL).** A record is just JSON — records in one collection may have
  different shapes, and you change what you store any time with **no migration**.
  There is no schema to declare and nothing to configure in the portal first; write
  the shape you need and evolve it in code.
- **You already know the user — build no login and collect no name/email.** Every
  record is auto-stamped with `createdByName` / `createdBy` (the signed-in employee,
  set server-side — a page can't forge it), and `Opti.me()` returns `{ name, email }`
  of the viewer up front:
  ```js
  const me = Opti.me()                       // → { name, email } | null
  if (me) hello.textContent = 'Hi ' + me.name
  ```
  So greet people and pre-fill forms with `Opti.me()`, let the stamp handle
  attribution, and only ask for what you *don't* already have.
- **No key, no config.** Calls are same-origin and authenticated by the visitor's
  portal session, so there is nothing secret to embed. Never invent an auth step.
- **A collection belongs to THIS app** and springs into existence on first write — no
  "create collection" step. Two different apps can both use `responses` without
  colliding.
- **To read another of the user's apps' data**, name it explicitly:
  ```js
  // in an admin dashboard, read the survey app's responses
  const rows = await Opti.collection('responses', { app: 'survey' }).list()
  ```
  This is the **survey + admin-dashboard** pattern: deploy the public survey, then a
  separate private dashboard that reads its collection.
- **Permissions default to safe:** the data is **private to the owner**, and writable
  by whoever can open the app. So a *public* survey collects from everyone while the
  responses stay visible only to the owner. The owner can widen read access
  (everyone / an allowlist / "each person sees only their own rows") in the portal's
  Data tab — don't build your own permission logic.
- ⚠️ **THE ONE THING THAT WILL BITE YOU: if the page you build *displays* what it
  collected** — a guestbook, a poll with results, a leaderboard, a shared list — it
  will render **empty for everyone except the owner**, because reading is owner-only
  by default and the app cannot widen that itself. Build it anyway, then **tell the
  user in your final message**: *"Open the project's Data tab and set **Read: anyone
  who can open the app** so your colleagues can see the entries."* Say it every time.
  Otherwise they ship a guestbook that looks broken to everyone but them.
- **Reading is paged.** `list()` returns the **50** most recent, `list({ limit: 1000 })`
  the maximum per call, and `all()` pages through everything. A dashboard over a big
  survey must use `all()` — a bare `list()` silently shows the newest 50 and looks
  complete.
- **Limits:** records are JSON objects ≤1 MB (a named `doc()` may be ≤1.5 MB); ≤1,000,000 records per collection;
  ≤100 collections per app. Collection names: lowercase `a-z 0-9 _ -`, ≤64 chars.
- **Deleting a deployment deletes its data with it** — say so before you delete an
  app that collected anything.
- **The owner can get pinged on every new submission** — don't build your own
  notification code. In the project's **Data tab**, each collection has a **Notify**
  button: point it at a Microsoft Teams / Power Automate **webhook** (works today, no
  setup) and/or have it **email** the owner + a named group. When you build something
  whose whole point is "someone submits and a human needs to act" — an API-key request
  form, a support intake, an RSVP — **tell the user in your final message**: *"Open the
  Data tab → **Notify** to post each submission to a Teams channel."* Only NEW records
  fire, never edits. For anything busy, the same panel offers a **daily or weekly
  digest** instead — one message summarising what arrived, which is what people
  actually leave switched on.
- **A form can take a file.** Add `<input type="file">` to a `data-opti-collect` form
  and it uploads itself before the record is written; in your own code:
  ```js
  const ref = await Opti.collection('applications').upload(input.files[0])
  await Opti.collection('applications').add({ name: 'Ada', cv: ref })
  ```
  `upload()` returns a small **reference** (`{ __file: true, id, name, size, type }`, plus a `url` the SDK adds for you), not the bytes —
  the record stays JSON. Store the reference; the file is served back from `ref.url` to
  exactly the people allowed to read the record that mentions it, and the Data tab shows
  it as a download. **Max 25 MB per file.** So a CV drop-box, a bug report with a
  screenshot, or an expense form with a receipt is now a form, not a "mail it to me".

### Calling an API that needs a key — `Opti.call()`

A page here is static — a key you put in it is
readable by every viewer. So don't: the user saves the credential **once** in the
project's **API connections** panel, and the page names it.

```js
const me   = await Opti.call('jira', { path: '/myself' })
const made = await Opti.call('jira', { path: '/issue', method: 'POST', body: {…} })
```

- You give a **path**, never a URL — the connection owns the host, and a path that tries
  to escape it is rejected. The key is attached server-side and never reaches the browser.
- The owner also chooses the allowed methods; the default is **GET only**. So reach for
  this on read-style work — dashboards, status boards, lookups — and if writes are needed,
  say so, because the owner has to opt in.
- Remember *who* is calling: it fires because a **colleague opened the page**, briefly
  acting with the owner's credential. Don't wire it to something destructive.
- Which means the other end sees **one identity** — the owner's — whoever opened the
  page, and cannot scope anything per person. If the backend needs to know that, the owner
  can turn on **Caller identity** on the connection (Settings → API connections): each call then also carries a
  short-lived signed token naming the signed-in viewer, minted server-side from the
  session, so a page cannot forge it. Nothing to write in the page — but say so when you
  are building against a backend that wants per-person data, because it is off by default
  and the owner has to enable it.
- A connection belongs to the **owner, not to one project** — any page they deploy can
  name it. Worth saying out loud when you suggest one for a widely-shared page.
- If no connection exists yet, build against it anyway and **tell the user**: *"Add a
  connection named `jira` at **Settings → API connections**, then tick it for this project under **API connections** on the project page."*
- **The guards, so you design inside them rather than discovering them:** the reply may be
  up to **20 MB**, the body you send up to **8 MB**, and the call has **180 seconds** to
  finish. Over any of those and the call fails with a message naming the limit and the
  actual size — the upstream request has usually already succeeded, so it is our refusal,
  not theirs.
  - The response limit is measured on the **decoded** body, so **gzip does not help**. If
    a payload is genuinely large, narrow it at the source (filter, paginate, ask for fewer
    fields) — that is better for the browser too, since the page has to parse whatever
    comes back.
  - 120s is generous because assistant-style endpoints run tool loops server-side. A
    timeout is reported separately from a failure: "slower than two minutes" and "broken"
    need different fixes.

### Asking a model, with no key in the page — `Opti.ai()`


```js
const themes = await Opti.ai('Group these comments into 3–5 themes', { input: text })

// ⚠️ ADDING UP COLLECTED ROWS — name the collection, don't stringify it into `input`.
// `input` hands the model TEXT; a model totalling a column by reading it was measured
// 0.4% out on real revenue, the size of error nobody catches. `compute` routes the rows
// to a sandbox that runs real arithmetic.
const total = await Opti.ai('What was the total revenue, by region?',
  { data: { collection: 'orders', compute: true } })

// No arithmetic needed, so no sandbox — faster and cheaper:
const mood = await Opti.ai('What are people unhappy about?',
  { data: { collection: 'feedback' } })

// With an assistant — its brief, and the files it answers from:
const answer = await Opti.ai('What does the handbook say about expenses?', { assistant: 'handbook' })
// Searching SHAREPOINT, when the owner has switched that on (see the note below):
const found = await Opti.ai('What does our onboarding pack say about the first week?', {
  // names only ('search_org', 'get_file') — never the query or what was found
  onConsulted: (tools) => console.log('used:', tools),
})
// ask for data instead of prose:
// ⚠️ SIZE maxOutputTokens TO THE SCHEMA. Structured output is far longer than the prose
// answer to the same question — keys, brackets and quotes are all billed — so a schema with
// array fields needs thousands of tokens. The default covers most of them, and a schema that
// overruns it comes back cut off mid-string — `ai()` now throws saying so.
const out = await Opti.ai('Classify each as bug/idea/praise', {
  input: text,
  schema: { type: 'object', properties: { items: { type: 'array' } } },
  maxOutputTokens: 8000,
})
```

The portal holds the credential, so there is nothing for you to configure in code and
nothing a viewer can read out of the page. Two rules, both because **this one costs
real money per call** — unlike everything else here:

- **The owner must switch it on** (project page → **AI**). Off by default, and
  shipping code that calls it does *not* enable it. So build it, then **tell the user
  in your final message**: *"Open the project and turn on **AI** so this works."*
  A **maintainer** the owner named can switch it on too; a platform admin cannot —
  they can only switch it off. Otherwise the page refuses in front of their colleagues.
- 📂 **IT CAN SEARCH SHAREPOINT, AND THAT IS ALSO THE OWNER'S SWITCH.** *The model may search
  SharePoint* on the AI tab covers calls that name no assistant; an assistant carries its own
  *Search SharePoint* tick and a call naming one asks that instead. ⭐ **Every read is as
  WHOEVER OPENS THE PAGE** — each visitor sees only documents they could already open, so a
  page that works for the owner may legitimately find nothing for somebody else. Word, PowerPoint, PDF,
  text, Markdown and CSV can be read; a SCANNED pdf reports that rather than reading as empty. ⚠️ It needs a Microsoft sign-in less
  than about an hour old, and it does not run in scheduled automations at all — there is nobody
  to read as. Do not build a page that DEPENDS on it without saying both of those to the user.
- ⚠️ **WHICH MODEL answers is the OWNER's setting, not yours.** The same AI tab picks
  **Fast** (the default), **Smart** (~10× the cost) or **Smartest** (~20×), and every
  `Opti.ai()` call on the project uses it — **including calls that name no assistant**,
  which is the common case and the one people miss. A page CANNOT pass a model: there is
  no such option, deliberately, because whoever wrote the page is often not the person
  whose allowance it spends. So if the answers a PAGE gets back are weak, the fix is one
  dropdown on the project, not a rewrite — say so in your final message rather than
  re-prompting around it.
  An **assistant carries its own** model too, and it WINS where it has one: a project on
  Fast with an assistant set to Smartest answers on Smartest for calls naming it. Leave
  the assistant's set to *"Take the project's"* unless it needs otherwise — an assistant
  is reusable across projects, so a model chosen on it spends at that rate on every one.
- **Put it behind a button someone chose to press.** Never on page load, never in a
  loop, never once per row. Every call spends the *owner's* monthly budget and it is
  a **colleague opening the page** who triggers it. Batch many items into one call
  rather than one call per item — that's the difference between a page that works all
  month and one that stops on the 3rd.

**Ask about a file.** Send it with the question — one call, nothing stored:

```js
const answer = await Opti.ai('What was the total, and what was it for?', {
  file: input.files[0],
})
```

That file goes straight to the model and is kept nowhere: no record, no collection, no
copy. Use it whenever the point is the ANSWER rather than the file.

**What you can attach.** Text and documents (`.txt`, `.md`, `.pdf`, `.docx`, code), data
(`.csv`, `.tsv`, `.xlsx` — loaded into a sandbox and computed with real arithmetic) and
images (`.png`, `.jpg`, `.webp` — the model looks at them). Audio and video are refused
before upload: the model cannot open them, and saying so beats charging you to find out.

**If you also want to KEEP the file**, store it first and name it by reference instead:

```js
const ref = await Opti.collection('claims').upload(input.files[0])
await Opti.collection('claims').add({ who: 'Ada', receipt: ref })

await Opti.ai('What was the total?', { file: { collection: 'claims', id: ref.id } })
```

⚠️ **On that second form the record is not optional.** `upload()` stores the bytes and
hands back a reference; the RECORD is what says whose file it is, and what the server
checks before showing it to a model. A reference never added to one is readable by nobody
— including whoever uploaded it — so asking about it answers *"No such file"*. Upload, add,
then ask. The one-call form above has no such step, which is why it is the one to reach
for.

**The limits to design around.** `prompt` + `input` is capped at **800,000 characters**
— a hard 400 — which is a whole collection rather than a page of it, so chunking is
usually the wrong instinct now: send the records and ask the question. The answer is
capped at **16,000 tokens** by default, **128,000** via `maxOutputTokens`; raise it when you want more than a short
reply — it no longer makes the budget check stricter. The gate asks only whether the owner
is already past their allowance: under it, the call runs in full however large, and the
cut-off lands on the next one.

⚠️ **Hitting that ceiling is not an error on the wire** — the answer returns `200` with less
of itself, so a `schema` call fails later at `JSON.parse` and reads as bad data rather than a
small ceiling. `ai()` now throws with the real cause for a `schema` call and warns for prose.
**A retry must ask for MORE room**; retrying at the same ceiling reproduces the truncation
exactly. `maxOutputTokens: opts.maxOutputTokens || 2000` is the bug that shape invites —
the fallback never fires when a value was already passed.

A `429` means the budget is spent **or** the provider is busy; either way the error
carries `retryable: true`, so the page can say "try later" instead of "broken". If the
portal has no AI key at all, every call is a **503** and the project page shows no
switch — don't send the user looking for one.

### Anything recurring is an automation, not `setInterval`

The page only runs while
someone has it open, so a tab that's closed does nothing. Automations live on the
**Automations** tab, and the user starts from a **recipe** rather than a blank form.

An automation starts **on a clock** (daily or weekly, in UTC) or **whenever an entry
arrives** in a collection — the second fires per submission, in seconds, and knows the
specific entry, so it can ask a question about that one. Then the form asks **what it
should look at** (there is no "digest vs fetch" control any more — the kind is derived):

- **What's arrived in the collection** — a digest *out*. For "post the week's entries to Teams every
  Monday". It goes to a saved channel, to the webhook that collection already uses, or
  to **email alone** — any one of those is enough, so a user with no Teams channel can
  still have it. Sending it nowhere is the only thing that won't schedule.
- **An API** — pulls data *in*. Calls one of the owner's **API connections** on
  the timer and stores the reply in the collection as a named doc. Needs a connection
  that allows GET, *not* a channel. This is how you build "show live Jira on the page"
  without calling out on every visit — the page just reads what the timer fetched:
  ```js
  const status = await Opti.collection('status').doc('jira').get()
  // { fetchedAt, source, value }  — `value` is the parsed reply
  ```
  Read-only by default — and a scheduled read may now **POST its query** (GraphQL, a search
  endpoint) when the connection allows it, so "always read-only" is no longer true of the
  request. What stays true is the consequence: a scheduled job does not change the other
  system's data, and a run that fails leaves the last good data in place rather than
  overwriting it with an error.
  A fetch can also **send** what it found, not only store it — see below.

**Saying something about what it found.** EVERY shape can reach into what it got and
speak about it — a fetch's reply, an arriving entry, or a scheduled digest's window of new
entries (a list: `[].title` is every new entry's title; left empty, a digest still sends
its plain count). Three optional parts, in this order:

- **A path** — `open`, or `issues[].title` for every one of them. Empty means the whole
  thing. Dots walk objects, `[]` walks lists.
- **A condition** — a field, a comparison and a value: *only if `issues` is not empty*.
  ⚠️ Evaluated the same way every time, and a **missing field sends nothing** — never
  suggest a model decides whether to alert. A list compares by how many there are.
- **An assistant** — writes the wording only; the condition still decides whether anything
  goes out, and the raw value stays in the message. Costs the owner's AI credits.

⚠️ **A fetch cannot be arrival-triggered** — it saves what it reads into the collection
that would trigger it, so it would set itself off. The portal refuses the combination.
An arrival trigger runs at most 60 times an hour.

**Automations are the OWNER's.** They run under the owner's account, credentials and AI
allowance — an editor or an admin viewing the project sees the facts, not the builder, and
the admin's portal-wide view is `/admin/automations`. Every automation can be **edited**
(same form, prefilled; the run history survives the change), **paused** (reversible), or
**removed** (confirms first; takes its history).

**Every automation can be tried without waiting.** *Run now* posts a real digest, really
calls the API (storing nothing), or fires an arrival trigger against the most recent
entry — or sample fields if there are none, saying which it used. Each keeps a **run
history**: every run with what happened at each step. Automations can be **paused**
(reversible, keeps everything) or **removed** (takes the history with it), and one that
fails five times in a row is switched off automatically with an email.

Never write a polling loop and imply it runs in the background.

**Default deploy workflow** when the user has a project on disk:
1. Run `npm run build` if needed.
2. **Dry-run first.** Build a file manifest via Bash:
   `(cd ./dist && find . -type f -printf '%P\t%s\n')` → parse into `[{ path, size }]`.
   Call `prepare_upload({ name, build_dir: "./dist", dry_run: true, manifest })`.
3. **Show the preview to the user and ask to confirm.** The dry-run response includes the slug, expected URL,
   version preview, total file count + size. Phrase like:
   *"Ready to deploy 12 files (192 KB) as `pricing-v2` → https://… (re-deploy: v3 → v4). Confirm?"*
4. **After confirmation**, call `prepare_upload` again *without* `dry_run` to get the bash command. Run it.
5. Show the live URL from the curl JSON response prominently in your reply.

**Mid-session redeploys**: skip the dry-run if the user has already confirmed what's being deployed in
this conversation (e.g. they said "fix the typo and redeploy" right after a previous deploy).

## How to figure out what to deploy

Use this priority order. Stop at the first match.

1. **Artifact in this conversation.** If you just produced an HTML page (single-file or hand-coded) that the user reacted to:
   - **Tiny single page** → `deploy` with the per-file `files: [{ path: "index.html", content_base64: <base64> }]` form.
   - **Multi-file or larger** → if you can write it to disk and run a networked upload, that's the **fast** path: write the files out, then `prepare_upload` (§2). Only if you *can't* run a networked upload, fall back to `deploy` with `zip_base64` — and keep it small, since that path is slow (payload rides through the model). For anything sizeable, point the user at the dashboard instead.

2. **Built project in the workspace.** Detect the build output folder using the table below. Run `npm run build` (or equivalent) if the folder is missing or stale. Then call `prepare_upload({ name, build_dir })` and execute the returned bash command. **Do NOT try to base64 the bundle yourself** — that path doesn't scale past tiny pages.

   | Project type | Folder to upload | Build first |
   |---|---|---|
   | Plain HTML/CSS/JS | folder containing `index.html` | no |
   | Vite (React / Vue / Svelte / Solid) | `dist/` | `npm run build` |
   | Create React App | `build/` | `npm run build` |
   | Next.js (static export) | `out/` | requires `output: 'export'` then `npm run build` |
   | React Router 7 (framework / SPA) | `build/client/` | `npm run build` (with `ssr: false`) |
   | Astro | `dist/` | `npm run build` |
   | SvelteKit | `build/` | requires `@sveltejs/adapter-static` |
   | Nuxt 3 (static) | `.output/public/` | `npx nuxt generate` |
   | Angular CLI | `dist/<project>/browser/` | `ng build` |
   | Gatsby | `public/` | `gatsby build` |
   | Hugo / Jekyll / Eleventy | `public/` or `_site/` | varies |
   | AI tools (Lovable / v0 / Bolt) | `dist/` | varies (usually Vite under the hood) |

   Rule of thumb: upload the folder that contains `index.html` at its root.

3. **Repo without a build step.** If there's an `index.html` at the repo root, upload the repo (excluding `node_modules`, `.git`, `.next`, hidden files, and anything in `.gitignore`).

## Naming

Pick a name **without asking** unless the user already gave one.

- Prefer the project's identity, in this order: an explicit name in the request → `package.json` `name` → repo folder basename → kebab-case summary of what was built (e.g. `q3-launch-page`).
- Slugify mentally: lowercase, alphanumeric + dashes, ≥ 4 characters.
- Re-deploys: if the user is updating an existing deployment ("update it", "redeploy with the changes", "fix the typo and redeploy") → reuse the **same name** as before. The portal will bump the version automatically and the URL stays stable.

After deploying, confirm the chosen name in your reply: *"Deployed as `q3-launch-page` (v2). Live at https://…"*.

## Output format

Always reply with the URL prominently and on its own line. Example:

> Deployed your landing page. It's live at:
>
> **https://deploy.optimizely.com/mahbub-ulalam/q3-launch-page/**
>
> Anyone in Optimizely can open it. Re-running `deploy` with the same name will publish a new version and keep that URL.

If `structuredContent.isReupload` is true, mention the new version: *"now v3"*. If the page is private, say who can see it (e.g. *"Private — visible to you + 2 invited people"*).

## Error recovery

The tool may return `isError: true` (or a 401 at the HTTP layer). Read the message verbatim — the server crafts targeted hints:

- **"Name must contain at least 4 alphanumeric characters"** — pick a longer name automatically; don't bounce back to the user.
- **"Total upload exceeds 250 MB"** — list the largest files (via Bash `du -h`), suggest tree-shaking / removing source maps / compressing images, then retry. ⚠️ **Before telling anyone the project is too big, check which door you used.** 250 MB is the cap on payloads that travel *through* the portal. A **`zip_url`** deploy is bounded differently — the portal streams it straight into storage, so the archive may be up to 250 MB *compressed* and unpack to **1 GB**. If the build is over the cap and you can put the zip somewhere with a public https link, pass `zip_url` instead of shrinking the build. If it is over even that, tell the user the cap and stop.
- **"You've reached your limit of N projects"** — call `list_deployments`, show what they have, and suggest `delete_deployment` for old or unused ones. More slots are requested by the person in the dashboard, not by you; the error names where — on this door it carries an absolute link, because you have no dashboard to be "in".
- **"Possible secrets detected" (HTTP 422)** — the upload was blocked because a file looks like it contains a real key/token/private key. Show the user the findings, offer to remove it or switch to a publishable/restricted key, and only retry with `allow_secrets: true` once they confirm it's intentional and client-safe. It's a warning with an override, never a hard block — but don't override silently or refuse outright; let the user decide.
- **"Manifest validation failed"** — the dry-run found issues (missing `index.html`, traversal, size cap). Fix the build / pick a different folder; don't bypass by skipping the manifest.
- **"This payload is X KB raw — too large for inline `deploy`"** — switch to `prepare_upload` immediately.
- **"Could not read the zip — it may be corrupt or only partially uploaded"** — the `zip_base64` payload was almost certainly **truncated in transit** (chat clients cap tool-call argument size), not actually corrupt. **Don't shrink-and-retry in a loop.** When you can't run a networked upload yourself, hand the deploy to the user: have them drag the file onto the **dashboard** (simplest), or run the `prepare_upload` curl in their own terminal (single-use token, ~10 min, files must be local).
- **HTTP 401 — "Your MCP access token has expired"** (or similar) — the message contains the exact command to reconnect. Relay it verbatim to the user; don't paraphrase or try other workarounds.

Never silently retry on failure — always surface what went wrong.

## Worked examples

### Example 1 — chat artifact

**User:** "Make me a coming-soon page for the new pricing dashboard with a glassmorphism vibe."
**You:** *(generate the HTML artifact)*
**User:** "Nice, ship it."
**You:** *(call `deploy` with `name: "pricing-coming-soon"`, `files: [{ path: "index.html", content_base64: "..." }]`)*
**Reply:** *"Deployed as `pricing-coming-soon`. Live at https://deploy.optimizely.com/<user>/pricing-coming-soon/"*

### Example 2 — Vite build in a repo

**User:** "Deploy this."
**You:**
1. `cat package.json` to confirm it's a Vite project; grab the `name` field
2. `npm run build` (silent if `dist/` is already fresh)
3. Build a manifest via Bash: `(cd ./dist && find . -type f -printf '%P\t%s\n')` → parse to `[{ path, size }]`
4. Call `prepare_upload({ name, build_dir: "./dist", dry_run: true, manifest })`
5. Show the preview to the user:
   *"Ready to deploy 12 files (192 KB) as `pricing-page` → https://… (new deployment). Confirm?"*
6. **User:** "yes"
7. Call `prepare_upload` again, this time **without** `dry_run`, to get the bash command
8. Run the `bash_command` via Bash exactly as given
9. From the curl JSON response: render `.url` prominently

Do NOT try to base64-encode the bundle in your own context. Real React/Vue/Svelte builds easily exceed 100 KB minified — that path will fail at output-token limits.

### Example 3 — re-deploy (mid-session)

**User:** "Change the headline to 'Q3 Offsite Recap' and redeploy."
**You:** *(edit the artifact / source, rebuild, then call `prepare_upload` directly without `dry_run` — user already confirmed the destination in the previous deploy this session)*
**Reply:** *"Updated `q3-offsite` (now v2) — same URL: https://…"*

### Example 4 — listing & cleanup

**User:** "Show me my deployments."
**You:** *(call `list_deployments`)*
**Reply:** *(render the table with names, URLs, versions, last-updated dates)*

**User:** "Delete the old `pricing-v1` one."
**You:** *"Want me to delete `pricing-v1` (live since 2026-04-12, 14 KB)? This can't be undone."*
**User:** "Yes."
**You:** *(call `delete_deployment({ slug: 'pricing-v1' })`, confirm in reply)*

### Example 5 — make a page private and share it

**User:** "Make my pricing page private, only Alice and Bob should see it."
**You:** *(call `update_deployment({ slug: 'pricing-page', visibility: 'private', allowed_emails: ['alice@optimizely.com', 'bob@optimizely.com'] })`)*
**Reply:** *"`pricing-page` is now private — visible to you + 2 invited people. Alice and Bob were notified they have access. Same URL: https://…"*

### Example 6 — deploy something private from the start

**User:** "Deploy this as `board-deck` but keep it to just me and the exec team for now."
**You:** *(after the usual dry-run/confirm, call `prepare_upload` without `dry_run`, passing `visibility: 'private'` and `allowed_emails: [...]`; run the bash command)*
**Reply:** *"Deployed `board-deck` (private — you + 4 invited). Live at https://…"*

### Example 7 — limit reached, ask politely

**User:** "Deploy this." → (you hit "You've reached your limit of 5 projects")
**You:** *(call `list_deployments` to show what they have, then:)*
*"You're at 5 of 5 slots. Want me to delete an old one, or request a +5 increase? If the latter, what's the use case I should put in the request?"*
*(If they want more slots rather than to delete one, point them at the dashboard — it has a "request more" form, and an admin reviews it, usually within a day.)*

### Example 8 — a survey that collects data (Collections)

**User:** "Build a quick team-feedback survey and put it up."
**You:** *(build a static page; wire the form with the Collections SDK — no backend, no key)*
```html
<script src="/collect.js"></script>
<form data-opti-collect="responses">
  <input name="name"><textarea name="feedback"></textarea><button>Send</button>
</form>
```
*(deploy it public so everyone can submit)*
**Reply:** *"Live at https://… — anyone at Optimizely can submit. The responses are private to you; see them in the portal's **Data** tab."*

**User:** "Now make me a page to read the results."
**You:** *(deploy a SEPARATE, **private** dashboard app that reads the survey's collection)*
```js
const rows = await Opti.collection('responses', { app: 'survey' }).list()
```
**Reply:** *"Your private dashboard is at https://… — it reads the survey's responses. Only you can open it."*

## What this Skill does NOT do

- Doesn't deploy server-rendered apps (Next.js SSR, Express, etc.). Static output only — but note apps **can** store and read data via **Collections** (above), so "it needs a database" is not a blocker.
- Doesn't file or approve limit requests or access requests, or manage the admin panel — those live in the dashboard.
- Doesn't expose URLs to the public internet. Anyone outside Optimizely — or off a private page's allowlist — sees a Microsoft sign-in / access-request screen.
