Deploy any static web app to a shareable, Optimizely-only URL — in seconds. From the dashboard, or directly from Claude.
🔗 Portal: https://deploy.optimizely.com 🔗 Get started: https://deploy.optimizely.com/start
Drop a built static site (a folder, a ZIP, or anything Claude generates) and get a URL like:
https://deploy.optimizely.com/your-name/your-project/
Anyone with an @optimizely.com Microsoft account can open it. Nothing is public by default — reaching somebody outside Optimizely takes a deliberate external link, one project at a time, and while that feature is approval-gated the control is shown only to the support admins. Sign-in is the same one you use for everything else.
Use it for: design mockups, AI-generated demos, prototype sharing, internal microsites, hackathon projects, before/after pages, one-off dashboards — anything that builds to HTML/CSS/JS.
Every page arrives with a backend already attached: a database, file uploads, AI, API calls that keep your keys off the page, and automations. So your page can collect data — a form, a survey, a poll — with no backend and no API key. See "Your app can store data" below.
Not for: server-rendered apps (Next.js SSR, Express, etc.), customer-facing production content (no SLA, no custom domain), or anything over 1 GB.
That's it. No installs, no API keys, no IT tickets.
Each one ends the same way: a page at /your-name/your-project/, and re-deploying under the same name
bumps the version and keeps the URL.
.zip or a single file onto the page.POST, for scripts and your own agent.Every other way below begins with files you already have. This one doesn't, and it is the only route into the portal that assumes no files, no terminal and no git. If you have never deployed anything, start here.
https://optigpt.optimizely.com/coder — you are already signed in, it uses the same Optimizely account. The full walkthrough is at /guide/builder; what follows is the short version.
Describe the tool you want in plain English. Site Builder writes the code, renders the page beside the conversation as it goes, runs it to check it actually works, and publishes it to a private URL only Optimizely can open. It arrives looking like Optimizely made it, with the database, AI and keyed API calls already wired up.
Good fit: a group dashboard or scorecard · a sign-up, intake or request form · a poll, survey or self-assessment · a launch page · an FAQ, roadmap or onboarding microsite · a quick prototype to put in front of people.
Not a fit: anything server-rendered (Next.js SSR, Express, Django) · anything that must sit on the open internet — a URL needs a sign-in unless an external link has been minted for it, and there is no custom domain · long-running jobs or queues · customer data of any kind · something you already have the files for (upload those instead).
How to get a good result. Say who it is for and what happens next, not a list of divs. "A page where the group votes on our offsite date and sees the running totals" beats a layout spec. Then iterate in plain English — "make the totals a bar chart", "only one vote each" — and it edits the files and re-renders. Twelve starter templates are there if a blank page is harder than a first draft.
Mentioning what should HAPPEN to submissions matters: "an intake form for design requests; submissions should reach our group" gets you a form that saves entries and posts them to a channel. Describing only the fields gets you a form that saves them and tells nobody.
Two apps, one account. Site Builder lives in OptiGPT and holds the conversation; this portal holds the project — its files, versions, sharing and URL. There is no second login and no token to paste. What it publishes is yours: it appears on your dashboard under your username, exactly as if you had uploaded it, and you can rename, share or roll it back here. You are never locked in — the files are ordinary HTML, CSS and JavaScript, and you can download them and deploy them yourself.
Already live and want to change it? Builder on a project card opens that project's active version in a fresh Site Builder conversation. Start in one, finish in the other. Apps Site Builder made carry a Site Builder badge.
Try it for real before anyone sees it. Site Builder's canvas is a sandbox — sign-in returns a made-up colleague and the database is a scratch copy, so anything that reads real data is guesswork there. Switch to the live draft and the same page is served from the portal at your real URL, with the real database, the real you and your real API connections. A draft is visible to you and your maintainers only, even on a project the whole company can open, and it never counts against your project limit. When it looks right, Publish makes it the next version of your live site — and the draft stays, so your next edit starts from what is live.
A project can also exist as nothing but a draft — Site Builder claims its address the first time you preview, before anything is published. Those sit under Drafts on your dashboard rather than among your live sites, and cost you nothing: an unpublished project never counts against your limit. On the project page, Discard throws a draft away and leaves the live site untouched — except on a project that has never been published, where the draft is all there is, so the button says Delete project instead.
The full walkthrough — the canvas pane by pane, the templates, the "does it run?" check, and what to do when it gets stuck — is at /guide/builder.
Easiest if you already have files on your machine — and the best choice for anything large or multi-file (a folder may total up to 1 GB; a .zip up to 250 MB).
index.html, a folder (dist/, out/, build/…), or a .zip. All work.Don't have anything to deploy yet? Click "Deploy a sample app" on the empty dashboard for a working live URL in two clicks.
Just one HTML file? Drag the
index.htmlstraight onto the page — no folder or zip needed. (The on-screen buttons pick a folder or a zip; for a lone file, drag it in.)
A document, not a site. Drag a .md on with no index.html beside it and the portal renders it
into a themed page — headings, tables, checklists, code — with a link that previews properly when you
paste it into Teams. Drop several and you get one page with a nav across them, README.md first.
Your .md files are kept exactly as you uploaded them; the page is added beside them, never in place
of them. It is the quickest way to share something an AI wrote for you: ask it for the markdown rather
than a web page, then drag the file on.
Re-uploading to the same name bumps the version. The URL doesn't change. Old links never break.
We keep your 10 most recent versions — the live one plus your last 9 — so you can roll back up to 9 versions if a deploy goes wrong. Older versions are removed automatically.
Open Versions on the project page to:
https://deploy.optimizely.com/your-name/project/v2/Re-deployed something broken? Just open Versions and set the previous one back as live — no need to re-upload the old files.
| Tool | Upload this folder |
|---|---|
| Vite (React, Vue, Svelte, Solid) | dist/ |
| Create React App | build/ |
| Next.js (static export) | out/ |
| Astro | dist/ |
| SvelteKit (static) | build/ |
| Nuxt 3 (static) | .output/public/ |
| Angular CLI | dist/<your-app>/browser/ |
| Gatsby | public/ |
| Plain HTML/CSS/JS | the folder containing index.html |
| Lovable / v0 / Bolt / similar | usually dist/ |
Rule of thumb: upload the folder that has index.html at its root.
Using a client-side router? (React Router, Vue Router — anything with a
basename.) Everything else works with no configuration: we rewrite root-relative paths and set<base href>for you. Routers are the exception, because they need the value in JavaScript — so read it fromOpti.basePathrather than hardcoding it:createBrowserRouter(routes, { basename: Opti.basePath })Don't build it from your name and project: the same page is also served at your short link (
/go/…) and at per-version preview URLs (/…/v3/), where a guessed value is wrong.Opti.basePathis always the prefix the visitor actually arrived on.
Connect once, then ask it to deploy. The portal speaks MCP, so any client that speaks MCP can use it: Claude.ai (web), Claude Desktop, Claude Code (CLI) — and Opal.
It's already set up for you. IT has added the Optimizely Deploy connector to Claude for the whole org — there's no URL to paste or custom connector to create.
Don't see it? Ask through the speech-bubble Feedback button in the top nav (inside Menu on a phone), or Get support in your own project's ⋯ menu.
Already there. The Optimizely Deploy MCP server is installed on Optimizely's Opal instance, so you can ask Opal to deploy without setting anything up.
It can also be added to any other Opal instance — it is an ordinary MCP server at
https://deploy.optimizely.com/api/mcp, and it authenticates each person separately, so
whatever it does is done as you and shows up under your name.
⚠️ Opal cannot send large uploads. It has no outbound network access of its own, so files travel inside the conversation and are capped at roughly 200 KB compressed — enough for a page and its styles, not for a built app with images. The portal can fetch a zip from a link (
zip_url), but not one that redirects, and Opal's own file links did when tried with a made-up file id. For anything bigger, use the dashboard or Claude Code, which upload the files directly.
If you don't have the claude command yet:
npm install -g @anthropic-ai/claude-code
Then register the MCP server globally (so it works in every project):
claude mcp add --scope user --transport http opti-deploy https://deploy.optimizely.com/api/mcp
⚠️ The
--scope userflag matters. Without it, the registration is only local to whatever folder you ran the command from.
Verify:
claude mcp list
Should show opti-deploy. First time you actually use it, a browser tab opens for sign-in. After that it stays silent while you use it, up to 90 days.
That is all most people need: connecting also hands Claude the deploy playbook. Want it to apply
without the connector, or pinned in your repo? The same instructions are published as a Skill at
deploy.optimizely.com/skill — read it, or save it to .claude/skills/opti-deploy/SKILL.md.
When you ask Claude to deploy a real project, it'll first show you a preview — what it's about to upload, where it'll live, whether it's a new deployment or a re-deploy — and ask you to confirm. No more "wait, that was the wrong folder" moments. Mid-session redeploys ("fix the typo and redeploy") skip the confirmation since you already approved the previous one.
In any Claude conversation:
"Build me a coming-soon page for the Q3 launch and deploy it as
q3-launch."
"Deploy this app as
pricing-redesign." (Claude figures out which folder)
"Update the headline and redeploy." (URL stays the same, version bumps)
"What did I deploy this week?" / "Show me the QR for my pricing page." / "Delete my old
staging-v1." (Claude confirms first)
"Rename
q3-launchto Q3 Launch Page." / "Pin my pricing demo." / "Make my pricing page private and share it with a teammate."
Claude generates / builds / packages, calls the deploy tool, replies with the live URL. You don't need to mention folders, file formats, or anything technical.
Big or multi-file build? Claude is perfect for a single page or a small app. For a large or multi-file build (a full React/Vite
dist/, lots of assets, several MB), the dashboard is the sure thing — it uploads straight from your browser, up to 1 GB for a folder. If Claude ever says it can't upload (some Claude environments block the file upload over the network), don't worry — just save the files and drag them onto the dashboard instead.
What Claude can do for you:
Want a repo to publish automatically on every push? Wire it up once: on the dashboard, open Ways to deploy → From GitHub; or, on a project's page, use Deploy on every push from GitHub under Deploy a new version, which pre-fills the workflow for that project.
A workspace's project? Use Deploy on every push from GitHub on its page — the dashboard's From GitHub lists only your own projects.
No secret to store: on a project's Project tab, Link this repository lets GitHub prove each run instead — see Deploying from GitHub with no stored secret. Otherwise the dialog walks you through three steps:
OPTI_DEPLOY_TOKEN..github/workflows/deploy.yml.name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci && npm run build
- name: Deploy to Optimizely Deploy Portal
run: |
cd "dist" # folder with index.html at its root (. for a static repo)
zip -qrX /tmp/deploy.zip . -x '.git/*' '.github/*' 'node_modules/*'
curl --fail-with-body -sS -X POST "https://deploy.optimizely.com/api/deployments/cli" \
-H "Authorization: Bearer ${{ secrets.OPTI_DEPLOY_TOKEN }}" \
-F "name=my-app" -F "mode=zip" -F "files=@/tmp/deploy.zip"
The workflow is self-contained — paste it into any repo, no extra setup or repo names to configure (actions/checkout handles your repo automatically). Every push to main rebuilds and redeploys — same URL, version bumped. The deploy lands in your namespace and counts against your cap (the token is personal — anyone with write access to the repo deploys as you). The Deploy from GitHub dialog on the dashboard generates this for you with your name/branch/folder filled in.
This is the single-curl version, and it is the one to read if you already have a workflow: that
endpoint is frozen, so a workflow you pasted last year keeps working untouched. The Deploy from
GitHub dialog now generates the second door below instead — same fields, same secret, but the
files go straight to storage.
The dialog's fields.
next-app, apps/web). The build
runs there and the output resolves to project-folder/build-folder. ⚠️ Always use paths relative to
the repo — a leading slash makes it absolute and the job fails with "No such file or directory."index.html lives (. for the repo root). The workflow then skips the
build entirely and just uploads.main, production, whatever you use.Secret scan. If a file looks like it holds a real key or token, a deploy from a workflow or script is
rejected (HTTP 422) with the finding. Remove it or use a publishable/restricted key; to ship a
client-safe key on purpose, add -F "allowSecrets=true" to the curl — or, on the direct-upload door below, send
"allow_secrets": true in the begin and finalize requests (the generated recipe does not, so add it there). If it's a real secret it doesn't
have to ship at all: save it under the project's API connections and call it by name with
Opti.call() — the key stays on the server.
The curl above sends the whole zip through the portal. There is a second door that sends your files
directly to Azure Blob Storage instead: the portal checks what you are about to upload, hands your CI
one short-lived upload URL per file, and only looks at the result. It avoids the extra hop, and each file is its
own request, so a client of your own can retry one that fails; the generated recipe instead stops on the first
failure and gives the upload back, so run it again. Nothing changes about who can
see the site, and the same secret scan runs before anything is published.
It is the way to send a build the first door will not take: the whole upload may be up to
1 GB here, against 250 MB for the single curl. One caveat — no
individual file may exceed 250 MB, however big the total; a build over either limit is
refused at step 1, before you upload anything.
It is three steps instead of one — POST https://deploy.optimizely.com/api/deployments/upload/begin, one
PUT per file, then POST https://deploy.optimizely.com/api/deployments/upload/finalize — so use it when
the size justifies it; for an ordinary build the single curl is simpler and faster.
#!/usr/bin/env bash
# ⚠️ Fail on the first error, not at the end: a PUT that silently fails turns into a
# confusing 409 from finalize ("declared 1200 bytes, arrived 0") three steps later.
set -euo pipefail
# Portable on both GitHub's ubuntu-latest and a Mac: `find -printf` and `stat -c` are
# GNU-only, `wc -c` and one of the two sha tools are everywhere.
sha() { command -v sha256sum >/dev/null 2>&1 && sha256sum "$1" | cut -d' ' -f1 || shasum -a 256 "$1" | cut -d' ' -f1; }
# 1. Describe what you are about to upload. No files are sent yet.
cd 'dist'
# jq builds the JSON, not printf: a filename containing a quote or backslash would produce
# an invalid manifest, and with set -e that now dies as a jq parse error naming nothing.
find . -type f ! -path './.git/*' ! -path './.github/*' ! -path './node_modules/*' | sed 's|^\./||' | while read -r f; do
jq -Rn --arg p "$f" --argjson s "$(wc -c < "$f" | tr -d ' ')" --arg h "$(sha "$f")" \
'{path:$p, size:$s, sha256:$h}'
done | jq -s --arg n 'my-app' '{name:$n, manifest:.}' > /tmp/begin.json
BEGIN=$(curl --fail-with-body -sS -X POST "https://deploy.optimizely.com/api/deployments/upload/begin" \
-H "Authorization: Bearer $OPTI_DEPLOY_TOKEN" \
-H 'content-type: application/json' --data-binary @/tmp/begin.json) || {
# ⚠️ Print the refusal: a failed $(…) keeps the body in BEGIN, and set -e would exit silently.
echo "$BEGIN" >&2; exit 1; }
UPLOAD_ID=$(echo "$BEGIN" | jq -r '.upload_id')
# ⭐ THE CREDENTIAL FOR THE REST OF THIS UPLOAD, and the reason the token above may be a
# single-use one. It is good for finalize and abort of THIS upload only, and dies with it —
# so the long-lived key is presented once, not three times, and never reaches the trap.
CONT=$(echo "$BEGIN" | jq -r '.continuation')
# Give the slot back on the way out, however we leave.
# ⚠️ INT and TERM as well as ERR: a cancelled or timed-out CI job dies by signal, which does
# not fire an ERR trap — and that is exactly the job this is here for.
trap 'curl -sS -X DELETE "https://deploy.optimizely.com/api/deployments/upload/abort?upload_id=$UPLOAD_ID" \
-H "Authorization: Bearer $CONT" >/dev/null || true' ERR INT TERM
# 2. Send each file to the URL minted for it. These do not touch the portal.
# -T streams the file; 8 at a time, because one at a time is slow for a big build.
# ⚠️ NUL-separated, because xargs splits on SPACES as well as tabs — a path with a space in
# it would mis-pair, and a file would be PUT to the wrong URL rather than failing cleanly.
echo "$BEGIN" | jq -j '.uploads[] | .path + "\u0000" + .url + "\u0000"' |
xargs -0 -P 8 -n 2 sh -c 'curl --fail-with-body -sS -T "$0" -H "x-ms-blob-type: BlockBlob" "$1"'
# 3. Publish. The portal now checks what actually arrived, scans it, and commits.
curl --fail-with-body -sS -X POST "https://deploy.optimizely.com/api/deployments/upload/finalize" \
-H "Authorization: Bearer $CONT" -H 'content-type: application/json' \
-d "{\"upload_id\": \"$UPLOAD_ID\"}"
Each upload URL is good for one file, for 30 minutes, and cannot write anywhere
else — not another path in your own upload, and not another project. If a file never arrives, or arrives
a different size than you declared, finalize refuses the whole deploy and nothing is published; your
previous version stays live.
The recipe runs as-is on GitHub's ubuntu-latest and on macOS — it avoids find -printf and stat -c,
which are GNU-only, and picks whichever of sha256sum or shasum you have. It needs jq.
Changed your mind, or the job died half way?
DELETE https://deploy.optimizely.com/api/deployments/upload/abort?upload_id=… gives the slot back
immediately. Otherwise an abandoned upload is reclaimed about an hour later — which matters because you
may only have 3 open at once.
curlAny script can deploy — no Claude, no GitHub. Create a token under Settings → Access tokens, then post a built folder to the deploy endpoint. Re-deploys keep the same URL and bump the version, exactly like every other route.
Using Claude Code in a terminal? That's the connector, not this — add it once with
claude mcp add(Path 2). This is for when there's no agent involved at all.
# $OPTI_DEPLOY_TOKEN = a deploy token (the "Deploy from GitHub" dialog's, or one from Access tokens).
# Zip the *contents* of dist/ so index.html is at the zip root, then POST it:
(cd dist && zip -qr /tmp/up.zip .)
curl -X POST https://deploy.optimizely.com/api/deployments/cli \
-H "Authorization: Bearer $OPTI_DEPLOY_TOKEN" \
-F "name=my-app" -F "mode=zip" -F "files=@/tmp/up.zip"
Jenkins, GitLab, Azure DevOps — any CI/CD. Nothing in Path 3 is GitHub-specific. The deploy step is
that curl to /api/deployments/cli with the token as a bearer header, so the same lines work from a
Jenkins stage, a GitLab job, an Azure DevOps task, or a shell script on your laptop. Store the token
however that system stores secrets and keep the rest identical — the endpoint doesn't know or care what
invoked it. A build too big for one curl goes through the
direct upload instead.
Settings → Access tokens offers two kinds. Pick before you paste one anywhere:
The card carries the five things you do at a glance:
Once a page of yours has been opened, the eye on its preview opens who viewed it.
Plus, on the preview: ★ pins it to the top, and the lock (top-right) is one click to Only me — hide it from everyone while you're still building. It suspends your sharing rather than overwriting it, so unlocking gives you back exactly the sharing you had. It outranks everything else: a locked page is hidden from the people and groups you shared it with, and so is the data it collected. To deploy straight into it, pick Only me at upload time.
Click the card and you get the project page (/projects/{name}), which has everything else:
/your-name/project/draft/ with your real data, Publish makes it the next version, and
Discard throws it away (see Site Builder)Submissions and calls are always attributed. Every record names the colleague who submitted it, and every API and AI call names the colleague whose visit triggered it — that is what makes "your own record", one-vote-each, and "it broke for one person" work. There is no anonymous mode, so if you are collecting something sensitive, tell people who will see it rather than letting them assume nobody will.
A shorter link. Claim a Custom link on the project page and the site also answers at
deploy.optimizely.com/go/your-name — no redirect, that URL just is the site. It is an alias,
never a rename: the original link keeps working forever, and the short one survives re-deploys and
renames, which is how a badly-named project gets a good link. First come, first served, and words that
could look official (payroll, hr, sso…) are off-limits.
Naming everyone works until it doesn't: you name them again on the next project, and nobody remembers to add the new joiner to all of them — which is why so much ends up public.
A group is a named list of people you keep in one place, under Settings → Groups. Share a project with the group rather than the people, then add somebody to the group once and they can open every project it's on — including ones you shared before they joined. Remove them, or delete the group, and that access goes away everywhere at once.
Groups are yours. You pick them in your own projects' Privacy dialog, and only you decide who is in them. People are emailed when you add them to a group, and when a project is shared with one — once, together, not one message each.
From Claude: "share the onboarding app with my onboarding group". Claude can share with a group you already have, but can't create one — that's Settings → Groups.
Sharing decides who may open a project. Editorship decides who may replace it.
Because a project lives at /{your-name}/{project}/, only you could ever deploy to it — so
covering someone's page while they were on leave meant a brand-new URL and a re-share. An
editor deploys new versions to the same link, with nothing to re-share.
On the project page, Who can update it is its own block, separate from who can open it. Add a colleague's Optimizely address and they can deploy over it from that moment, and manage its data and sharing, from their own account. It shows up for them under Sites you maintain.
An editor cannot rename it, delete it, move its custom link, or add more editors — that's ownership, not maintenance — through Claude too. Every version records who deployed it, so the history stays honest.
Editorship is not suspended by a lock. Locking hides a project from its audience; the point of an editor is that they keep working while you do.
Every project has an Activity tab: what has happened to it, newest first.
Deploys and rollbacks, who changed the sharing, every call your page made through
Opti.call and whether it worked, automation runs, and AI use.
Start with Only problems. It carries a count, so whether anything is wrong is answered before you read anything — a failed call, an automation that stopped, a page that has been 404ing since Tuesday. A call that never reached the other end counts as a failure, not just a 4xx.
Whoever can update the project can read it, so a colleague maintaining your page can find out why it broke without asking you. They see calls but not who made them; who viewed the page is on Analytics, for owners, editors and admins.
It covers the last 7 days by default, and up to 30. In code: Opti.activity(); in Claude: read_activity. On a very busy project the list says
so rather than quietly shortening it.
Open a private project you haven't been shared on and you get a page saying it isn't shared with you yet, plus a Request access button. Press it and the owner sees it in the portal — on their bell, where they can Approve or Decline without leaving the page. Approving adds you to that project's allowlist; you'll be told either way.
If the project is locked, there's no button. A lock means the owner has taken it offline for everyone, so granting access wouldn't help and offering to ask would only waste your time.
Requests you've made and tickets you've filed are both under Settings → Your requests.
Duplicate on a card starts a new project from that one's files — yours, one you maintain, or one on
Browse that is published to everyone. Don't download the .zip and re-deploy it: Duplicate carries more,
and says what it carried.
Taking the entries across as well. The copy starts empty on purpose, but you can move the data yourself in two steps:
site/ and,
for every collection, both data/<collection>.csv and data/<collection>.json, plus any files
people attached and a MANIFEST.md saying what was left out..csv from that zip.⚠️ Import reads a CSV, not JSON. The .json beside it in the zip is there for reading and for
feeding another system — pasting it into Import will not work. Use the .csv.
⚠️ You only ever get the entries you are allowed to read. If a file comes out named
…-your-responses.csv, that is the collection showing you your own rows rather than everyone's.
📖 Every call, with its options — paging, filters,
counts(), uploads — is in Everything a page can call. This section is the why; that one is the whole API.
This is the part people don't expect. A static page you deployed here can save and read data: a survey, a sign-up form, a poll, a shared list. There is no server to run, no database to set up, and nothing secret to paste into your code.
Two collections that point at each other. A contact row carrying an account_id, a task
carrying a project_id — the other record lives in its own collection, and a page used to have
to download both and match them itself. list({ join: { collection: 'accounts', on: 'account_id' } })
brings the related record with each row, as related beside data. The accounts must be saved
under that id as their key (doc(key), addAll(rows, { key }), or an ingest key) — a join never
searches the other collection's fields, and it finds at most one record. ⚠️ You only get what you
may read: the other collection is checked against your own access exactly as the rows are, so a
join is never a way to see a collection you could not open directly. The whole call is in
Everything a page can call.
Showing them in an order you choose. page({ sort: '-amount' }) asks the server for the order,
so you get the dearest across the whole collection rather than the dearest of the page you
happened to download — the difference that matters on anything past a few hundred rows. A -
prefix is descending, no prefix is ascending. Sorting by one field works; two at once needs an
index for that exact pair, and is refused by name rather than answered in some other order.
⭐ Sorting and filtering together works: page({ where: …, sort: '-amount' }) is the largest of
every match, not of the rows one page happened to read. Each page of that walk looks through the
whole collection, up to the same limit as any filtered read — so if the reply says truncated, the
order covers only the records it examined.
⚠️ A sorted read pages with its cursor, not an offset. Ordering by one of your own fields
carries no tiebreaker, so rows holding the same value have no order the database promises from one
call to the next — counting past them would show one twice and miss another. The cursor carries the
database's own place in the result instead, so following nextUrl just works. ⚠️ Very rarely that
place is too long to put in a link, and the reply then says stoppedEarly instead of pretending the
collection ended — ask for a smaller page.
Bringing in a lot of rows at once — a sync, an import, a refresh button. Writing them one at
a time works and is slow: three thousand rows is three thousand requests, several minutes, and if
the tab closes half way there is no record of where it stopped. Use addAll(rows, { key: 'id' })
instead — it sends them in batches for you, and because each row is addressed by your id, a
second run updates in place instead of writing the whole set again. The reply says how many were
created against updated and unchanged, so a run that duplicated its data is visible
immediately rather than in a row count weeks later. ⚠️ Without { key } every row is appended, so
a refresh button writes the entire set again on every click.
You can also add an entry by hand — Add entry on the Data tab, beside Import. It prefills the columns the collection already has, so a hand-typed row lands in the same shape your page submits, and on an empty collection the first entry is what names the columns. Handy for seeding a couple of rows to try a digest against.
Easiest way — ask Claude: "Add a feedback form to this page and save the responses." or "Build me a private dashboard that shows the survey answers."
Writing it yourself is one script tag and a form:
<script src="/collect.js"></script>
<form data-opti-collect="guestbook">
<input name="message" placeholder="Say something…">
<button>Sign</button>
</form>
That page is finished. It saves every submission — and each one is automatically stamped with the colleague who sent it, because the page sits behind your Optimizely sign-in and the portal already knows who they are. You never build a login, and there is no key that could leak.
No schema. Every record is just JSON: records can differ, and you change what you store any time. Nothing to define up front, no migrations.
Listing the entries back is a few more lines, and opti:saved tells you when to refresh:
<ul id="entries"></ul>
<script>
// The async wrapper matters: a plain <script> is a *classic* script, and
// top-level `await` there is a SyntaxError that kills the whole block.
(async () => {
const list = document.getElementById('entries')
async function render() {
const rows = await Opti.collection('guestbook').list()
// Built as elements, never innerHTML: an entry is a colleague's text, and markup in it must stay text.
list.replaceChildren(...rows.map((r) => {
const li = document.createElement('li')
li.textContent = `${r.createdByName}: ${r.data.message}`
return li
}))
}
document.addEventListener('opti:saved', render) // refresh after a submit
render()
})()
</script>
You never asked who the visitor is — createdByName is filled in for you, from the sign-in.
Know the visitor up front. You don't need to write a record to know who's there. Opti.me() hands you
the signed-in person synchronously — greet them, or pre-fill a form, with nothing to ask:
const me = Opti.me() // → { name, email } · null off-portal or on an external link
if (me) greeting.textContent = 'Hi, ' + me.name
It is the viewer's own identity, for display — the stamp on what they write is still set by the server, so it can't be faked from the page.
Two apps, one dataset. A second app of yours can read the first one's data — a public survey and a
separate private dashboard over its responses is a supported pattern, not a hack:
Opti.collection('responses', { app: 'survey' }).list().
Read it back in code with Opti.collection('guestbook').list(), or just open the project's Data tab.
You can also ask Claude "summarise the responses to my survey" — it reads them directly — or have it
write records in, on a project you maintain: "add these deals to the journeys collection".
Who sees what: anyone who can open the page can submit; by default only you can read the responses back. So a survey you share with the whole company still keeps its answers private to you.
⚠️ If your page shows the data back, that default makes it look empty to your colleagues — a guestbook, a poll with results, a leaderboard. Open the project's Data tab and set Read: anyone who can open the app (or each person sees their own). The app cannot widen that itself; only you can.
In a hurry? Open any project and use Connect → Database. Tell it what you're doing and it writes the snippet for you, ready to paste.
Attachments. Put an <input type="file"> in that form and the file rides along with the entry — a CV
drop-box, a bug report with a screenshot, an expense claim with a receipt. Up to 25 MB each; the Data tab
shows them as downloads, to exactly the people allowed to read the entry.
Getting told about it. Save your Teams webhook once in Settings → Channels (for a team project, the workspace's Settings tab → Channels), where it gets a name — then in the Data tab, Notify posts each new submission to it (who sent it, and the fields). It can also email you and a named group. Saving it once means every project can use the same channel, and rotating the URL is one edit rather than a hunt. If the collection is busy enough that one ping per entry would get muted, set up an hourly, daily or weekly digest on the Automations tab instead — one message summarising what arrived.
Finding one entry. Once a collection has more than a handful of entries, the Data tab gets a search box
and every column header sorts — click once for ascending, again for descending, a third time to go back to
newest-first. On a collection that fits one page, both work on what is in front of you, instantly (and the
search also matches who submitted it, and numbers and lists). Past one page they ask the server, so they
cover every entry — the box says "Search text in all 4,000 entries…" and the count is of all of them. That
search matches text fields only, and the row says so beside the count; a number or the submitter's
name is found by the loaded-entries search, or by Show → entries where…. It asks the fields the tab
has seen, so while only part of the collection has loaded the row says how many ("text in the 6 fields
seen so far") — for a field only older entries carry, type its name into Show → entries where…. A column sorts the same
way whether the browser or the server does it — the store's order, the one your page's sort gets: capitals
before lower case (Banana before apple), and an entry that never had the field first going up, last going down. Three cases stay with the loaded entries, and the
row says so when they do: while a Show → entries where… question is open, on a collection with more fields
than one search can hold, and when a column's name has a dot, a bracket or punctuation like (USD) in it (common in CSV imports).
Changing many at once. Once a Show → entries where… question has answered, you can change or delete every entry that matched in one go — the middle ground between editing one row and emptying the whole collection.
Everything above needs a person with a browser. If you want a tool you don't control — a Zendesk trigger, a GitHub Action, a Power Automate flow, someone else's script — to add entries, open the project's Data tab and create an ingest link. You get a URL with a secret in it; anything that can POST JSON to a URL can now add entries, and nothing else:
It is owner-only: being allowed to deploy to a project does not let somebody hand out a standing invitation to write into it. The entry is stored exactly as it arrives — we do not unwrap or reshape the payload, because "what Zendesk sends" and "what GitHub sends" have no shape in common and guessing would be a rule nobody could state.
One entry per request, and the body is the entry:
POST https://deploy.optimizely.com/api/ingest/{token}/{collection}
{ "account": "Acme", "arr": 40000 }
If you want to choose the id an entry is saved under — an account number, a CRM id — so the next run updates that entry instead of adding a second copy of it, put the key in the URL:
| Request | What it does |
|---|---|
POST …/{collection} | Add one entry, id generated for you |
PUT …/{collection}/{key} | Replace the entry at that key (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 an entry a feed wrote (needs "let it remove records") |
POST …/{collection}/batch | Up to 200 of the above in one call |
PATCH is the one to reach for when a sender wants to change a single field. Because the link cannot
read, the alternative is keeping your own copy of every entry and sending the whole thing back, which
drifts.
Sending a lot at once. batch takes an array, and it is the one door where the body is not
the entry: each item is a small envelope saying what to do, with the entry itself under data.
POST https://deploy.optimizely.com/api/ingest/{token}/{collection}/batch
[ { "data": { "account": "Acme", "arr": 40000 } },
{ "key": "acct-1", "data": { "account": "Acme", "arr": 40000 } },
{ "op": "patch", "key": "acct-2", "data": { "status": "stale" } },
{ "op": "delete", "key": "acct-3" } ]
No key means add, a key means replace at that key, and op spells out the other two. An item that
is a bare entry — no data wrapper — is refused, and so is one that puts extra fields
beside data: { "data": { … }, "team": "infra" } used to store only the inner part and
drop the rest.
Read the reply, not just the status code. Items succeed and fail independently, so a batch
where some landed answers 200 and puts the detail in the body — throwing away the ninety
that worked because ten did not would be worse:
{ "accepted": 3, "failed": 1,
"created": 1, "updated": 2, "unchanged": 0, "deleted": 0,
"results": [ …, { "index": 3, "key": "acct-9", "error": "This token may only add records." } ] }
⚠️ created versus unchanged is how you tell a sync is duplicating. A run that adds 2,000
rows and a run that updates the same 2,000 both say accepted: 2000 — so if a feed re-sends the
same records and the reply keeps saying created, it is adding them again rather than
updating them, and the collection is growing every run. That means it is sending no key.
⚠️ If NOTHING landed you get 422, not 200. Nothing was stored, so nothing is thrown away
by saying so — and a sender that only checks the status code would otherwise report success
forever. The body still names the reason per item, and that is the first thing to read when a
feed goes quiet.
Your page can summarise, classify or extract without you holding a key:
const themes = await Opti.ai('Group these comments into 3-5 themes', { input: text })
Two things make this different from everything else in this guide. It costs money each time, so it's off until you turn it on for a project (the AI panel on the project page, which also shows what you've used this month). And every call spends the project owner’s monthly AI allowance, or the workspace’s for a team project, even when it's a colleague clicking — so put it behind a button someone chose to press, and send fifty rows in one call rather than fifty calls.
Ask about a file. Send it with the question — one call, nothing stored:
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:
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.
Which model answers. Your project's AI tab picks it: Fast (the default), Smart (about 10× the cost) or Smartest (about 20×). It applies to every AI call the page makes, and an assistant can carry its own choice that overrides it. If answers are not good enough, this is the first thing to try — no code change. You or a maintainer you have named can change it; a platform admin can only lower it.
How much you can send. One call takes up to 800,000 characters of prompt plus
input — a whole collection rather than a page of it, so send the records and ask the
question instead of summarising in batches. The answer is 16,000 tokens by default and up
to 128,000 if you ask for more with maxOutputTokens. If it runs out of room the reply
says so rather than arriving quietly half-finished.
The allowance is counted in credits: you get 200 a month, and 1 credit = 100,000 tokens. A typical call costs a small fraction of one, so most people will see under 1 credit used at the end of a month — the meter is there for the page that gets away from you, not for everyday work. Need more? There's an Ask for more link right beside it.
It can look things up, and it can do real arithmetic. The plain call searches the web when a question needs a fact it doesn't have, and runs code when a question needs a calculation — so "what changed in the news this week" and "what's the median of these 400 numbers" both work with no setup at all. That last one matters more than it sounds: a model asked to add up a column without the code tool will produce a confident wrong total.
⚠️ Adding up what you collected. input hands the model text. That is right for "group these
comments" and wrong for money: a model totalling a column by reading it gets close and confidently
misses — measured here at 0.4% out on a revenue column, the size of error nobody catches. Name the
collection instead and ask for compute, and the rows go to a sandbox that runs real arithmetic:
// Exact — the rows are computed, not read
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' } })
compute is your call, with no default — a spreadsheet declares itself tabular and rows cannot, so
guessing would trade either the arithmetic or several seconds and real money on a question that never
needed it. You can narrow first with where, using the same operators as any collection read. It reads
with your own access: nothing becomes visible that wasn't already.
When a page needs the model to know something specific — a handbook, a price list, last quarter's numbers — write an assistant in Settings → Assistants. It's a short brief plus files, saved under a name. Then tick it on for the projects that should use it, on each project's AI tab — the same way an API connection works. The page calls it by name:
const answer = await Opti.ai('What does the handbook say about expenses?', {
assistant: 'handbook',
})
A project can have several ticked on, and the same assistant can be on several projects — write
"triage NPS feedback" once and every page that needs it names the same one. Change the brief in
Settings and it changes everywhere, which is the whole reason it lives there rather than on a
project. A call with no assistant behaves exactly as before, and stays the common case.
Give it files — handbooks, notes, PDFs, spreadsheets, CSVs. There's one picker and we sort out where each one goes, because the destination is a correctness question rather than a preference:
| What happens to it | |
|---|---|
| Prose — handbooks, notes, policies, PDFs | Searched and quoted back |
| Spreadsheets and CSVs | Loaded into a sandbox and computed |
That second row is why it matters: a model asked to total a column without running code adds it up in its head, confidently and wrongly. Each file shows which way it went, and adding a spreadsheet turns "run code" on for you.
⚠️ Everyone who can open a project it's on can ask about its documents — and the answer quotes them. That makes the real audience of a document the union of every project the assistant is ticked onto, so tick one onto a project shared with everyone at Optimizely and you have published its documents to everyone at Optimizely.
This is the one way an assistant differs from an API connection. A connection's key never leaves the server — what a colleague gets is an action taken with it. An assistant's documents are the answer. Each assistant in Settings shows which projects it's on; that list is the audience.
Anyone who can open the project's page can use its AI. Ticking an assistant onto a project is the owner's or an admin's — it widens what that page discloses. Writing one, and adding its files, is yours alone, in your own Settings.
Some data never comes from your page at all: it already exists in Snowflake — and most of what people actually want is in there. Salesforce customer data (accounts, opportunities, renewals, the CSM mapping, the org tree), Opal usage and credits, product and instance tables. If the answer to "where does this come from?" is a warehouse table rather than somebody filling in a form, a data feed is the way in, and it is the only way in: a page cannot query Snowflake itself.
An admin, or a feed manager who can deploy to your project, can point a data feed at one of your collections — a query and a cadence — and the platform fills it on a timer. You do nothing, and no warehouse credential ever goes near your project.
Three things a run can do, which the admin chooses on the feed:
MD5(TO_JSON(ARRAY_CONSTRUCT(TO_VARCHAR(DATE, 'YYYY-MM-DD'), EMAIL, MODEL))) AS ROW_KEY, with your own columns in place of these.A large result takes several runs. A feed that deletes what its query drops still does, at the end of the last run, but only if what it stored adds up to what Snowflake returned. A key that repeats, a row without one, a row too big to store, a row someone else wrote, or a result that does not say how many rows it holds keeps everything, and the feed's status says why.
Dates arrive as Snowflake sends them. A DATE column is stored as a day count such as "19724", and timestamps as
seconds, so new Date(row.DATE) on a page gives the wrong date. Format them in the query instead, for example
TO_VARCHAR(DATE, 'YYYY-MM-DD') AS DATE. Two columns with the same name both arrive, the earlier one as NAME_2.
What you see. A line on the collection in your Data tab — "Every hour · filled by a data feed,
managed by …" — and entries arriving with the warehouse's own column names on them. You read it like any
other collection: Opti.collection('accounts').list() cannot tell the difference, and neither can a page.
Refreshing it yourself. A page that reads a feed-filled collection can ask for the query to be run
again — Opti.collection('accounts').fill(). It takes no arguments, because the admin owns the query; you
are only asking for it to happen now rather than at the next scheduled run. Everyone looking at the page
shares one refresh limit, so a refusal usually means somebody else just pressed it, and says so.
What you cannot do from there. Change it, re-point it or pause it — the feed belongs to the admin who set it up, so ask them. What you can do is delete the collection, which stops it for good. That asymmetry is deliberate: they decide what a feed is, you keep the right to end one writing into your project.
How to ask for one. Tell a platform admin which project and collection it should fill and what the data is — the account list for my team, this quarter's renewals, who used how many credits. They write the query. If you do not know who to ask, open the speech-bubble Feedback button in the top nav (inside Menu on a phone), or Get support in your own project's ⋯ menu and choose Warehouse data — a data feed. The box pre-fills with the three things an admin needs (which project, which collection, what data), so the request can be answered rather than replied to.
⚠️ It is deliberately not self-serve. The warehouse holds customer data, and once rows are copied out of Snowflake its own access rules no longer apply to them — so a person decides, every time, which query runs and which collection it lands in. Asking is not a formality queue; it is the control.
Instead of attaching a file, let the model go and find one. Tick The model may search SharePoint
on the project's AI tab, and Opti.ai() can search and read SharePoint while it answers:
const answer = await Opti.ai('What does our onboarding pack say about the first week?')
No link to paste and no file to upload — the model searches when the question needs it.
⭐ It reads as whoever is looking at the page, never as you. Each person sees only the documents they could already open in SharePoint themselves, and nobody sees anybody else's. That is worth knowing in both directions: nothing of yours leaks to a colleague, and a page that works perfectly for you may find nothing for somebody with different access — which is correct, not a bug.
⚠️ The flip side, which is yours to weigh. Answers can quote what the reader's documents say, and your page's code receives that answer as a string. Turn this on for pages you would trust with a colleague's files.
⭐ You do not have to involve the model at all, and this needs NO project setting. A page can read SharePoint directly and then do whatever it likes with the text — show it, chart it, count something in it, put it in a table:
const { results } = await Opti.sharepoint.search('onboarding')
const doc = await Opti.sharepoint.read(results[0].url)
document.querySelector('#notes').textContent = doc.text
Opti.me() tells you who is reading. Opti.directory tells you what else the company directory
knows about them — which is usually the thing you actually want to branch on:
const mine = await Opti.directory.groups.mine()
if (mine.items.some((g) => g.name === 'DSG_SDR_ALL')) {
document.querySelector('#actions').hidden = false
}
⚠️ This hides controls; it does not guard anything. Everything the page receives is readable in devtools by whoever opened it, so treat it as a courtesy to people who should not be clicking a button — never as the thing stopping them. What stops a write is the collection's own rule.
⭐ groups.members(id) gives you the whole group, however big it is — the portal follows
Microsoft's own pages for you. EPiServer Everyone is over five hundred people and comes back
complete.
⚠️ It is colleagues only, so the number you get is smaller than the group's own count: a group
can contain customer and partner guest accounts, and those are removed. truncated is there for
completeness — it would mean a group had grown past anything this company currently has.
Every answer is { items, truncated }, never a bare array, because a list cut short looks exactly like a
complete one. A search returns up to 100 matches and sets truncated when there are more; a
roster and your own groups are never cut off.
Searching with an empty string is a real question rather than a mistake: users.search('') browses
the staff list, and groups.search('') the groups.
⭐ And a spreadsheet comes back as rows, not as prose. Excel and CSV files answer with
columns and rows, so a chart needs no parsing — which is most of what people actually keep
in SharePoint:
const sheet = await Opti.sharepoint.read(url) // a .xlsx
const totals = sheet.rows.map((r) => Number(r.revenue))
Check doc.rows before doc.text, and remember every cell is a string — convert the columns
you mean to use, or an order code 007 reads back as 7.
A SharePoint list reads the same way. Paste the link to any of its views — people and lookups
come back as names — but the view's own filters aren't applied, so filter rows yourself.
Which part of the workbook you get. The first visible tab — hidden tabs are skipped unless a
file has no readable visible tab, and doc.sheets lists the visible tabs that hold cells (not charts). The heading row is the
one the sheet's own filter starts on, or else the first row with anything in it, so a title typed
above your headings becomes the headings. Empty rows are skipped. Dates come back as Excel's serial
numbers (45123), so convert a column you know is a date; a broken formula comes back as its error,
such as #NAME?.
Another tab, or all of them. Name the tab: Opti.sharepoint.read(url, { sheet: 'Budget' }). Case
doesn't matter, and a hidden tab is read when you name it. A name the workbook doesn't have is refused
with the names it does. To read every tab, read once and loop over doc.sheets. Each tab is its own
read, and SharePoint reads per person per minute are limited, so read only the tabs you need.
The Microsoft token is captured when you sign in, so a session created before this feature — or one whose token aged out — has nothing to read with. Fix it at Settings → Microsoft connection: Reconnect Microsoft takes you to Microsoft and brings you straight back. You stay signed in to the portal.
⚠️ If that page offers no Reconnect button, the button would not have helped. It means a re-authentication has already happened and still produced no Microsoft access — pressing it again would just send you round the same loop, so it is not offered. There is nothing for you to change: SharePoint reading is unavailable on that account for now, and everything else in the portal works as normal.
If you are building a page, the thrown error tells you which case you are in: e.needsReconnect
is true when signing in again would fix it, and e.reconnectUrl returns the reader to your page
afterwards. Offer it as a link rather than redirecting — somebody came to read your page.
Nothing is summarised, so there is nothing to be wrong: no tokens, no allowance spent, and no
model deciding what mattered. Use Opti.ai() when you want a QUESTION answered, and this when
you want the CONTENT. Both read as whoever opened the page — ⚠️ but only the Opti.ai() route
needs the tick. Showing a document needs no setting: it reads as the visitor, so it can only
ever show them a file they could already open themselves.
An assistant carries its own switch. Tick Search SharePoint in the assistant editor, beside Search the web. A call that names an assistant asks the assistant's switch and ignores the project's — the same rule web search follows. So an assistant deliberately left unticked stays offline even on a project where the reach is on.
And a turn that may search SharePoint does not search the web. The two are never offered
together, so ticking SharePoint turns web search off for that assistant — the editor shows it as
off while SharePoint search is on — and the project's SharePoint switch does the same for a
plain Opti.ai() call. Untick SharePoint and web search is back as it was.
What it can read. Word documents, PowerPoint decks, PDFs, text and Markdown come back as text; Excel and CSV come back as rows, never as prose — read as prose a spreadsheet produces confident wrong arithmetic, so the numbers stay numbers for code to add up. ⚠️ A scanned PDF is pixels rather than text, so it says so rather than coming back empty — there is nothing to read without OCR.
Knowing whether it actually searched. "It searched and found nothing" and "it never
searched" produce the same shrug of an answer, and only the second means something is switched
off. onConsulted tells them apart:
await Opti.ai('What does our onboarding pack say?', {
onConsulted: (tools) => console.log('used:', tools), // e.g. ['search_org', 'get_file']
})
Names only — never what was searched for or what was found, because that is the reader's business and not the page's.
⚠️ It needs a recent Microsoft sign-in, and that is the real limit today. The permission to read SharePoint rides on the visitor's sign-in and lasts about an hour. After that the model reports that the sign-in expired and the fix is to sign out of the portal and back in. For a page people keep open all day, expect this; for a page somebody opens, uses and leaves, it is invisible.
⚠️ And it does not run unattended. A scheduled automation has nobody signed in to read as, so the tools are not offered there at all — the tick on an assistant means "when a person asks".
Your page is static, so a key written into it is readable by everyone who opens it. Instead, save the key once in the project's API connections panel, and let the page ask for it by name:
const me = await Opti.call('jira', { path: '/myself' })
The key stays on the server and never reaches the browser. You give a path, not a URL — the connection decides the host — and you choose whether pages may only read (the default) or also write. A connection belongs to you, not to one project: any project you tick it for can call it, so its real audience is the audience of all those projects' pages. Worth knowing before you tick "allow writes": the call happens because a colleague opened your page, briefly acting with your credential.
Pick how the API authenticates. APIs do not agree on this, so the form asks. Five ways:
| you fill in | typical of | |
|---|---|---|
| A header | header name + credential | Authorization: Bearer …, X-Api-Key |
| A query parameter | parameter name + credential | keys that ride in the address, like ?api_key= |
| A username and password | both halves | older internal APIs — the portal does the encoding |
| A client id and secret | token URL, client id, secret, optional scope | Salesforce, Marketo, Microsoft Graph |
| Several tokens, one chosen per call by a key | header name + an optional scheme (Bearer); the tokens come after saving | one Opal PAT per instance, one key per account or site |
Several tokens keeps one connection for many accounts instead of one connection each. Save it, then
open its Tokens list and add each token under a key you choose — cs-ai-prod, sandbox-2; for a token
Opal issued, the list offers its instance id as the key and shows whose account it acts as and when it
expires. A page then names the key on every call — Opti.call('opal', { key: 'cs-ai-prod', path }) —
and Opti.connectionKeys('opal') lists the keys with your labels, never a token, so a dashboard can loop
over every instance without changing when you add one. A missing or unknown key is refused; it never
falls back to another token. Each token has its own Try it. Anyone who may change the connection may
add a token, and the list records who added each. A copy made by Duplicate carries no tokens.
That last one is two steps: the portal swaps your id and secret for a short-lived token before each call, and never stores the token. It is what lets you connect an API whose credential expires every hour without re-pasting it every hour.
There is also No credential — the URL carries the secret, for webhook-style endpoints (Zapier, Teams, Power Automate) where the address is the key. Tick it deliberately; a blank credential box does not mean the same thing.
⚠️ A query credential travels in the web address, so it may appear in the other system's logs. Use a header where the API allows one.
Paths it never forwards. The form also takes a list of path prefixes a page may never call, whatever
the method — matched however the path is spelled (case, //, .., percent-encoding). A connection to
Opal starts with /authz/ there, so no page can revoke the very token it is using; you can clear it.
Try it before you write a page against it. Each saved connection has a Try it button: give it a
path and it sends a real GET with your credential and reports what came back — 200, 401, 404, and
how long it took. Several things have to be right at once — the address, the methods, and whatever the
authentication kind you chose asks for (a header name, a parameter name, a username and password, or a
token URL with its client id and secret) — and the credential is never shown again once saved, so this
is the only way to find a typo without deploying a page first. A successful reply is not shown — you get
the status and the timing, not the contents.
Nothing typed sends a GET. If the API only answers POST — GraphQL, Elasticsearch, Jira's search,
Snowflake — write the request in the Body box and it goes as a POST instead. The connection's own
methods still have to permit POST, so this can only narrow what you already allowed, and a connection
that permits neither GET nor POST has no safe test at all: the button says so rather than filing
something real on the other side.
⚠️ The body is yours alone. An admin looking at your connections can press Try it — that has always
been true, and it sends a GET — but only the person the credential belongs to can write a body and send
it. Composing a request through somebody else's secret is a different thing from checking it still works.
And the panel now says whether it has been working: how many calls pages have made in the last 30 days, how many of those failed, and when it was last tested. Those are two different facts and it says which is which — a connection can be tested by you and never called by anybody, which is exactly the state a connection nobody wired up yet is in.
The limits. A reply can be up to 20 MB, the body you send up to 8 MB, and a call has 180 seconds to finish. Over any of those, the call fails with a message naming both the limit and the actual size. Read it carefully: by then the other API has usually already answered, so it's the portal declining to pass the reply on rather than your API breaking — which is exactly what it looks like from the page.
Size is measured after decompression, so gzipping a big reply won't get it through. If you're close, ask the other system for less: filter, paginate, or request fewer fields. Your page has to parse whatever comes back, so a narrower reply is the faster page anyway.
Your page calls with your saved credential, so the system on the other end sees one caller: the portal. It cannot tell which colleague opened the page — which is a problem the moment you want to look something up for that person.
You cannot solve it from the page. Anything a page sends about who is using it comes from that person's browser, so it is whatever they choose it to be. A backend trusting it would be trusting the caller to say who the caller is.
The portal can, because it checked the sign-in before the call went out. Turn on Caller identity on the connection in Settings, and every call carries one extra header:
X-Opti-Identity: <JWT>
A short-lived HS256 token, signed with a secret shown once when you switch it on. Give that secret to whoever runs the backend; they verify it in three lines:
import { jwtVerify } from 'jose'
const { payload } = await jwtVerify(
req.headers['x-opti-identity'],
new TextEncoder().encode(process.env.OPTI_IDENTITY_SECRET),
{
issuer: 'https://deploy.optimizely.com',
audience: 'jira', // the connection name
clockTolerance: '60s', // your clock and ours are not the same clock — see below
},
)
// payload.sub the person's email — verified, not claimed
// payload.name their display name
// payload.project which page called, as owner/slug
Keep the clock tolerance. The token lives five minutes, and two machines decide whether that has elapsed. Most JWT libraries allow no skew at all, so a server running a minute fast refuses every token — and reports it as an expiry failure, which reads like a signing problem and sends you looking in the wrong place.
What the signature is worth. Editing the email invalidates it. A token minted for one
connection is refused at another, because the connection name is the audience. It expires
in five minutes, so a captured one is not a key. And nothing in the page can produce one —
Opti.call refuses caller-supplied headers, so the token cannot be replaced on the way
out.
Where. Settings → API connections, under the connection itself: Caller identity
(the ? beside it explains what it does). Switching it on issues the key and shows it once — copy it then, because
nothing can display it again. Lost it, or think it leaked? New key issues another and
the old one stops working immediately, so the only cost is telling the backend the new
value. Turn off destroys the key as well as clearing the flag; a disabled connection
still holding a live signing key is a credential nobody remembers to rotate.
One key per connection — never reuse one. HS256 is symmetric: the key that verifies is the key that signs. Give two backends the same key and either of them can mint a token claiming to be anyone and present it to the other. Per-connection keys mean a leak is contained to one integration and you can rotate it alone.
Off by default, and worth leaving off unless the other end verifies it: sending a colleague's address to a system that ignores it is a disclosure that buys nothing.
Your page only runs while someone has it open, so anything recurring is set up in the portal rather than in code. On the Automations tab you say what should happen without you. Start from one of the recipes — email me a weekly summary, watch an API and tell me when something needs attention — and edit what it fills in; nobody has to start from a blank form.
An automation starts either on a clock or when something happens:
accountId, an order carrying its customer — name that collection and that field, and
the matching record travels with it as related, beside the entry rather than merged into it. Pick
which of its fields to send, or leave that empty for all of them. If the id is missing or matches
nothing the automation still runs, and the run says which id it couldn't find.
The id is the key that record was saved under — and an entry written by a form gets a generated
one you never see. So the collection you look in usually wants importing with keys you choose,
one per record (an account number, a CRM id), through PUT /api/ingest/{token}/{collection}/{key}.
Then put that same value on the arriving entry. Matching on an ordinary field instead — "the
account whose email is…" — is a search of that collection on every single arrival, which is why
the lookup is by key and not by field.And it does one of these:
The form asks what it should look at — this collection's entries, or an API — rather than asking you to pick a product concept; everything below follows from that answer.
Whichever it looks at, you then say where the result goes. A Teams channel, your inbox, a
named group of colleagues — and, since it can also post the result on to one of your API
connections, another system. That last one is what turns "tell me a request came in" into
"raise the ticket". It only offers connections you have allowed POST on, and it says so on
the form when none of yours do.
Looking at the collection — "every Monday, tell me what arrived." It can go to a Teams channel, to your inbox, or to a named group of colleagues — email alone is fine, so you don't need a channel to get one.
Looking at an API — "every morning, call Jira and save the result." It calls one of your API connections on the timer and stores the reply in the collection, so the page reads data that's already fresh:
const status = await Opti.collection('status').doc('jira').get()
// { fetchedAt, source, value } — `value` is the reply
A fetch can also keep every result instead of overwriting one value, which is what turns it from today's answer into a history you can chart.
This is usually better than calling the API on every visit: nobody waits for it, you don't hammer the other system once per viewer, and a page nobody opened today is still current. It needs an API connection (not a channel), and it is always read-only — a scheduled job never writes to the other system. If a run fails, your last good data stays put rather than being replaced by an error.
A fetch used to store the reply and tell nobody, and a digest could only say how many. Every shape
can now reach into what it got and speak about it — including the scheduled digest, whose window is a
list: [].title is every new entry's title, and left empty it still sends its usual count. That is what
makes the flagship one buildable: every Monday, read what arrived, have an assistant summarise it, email
me.
open, or issues[].title for every one of them. Leave it empty for the whole
thing. Run now shows you the real answer first, so you can read the shape before you write a path
for it.issues is not empty,
only if open is more than 10. A list compares by how many there are. ⚠️ If the field isn't
there at all, nothing is sent — the alternative is a broken integration mailing you every morning
forever. This is checked the same way every time, deliberately: something deciding whether to interrupt
you shouldn't give a different answer on Wednesday for the same data.You never have to wait to find out it works. Every automation has Run now: a digest really posts, using your real numbers when there are any, and never uses up the entries the scheduled one will report; a fetch really calls and shows you the reply without saving it; and one that runs on arrival fires against your most recent entry — or against sample fields if the collection is empty, saying plainly which it used. You never have to submit junk into your own live collection to find out whether a condition is right.
And you can see what it has been doing. Every automation keeps a run history: each run, newest first, with what happened at each step — where the data came from, whether the condition held, whether the message was accepted. Runs you fired yourself are marked by hand, because "it worked when I pressed it" is a different claim from "it works on its own". It records what happened, never the data itself.
Change it, pause it, or remove it. Edit opens the same form filled in with what the automation does now — and because the change keeps its identity, the run history survives it. Pause stops it running and keeps everything, though a large result it is part-way through collecting still finishes, and a feed set to replace everything then replaces its older rows; Remove asks first, then deletes it and its history. A connection that refuses counts as a failure, and so does a channel unless an email was heard; on a digest, a failed email alone does not count, because one mail outage would otherwise switch off every email digest. A digest nobody received keeps its entries for the next run; where a digest has a connection, that alone decides, because sending to it again would repeat real work. And if an automation fails five times in a row we switch it off and email you, rather than quietly retrying against a broken setting forever — a stopped automation and a quiet one look identical otherwise.
⚠️ One combination is refused: an automation that fetches can't also run on arrival, because it saves what it reads into the collection that would trigger it — it would set itself off forever. And an arrival trigger runs at most 500 times an hour, so a busy form can't flood a channel.
Entries a feed adds count as arrivals, except on a feed set to Replace everything. When a data feed or another automation adds a row to a collection — a new contact from Snowflake, a run appended to a history — an automation on that collection set to run whenever an entry arrives runs for it, exactly as it does for a form submission. An assistant automation spends AI credits on each one. Rows the feed only re-reads don't count; with …and whenever one changes ticked, only a row whose content actually moved does — never the time stamp a feed writes on every row. A summary the assistant saved onto a row stays as long as the row's content doesn't change.
A feed's first fill is read as a fill, not as arrivals, when it adds more entries than the hourly limit: nothing runs for those entries, and the run history records one run saying so — one of the hour's runs, not sixty. Every later run's new entries are arrivals. Past the hourly limit, one run per write says how many entries weren't handled: a batch sent in one request is one write, while entries sent one request at a time are one write each. A CSV import and a bulk update from the Data tab never set off an arrival automation.
Everything above assumes you have something to show. Sometimes you don't: you want somewhere for another system to send data, a query that runs every morning, or a weekly summary posted to a channel — and there is no page in that story at all.
On the dashboard, under the upload bar: "No page? Collect data instead." It asks for a name and nothing else. What you get is a project with the whole backend and no front end — a data project.
Everything it does lives on its Automations tab — the inbound link, the scheduled
fetch, the digest, and whether each one is actually working. Start there: it asks you to
name a collection first (one kind of thing this project stores — tickets, signups,
daily-snapshot), because everything else writes into one. ⚠️ That name can't be changed
afterwards, so pick the plural of the thing you're collecting. You can add more collections later from
the same place, or from the Data tab.
It can do three things, and they are the three things a page would have done for you:
Every one of them can be tried immediately and keeps a run history (see Automations).
You still get everything else a project has: who can open it, who can update it, CSV export, the record table, and the audit trail.
What it does NOT have is a page — so there is no Open button, no short link, no QR code and no visitor analytics, because there is nothing to visit. Its address is not a web address; it is just where its data lives.
Getting it all back out. ⋯ → Download everything on the project page gives you one zip holding
every collection as CSV and JSON plus every file people attached — and unlike Download, it works
on a project that has no page at all, which is the whole of this shape. ⚠️ It nests its contents under
site/ so that uploading the zip by mistake cannot republish your entries as pages; use Download
when you actually want to re-deploy.
You can change your mind at any time. Deploy files to a data project and it becomes an ordinary project with a page, keeping everything it has already collected. It takes no new slot, because a data project already counts as one of your projects. There is no way back the other way — an ordinary project already has every one of these features, so there is nothing to convert to.
Everything else on this page shares with colleagues. "Public" here has always meant everyone at Optimizely, never the internet. For a customer, an agency, a candidate or a partner, this is the door.
⚠️ You probably cannot see it yet. While the feature is approval-gated the panel is rendered only for the support admins, so on most people's screens it is not there to find — ask one of them rather than hunting for it. On theirs it is Project details → Open from outside Optimizely (the panel's title; "external link" is what it mints, not what the heading says).
It mints one address that opens the project with no sign-in at all.
<form> on the page can save entries into
one collection you have set to anyone who can open the app. The panel then shows the exact
address to point the form at. Entries are stamped external-link, never a colleague's name, and are
rate-limited rather than prevented.⚠️ Unlisted, not secret. Anyone holding the address can open it and it never appears in Browse.
Treat it like a document link, not a password. Opti.me() returns null there — there is nobody to
name — so check before you use it, or the page breaks for the one audience that cannot tell you.
Every project with a page has an Analytics tab: visits over time for the window you pick, and Who viewed — each colleague who has opened it, how many times, and when they last did.
As a spreadsheet: the tab's Download as CSV links give colleagues' visits per day (or week or month) in the window you picked, and every viewer, all time. Each download is recorded in the project's Activity.
From your own page: Opti.analytics({ days }) returns the same figures. It answers owners,
editors and platform admins only, so put it on a panel they open, not on every visitor's page.
From Claude or OptiX: ask "who has looked at my site?" or "how many visits this week?" —
read_analytics over the Claude connection, and OptiX in the portal.
Click your name/avatar in the top nav to go to your own URL —
https://deploy.optimizely.com/your-name — a personal home with five sections:
This page is private — only you can see it. (Admins are an exception.)
Your dashboard shows them too, in one Quick access strip — starred, shared with you, and recently viewed together — so you don't have to leave home to find them.
Every card across the portal — your dashboard, your page, and Browse — has a ★ in the corner:
Sharing a page is just sending its URL. When a colleague sends you theirs:
Un-starring removes it from Starred but keeps it in Recently viewed.
Browse is in the top nav for everyone. It shows every public project in the org — no opt-in, nothing to tick — plus any private ones shared with you. Starter apps stay out of it, and a locked project is hidden from everyone.
It opens on colleagues' work, because your own already has a home on your dashboard. Sort by most opened this month (counted in people, not clicks), recently updated, or newest. Search covers names, notes and tags.
Tags are how a project gets filed — add up to 20 on the project page or straight on its card, and click one anywhere to see everything else like it. Your own projects there keep the full owner toolset; colleagues' are read-only with a ★ Save.
Everything above belongs to you. A workspace is the answer when it should belong to a team instead: it is a namespace with people in it, and it owns the projects created in it. When you move team or leave, they stay exactly where they are — nobody has to hand anything over, because it was never yours to hand over.
That is the whole point, and it is why a workspace is not the same as sharing:
| Who owns the project | What happens when that person leaves | |
|---|---|---|
| Sharing / a group | You | It leaves with you |
| An editor | You | It leaves with you; they could only ever update it |
| A workspace | The workspace | Nothing. It stays, at the same link |
Go to Workspaces in the top nav → New workspace. Give it a name and an address. Anybody can make one — there is no approval to wait for.
⚠️ The address is permanent. It comes out of the same pool as everybody's username, so
/design can be a person or a workspace but never both, and it cannot be changed afterwards.
The dialog checks it as you type.
You may own up to 3 workspaces (an admin can raise that for one person). Hand one to a colleague and you get the slot back — a workspace always has at least one Admin, so it can never be orphaned.
Roles belong to the workspace and are not the same as a project's editors:
| Role | Can |
|---|---|
| Admin | Everything: add and remove people, deploy to any project the workspace owns, change its name |
| Can add projects | Add new projects, and update the ones they made |
| Can view | Open every project in the workspace |
⚠️ Someone who can add projects cannot overwrite a colleague's live site. That is deliberate — a team of eight where everyone can replace everyone's work is a blast radius nobody asked for.
⚠️ A workspace always keeps at least one Admin. The last one cannot be removed or given a lesser role, and a platform admin cannot delete that person's account until someone else in the workspace is an Admin. Make the change in the workspace's People tab; a suspended workspace has to be reactivated first.
Adding somebody emails them, because it opens every project the workspace owns.
Open the workspace (https://deploy.optimizely.com/{its-address}) → the Projects tab has the
same uploader as your dashboard. What you deploy there lives at
https://deploy.optimizely.com/{workspace}/{project}/ and belongs to the workspace: its data,
its API connections, its AI budget and its cap are all the workspace's, not yours.
⚠️ An existing personal project cannot be moved into a workspace. A project is owned by the workspace from birth — its data is stored under that name from the first write — so there is nothing to move it with. Duplicate copies one into a workspace where you can add projects (as Admin or Can add projects) instead, starting it fresh at version 1; the original stays where it is, and its data stays with it.
Claude, Opal and Site Builder can all deploy into one. Ask for it by name — "deploy this into the Growth Lab workspace" — and the workspace owns the result. Claude and Opal read the list of workspaces you may use when they connect, so they can only offer the real ones.
⚠️ Say the workspace, or you get a personal project. None of them guess: leave it out and the project is yours alone, which looks exactly like success until somebody goes looking for it under the team.
A workspace is never deleted while it holds anything — the links would break, and the projects would have no owner. Suspend it instead (an Admin, from the workspace's Settings tab): nothing new goes in, everybody keeps what they can already open, and it can be reactivated at any time. An empty one a platform admin can remove outright.
Some things belong to you, not to a single page: a credential, a Teams channel, a group of people. Keeping them on a project made the same channel look like five different ones, so they live in one place — Settings, in the top nav — and every project refers to them by name.
| What it is | Why it's here and not on a project | |
|---|---|---|
| API connections | A saved credential your pages call by name — Opti.call('jira', …) | One key, for every project you tick it for. The secret is write-only: replace it, never read it back. |
| Channels | A Teams or Power Automate webhook, saved once with a name | Announce deploys, post form submissions, send digests — all to the same saved channel. Rotate the URL in one place. |
| Groups | A named list of people | Share a project with the group instead of naming everyone. Add someone once and they get every project it's on. |
| Access tokens | What can act as you — Claude, the CLI, a GitHub Action | They deploy into your namespace against your quota, so revoke rather than share. |
| Your requests | Support tickets and limit increases you filed | So you can see what you asked for without going hunting. |
Each row shows which projects use it, and you can attach one to a project from either end — from Settings, or from the project's own page.
You don't have to go there first. Wherever a project asks for a channel, a connection or a group, you can make one on the spot — it's created and selected without leaving the page. Settings is where you manage them: rename, test, see what they're on, remove.
Bottom-right of every page there is an Ask OptiX button. It knows this portal, it can see the same things you can, and it answers in a sentence rather than sending you to a page to read.
What it answers well. "Who can open this project, and why can't Sam?" · "What has my sign-up form collected?" · "How do I share with a whole team?" · "Who's viewed this?" · "How much AI budget have I got left?" · "Has anyone built a dashboard like this already?" · "Is anything waiting on me?"
It sees exactly what you see — no more. Every lookup runs as you. Your own projects in full; other people's only if they're public or shared with you, and then without their allowlist, because who else can open somebody's project is theirs to know. It cannot read another person's private project, and it cannot read a credential — not even to name one.
It can draft, never do. Ask it to create a group, file a support request, change a project's privacy, or attach one of your saved groups / connections / channels to a project, and you get a confirmation card: what will happen, who it reaches, how many people get emailed. Nothing happens until you press the button, and the request then goes out as you — the audit trail shows your name, not OptiX's. Cards expire after ten minutes. For a group you can fix the member list on the card before confirming.
Things it deliberately can't do at all: rename or delete a project, or add editors.
⚠️ It CAN propose a connection (propose_connection) — right up to the credential, which you
type on the confirm card yourself — and it can propose publishing a draft, which makes a new live
version. It can also propose an assistant, a collection, an automation, a group, a privacy change,
an attachment and a support ticket. Every one of them is a PROPOSAL on a card you confirm; none of
them happens because OptiX decided to.
Ask as much as you like. Unlike Opti.ai() on a deployed page, OptiX has no monthly
budget. Conversations are saved to your account so you can come back to them, and an admin
can read one — it's recorded in Activity when they do.
A page can email Optimizely people — a support desk answering a request, a form confirming what it received — or put a line in their portal bell.
await Opti.sendMail({ to: 'jane.doe@optimizely.com', subject: 'Your request #142 has a reply', text: 'Hi Jane,\n\nIt is fixed.' })
// { sent: 1 } — or { sent: 0, skipped } where email is not live
await Opti.sendMail({ to, subject, text, bell: true }) // email AND a line in their portal bell; the reply adds { bell }
await Opti.notify({ to: 'jane.doe@optimizely.com', title: 'Your request #142 has a reply' }) // the bell alone
// { bell: 1 } — with bellFailed when some could not be saved
to and cc, at most 10 per email. One outside address refuses the whole send and names it. There is no bcc, from or replyTo.bell: true on Opti.sendMail (both) when the person might not. A bell has no body: Opti.notify takes to, cc and title, nothing else.Until email is switched on, the refusal reads "This app is not allowed to send email or notifications. Ask a portal admin to allow it for this project." Every send is in the project's Activity as
Opti.sendMail, with who sent it, to how many, and whether by email, bell or both.
The whole SDK. It is already on the page (<script src="/collect.js">), there is nothing to
install, and no key to paste. Types for your editor: /// <reference path="https://deploy.optimizely.com/opti.d.ts" />.
Data — Opti.collection('name') (add { app: 'other-project' } to read another of your projects)
| Call | What it does |
|---|---|
.add(data) | Save a new entry. Stamped with who sent it, by the server |
.list({ limit, offset, where }) | Newest first. 50 by default, up to 5000 per call |
.page({ … }) | Same, plus total, hasMore and nextCursor — use it whenever you show a count |
.counts(field, { where }) · .aggregate({ sum, avg, min, max, by }, { where }) | How many of each value, and how much — over the whole collection, not the page you fetched; by groups totals as counts groups. Both report skipped and truncated |
.all(opts?, onPage?) | Every matching entry, paged for you. onPage renders progressively instead of freezing |
.update(id, data, { rev }) | Replace an entry. Pass rev and a clashing write gets a 409 instead of winning |
.remove(id) | Delete one |
.upload(file) | Store a file (up to 25 MB), get a reference to put in an entry |
⭐ Charts. Load any charting library from a CDN — Chart.js, D3, whatever you like — and feed it .counts(field), which answers over the whole collection in one small request. No AI needed, and a model asked to add up a column can be confidently wrong where this cannot.
One row per person — the server derives the row from the session, so no page can write to somebody else's:
.mine() · .save(data) · .forget()
Refresh a collection a data feed fills — .fill(). A collection filled from the company
warehouse used to be refreshable only by the admin who built the query, or by waiting for the
timer; now the page that reads it can ask. An admin still owns the query — this only asks for it
to be run again, and takes no arguments. ⚠️ Everyone looking at the page shares ONE refresh
limit, because the warehouse is what is being protected, so a refusal usually means somebody else
just pressed it — show the message rather than retrying.
Many rows at once — .addAll(rows) appends them; .addAll(rows, { key: 'id' }) upserts on
your own id, so a sync or a refresh button can run twice without writing the whole set again.
Pass a third argument to watch progress: .addAll(rows, { key: 'id' }, (reply, done, total) => …).
It chunks for you and sums the reply, which says created / updated / unchanged / deleted
rather than one flat total — so a run that duplicated its data is visible in the first response
instead of in a row count weeks later.
A record you address by your own key — .doc('key'): .get() · .set(data, { rev }) · .update(fn) · .remove().
Use it for one shared thing (doc('status')) and for many records keyed on an id from another system:
for (const row of rows) await c.doc(row.id).set(row) is an upsert, so a sync can re-run without duplicating.
⚠️ .add() appends — a refresh button built on it writes the whole set again on every click and still answers 200.
⚠️ Creating needs write access; replacing an existing record also needs the right to change it, which on the
default recordMode: 'own' is whoever created it — a collection several people refresh needs recordMode: 'shared'.
Use .update(fn) for anything several people edit — it reads, applies your change and writes back
guarded, retrying if someone got there first. Without it, two simultaneous edits mean one silently vanishes.
Ask a question instead of downloading everything — where, filter with or, sorting and paging by cursor have their own section below, Asking a collection a question.
Only the fields you need. fields narrows what each record carries — worth it when a row
holds something long you are not drawing:
await Opti.collection('teammates').page({ fields: ['name', 'organization_name'] })
Two collections in one read — join. A contact carries an account_id; the account lives in
another collection. Asking for both used to mean downloading both and matching them in the page.
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 // still the CONTACT's name — `related` sits beside data, never merged in
on names the field on the row you are reading whose value is the other 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. ⚠️ You only get what you may read: the other
collection is checked against your own access, exactly as the rows are — so a join is never a way
to see a collection you could not open directly. A row whose relation is missing or hidden
arrives with relatedNote saying so, instead of quietly looking complete.
It is a lookup by id, not a search, so it costs the same whether the other collection holds ten
rows or ten thousand. Ids are deduped — four hundred contacts across five accounts is five
lookups — and a read needing more distinct lookups than the cap says join.truncated.
The question is still asked of the whole record and total is unaffected — narrowing the
payload never narrows the answer. A field a record does not have is simply absent.
⚠️ truncated means total is a floor, not the answer. A filtered read examines up to
10,000 entries and then stops — worth checking on a collection that keeps growing, because a
truncated count reads exactly like a complete one.
The rest of the SDK
| Call | What it does |
|---|---|
Opti.ai(prompt, { input, assistant, schema }) | Ask a model. Returns text, or parsed data when you pass a schema. It can search the web and run code. Off until the owner or a maintainer switches AI on |
Opti.sharepoint.search(query) · .read(url, { sheet? }) | SharePoint with no model in the way. search returns { results: [{ name, url, summary, lastModified }] }; read returns { name, text } for a document and { name, columns, rows } for a spreadsheet or a SharePoint list; read(url, { sheet }) reads a workbook's other tab by name. Nothing is summarised — no tokens, no allowance, nothing to hallucinate. Reads as whoever opened the page, so each person sees only their own documents. Needs no project setting — it runs on the reader's own Microsoft access |
Opti.directory.groups.mine() · .search(q) · .members(id) · Opti.directory.users.search(q) | Who the reader is, beyond their name. groups.mine() gives the Entra groups the person reading the page belongs to, so one page can show action buttons to an SDR and hide them from a CSM. Every answer is { items, truncated } — check truncated before saying "everyone". Colleagues only; the tenant is mostly customer and partner accounts and they are filtered out at the source. ⚠️ It tells you, it does not enforce. A visitor can read any of it in devtools, so hiding a control is a courtesy — what stops a write is the collection's own rule. Returns nothing on an external link |
Opti.call(connection, { path, method, body, key }) | Call another system through a saved API connection, by name. The credential stays on the server. key picks one of a connection holding several tokens — required there, never defaulted |
Opti.sendMail({ to, cc, subject, text }) | Email Optimizely addresses only, as whoever pressed the button; bell: true also rings their bell. See its section. |
Opti.notify({ to, cc, title }) | A line in the portal bell alone, no email. See its section. |
Opti.connectionKeys(connection) | The keys of a connection holding several tokens — [{ key, label, instance }], never a token. instance is there for an Opal PAT, because every Opal path carries it; a key for another platform simply has none. Loop over it for a page across every instance |
Opti.me() | The signed-in visitor ({ name, email }), for display. The server decides who wrote what |
Opti.activity({ days }) | The project's Activity tab, as data: changes, API calls, automation runs and AI calls over the window, for the project's owners, editors and platform admins. Opti.activity() asks for a view with no one named for a call or a run and no model name, whoever opens the page. A log that could not be read is listed in unread |
Opti.analytics({ days }) | The project's Analytics tab, as data: visits over time and each person who opened the site, how often and when they last did. It answers the project's owners, editors and platform admins only, and throws the server's sentence for anyone else, so put it on a panel they open. totals covers the window; viewers covers all time, so the two counts of people need not match |
Opti.owner · Opti.app | Who owns this project, and its slug |
Opti.basePath | The prefix this page is served under — only needed by client-side routers |
Opti.ready | True when the portal is serving the page (so it has a store) |
Opti.external | True when the page is being served through an external link — to somebody outside Optimizely. Opti.me(), Opti.owner and Opti.app are all null there, so branch on this rather than assuming a name |
Ask a question instead of downloading everything. where is answered across the whole
collection, so the count is about your data rather than about what you fetched:
await Opti.collection('signups')
.page({ where: { path: 'status', op: 'eq', value: 'open' }, limit: 5000 })
// → { records, total, hasMore, offset, nextCursor?, nextUrl?, scanned, truncated? }
⭐ Walking a long collection: carry on, don't count. .page() hands back a nextCursor, which
marks where it stopped. Pass it as cursor for the next page and every page costs the same, however
far in you are — where offset makes the database read through everything it skips, so page 40 of a
big collection is slow in a way page 2 is not. .all() already does this for you; nextUrl is the
same request one page on, ready to fetch as it is.
let cursor, rows = []
do {
const p = await Opti.collection('signups').page({ limit: 1000, cursor })
rows = [...rows, ...p.records]
if (p.stoppedEarly) throw new Error(p.stoppedEarly) // more rows, no link to them
cursor = p.nextCursor // absent once there is nothing left
} while (cursor)
⚠️ Loop while there is a cursor, then check hasMore. If it is still true with no cursor the
reply carries stoppedEarly: there are more rows and the next page cannot be linked, so what you
have is a PART of the collection and must not be shown as all of it. .all() raises that for you.
⚠️ Walking with a where? Check truncated too. A filtered read looks at a bounded number of
records, so a walk can reach that bound and stop — hasMore is false and there is no cursor, exactly
as at the true end. truncated: true is what tells the two apart. .all() throws for you; a hand-rolled
loop has to look. (.counts(field) answers over the whole collection in one request, and is usually
what a filtered walk was really after.)
⚠️ A cursor belongs to the read that made it. Change the question, the collection or who is
asking and it is refused rather than quietly answering from the wrong place — start again from the
first page. Sending cursor and offset together is refused too: they are two ways of asking for
the same page, and honouring one silently would hide which answer you got. Cursors are for one
sitting — good for a day, not for storing — and every refusal carries the code invalid_cursor.
Operators: eq ne gt gte lt lte contains startsWith endsWith empty notEmpty in.
startsWith and endsWith are the stricter halves of contains — same case-insensitivity, same element-wise test over a list, but anchored. in takes a list
({ path: 'team', op: 'in', value: ['data', 'web'] }) and only reads — updates and deletes refuse it.
A field holding an object matches no comparison — only empty and notEmpty see it. Ask about a field inside it (meta.team) instead.
where also takes an array, ANDed — which is how you write a range, since one condition
cannot carry two bounds. Up to four.
⭐ filter adds or and groups — { or: [cond, { and: [...] }] } — and matches EXACTLY: eq is
case-sensitive (ieq, ine, iin, icontains, istartsWith and iendsWith ignore case) and 5 is
not "5". A search box is an or of icontains. Reads only; not beside where.
Either question can be sorted, and totalled. page({ filter, sort: '-amount' }) orders every
match, not the page you fetched. .aggregate({ sum: 'amount', by: 'region' }, { filter }) adds them up
on the server — sum, avg, min, max, grouped by by — and .counts(field, { filter }) counts each
value; both say truncated if they could not look at every record.
Lists and blanks are their own questions in filter. A field holding a list is asked with has
({ path: 'tags', op: 'has', value: 'urgent' }), hasAny, hasAll or size. A list of things —
order lines, attendees — is asked item by item, { path: 'lines', any: [ … ] }, and one item must
answer the whole inner question, so "a line with sku A-1 and at least 2 of them" never matches an order
where one line has the sku and another the quantity. And exists, missing and isNull tell "never
sent" from "sent empty", which empty deliberately folds together.
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 (2026-09-14) order correctly — that is what makes a date range work.
all() takes the same options: all({ where }), or all(opts, onPage) to render as it pages.
A filtered read examines a bounded number of records: past that, page() says truncated: true
and all({ where }) throws rather than return part of the matches — read with all({ fields })
and filter in the page instead.
Projects
+10 (admins approve up to 500)..zip, and it is also the ceiling on
any single file. ⚠️ A dragged folder does not travel through the app: it goes straight to
storage and may total 1 GB./…/v2/), and you can set any of them live from the project page.Data
| Entries per collection | 1,000,000 |
| Collections | 100 per project, 1,000 per person |
| Reading | 50 per call by default, 5,000 maximum — pass limit, or you get 50 |
| Response size | 8 MB — a page comes back shorter, never silently cut |
| One entry | 1 MB (a named doc() — the keyed kind you'd cache an API reply in — may be 1.5 MB); bigger wants a file, not a field |
| A filtered read | examines up to 10,000 entries, and tells you if it stopped early |
| A question | up to 4 conditions, ANDed — no OR |
| Batch write | 200 add/update/remove per call — a different limit that happens to be 200 |
| Ingest | 10,000 entries a minute, per token — a project may hold 5, so the ceiling multiplies by the number of senders |
| File on an entry | 25 MB |
| CSV import | 8 MB |
The Data tab starts at 50 rows and the Per page picker beside the count goes up to 1,000 — the same ceiling the API has. Bigger pages are worth it for an export and slower to open, which is why they are a choice rather than the default.
Policies
claude mcp list. If opti-deploy is missing, re-run the claude mcp add --scope user … command.Some Claude clients don't auto-open the auth URL — they print it to logs you might not see. To force the flow manually:
pkill -f mcp-remote && rm -rf ~/.mcp-auth
npx -y mcp-remote https://deploy.optimizely.com/api/mcp --debug
This runs the auth flow interactively and prints the URL. Open it, sign in, the token gets cached. After that, restart your Claude client and it'll use the cached token.
invalid_client"Token cache may be stale (e.g. after we redeploy the server). Clear it:
rm -rf ~/.mcp-auth
Then re-trigger the OAuth flow by running any Claude command that uses the deploy tool.
Read the error message — it includes the exact command to reconnect, tailored to your client. Typically:
claude mcp remove opti-deploy
claude mcp add --scope user --transport http opti-deploy https://deploy.optimizely.com/api/mcp
…or for Claude Desktop / Web: Settings → Connectors → re-authenticate "Optimizely Deploy". Claude renews its own tokens, and asks you to sign in again after 90 days at most, or after 30 days unused.
On a project's Project tab, Deploy on every push from GitHub → Link this repository names the repository and branch that may deploy it. GitHub proves each run came from there, and the run gets a credential for that project alone, for 15 minutes. Copy the workflow it shows you. There is no secret to add or rotate. Runs act under the name of whoever linked it, so unlink it if you leave the project. Pull requests cannot deploy this way.
Access tokens expire. The one the Deploy from GitHub dialog mints lasts 90 days, and you're emailed before it lapses; a token you create by hand under Settings → Access tokens lasts 30 days. When one lapses it stops working and drops off the list — nothing says "expired", it's simply no longer there — so an Action that pushed fine for months just starts failing.
Fix: generate a new token from the project's page (Deploy on every push from GitHub) and replace the value in your repo secret. Same workflow, same URL, same project.
Claude is different — it re-authenticates itself, so you never rotate anything by hand. If it ever does ask, the error carries the exact reconnect command.
Tip: open Settings → Access tokens now and then. Anything expiring within 14 days is flagged in amber, so you can rotate it before it breaks.
A browser session lasts at most 24 hours from when you last proved who you are, and ends after 8 hours with no requests. An open dashboard in a visible tab keeps asking, so it can last the full 24 hours; lock your laptop rather than relying on this. Microsoft may ask for your password and second factor when that happens. A published page left open goes to sign-in by itself and returns to where you were, unless something has been typed or changed in it. Then it stays put, and a save is refused rather than sent: copy what you typed before reloading. The portal's own pages stay put too, and when you come back to the tab they show a notice with a link to sign in again.
Sign out everywhere, at Settings → Access tokens, ends every browser session you have, including that one. It does not sign you out of Microsoft, so on a lost laptop change your Microsoft password too. Connected Claude apps have to reconnect, and a repository linked for GitHub deploys has to be linked again. API keys and GitHub deploy tokens keep working.
Open Settings → Access tokens. It lists the tokens that let Claude, the CLI and GitHub deploy as you — when each was added and when it expires — and Disconnect everywhere revokes all of them at once, and signs you out of every browser too.
Everything using them stops working until it reconnects: Claude will ask you to sign in again, a GitHub Action needs a fresh token in its repo secret, and a linked repository has to be linked again. Your deployed sites and their data are untouched — this only revokes the keys, never the pages.
Trim your build:
vite build --sourcemap false).webp / .avif over .png)If your project is genuinely over 250 MB, this isn't the right hosting platform. Talk to engineering about alternatives.
Open the dashboard, click Request more (only appears once you hit the cap), provide a short reason. Admins approve in +10 increments, up to a 500 ceiling. You'll get an in-portal notification when approved.
You can also free a slot by deleting an old deployment from the dashboard.
Fastest fix: open Versions on the project page and set the previous version back as live — instant rollback, no re-upload needed (we keep your last 10 versions, so you can revert up to 9 back). Or, if you just have the right folder handy, re-upload it to the same name — each re-upload replaces all files at that URL and the URL stays the same.
Is it secure?
Every URL requires Microsoft sign-in via the same Entra app the rest of our internal tooling uses, with tenant restrictions (@optimizely.com accounts only) — with one exception you turn on yourself. An external link opens one project to somebody with no account, and nothing is reachable that way until you mint it: it serves the page and, only if you tick Let this link collect, writes to one collection. It cannot read your data, use AI or reach a connection, and one click revokes it.
Can I share with people outside Optimizely? Yes, though not by yourself yet. The panel — Open from outside Optimizely, on Project details — is rendered only for the support admins while the feature is approval-gated, so ask one of them. It mints one address that opens the project with no sign-in at all: unlisted rather than secret, revocable in one click, and read-only unless you tick Let this link collect. What is still not possible is a custom domain — everything lives under the portal's domain.
What's the SLA? None — this is best-effort internal tooling, not production infrastructure. Don't host anything customer-facing here.
Why not Vercel / Netlify / Cloudflare Pages?
Can my deployment have a custom domain? Not in v1. Everything lives under the portal's domain. Open a feature request if this matters for your use case.
How long are deployments kept? Indefinitely until you delete them. We may add inactivity-based archival in the future; we'll announce before changing behavior.
Can I see who else has deployed what? Browse shows every public, unlocked project to every signed-in employee — an admin gets no extra reach there. What stays yours alone is anything private or Only me, and your own dashboard.
Can I deploy from CI / scripts?
Yes — from GitHub with the Deploy from GitHub dialog (Path 3), and from Jenkins,
GitLab, Azure DevOps or any script with a token and one curl (Terminal / CI). The
dialog's token is deploy-only and valid 90 days.
What's the difference between deploy and prepare_upload in Claude?
You don't need to think about this — Claude picks automatically. deploy is for tiny artifacts (a chat-generated HTML page, a hand-coded one-pager). prepare_upload is the default for any real build with a dist/ or out/ folder; it generates a curl command Claude runs from the shell, so the bundle never goes through the chat. Same end result either way.
# Claude Code: install & register globally
npm install -g @anthropic-ai/claude-code
claude mcp add --scope user --transport http opti-deploy \
https://deploy.optimizely.com/api/mcp
# Verify
claude mcp list
# Reset OAuth cache if anything's stuck
rm -rf ~/.mcp-auth
| Resource | URL |
|---|---|
| Portal (dashboard) | https://deploy.optimizely.com |
| Get started (what it is, how to connect Claude) | https://deploy.optimizely.com/start |
| Access tokens (create, list, revoke) | https://deploy.optimizely.com/settings/tokens |
| MCP server URL | https://deploy.optimizely.com/api/mcp |
| Claude.ai connectors page | https://claude.ai/settings/connectors |