# The toolset to complete your e-commerce. Your shop sells. We make the tools around it: the pages your customers read, the tests that tell you what works and the numbers behind both. Each one focused on its job, all of them under one account. - [Sign in or create an account](https://www.obelism.studio/login) ## Products ### Obelism Content The headless CMS you model yourself. Build pages from your own blocks, edit them right on your site or by prompt, and pick products straight from your Shopify store. [Read more](https://content.obelism.studio/) ### Obelism Improve A/B tests and feature flags. Get hard data on what works for your customers, and release big features without breaking a sweat. [Read more](https://improve.obelism.studio/) ### Obelism Numbers Web analytics for every site. Visitors, pages, sources and the clicks on every button, with no IP addresses stored. It comes with every Organization. [Read more](https://numbers.obelism.studio/) ### Obelism Media One library for every file your products use. Folders, tags and upload rules keep it sorted, crops keep their focal point, and Content picks its images straight from it. [Read more](https://media.obelism.studio/) ## Made to work together ### One account. Sign in once, invite your team to one Organization and use every product with it. ### One set of numbers. Visitors, sources and clicks land in one place, so a test result and a page’s click rate tell the same story. ### Your Shopify store. Connect it once and pick its products and collections in Content. ### One library. Every image lives in Media once, and Content and your store use the same file. ## But what about ... ### What is Obelism? Obelism is named after the scholars’ practice of marking a passage in a manuscript with an obelus, the sign in the margin that says “look at this again”. Our ⁜ comes from it. That is what we make for your shop: a feedback loop. You see what your visitors do in Numbers, test what could work better with Improve and change your pages with Content, without a deploy. Each change shows up in the numbers again, so your e-commerce platform keeps getting better. ### Is there a free plan? No. Pick Small, Medium or Large for Content & Media, Improve or both, from €49 a month, and both together are 15% off — see Pricing for what a size includes. If you need something else, a custom plan is arranged with us. ### Do I need every product? No. Use Content, Improve or both — at least one of the two. Numbers comes with either, and Media is part of Content. They share your account, your Organization, your numbers and your files, so adding the other one later is no new setup. ### Does it work with Shopify? Yes. Connect your store to a Space with the Obelism Shopify app and pick its products in Content, tag them on your photos in Media and add those files to products. Improve runs on any site, your theme included. ## Want to get in? Create your account and your organization — one sign-in for all four tools. Pick a plan when you are ready, or request a trial first. Or write to hello@obelism.studio. - [Create your account](https://www.obelism.studio/#sign-up) ## For developers and agents - [llms.txt](https://www.obelism.studio/llms.txt): which product to use, and each product's own llms.txt - [Set up Obelism for Claude](https://www.obelism.studio/agent-setup.md): the plugin that connects every product at once, its zip, and the MCP server they share - [Plugin manifest](https://www.obelism.studio/downloads/obelism-plugin.json): the current plugin version, zip and MCP server, for agents - [llms-full.txt](https://www.obelism.studio/llms-full.txt): this page and every product's docs in one file - [Design and brand guide](https://www.obelism.studio/design.md): the Obelism design system # Obelism Content docs (https://content.obelism.studio/docs) # Agent API and MCP (https://content.obelism.studio/docs/api/agents) The agent API does what the dashboard does, with a bearer token, for agents and for anyone scripting Content. The same operations are [MCP tools](#mcp). The step-by-step guide is [`/agent.md`](https://content.obelism.studio/agent.md), written for agents; the full reference is [`/openapi.json`](https://content.obelism.studio/openapi.json). Send and expect JSON. Errors are [problem JSON](/docs/api/errors) with a stable `code` and a `hint`. Content's endpoints are on `content.obelism.studio`: www serves accounts, organizations and spaces, and answers a Content path with a `404` that says so. ## Signing in [#signing-in] Accounts are shared by every Obelism product and served by www.obelism.studio. Content answers `/api/agent/auth/*` with a `308` there, so following redirects works too. ```http POST https://www.obelism.studio/api/agent/auth/sign-in { "email": "…", "password": "…" } ``` answers `{ "token", "tokenType": "Bearer", "expiresAt" }`. Send it as `Authorization: Bearer `. It is the same session a browser login holds, lasting 30 days, renewed while in use. `GET /api/agent/me` shows whose it is. For working on a space session after session, don't sign in again every time: create a **management token of your own** — `POST …/{environment}/api-tokens` with `{ "name": "…", "kind": "management", "scopes": ["model", "content"] }` —, put its `token` (a `cms_m_…` value, shown once) in an untracked environment file, and send that instead of the session token. A `content` scope reads and writes the environment's content through this API without your session (migrations, imports); `model` — the default without `scopes` — keeps the model in Git through the [management API](/docs/api/management). Creating or revoking tokens needs the developer or admin role and is refused in production (`production_protected`): a person makes production's tokens in the dashboard. Revoking a lost one is `POST …/api-tokens/{tokenId}/revoke`. ## Where to work [#where-to-work] ```http GET /api/agent/organizations → organizations, your role in each GET /api/agent/organizations/{org}/spaces → spaces, their locales POST /api/agent/organizations/{org}/spaces → a new space (admins and developers), optionally from a `starter` model PATCH /api/agent/organizations/{org}/spaces/{space} → rename it, or change its locales ``` For the whole space, under `/api/agent/organizations/{org}/spaces/{space}`: | Request | Does | | ----------------------------- | ----------------------------------------------------------------------------------------------- | | `GET members` | Who may open the space, with their role in it | | `PUT members/{userId}` | A member's [role in this space](/docs/guides/roles#roles-per-space) (admins) | | `GET locale-removal?locale=…` | What [removing a locale](/docs/guides/locales#removing-a-locale) would delete, before you do it | | `GET usage` | [Delivery API requests](/docs/api/delivery#rate-limits-and-usage) per environment and day | Everything else is under `/api/agent/organizations/{org}/spaces/{space}/{environment}`: | Area | Operations | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Content model | list, read, save, replace and delete content types (`content-types`, `content-types/{apiId}`; a new type gets its kind's [template fields](/docs/guides/content-model#new-types-start-with-the-usual-fields), listed by `GET /api/agent/content-type-templates`; saving an existing type **merges** the fields it sends, keeping the ones it leaves out, and `removeFields` names the ones to drop, deleting their values); the whole model as a file (`GET model`, `POST model/plan` — answers a `planId` — `POST model/apply` by that or by the file) | | Entries | list (`search`, `contentType`, `status`, `parentId`, `sort`, `limit`, `skip`), validate (`POST entries/validate`, a dry run of `create`), create, read, save (`baseRevision`), publish (optionally some `locales`, and `exclude` for a [shared block's](/docs/guides/content-model#shared-blocks) live pages), unpublish, schedule, move (with `slugs` per locale), duplicate, detach a shared block (`POST entries/{id}/detach-block` with `uid`, `baseRevision`), delete, preview; publish impact (`GET entries/{id}/publish-impact`); [bulk actions](/docs/guides/publishing#several-entries-at-once) (`POST entries/bulk`) | | Review | `GET`/`POST entries/{id}/review`, and `…/review/approve` (optionally `exclude` for a shared block), `…/review/send-back`, `…/review/withdraw` ([review before publishing](/docs/guides/publishing#review-before-publishing)) | | Comments | `GET`/`POST entries/{id}/comments`, `PATCH`/`DELETE comments/{commentId}` ([comments](/docs/guides/comments)) | | Translation | `POST entries/{id}/translate` (one entry), `POST translations` (many, in the background), `GET translations` (what is waiting) ([translating](/docs/guides/locales#agents)) | | History | `GET entries/{id}/versions`, `GET …/versions/{versionId}`, `POST …/versions/{versionId}/restore` | | Trash | `GET trash`, `POST trash/{id}/restore`, `DELETE trash/{id}` | | Redirects | `GET redirects`, `POST redirects/create` (`from`, `to`: a path, with or without a leading `/`, or an absolute URL), `POST redirects/delete` ([redirects](/docs/guides/redirects#adding-one-by-hand)) | | Plugins | `GET plugins`: the [plugins](/docs/guides/plugins) on in the environment. A field whose `editor` names one has it as its input for people; write its value as for any field of its type | | Assets | upload (multipart with a `file` field up to 50 MB, a `filename` field names it, or base64 in a JSON body up to 1 MB; the bytes prove the type and an image's `width`/`height`, and a false renderable-image claim is refused; `POST uploads/assets` + `PUT` its `uploadUrl` + `POST assets/from-upload` takes more, videos up to 100 MB), list (`search`, `mimeType`, `tag`, `limit`, `skip`), read, change (alt, title, focal point, `tags`, `private`, `revokeLinks`), replace the file (`PUT assets/{id}/file`, with the same body), delete | | Designs | `POST uploads` (a URL to `PUT` a file to) and `POST design-imports`: a [page from Claude Design](/docs/guides/claude-design) as a draft on the space's blocks | | Promotion | `POST promotion/plan`, `POST promotion/apply` | | Export, import | `GET export`, `POST uploads/import` (a URL to `PUT` an export file to, so megabytes skip the agent's context), `POST import/plan`, `POST import/apply` (by `file` or that `uploadId`) ([export and import](/docs/guides/environments#export-and-import)); `GET export/sql` for portable SQL files ([taking your content elsewhere](/docs/guides/environments#taking-your-content-elsewhere)) | | Activity | `GET audit-log` ([activity](/docs/guides/activity)) | | Improve | `GET improve`: the tests and flags the [improve block](/docs/guides/ab-tests) can name | | Connect a site | `GET setup`, [API tokens](/docs/guides/api-tokens), webhooks (create, edit, pause, rotate the secret, test, delete) and their deliveries (list, redeliver), `GET`/`PATCH settings` (preview URL, product and collection paths, prompt endpoint, publishing, the model's source, AI translation) | Your [role](/docs/guides/roles) decides what you may do. In production the agent API doesn't create or revoke tokens, add or delete webhooks, or change settings (`403 production_protected`): a person does those in the dashboard. ## Saving content [#saving-content] To change an entry, read it, change its `draft`, and send the whole content back with the revision you read: ```http GET …/entries/{id} → entry.draft, entry.draftRevision PUT …/entries/{id} { "content": { … }, "baseRevision": 3 } ``` A `409 conflict` means someone saved in between: read again and redo the change. Saving checks value shapes; publishing also enforces required fields. Rich text may be sent as [Markdown](/docs/api/rich-text#writing-it-as-markdown). ## MCP [#mcp] The MCP server is `https://content.obelism.studio/mcp` (Streamable HTTP). It signs in with OAuth 2.1 (a browser window asks you to approve the connection), so there is no token to copy; clients find everything through `/.well-known/oauth-protected-resource/mcp` and register with Dynamic Client Registration. Each agent API operation is a tool named after its `operationId` in `/openapi.json` (`listContentTypes`, `createEntry`, `saveEntry`, `publishEntry`, `getProjectSetup`, …), taking the path parameters and body fields as one input. Tools run as you, within your role; a read-only grant (`cms:read`) gets only the reading tools. See [connect an agent](/docs/getting-started/agents) to set one up. # Delivery API (https://content.obelism.studio/docs/api/delivery) Read access to one space environment's content, for the site that shows it. Create a [token](/docs/guides/api-tokens) under **Settings → API tokens** (in develop and staging an agent can create one with `createApiToken`; production tokens are made in the dashboard) and send it in the `Obelism-Token` header, or as a bearer token. ```sh curl -H "Obelism-Token: cms_d_…" \ "https://content.obelism.studio/api/delivery/v1/pages?path=blog/hello&locale=nl" ``` Both headers take every kind of token. Only `Obelism-Token` lets a delivery token's responses be answered from the [edge cache](#caching): a request with `Authorization` always reaches the API. The [JavaScript SDK](/docs/sdk/javascript) sends `Obelism-Token`. | Token | Sees | | -------------------- | ------------------------------------------------------------------------- | | Delivery (`cms_d_…`) | Published content only | | Preview (`cms_p_…`) | The latest drafts of everything | | Site (`cms_s_…`) | Drafts, as the editor [signed in on your site](/docs/guides/site-editing) | | Canvas (`cms_v_…`) | Drafts, as the person on the [canvas](/docs/guides/canvas), for 8 hours | A token is tied to one space and one environment. Name the ones you expect in the `Obelism-Space` and `Obelism-Environment` headers (`space` and `environment` on the SDK's `ContentClient`) and a token for any other gets a `401`: do that wherever the token comes from the request, like the canvas's `cms_token`. Revoking it takes effect on the next request (within 60 seconds for a delivery token answered from the [edge cache](#caching)). Management tokens (`cms_m_…`) are refused here. CORS is open to any origin, so a page on any site can read published content with a delivery token. Tokens that see drafts (preview, canvas and site tokens) belong on your server: from a browser they only work on the environment's preview URL, and any other origin gets a `403`. The [JavaScript SDK](/docs/sdk/javascript) wraps every endpoint. ## Endpoints [#endpoints] | Endpoint | Returns | | ------------------------------------ | ------------------------------------------------------------------------------------ | | `GET /api/delivery/v1/pages?path=…` | `{ entry }`: the page at a path | | `GET /api/delivery/v1/entries/{id}` | `{ entry }` | | `GET /api/delivery/v1/entries` | `{ items, total, limit, skip }` | | `GET /api/delivery/v1/content-model` | The environment's [model file](/docs/guides/content-model#the-model-file) | | `GET /api/delivery/v1/redirects` | `{ items: [{ locale, from, to }], total, limit, skip }`: [moved pages](#moved-pages) | Query parameters: - `locale`: a locale code; `*` for every locale (localized fields become `{ [locale]: value }`); the space's default when left out. An empty value falls back along the locale's [fallback chain](/docs/guides/locales#fallbacks). - On `/pages`, `locale` also picks the paths the page is looked up in: each locale can have [its own paths](#paths-per-locale). `*` and leaving it out look in the default locale's. - `depth`: how many levels of references to inline, `0`–`5`, default `1`. Past it, a reference is `{ id, contentType }` only. - `richtext`: `json` (default) or `html`; see [rich text](/docs/api/rich-text). - On `/entries`: `contentType`, `parentPath` (direct children of the entry at that path in the requested locale, `''` for the root), `limit` (1–100, default 20), `skip`, `select` (below), field filters and `order` (below). Entries come in the order of the space's tree unless `order` says otherwise. ## Filtering and ordering [#filtering-and-ordering] With `contentType`, `/entries` filters on that type's fields: ```sh # The 10 newest news posts GET /api/delivery/v1/entries?contentType=post&fields.category=news&order=-fields.date&limit=10 # Posts by one author, from this year GET /api/delivery/v1/entries?contentType=post&fields.author=entry_…&fields.date[gte]=2026-01-01 # The product page of a Shopify product GET /api/delivery/v1/entries?contentType=productPage&fields.product=gid://shopify/Product/42 ``` | Filter | Matches | | ----------------------- | ----------------------------------------------------------------- | | `fields.x=value` | Equal; on a field with several values, contains it | | `fields.x[ne]=value` | Not equal; on a field with several values, doesn't contain it | | `fields.x[in]=a,b` | One of them; on a field with several values, contains any | | `fields.x[gt]=value` | Also `gte`, `lt`, `lte`, on number and date fields with one value | | `fields.x[exists]=true` | Has a value (`false`: has none) | - Text, textarea, select, reference, asset, number, date, boolean and Shopify fields can be filtered: a reference or asset by its id, a Shopify item by its GID (`gid://shopify/Product/42`, whatever its handle; `eq`, `ne`, `in` and `exists`), a boolean as `true` or `false`, a date as `YYYY-MM-DD` or an ISO 8601 date-time. Several filters must all match. - Localized fields, rich text, blocks and JSON can't be filtered yet, nor can a [field shown only sometimes](/docs/guides/content-model#fields-shown-only-sometimes): its hidden values are delivered as `null`. - Preview tokens filter drafts, delivery tokens published content. `order` takes `publishedAt`, `firstPublishedAt`, `updatedAt`, `name`, `path` or `fields.` (a field with one value, not localized), with `-` in front for descending. Entries without a value come last. ### Settings, navigation and other single entries [#settings-navigation-and-other-single-entries] Find a data entry by its type and a field rather than its id, so the site's code never holds an `entry_…`: ```sh # The one entry of a settings type GET /api/delivery/v1/entries?contentType=settings&limit=1 # The navigation with key "main" GET /api/delivery/v1/entries?contentType=navigation&fields.key=main&limit=1 ``` With the [SDK](/docs/sdk/javascript#filtering-and-ordering): `content.getFirstEntry({ contentType: 'navigation', fields: { key: 'main' } })`. ## Selecting keys [#selecting-keys] `/entries` returns each entry with its fields, references inlined and files looked up. A navigation, a sitemap or a listing of a hundred pages rarely needs all of that, and the content is the bulk of the response: `select` names the keys to return instead, without reading any of the entry's content. ```sh # A sitemap: every page's path GET /api/delivery/v1/entries?contentType=page&limit=100&select=path # A list of posts: their path, title and date GET /api/delivery/v1/entries?contentType=post&select=path,title,publishedAt&order=-publishedAt ``` Each item carries only the keys named, in the shape they have in a full entry: `id`, `contentType`, `parentId`, `name`, `slug`, `path` (the requested locale's), `paths` (every locale's), `position`, `publishedAt`, `firstPublishedAt`, `updatedAt`, and any field of `contentType` by its `apiId`. Field keys need `contentType` and return the stored value as it is — a reference or an asset stays its id — resolved to the requested locale when the field is localized. Rich text, blocks and JSON can't be selected: they need resolving. `depth` and `richtext` do not apply. ## Moved pages [#moved-pages] When a page moves (a new slug or parent), its old path, and those of the pages below it, keep pointing at it. `/pages?path=` answers `404` with where the page is now: ```json { "error": "Entry not found: it moved", "redirect": { "path": "blog/new-slug" } } ``` so a site can answer with a `301` instead of a 404. Redirects are per locale: a page's old path is kept in each locale whose path changed, and `/pages?path=…&locale=nl` answers with the page's path in that locale. `GET /api/delivery/v1/redirects` lists every old path with its current one and its locale, paths without a leading `/` as everywhere in the API (`?locale=nl` for one locale; up to 1000 at a time, `limit` and `skip`), for redirect rules built ahead of time. A `to` that is an absolute URL is a redirect someone recorded by hand (agents and management tokens with `POST …/redirects/create`), for an old path of an import no page here ever had. Only redirects to entries the token can see are included. A page created or moved onto an old path takes it over, and deleting the entry removes its redirects. Manage them under **Settings → Preview and site → Redirects**; see [Redirects](/docs/guides/redirects) for answering them on your site. ## Paths per locale [#paths-per-locale] A page has a path in every locale of its space, in `paths`, and `path` is the requested locale's (the default locale's for `locale=*`). A locale without its own slug uses the one of the next locale along its [fallback chain](/docs/guides/locales#fallbacks), so every page exists in every locale. The paths never contain the locale itself: whether a site serves `/nl/over-ons`, `nl.example.com/over-ons` or `example.nl/over-ons` is its own routing. Use `paths` for `hreflang` links, language switchers and sitemaps. ```json { "path": "over-ons", "paths": { "en": "about-us", "nl": "over-ons" } } ``` Set a page or folder's slug per locale under **Move** in its sidebar, or with the agent API's `moveEntry` (`slugs`). Data entries have no path: `path` is `null` and `paths` is `{}`. ## Entries [#entries] ```json { "id": "entry_…", "contentType": "page", "name": "Home", "slug": "home", "path": "home", "paths": { "en": "home", "nl": "home" }, "publishedAt": "2026-10-12T19:35:12.473Z", "firstPublishedAt": "2026-10-12T19:35:12.473Z", "updatedAt": "2026-10-12T19:35:12.419Z", "fields": { "title": "Thuis", "body": [ { "_uid": "b_…", "_type": "hero", "heading": "Welkom", "image": { "id": "asset_…", "url": "https://content.obelism.studio/assets/asset_…/photo.png?w=1600&q=80", "filename": "photo.png", "mimeType": "image/png", "size": 48211, "width": 1600, "height": 900, "title": null, "alt": "An orange test pattern" } } ], "author": { "id": "entry_…", "contentType": "author", "name": "Ada Lovelace", "fields": { "name": "Ada Lovelace" } } } } ``` - Every field of the content type is present; empty ones are `null`. - Data entries have no `slug` or `path` (`null`). - Blocks keep `_uid` (stable, for the canvas) and `_type`. - A [shared block](/docs/guides/content-model#shared-blocks) is expanded inline into the block the site already renders, with `_sharedFrom: { id, name }` so it can tell where it came from. Blocks nested inside it get uids prefixed with the use's (`{ useUid }:{ uid }`), so a page's delivered tree never repeats one. - Any blocks list can hold the [improve block](/docs/guides/ab-tests), `{ _uid, _type: "_improve", kind, slug, variants }`. - References and [assets](/docs/guides/assets) are inlined; a referenced entry has the same shape as any entry (shortened above). Publishing only accepts references to published entries; one that can't be resolved anyway (an entry promoted without what it points at) is `null`, or left out of a list. - [Links](#links) name their page (`{ id, title, slug, path }`) without inlining it, whatever the `depth`; a link to a product names its product page the same way. - [Shopify fields](#shopify-fields) are returned as stored: `{ id, handle }`. - Folders are never returned themselves; they only group entries by path. ### Links [#links] A [`link` field](/docs/guides/content-model#links) is answered as `type: "entry"` with the page it goes to, `type: "url"` with its address, or `type: "product"` with a Shopify product, each with `query` and `hash` (`null` when not set) and `target` (`"_blank"`, or `null` when the content doesn't ask for a new tab): ```json { "cta": { "type": "entry", "entry": { "id": "entry_…", "title": "Pricing", "slug": "pricing", "path": "pricing" }, "query": "plan=pro", "hash": "compare", "target": null }, "footer": { "type": "url", "url": "https://github.com/Obelism", "query": null, "hash": null, "target": "_blank" }, "buy": { "type": "product", "product": { "id": "gid://shopify/Product/42", "handle": "mug" }, "entry": { "id": "entry_…", "title": "The mug", "slug": "mug", "path": "products/mug" }, "query": null, "hash": "reviews", "target": null } } ``` - `title` is the page's `title` field in the requested locale (its name when it has none), and `path` its path in that locale. - There is no ready-made address: how a page's `path` and locale become a URL (`/nl/pricing`, `nl.example.com/pricing`, the `home` page at `/`) is the site's routing. Build it from `path`, then `?` and `query`, then `#` and `hash` (`target` asks for `target="_blank"`), or let the SDK's [`linkHref`](/docs/sdk/javascript#links) do it with your routing. - A product link's `entry` is its [product page](/docs/guides/shopify#product-pages) (the published one, or for a preview token the draft too), or `null` when it has none: link to your own product route by `product.handle` then. The link itself is never `null`; whether the product still exists is the store's to say. - A link to a page the token can't see is `null`: a delivery token only sees published pages, a preview token drafts too. Publishing refuses a link to a draft and unpublishing a page that published content links to, so this only happens with content promoted without its page. ### Shopify fields [#shopify-fields] A [Shopify field](/docs/guides/shopify) holds a collection or product of the space's Shopify store, as its id and the handle it had when picked (a list when `multiple`): ```json { "collection": { "id": "gid://shopify/Collection/451234", "handle": "new-arrivals" } } ``` Nothing else about the item is stored or inlined, whatever the `depth`: read its title, price, stock and images from Shopify's Storefront API by the `id`, or with the SDK's [`fetchShopifyCollection` and `fetchShopifyProducts`](/docs/sdk/javascript#shopify). Delivery's cache never has to follow the store. ## Errors [#errors] Errors are `{ "error": "…" }`: | Status | When | | ------ | -------------------------------------------------------------------------------------------------------------------------------------- | | `400` | A parameter or filter is invalid | | `401` | The token is missing, invalid, expired or revoked, or for another space or environment than `Obelism-Space` / `Obelism-Environment` | | `403` | A management token; a draft token from a browser on another origin than the preview URL; or Content is turned off for the organization | | `404` | No page at that path (with `redirect` when one moved from it), or no entry with that id | | `429` | The token made more than 1,000 requests in 10 seconds; see [rate limits](#rate-limits-and-usage) | ## Rate limits and usage [#rate-limits-and-usage] Each token can make **1,000 requests per 10 seconds**: far more than a site's traffic (most of it is answered from the cache) or a static build fetching every page, but a loop stops there. Over it, requests get `429` with `Retry-After` and `RateLimit-Policy: "delivery";q=1000;w=10` until the window has passed. Every request with a valid token that reaches the API is counted, per environment and day, and so is how many the cache answered. Requests answered from the edge cache don't reach it and aren't counted. See them under **Settings → API tokens → Usage** (this month per environment, and the last 30 days), or with `GET /api/agent/organizations/{org}/spaces/{space}/usage`. Counts are written every few seconds, so the latest requests may take a moment to show. ## Caching [#caching] A delivery token sent as `Obelism-Token` is answered from Cloudflare's edge, close to where the request comes from, without reaching the API: per path, query and token, for 30 seconds and up to 30 more while a fresh copy is fetched. Publishing, unpublishing, moving or deleting, a model or locale change and revoking a token clear the environment's copies right away, so a change usually shows on the next request and is never more than 60 seconds old. `CF-Cache-Status` shows whether the edge answered. Preview, site and canvas tokens are never cached. Behind the edge, responses are cached per environment and invalidated the moment anything is published, so whatever reaches the API is current. `X-Cache: HIT | MISS` shows which you got. Don't put responses in your own shared CDN cache keyed only on the URL: they belong to the token. To rebuild or revalidate your site when content changes, use a [webhook](/docs/guides/webhooks). Asset files are public and immutable (a [private file](/docs/guides/assets#private-files)'s `url` is signed and lasts a day), cached for a year, with `ETag` and `Range` support (see [assets](/docs/guides/assets#serving)). They are answered on `/assets/{id}/{filename}`, the URL above, where images are optimized; the raw originals stay on `/files/{id}/{filename}`, which keeps serving them for the files that existed before media took over. # Errors (https://content.obelism.studio/docs/api/errors) The agent API and the MCP tools answer errors as `application/problem+json` ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)): ```json { "type": "https://content.obelism.studio/docs/api/errors#insufficient_role", "title": "Your role in this organization does not allow this action.", "status": 403, "detail": "Your role (reader) does not allow this action.", "code": "insufficient_role", "hint": "Ask an admin of the organization for a role with more access." } ``` A plain-text `403` with `error code: 1010` is Cloudflare's bot check, not the API: it blocks Python's default `Python-urllib/3.x` User-Agent at the edge. Set a custom User-Agent (or use `requests`/`httpx`) and the request reaches the API. A `404` for a Content endpoint called on `www.obelism.studio` means it lives on `content.obelism.studio`; www serves only accounts, organizations and spaces. Act on `code`, never on `detail`'s wording. `invalid_content` adds `issues`, a list of `{ path, message }`. The [delivery](/docs/api/delivery#errors), [management](/docs/api/management) and [site](/docs/api/site) APIs answer the simpler `{ "error": "…" }`, with the same statuses: `413` for a file over 50 MB or a JSON body over its limit, `429` with `Retry-After` over a rate limit. ## Codes [#codes] ### invalid_json [#invalid_json] The body isn't valid JSON. Send a JSON object with `Content-Type: application/json`. ### invalid_request [#invalid_request] A field or parameter is missing or invalid; `detail` names it. ### invalid_content [#invalid_content] The content or definition doesn't fit the content model. `issues` lists every problem as `{ path, message }`, with dotted paths into what you sent (e.g. `body.0.heading.en`). Fix all of them, then send again. ### unauthorized [#unauthorized] No bearer token, or it is invalid or expired. Sign in again. ### invalid_credentials [#invalid_credentials] Email and password don't match an account. ### password_reset_required [#password_reset_required] The account has to reset its password in the dashboard before signing in. ### invalid_verification_code [#invalid_verification_code] The verification code is wrong or expired. Signing in sends a new one. ### unknown_organization [#unknown_organization] The organization doesn't exist or you aren't a member. List yours with `GET /api/agent/organizations`. ### unknown_space [#unknown_space] The space isn't in that organization. List them with `GET …/spaces`. ### insufficient_role [#insufficient_role] Your role doesn't allow this. Readers read; editors write and publish; developers and admins change the model and manage API tokens, webhooks and settings. ### product_disabled [#product_disabled] An admin of the organization has disabled Content for it, in the organization's settings on www.obelism.studio. The dashboard, MCP, this API and the management API refuse it, whatever your role. Delivery and preview keep serving your sites. Ask an admin to enable Content again. ### token_expired [#token_expired] The API token expired: tokens are created to expire in 30 days, 90 days, a year (the default) or never. Create a new one under Settings → API tokens, or with createApiToken, put it where the old one was used, and revoke the old one. The delivery and management APIs answer an expired token with `401` and `"code": "token_expired"` too. ### token_not_allowed [#token_not_allowed] A management token was sent where it may not be used. A token with the `content` scope reaches the agent API's content routes (entries, assets, the trash, redirects, and reading content types) of its own space and environment only; everything else (tokens, webhooks, settings, the model, promotion) needs a person's session. A token without the `content` scope can't use the agent API at all. Create a management token with the scope you need under Settings → API tokens, in the environment it should write. ### model_managed_by_code [#model_managed_by_code] This environment's content model is managed in code: the dashboard, the agent API and MCP can't change its content types. Change the model file in the repository and apply it with `npx @obelism/content model apply` (the management API), or have an admin or developer give the model back to the dashboard under Settings → Publishing → Where the content model is changed. ### review_required [#review_required] Publishing in this environment is reviewed: only admins publish, unpublish, schedule, delete published entries, or promote and import published content there. Ask for review with `POST …/entries/{entryId}/review` (or `request_review` in `POST …/entries/bulk`); an admin approves it with `…/review/approve`, which publishes it. ### production_protected [#production_protected] The agent API doesn't make this change in production: creating or revoking API tokens, creating or deleting webhooks, and changing the preview URL or prompt endpoint. Ask a person with the admin or developer role to do it in the dashboard under Settings, or make the change in `develop` or `staging`. ### resource_not_found [#resource_not_found] The entry, content type, asset, API token or webhook doesn't exist in this space and environment (a revoked token can't be revoked again). ### conflict [#conflict] Someone changed it since you read it. Read it again and redo your change on the current `draftRevision`. ### impact_required [#impact_required] A shared block you are publishing is live on pages, and the request doesn't say which of them it may update. Send `exclude`, a list of the pages to leave on the version they show now; an empty list updates every live page. `GET …/entries//publish-impact` lists them. ### rate_limited [#rate_limited] Too many requests: the agent API and MCP take 300 a minute per person. Wait the `Retry-After` seconds, then slow down. ### payload_too_large [#payload_too_large] The body is too large. Files are limited to 50 MB; JSON bodies to 1 MB, 4 MB for entry content or a model file and 32 MB for an import file. ### not_found [#not_found] No endpoint at this path. See `/openapi.json`. ### method_not_allowed [#method_not_allowed] The endpoint exists but not for this HTTP method. ### internal_error [#internal_error] Something went wrong on our side. Retry later. # Images (https://content.obelism.studio/docs/api/images) Add a query to an image asset's `url` for a resized, re-encoded copy (the delivery URL already names `w` and `q`; swap them for what a layout needs). Without `w` or `h` an image is served at its own width, up to 3840 pixels: ```html … ``` | Parameter | Value | | --------- | ----------------------------------------------------------------------------------------- | | `w`, `h` | Pixels, up to 3840 | | `fit` | `scale-down` (default), `contain`, `cover`, `crop`, `pad` | | `format` | `auto` (default: AVIF or WebP when the browser takes them), `avif`, `webp`, `jpeg`, `png` | | `q` | Quality, 1–100 (default 80) | | `focus` | With `cover` or `crop`: `x,y` (0–1 from the top left) to keep in view, or `auto` | - PNG, JPEG, GIF and WebP can be transformed; other files, AVIF uploads included, ignore the query and are served as they are. - Assets carry their `width` and `height`, for the `width` and `height` attributes, so the page doesn't shift as images load. - `format=auto` picks AVIF for images up to 1200 pixels on their longest side and WebP up to 2400, the sizes Cloudflare can encode them at quickly. Larger copies, such as a 2x hero at 3840, keep the source's format. An explicit `format` is used as asked; Cloudflare falls back to WebP or JPEG when an image is too large for AVIF. - Copies are cached like the originals: public, for a year. They ignore `Range`. The edge keeps one copy per width, quality and `Accept` for a day, answered with `X-Cache: HIT`; `cf-cache-status` is Cloudflare's separate front cache and says `BYPASS` for a Worker response either way, so it isn't the sign of a miss. - `q=80` is the default, and what delivery names. Re-encoding at `q=100` can produce a copy several times the original file's size; ask for it only when the image really needs it. - An image's **focal point** (set by clicking the image in its details, in the **Media** library or on a picked file in the entry form) comes with the asset as `focalPoint: { x, y }`. Pass it as `focus` when you crop: since it is part of the URL, moving it gives new URLs instead of a year-cached old crop. The SDK's `imageUrl` and `imageSrcSet` add it for `cover` and `crop` (`@obelism/content` 0.9.0 and later). With the SDK: ```ts import { imageSrcSet, imageUrl } from '@obelism/content' const src = imageUrl(asset, { w: 960 }) const srcSet = imageSrcSet(asset, [480, 960, 1440], { format: 'auto' }) ``` In Next.js, allow `https://content.obelism.studio/assets/**` in `images.remotePatterns` to use `next/image`, or pass these URLs to a plain `` and skip Next's optimizer. In a React Router or Hydrogen storefront, use a plain `` with the `srcset` above: Hydrogen's `` only transforms Shopify CDN URLs with its default loader. # API (https://content.obelism.studio/docs/api) Content has one API per job, and they share a token model: a **delivery** token reads published content, a **preview** token reads drafts, a **management** token changes the model and content, and a **site** token acts as an editor on their own site. Every token belongs to one space and one environment. | API | Use it for | | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | [Delivery](/docs/api/delivery) | Read a site's content with a delivery or preview token: pages by path, entries, locales, depth. | | [Management](/docs/api/management) | Read, plan and apply the content model, and write content with a content-scoped token. The CLI and model-in-Git use it. | | [Site](/docs/api/site) | What a site's server calls with an editor's site token, when editing on the site. The SDK's `/cms` handler forwards to it. | | [Agent API and MCP](/docs/api/agents) | Everything the dashboard does, over HTTP or as MCP tools, for agents. | | [Rich text](/docs/api/rich-text) | The document format rich text fields deliver, including embedded entries and assets, and Markdown. | | [Images](/docs/api/images) | Resized, re-encoded copies of an image asset with a query on its URL. | | [Errors](/docs/api/errors) | Every error code, in RFC 9457 problem JSON. | Start with [API tokens](/docs/guides/api-tokens) for which token to create, and [the JavaScript SDK](/docs/sdk/javascript) for the client that sends them. # Management API (https://content.obelism.studio/docs/api/management) The management API keeps a [content model file](/docs/guides/content-model#the-model-file) in step with one space environment. It is what [the CLI](/docs/sdk/cli) calls; use it directly from other tooling. Every request takes `Authorization: Bearer cms_m_…`, a management token of the environment (Settings → API tokens, kind Management). The token acts as the person who created it, with their current [role](/docs/guides/roles): changing the model needs an admin or developer. | Request | Does | | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `GET /api/management/v1/model` | The environment's model file | | `POST /api/management/v1/model/plan` `{ model, prune? }` | The plan: `{ changes, issues }` | | `POST /api/management/v1/model/apply` `{ model, prune? }` | Applies it, all or nothing, answering the plan | | `GET /api/management/v1/improve` | `{ enabled, sources }`: the Improve tests and flags the [improve block](/docs/guides/ab-tests) can name | - `changes` lists every content type the file creates, updates or deletes, with the entries that would lose values when a field is removed. - `issues` lists what stands in the way (a field whose type changes, a type in use changing kind, deleting a type others use); `apply` refuses while there are any. - `prune: true` also deletes content types the file leaves out. Without it, a file can describe part of a model. - A type's `groups`, `accent` and `code` ([how the dashboard shows it](/docs/guides/content-model#groups-accent-and-code)) are in every export. A file that leaves them out keeps what the environment has, so a file from before them applies without undoing them. Errors are `{ error, issues? }` with `400`, `401` (missing, invalid or revoked token) or `403` (the role doesn't allow it, or Content is turned off). Applying sends `model.changed` [webhooks](/docs/guides/webhooks). Full request and response shapes are in [`/openapi.json`](https://content.obelism.studio/openapi.json) (tag Management). ## Content with a management token [#content-with-a-management-token] A management token can also have the **content** scope (Settings → API tokens → What it may do, or `scopes: ["content"]` in the agent API). With it, the token is a bearer token for the [agent API's](/docs/api/agents) content routes in its own space and environment: entries (including [bulk actions](/docs/guides/publishing#several-entries-at-once)), assets, the trash and redirects, and reading content types. Use it for migrations, imports and scheduled jobs that shouldn't depend on someone's session. ```sh curl -X POST "https://content.obelism.studio/api/agent/organizations/$ORG/spaces/$SPACE/develop/entries" \ -H "Authorization: Bearer $CONTENT_MANAGEMENT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "contentType": "post", "name": "Imported post", "slug": "imported-post", "content": { "title": { "en": "Imported" } } }' ``` The token still acts as the person who created it, with their current role: if they become a reader or leave the organization, it stops writing. Everything else (tokens, webhooks, settings, the model, promotion) refuses it with [`token_not_allowed`](/docs/api/errors#token_not_allowed), and so does any other space or environment. Existing management tokens have only the model scope. # Rich text (https://content.obelism.studio/docs/api/rich-text) Rich text fields are ProseMirror-style JSON documents, with TipTap's node and mark names. The entry editor writes exactly this format, and the delivery API answers it by default. ```json { "type": "doc", "content": [ { "type": "heading", "attrs": { "level": 2 }, "content": [{ "type": "text", "text": "Intro" }] }, { "type": "paragraph", "content": [ { "type": "text", "text": "Some " }, { "type": "text", "text": "bold", "marks": [{ "type": "bold" }] } ] }, { "type": "embeddedAsset", "attrs": { "id": "asset_…", "asset": { "url": "…", "alt": "…" } } } ] } ``` - **Nodes**: `paragraph`, `heading` (`level`), `blockquote`, `bulletList`, `orderedList` (`start`), `listItem`, `codeBlock` (`language`), `horizontalRule`, `hardBreak`, `text`, `embeddedEntry`, `embeddedAsset`. - **Marks**: `bold`, `italic`, `underline`, `strike`, `code`, `link` (`href`, `target`, `title`). ## Embeds [#embeds] Embedded entries and assets are stored by id and checked like references. In delivery responses they are resolved into `attrs.entry` / `attrs.asset`, following `depth`; ones that can't be resolved (unpublished, deleted) are left out. ## Writing it as Markdown [#writing-it-as-markdown] The agent API and MCP accept Markdown for a rich text field, and store it as a document; prompt editing reads and writes rich text as Markdown too. Embed an asset with `![alt text](asset:asset_…)` and an entry with `![](entry:entry_…)`. ## As HTML [#as-html] With `?richtext=html` the delivery API answers each rich text field as an HTML string instead: - images as `
`, other files as links; - embedded entries as `
`, for the site to fill; - links only ever have safe URLs (`http(s):`, `mailto:`, `tel:`, `#` and paths): others are refused when content is saved. ## Rendering it [#rendering-it] - React: `` from the [React SDK](/docs/sdk/react#rich-text), with your own components per node, mark or embed. - Anything else: `richTextToHtml(doc, { renderEntry, renderAsset })` and `richTextToPlainText(doc)` from the [JavaScript SDK](/docs/sdk/javascript#helpers). # Site API (https://content.obelism.studio/docs/api/site) When an editor [signs in on your site](/docs/guides/site-editing), your server holds a **site token** (`cms_s_…`) for them. The SDK's `createSiteEditing` handler forwards the prompt bar's `/cms/api/…` calls here, so you only need this API when building that handler yourself. `Authorization: Bearer cms_s_…`, from your server only. The token acts as the editor, with their [role](/docs/guides/roles) at the time of each request, in one space environment, and expires after 8 hours. | Endpoint | Does | | ---------------------------------------- | -------------------------------------------------------------------------- | | `POST /api/site/v1/token` | Trades the one-time code and PKCE verifier for a site token | | `GET /api/site/v1/session` | Who, where, and `features` (`prompt`, `create`, `save`, `publish`, `form`) | | `DELETE /api/site/v1/session` | Signs out | | `POST /api/site/v1/entries` | A new page from a prompt, stored as a draft (`201`) | | `GET /api/site/v1/entries/{id}` | The stored draft and its `draftRevision` | | `POST /api/site/v1/entries/{id}/preview` | Unsaved content, shaped like the delivery API | | `POST /api/site/v1/entries/{id}/prompt` | A prompt applied to unsaved content; saves nothing | | `POST /api/site/v1/entries/{id}/view` | Unsaved content as a prompt sees it, for editing as JSON | | `POST /api/site/v1/entries/{id}/apply` | Changes to that view applied to unsaved content; saves nothing | | `GET /api/site/v1/pages` | Every page, drafts included, with its paths per locale | | `POST /api/site/v1/entries/{id}/save` | Stores content as the draft (`baseRevision`; `409` on conflict) | | `POST /api/site/v1/entries/{id}/publish` | Publishes the stored draft | | `POST /api/site/v1/form` | A URL of the entry form page (`/site/form#code=…`) for the bar to frame | | `GET /api/delivery/v1/…` | Drafts, as with a preview token | `features` tells the prompt bar what to offer: readers get none, and see drafts only. Errors are `{ "error": "…" }`; `prompt` and `entries` (a new page) take 20 a minute per person and answer `429` with `Retry-After` past that. ## The form's routes [#the-forms-routes] The prompt bar's **Form** is Content's own page `/site/form`, framed by your site. It trades the one-time code in its URL for a **form token** (`cms_f_…`), which it keeps in memory and calls these with. A form token only works here (never on the routes above or the delivery API), and expires or is revoked with the site token it was opened from. The site token itself works here too. All of them need edit rights. | Endpoint | Does | | -------------------------------------- | ------------------------------------------------------------------------------ | | `POST /api/site/v1/form/token` | Trades the code (`{ code }`, no bearer) for `{ token, expiresAt, siteOrigin }` | | `GET /api/site/v1/form/entries/{id}` | What the form needs for the entry: model, locales, assets and entries it uses | | `GET /api/site/v1/form/entries` | The entry picker: `q`, `type`, `limit`, `skip` | | `GET /api/site/v1/form/assets` | The asset picker: `q`, `mimeType`, `tag`, `limit`, `skip` | | `POST /api/site/v1/form/assets/upload` | Uploads (`multipart/form-data`, `file` fields) | | `GET /api/site/v1/form/assets/details` | One file for its details: `assetId` | | `POST /api/site/v1/form/assets/update` | Changes a file's `title`, `alt`, `focalPoint` or `tags` (`{ assetId, … }`) | Full request and response shapes are in [`/openapi.json`](https://content.obelism.studio/openapi.json) (tag Site). # Change log (https://content.obelism.studio/docs/change-log) Notable changes to Obelism Content, newest first. Changes to the SDK packages (`@obelism/content`, `@obelism/content-react`) have their own release notes in the [SDK repository](https://github.com/Obelism/sdk/blob/main/packages/content/CHANGELOG.md), one changelog per package. ## 2026-10-31 [#2026-10-31] - **Every list drags into order.** A list field that holds several items — assets, references, Shopify products or collections — and a select field's options move by a row's grip, or with Space and the arrow keys. Blocks and a content model's fields already did. See [fields](/docs/guides/content-model#fields). - **Every section of the entry editor folds.** The chevron on **Search and sharing** never closed its fields; now every group on the entry page folds, and a check's **Fix** opens the section it points into. - **Export and import, side by side.** **Settings → Export and import** is two cards now: **Export** (the JSON an import reads back, and plain SQL for other tools) and **Import**, which lists what it reads (one Obelism JSON export, not SQL or a zip) and offers **Download a template**: this environment's content model with one example entry. See [export and import](/docs/guides/environments#export-and-import). - **A Space name can't run as SQL.** The SQL export writes the Space's name into a comment at the top of each file; a name with a newline ended that comment, so the rest of the name ran as SQL when someone loaded the files. Newlines are collapsed now, and the README and header are tested against a loaded database. ## 2026-10-30 [#2026-10-30] - **Export and import moved to Settings.** Admins and developers find the environment's JSON and SQL downloads and importing a file under **Settings → Export and import**, instead of on Promote. - **Your content as plain SQL.** **Settings → Export and import → Download … as SQL** gives a zip with a schema, one file of `INSERT`s per table (the content model, entries, paths and redirects), an `import.sql` that loads them in order and a README. It is for taking your content to another tool, not a backup Obelism imports back; asset files stay in Media. The agent API's `GET …/export/sql` answers the same files as `{ name, content }`. See [taking your content elsewhere](/docs/guides/environments#taking-your-content-elsewhere). - **Signed uploads keep an image's dimensions.** A file `PUT` to `POST …/uploads/assets` now records its width and height from the image header, so `from-upload` makes a complete asset. Files uploaded before this fill theirs in the first time they are served. See [Assets](/docs/guides/assets). - **Image URLs ask quality 80.** Delivery URLs name `q=80` instead of `q=100`: re-encoding at 100 could make a resized copy several times the original's bytes. The edge cache is unchanged; `X-Cache` says whether it answered. See [Images](/docs/api/images). - **Content type saves merge.** On the agent API and MCP, saving an existing type keeps the fields the call leaves out and changes the ones it sends; `removeFields` removes explicitly. Fields can also change type (`text`, `textarea`, `number`, `boolean`, `datetime`, `select` to `json`, `json` to text, `text`↔`textarea`) with their values converted, and localization can switch on those and `json`. See [the content model](/docs/guides/content-model#changing-the-model). - **A reference cycle larger than 100 publishes across calls.** A stalled bulk publish finds the whole cycle from the entries the call names and takes it live in one transaction, up to 500 entries. See [publishing several entries at once](/docs/guides/publishing#several-entries-at-once). - **A false image is refused.** Inline, multipart and signed uploads refuse a file claiming a browser-renderable image type whose bytes prove nothing image-like, and multipart's `filename` field now names the asset over the part's name. - **A wrong host says where to go.** A Content agent path called on `www.obelism.studio` answers problem JSON with the `content.obelism.studio` address, and the errors page explains Cloudflare's plain-text `403` for Python's default User-Agent. See [Errors](/docs/api/errors). ## 2026-10-29 [#2026-10-29] - **Blocks shared across pages.** A block type can now have entries, **shared blocks**, placed in a page's blocks list as `{ "_type": "_shared", "entry": "entry_…" }`. Delivery expands one inline with `_sharedFrom`, publishing it updates every live page it shows (or leaves the pages you untick on what they show now), and **Detach** turns a use back into a local block. Pages held on an older version catch up on their next publish. A block publish sends `block.published`. See [shared blocks](/docs/guides/content-model#shared-blocks), [publishing](/docs/guides/publishing#shared-blocks) and [delivery](/docs/api/delivery#entries). ## 2026-10-28 [#2026-10-28] - **Shopify Oxygen.** A guide for a React Router site on Shopify's host: the delivery token in the storefront's variables (Oxygen issues no OIDC token, so a connected deployment can't cover it), the public environment the canvas needs, the headers the editor needs, and caching with a TTL where Oxygen can't purge by tag. See [Shopify Oxygen](/docs/getting-started/oxygen). - **React Router, end to end.** The [React Router quick start](/docs/getting-started/react-router) now covers editing on the site (`/cms/login`) and the CSP both the canvas and the prompt bar need, not just the canvas. See [editing on your site](/docs/guides/site-editing). - **Files up to 50 MB.** Uploads from Content, the agent API and replacing a file in Media take files up to 50 MB (videos stay at 100 MB). Over 25 MB, the upload warns that the file adds to your storage quota. See [Assets](/docs/guides/assets). - **One toolbar over the content list.** The views are **All**, **Changed**, **Drafts** and the ones you save, no longer one per content type: content types are in a **Type** filter that takes several at once, statuses and **Created by me** in **Filter**. **Show as** adds **By content type**, every entry under a heading per type. **Save as view** appears when a filter matches none of your views. Old links with one `type` keep working; several are a `type` each. See [the content list](/docs/guides/entries#the-content-list). - **Plugins.** Your Organization's own web pages in Content, in a sandboxed frame: as the input of a field (a brand colour, a map pin, a PIM product) or as a dashboard page. An admin registers one from its manifest under **Plugins**, a developer turns it on per environment under **Settings → Preview and site** and picks it as a field's **Input**. A plugin gets only what its manifest's permissions say, sets the field's value as an unsaved change, and can get a five-minute token for its backend. See [Plugins](/docs/guides/plugins). ## 2026-10-27 [#2026-10-27] - **Fields shown only sometimes.** A field can have **Show only when** another, earlier field is on, has an option or text, or has a value, so one block type covers a hero with and without a call to action. Hidden fields keep their values, are delivered as `null`, and are only required while shown. In model files and the APIs it is `when`. See [fields shown only sometimes](/docs/guides/content-model#fields-shown-only-sometimes). - **Preview images for blocks.** A block type can show a picture of itself where editors add a block: upload a screenshot under **Preview image** in its settings, or give an `https://` URL. In model files and the APIs it is `previewImage`, a URL or a media asset id. See [preview images](/docs/guides/content-model#preview-images). ## 2026-10-25 [#2026-10-25] - **Image URLs without `w` or `q` work.** `/assets` no longer refuses an image URL that leaves them out: it serves the image at its own width (up to 3840 pixels) and quality 80, still optimized. AVIF uploads load again: they are served as they are, since Cloudflare can't transform them. See [Images](/docs/api/images). - **Store changes reach your site.** With a Shopify store connected, webhooks can select `product.updated`, `product.deleted`, `collection.updated` and `collection.deleted`: a product's price, stock, images or title changing, a collection changing, or either being deleted. Each names the item, a product's collections, the published pages that show them with their paths, and cache tags, so your site revalidates exactly those pages and its feeds. See [Webhooks](/docs/guides/webhooks#shopify-changes). - **Shoppable images.** Files in Media can be tagged with the products of the space's Shopify store, each on its point in the picture. Turn on **Show its products** for one use of a file (in **For this use**, or 🛍 on a file in rich text, or `products: true` in the value), and delivery adds the file's products to that asset: `products: [{ id, handle, position }]`. Draw hotspots or list the products next to the image; prices and stock come from the Storefront API. See [Assets](/docs/guides/assets#products-in-a-file). - **Product and collection pages take their title from Shopify.** The `productPage` and `collectionPage` types Settings offer have no Title field anymore, so a new page no longer needs one filled in before it can be published: read the product's or collection's title from the Storefront API, as its price and images. A page's name, which delivery's links show as its title, starts as the item's title. Types added before keep their Title field; delete it in the content model to do the same. - **Drafts only for the space you name.** The delivery API answers `401` when a token's space or environment differs from the request's `Obelism-Space` or `Obelism-Environment`, for every kind of token. Pass `space` and `environment` to the `ContentClient` you read `cms_token` with, so a `cms_token` made for someone else's space can't put its drafts on your site. See the [visual editor](/docs/guides/canvas). ## 2026-10-24 [#2026-10-24] - **JSON body limits.** The dashboard, agent, management and site APIs refuse a JSON body over its limit with `413`: 1 MB, 4 MB for entry content or a model file, 32 MB for an import file. See [Errors](/docs/api/errors#payload_too_large). ## 2026-10-23 [#2026-10-23] - **Transfer a space** to another organization you're an admin of, from Settings. Everything in it moves along, in Content, Improve and numbers, and its tokens, webhooks and Shopify store stay connected. See [Spaces](/docs/guides/spaces#transferring-a-space). ## 2026-10-22 [#2026-10-22] - **Docs.** The guides are grouped (modelling, editing, publishing, integrations, administration), with new pages on [editing entries](/docs/guides/entries), [spaces and settings](/docs/guides/spaces) and [API tokens](/docs/guides/api-tokens), and the [agent API](/docs/api/agents) lists every area it covers. ## 2026-10-21 [#2026-10-21] - **The canvas.** Every page of an environment side by side on one board, your site in each frame: a column per page type, panned and zoomed, at desktop or mobile width. Click a page to edit it with its form or by prompt, keep unsaved changes on several pages at once, and **Save all**. It replaces the visual editor: **Open in the canvas** on an entry's page opens it with that page selected, and links to the visual editor go there. With a Shopify store, a **Products** column holds the product pages and any product of the store at your site's product route, where **Add a product page** gives it one. Frames are as tall as their pages with `@obelism/content` 0.20 or later. See [Canvas](/docs/guides/canvas). - **Variants side by side.** On the canvas, a page with an A/B test or flag block shows its options next to each other, a frame per option, following your edits. See [Canvas](/docs/guides/canvas#variants-side-by-side). - **Change the whole site by prompt.** On the canvas, with no page selected, **Change the site** plans a request across the pages on the board and runs it as a prompt on each page it is about, unsaved until Save all. See [Canvas](/docs/guides/canvas#changing-the-whole-site). - **Collection pages.** With a Shopify store, collections get pages like products do: a page type with a unique Collection field (Settings offer `collectionPage`) holds the page of one collection, in a `collections` folder, started from the collection under New entry. Settings → Preview URL sets where your site shows a collection (`/collections/{handle}` by default), the canvas has a **Collections** column, and the prompt bar on your site offers **Add a collection page** on a collection the site marks with `editableCollection` (`@obelism/content` 0.20). The agent API's settings take `collectionPath`. See [Shopify](/docs/guides/shopify#collection-pages). - **A calmer media page.** Media shows files as tiles, cropped around their focal point; drop files on it to upload. Clicking one opens its details: the focal point on a large preview with how common crops keep it, its title and alt text in every language, tags, and replacing, privacy and deleting. The same details open from a picked file in the entry form, the visual editor and the site's prompt bar form. ## 2026-10-20 [#2026-10-20] - **Links to products.** With a Shopify store connected, a link field that allows **Shopify products** can go to a product of the store, picked like a Shopify field's, without making a page for it. Delivery answers `type: "product"` with the product's id and handle, and its product page when it has one. A page type with a unique Product field is a product page type: one published page per product, for what the store doesn't hold. Settings offer a ready `productPage` type, a new product page starts from a product (its handle is the slug), and delivery filters Shopify fields by id (`fields.product=gid://shopify/Product/42`). On the site, the prompt bar offers **Add a product page** on a product the site marks with `editableProduct`, and opens product pages at the site's product route (`@obelism/content` 0.18). Product pages go in a `products` folder, and Settings → Preview URL sets where the site shows a product (`/products/{handle}` by default), where the visual editor opens product pages. See [Shopify](/docs/guides/shopify#product-pages). ## 2026-10-19 [#2026-10-19] - **Shopify.** Connect a space's Shopify store (Settings → Shopify, or the Obelism app in Shopify) and pick its collections and products in a new `shopify` field type, with a picker that searches the store, in the editor, the visual editor and the site's prompt bar. Settings offer a ready product grid block, and Copy as .env adds the store and a Storefront token; the SDK's `fetchShopifyCollection` and `fetchShopifyProducts` read live prices and stock. See [Shopify](/docs/guides/shopify). - **Link fields.** A new `link` field type for buttons, cards and menus: to a page of the space, picked like a reference and following it when it moves, or to any address, each with an optional query and anchor. Delivery answers it with the page's id, title, slug and path (`null` while the page isn't published). The website starter's button uses one. See [links](/docs/guides/content-model#links). - **Settings.** Space settings is called Settings now, at `/settings` (old links still work), as in Improve, and the organization, space and environment are picked with the same switcher in every Obelism product. - **Every organization has a space.** A new organization starts with a space called Default, in English, so there is somewhere to start; rename it or change its locales under Settings. An organization's last space can't be deleted. Spaces are becoming shared by every Obelism product, so Improve's tests and the numbers will belong to one too. ## 2026-10-18 [#2026-10-18] - **AI translation for the whole space.** Settings → AI translation: turn it on or off, choose the languages, give the model instructions, and translate automatically when the default language is saved. Translate many entries at once from the content list, in the background, saved as drafts; agents translate and queue through the API. See [translating](/docs/guides/locales#translating). - **Ask the docs.** ⌘K in the docs searches them and answers questions from them with AI, as Improve's docs do. Plus a [React Router](/docs/getting-started/react-router) guide, and breadcrumbs in search results. - **Comments and presence.** Comment on an entry or one of its fields, reply and resolve threads, in the entry editor's sidebar; the toolbar shows who else has the entry open, and warns when they have unsaved changes. See [comments and presence](/docs/guides/comments). - **Delivery usage and rate limits.** Settings → Usage shows the delivery API requests per environment this month and per day, cache hits included; each token can make 1,000 requests per 10 seconds. See [rate limits and usage](/docs/api/delivery#rate-limits-and-usage). - **Content types need a description.** Every content type says what it is for, in a sentence: editors see it, and prompts use it to pick the block type for a new section. The model can't change until types without one are given one. See [the content model](/docs/guides/content-model#content-types). - **Publishing some languages.** Publish only the languages that are ready; the others keep what was published, or fall back. See [publishing](/docs/guides/publishing#publishing-some-languages). - **Translating entries.** The language switch shows how far each locale is translated, and **Translate from …** fills in a locale's missing texts by machine translation, to check and save. See [locales](/docs/guides/locales#translating). - **Private files.** A file can be made private: it is then only served with a signed URL that delivery hands out and that lasts a day, and its links can be revoked. See [assets](/docs/guides/assets#private-files). - **Tags and replacing files.** Tag files under Media and filter the library by tag; replace an asset's file while keeping its id, so content using it shows the new file. See [assets](/docs/guides/assets#tags). - **More validations.** Text fields take a `format` (URL, email, hex color or slug) and textareas a `pattern`; text and number fields can be `unique` among published entries; `multiple` assets and references take `min` and `max`; and most fields a `defaultValue` for new entries and blocks. See [the content model](/docs/guides/content-model#fields). - **Access per product.** Admins can keep a member out of Content or Improve on www (Organization → Product access); for that product they are then no member. See [roles](/docs/guides/roles#access-per-product). ## 2026-10-17 [#2026-10-17] - **Roles per space.** Admins can give a member a role in some spaces only (Settings → Members): they then see just those spaces, with that role in each. See [roles](/docs/guides/roles#roles-per-space). - **Removing a locale.** A saved locale (not the default) can be removed under Settings, deleting its values, asset text, slugs, paths and redirects in every environment after you confirm with its code. See [locales](/docs/guides/locales#removing-a-locale). - **Models locked to code.** An environment's content model can be marked as managed in code (Settings → Content model, or `model apply --lock`): read-only in the dashboard, the agent API and MCP, changed only by applying the model file. See [the model in Git](/docs/guides/model-in-git#locking-the-model-to-code). - **Review before publishing.** An environment's publishing can be reviewed: only admins publish there, and editors and developers ask for review, which an admin approves or sends back. See [publishing](/docs/guides/publishing#review-before-publishing). - **Activity.** An audit log per space: who published, moved, deleted or restored what, changed the model, promoted or imported content, and changed tokens, webhooks or settings, and whether they used the dashboard, an agent, a token or a schedule. See [Activity](/docs/guides/activity). - **Export and import.** Download an environment's content and model as one file, and import it like a promotion: as a backup, or to move content between environments. See [environments](/docs/guides/environments#export-and-import). - **Content tokens.** Management tokens get scopes: `model` (as before) and `content`, which writes content through the agent API without a person's session, for migrations and imports. See [the management API](/docs/api/management#content-with-a-management-token). - **Bulk actions.** Tick entries in the Content list to publish, unpublish, move or delete them together, or use `POST …/entries/bulk`. See [publishing](/docs/guides/publishing#several-entries-at-once). - **Starter models.** A new space can start with a blog, marketing site or docs model. See [the content model](/docs/guides/content-model#starting-a-space-from-a-model). - **Focal points.** Click an image under Media to set where crops keep it. The delivery API answers `focalPoint` on assets, and `focus=x,y` on a file URL crops around it. See [images](/docs/api/images). - **Promotion cleans up.** The plan lists entries only the target has; tick them to move them to the target's trash in the same promotion. - Deleted organizations' spaces, content and files are removed 30 days after the deletion. - **URLs per locale.** Pages and folders have a slug per locale (**Move** in the sidebar), falling back along the locale chain. The delivery API looks `/pages?path=…&locale=nl` up in that locale and answers `paths` on every entry; redirects and webhooks are per locale too. With `{locale}` in the preview URL, the visual editor opens each language's own page. See [Locales](/docs/guides/locales#urls-per-locale). - **Scheduled publishing.** An entry can publish and/or unpublish at a set time: **Schedule…** under Status in its sidebar, or the agent API. A scheduled run acts like the person who scheduled it pressing the button, with the same checks, webhooks and history; a refused run shows why in the sidebar. See [Publishing and scheduling](/docs/guides/publishing). - **Filter and order entries.** The delivery API's entry list filters on a content type's fields (`fields.`, with `ne`, `in`, `gt`, `gte`, `lt`, `lte` and `exists`) and orders by date, name, path or a field. That also finds settings, navigation and other single entries without hard-coding their ids. See the [delivery API](/docs/api/delivery) and the SDK's `getFirstEntry`. - **Templates for new content types.** A new page type starts with a title, a body and SEO fields, a data type with a title, a block type with a heading and an anchor. The agent API and MCP add the same fields unless you send your own. - **Duplicate an entry** from its sidebar or the agent API: a draft next to it, named " (copy)". - **Rename and delete spaces** in Settings. Deleting a space (admins only) removes its content and files. - Asset and entry pickers and the media page search and page through the server, so large spaces stay fast. Each entry keeps its newest 100 versions. - **Drafts need a token.** The visual editor gives the site a short-lived, read-only token (`cms_token`, 8 hours) to read drafts with; `?cms_preview=1` on its own no longer shows anything. Tokens that see drafts are refused from a browser on any origin but the environment's preview URL. See the [visual editor](/docs/guides/canvas). - **Asset changes reach sites.** Changing an asset's alt text or title updates every environment whose content uses it and sends the new `asset.updated` [webhook](/docs/guides/webhooks). - **Publishing checks references.** Published content only points at published entries: publishing refuses a reference or embed to a draft. - **Limits.** Files over 25 MB are refused (413). The agent API and MCP (300 calls a minute) and prompts (20 a minute) are limited per person and answer 429 with `Retry-After`. - These docs, at content.obelism.studio/docs, with search and `/llms.txt`. ## 2026-10-16 [#2026-10-16] - **The bridge script has the prompt box.** `https://content.obelism.studio/bridge.js` is now the SDK's bridge, so sites without a bundler get prompt editing too. - **Agents set up a site.** The `getProjectSetup` MCP tool tells an agent how to start a site's repository with `npx @obelism/content init`, which writes the model file and an example per content type. See [Agents](/docs/getting-started/agents). - **A/B tests and feature flags as a block.** Every blocks field takes the built-in improve block: a list of blocks per option of an improve test or flag. See [A/B tests and feature flags](/docs/guides/ab-tests). - **Entries are edited in a form.** One input per field, blocks as cards that nest and move, asset and entry pickers, a language switch for translated fields, and Save draft / Publish in a sticky bar (Cmd/Ctrl+S saves). The content model editor groups types as pages, data and blocks. - **New pages by prompt.** On your own site, the prompt box can also write a new page from a path and a description, stored as a draft. - Organizations have a plan per product; an admin can turn Content off for an Organization on www. The delivery API keeps serving. - Fixed: signing in from a dashboard link no longer ends on a 404. ## 2026-10-15 [#2026-10-15] - **One sign-in for every Obelism product**, on www.obelism.studio. Organizations, members and invites are managed there too. - **Edit on your own site.** Editors sign in from the site itself and edit with drafts and prompts, outside the visual editor. See [Editing on your site](/docs/guides/site-editing). - **Edit by prompt** in the visual editor: describe a change and it shows on the page, unsaved, with undo. See [Prompt editing](/docs/guides/prompt-editing). - API tokens, webhooks and space settings can be managed from the [agent API](/docs/api/agents) and MCP. ## 2026-10-14 [#2026-10-14] - A getting-started guide and a dashboard prompt for setting up your agent. - Fixed: asset fields that allow `image/*` accept images. ## 2026-10-13 [#2026-10-13] - **Images resized on the fly** with width, height, fit, format and quality parameters. See [Images](/docs/api/images). - **Promote content between environments**, with a plan of what will change first. See [Environments](/docs/guides/environments). - **The content model in Git**: management tokens, the management API and the `content model pull | plan | apply` CLI. See [The model in Git](/docs/guides/model-in-git). - **Version history and rollback** for entries. - **Rich text** edited with a toolbar in the visual editor. - **Webhooks**, signed and retried for about 11 hours. See [Webhooks](/docs/guides/webhooks). - The entries list pages, searches and filters. - Admins can rename the Organization. - Validation messages are written for people. - Uploaded files are served so they can't run as pages. - Model changes and usage checks stay fast on large spaces. ## 2026-10-12 [#2026-10-12] - The first version: content models per space and environment (pages, data and blocks), the entry editor, media, the [delivery API](/docs/api/delivery) with API tokens, the visual editor, the agent API and an MCP server. Rich text is stored as JSON and accepts Markdown. # Privacy & GDPR (https://content.obelism.studio/docs/gdpr) This page describes what data Obelism Content stores about the people who edit your content, what it does (and doesn't) see of your website's visitors, and which services process it. Use it to fill in your own privacy policy, records of processing and data processing agreement. This is a technical description of data flows, not legal advice. Review it with your own counsel before you rely on it in a privacy policy or DPA. ## Your content is your data [#your-content-is-your-data] Everything you put into Content (entries, rich text, asset files, alt text and titles, names and paths) is stored as you wrote it. Content doesn't inspect it for personal data. For that content, your organization is the controller and Obelism processes it on your behalf. If your content includes personal data, such as a team page with names and photos, or a testimonial with a customer's name, treat it like any other personal data you hold: you decide what goes in, and you remove it when you have to (see [deleting content](#deleting-content)). ## Your website's visitors [#your-websites-visitors] Content doesn't track the visitors of your site. The delivery API stores nothing about who asked for content: no IP address, no cookie, no visitor id. For each API token it keeps only when it was last used. Two kinds of request can still reach Content from a visitor's browser: - **Asset files** (`/files/{id}/{name}`) are served from `content.obelism.studio`, so a visitor's browser loads your images and files from there. Content sets no cookies on these responses. - **Delivery API calls** made from the browser with a delivery token, if your site works that way rather than calling the API from your server. Like every request to a Cloudflare Worker, these pass through Cloudflare and are kept in Content's request logs (Workers Logs). Content's own database stores nothing about them. ## People who edit your content [#people-who-edit-your-content] Accounts, organizations and memberships are shared by every Obelism product and live on www.obelism.studio: signing in, sign-up and the profile happen there, not in Content. Content reads the same session cookie (`obelism_session`, set on `.obelism.studio`) and the same tables. What Content itself stores about editors: | Data | What it is | Kept | | ----------------------- | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | **Author ids** | The user id of who created or last changed an entry, a content type, an asset, an API token or a webhook | As long as the item exists | | **Version history** | Every save and every publish stores a version of the entry, with its content, time and the user id who made it | The entry's newest 100 versions, as long as it exists | | **Site sessions** | When someone signs in to edit on your own site: their user id, the space, the redirect URI and a hashed token | 8 hours; ended sessions are cleared the next time that person signs in on a site | | **Preview tokens** | The read-only token the canvas gives your site: the user id and a hashed token | 8 hours; expired tokens are cleared whenever a new one is made | | **MCP connections** | OAuth grants and hashed tokens for AI tools connected to Content at `/mcp` | In Cloudflare KV | | **Rate limit counters** | Prompts and agent API calls counted per user id, per minute | Per minute, in Cloudflare | Tokens are stored only as SHA-256 hashes. The dashboard and the agent API show an editor's name or email address next to versions by reading it from the shared account; Content doesn't copy it. If an account is removed, the author ids that pointed at it are cleared, and its site sessions and preview tokens are deleted. The content and versions themselves stay. ## Prompts [#prompts] When an editor [edits by prompt](/docs/guides/prompt-editing) or writes a new page by prompt, Content sends a request to a language model with: - the editor's prompt, - the entry or selected block in one locale (rich text as Markdown), or, for a new page, its path, content type and an existing page as an example, - the content type definitions involved. It doesn't send the editor's name, email address or user id. Prompts and answers aren't stored in Content's database; the answer becomes an unsaved draft, which the editor saves or discards. Where the request goes: - **Workers AI** (Cloudflare), by default. - **Your own prompt endpoint**, when you set one under **Settings → Preview and site → Prompt editing**. Then the request goes to your server instead, signed with your endpoint's secret, and whatever model you call there is your own sub-processor, not Obelism's. If your content holds personal data, it can be part of what is sent. Choose your own endpoint if it shouldn't reach Workers AI. ## Webhooks [#webhooks] [Webhooks](/docs/guides/webhooks) send to the URL you configure. Their payloads carry ids, names and paths, never entry content: for an entry its id, content type, name and path; for an asset its id and file name; for model and locale changes the content type ids and locale codes. Deliveries, with their payloads, are kept for 30 days. ## Deleting content [#deleting-content] - **Entries**: deleting an entry deletes it with its version history in that environment. - **Assets**: deleting an asset removes its file from storage. An asset still used by any entry, draft or published, in any environment, can't be deleted until it's removed there first. Asset URLs are public to anyone who has them and may be cached by browsers for up to a year, so a copy can outlive the deletion there. Cloudflare's edge keeps its copy for up to a day. - **Organizations**: deleting an organization (on www) stops its delivery API, dashboard and editing at once. Thirty days later its spaces are removed with all their content, versions and files. - **Versions**: there's no way to delete single versions yet. To remove personal data from an entry's history, delete the entry, or contact us. ## Data retention [#data-retention] Content has no automatic retention limit for content, assets or version history: they stay until you delete them. Webhook deliveries are kept for 30 days. If your privacy policy commits to a retention period, contact us and we'll help enforce it. ## Sub-processors & data residency [#sub-processors--data-residency] | Service | Used for | Where | | ------------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------------- | | **Cloudflare Workers** | Runs Content, its API and its dashboard; request logs and traces | Cloudflare's global network, at the location nearest the request | | **Cloudflare Hyperdrive** | Connection pooling between the Worker and the database | Cloudflare's global network | | **Cloudflare R2** | Uploaded asset files | Cloudflare | | **Cloudflare KV** | The session cache, cached delivery API responses (1 hour) and MCP OAuth grants | Cloudflare's global network | | **Cloudflare Images** | Resizing images on asset URLs (`?w=…`) | Cloudflare's global network | | **Cloudflare Workers AI** | Prompt editing, unless you set your own prompt endpoint | Cloudflare's global network | | **PlanetScale** | The PostgreSQL database: content, the model, versions, accounts and organizations | **Frankfurt, Germany (AWS `eu-central-1`)** | Content shares its database with Improve and www.obelism.studio. For a current, complete sub-processor list or a signed DPA, contact us at [hello@obelism.studio](mailto:hello@obelism.studio). ## Checklist for your own privacy policy [#checklist-for-your-own-privacy-policy] - [ ] List Obelism as a processor for the content you store in Content, and the sub-processors above. - [ ] Check whether your content holds personal data (names, photos, quotes) and how you'd remove it on request, including from version history. - [ ] Decide whether prompts may go to Workers AI, or set your own prompt endpoint. - [ ] If your site serves asset files from `content.obelism.studio`, note that visitors' browsers request them from Obelism, through Cloudflare. - [ ] Note that each entry keeps its newest 100 versions; factor that into your own retention commitments. # Connect an agent (https://content.obelism.studio/docs/getting-started/agents) Obelism Content has an MCP server at `https://content.obelism.studio/mcp`. Its tools are the [agent API](/docs/api/agents): organizations and spaces, the content model, entries, assets, promotion, API tokens and webhooks. It signs in with OAuth, so there is no key to copy: the first time, your browser opens and you allow the connection. The dashboard's **Agents** page has the address and the Claude Code command, what an agent may do, and the apps connected to Content. ## The Obelism plugin for Claude [#the-obelism-plugin-for-claude] In Claude Code, the Obelism plugin connects Content, [improve](https://improve.obelism.studio/docs/api/agents/mcp) and numbers at once, with skills for the jobs that span them: [importing a page from Claude Design](/docs/guides/claude-design), connecting a site, and running an A/B test on a block. ``` /plugin marketplace add Obelism/sdk /plugin install obelism@obelism ``` On claude.ai, an Organization Owner can add the same marketplace under Organization settings → Plugins. The plugin's source is in [Obelism/sdk](https://github.com/Obelism/sdk/tree/main/plugin). ## Let the agent set itself up [#let-the-agent-set-itself-up] Paste this into your coding agent: ``` Fetch and follow the instructions to set me up for Obelism Content from https://content.obelism.studio/getting-started.md ``` The instructions there cover Claude Code, Codex, OpenCode, Cursor, VS Code, Windsurf and any other MCP client, and ask before touching your agent's instructions file. The dashboard's home page has the same prompt in a new space, ready to copy. ## Or add it by hand [#or-add-it-by-hand] ```sh # Claude Code claude mcp add --transport http --scope user obelism-content https://content.obelism.studio/mcp # Codex codex mcp add obelism-content --url https://content.obelism.studio/mcp codex mcp login obelism-content ``` In Claude (web and desktop) and other apps with connectors, add a custom connector with the URL `https://content.obelism.studio/mcp`. ## What it can do [#what-it-can-do] The tools run as you, with your [role](/docs/guides/roles) in the organization: they can do no more than you can in the dashboard. A read-only grant gets only the reading tools. Disconnect an app under [Connected apps](https://www.obelism.studio/account/connected-apps) on your Obelism account. - **Model content**: read the content types, change one, or plan and apply the whole model as a file. - **Write content**: create entries, save drafts, publish, move, restore a version, upload assets. - **Start a site**: `getProjectSetup` gives the `npx @obelism/content init` to run in the site's repository. - **Connect a site** in develop and staging: create a delivery or preview token, a webhook, send it a test and check it got a 2xx, and set the preview URL. In production, an agent can read tokens, webhooks and settings, but not create them: it tells you what to add under Settings instead. Token values and webhook secrets are shown once; an agent puts them in an untracked environment file and never repeats them. ## For agents [#for-agents] - [`/agent.md`](https://content.obelism.studio/agent.md): the agent API step by step, with every error code. - [`/openapi.json`](https://content.obelism.studio/openapi.json): every endpoint. - [`/llms.txt`](https://content.obelism.studio/llms.txt): these docs, as a list for language models. # Astro (https://content.obelism.studio/docs/getting-started/astro) This sets up an Astro site that renders every page entry at its path on the server and shows drafts in the canvas. It uses the JS SDK only; the page reloads after each save in the editor. ## 1. Start the repository on a space [#1-start-the-repository-on-a-space] Create a **management token** under **Settings → API tokens**, then run in the site's root: ```sh export CONTENT_MANAGEMENT_TOKEN=cms_m_… npx @obelism/content init ``` `init` writes the model file (`content/content-model.json`), an example of what the delivery API answers for every content type (`content/examples/`), installs `@obelism/content`, and adds the token variables to `.env.example`. Put a delivery token in `.env` (never commit it): ```sh CONTENT_DELIVERY_TOKEN=cms_d_… # The space and environment it reads (Space settings), so the canvas's # token for another space reads nothing here. CONTENT_SPACE_ID=space_… CONTENT_ENVIRONMENT=production ``` ## 2. Render on the server [#2-render-on-the-server] Drafts depend on the request, so the pages render on the server: set `output: 'server'` and add the adapter for where the site runs (Node, Cloudflare, Netlify, Vercel). ```js title="astro.config.mjs" import node from '@astrojs/node' import { defineConfig } from 'astro/config' export default defineConfig({ output: 'server', adapter: node({ mode: 'standalone' }), }) ``` ## 3. The clients [#3-the-clients] One client for published content. In the canvas the page's URL carries `cms_token`, a token for the person editing that reads drafts; that request gets a client of its own. ```ts title="src/lib/content.ts" import { ContentClient } from '@obelism/content' export const delivery = new ContentClient({ token: import.meta.env.CONTENT_DELIVERY_TOKEN, }) // Drafts in the canvas, with the token it adds to the URL. Anyone can // add `cms_preview=1` to a URL, so that alone shows nothing. The space // and environment make a token for another space read nothing here. export const getContent = (url: URL) => { const token = url.searchParams.get('cms_token') return token ? new ContentClient({ token, space: import.meta.env.CONTENT_SPACE_ID, environment: import.meta.env.CONTENT_ENVIRONMENT, }) : delivery } ``` ## 4. Every page by path [#4-every-page-by-path] ```astro title="src/pages/[...slug].astro" --- import { ContentApiError, editable, richTextToHtml, type Block, type Entry, type RichTextNode, } from '@obelism/content' import { delivery, getContent } from '../lib/content' type HeroBlock = Block<{ heading: string | null }, 'hero'> type TextBlock = Block<{ text: RichTextNode | null }, 'text'> type PageEntry = Entry< { title: string | null; body: (HeroBlock | TextBlock)[] | null }, 'page' > const path = Astro.params.slug || 'home' const entry = await getContent(Astro.url) .getPage(path, { depth: 2 }) .catch((error: unknown) => { // A made-up, expired or other space's `cms_token`: show what's published. if (error instanceof ContentApiError && error.status === 401) { return delivery.getPage(path, { depth: 2 }) } throw error }) if (!entry) return new Response(null, { status: 404 }) // Let the canvas show the site in its frames. Astro.response.headers.set( 'Content-Security-Policy', "frame-ancestors 'self' https://content.obelism.studio", ) const { fields } = entry --- {fields.title}

{fields.title}

{fields.body?.map((block) => block._type === 'hero' ? (

{block.heading}

) : (
), )}
``` `content/examples/` shows what each of your content types looks like; adjust `PageEntry` to your own model. - `editable` marks the entry and its blocks (`data-cms-uid`), so editors can click them. - The bridge script does nothing outside the editor. Inside it, the page reloads after every save; to re-render on each keystroke instead, listen for `ObelismBridge.on('update', ({ entry }) => …)` (see the [canvas](/docs/guides/canvas)). - The `frame-ancestors` header lets the CMS frame the site. Set it wherever your site sets headers if it sends a stricter policy, and drop `X-Frame-Options: DENY`. Then set the environment's **preview URL** (Settings) to where the site runs, such as `http://localhost:4321`, and open the **Canvas**. ## Next steps [#next-steps] - [Rebuild or revalidate on publish](/docs/guides/webhooks) with a webhook. - [Images](/docs/api/images): resized and re-encoded copies with a query on the URL. - [A/B tests in content](/docs/guides/ab-tests), with Obelism Improve. # Plain HTML (https://content.obelism.studio/docs/getting-started/html) The delivery API is plain HTTPS and JSON, so any language or framework can read it. This page uses `fetch` and the bridge's script tag. ## Read a page [#read-a-page] ```js const response = await fetch( 'https://content.obelism.studio/api/delivery/v1/pages?path=home', { headers: { Authorization: `Bearer ${CONTENT_DELIVERY_TOKEN}` } }, ) const { entry } = await response.json() ``` `entry.fields` holds every field of the page's content type; see [the delivery API](/docs/api/delivery) for the shape, the other endpoints and the parameters. CORS is open, so a browser may call it with a **delivery** token, which only sees published content. Keep **preview** tokens on a server: they see drafts. ## Make it editable [#make-it-editable] Include the bridge on every page: ```html ``` and mark each block with its `_uid`: ```html
…
``` In the canvas, clicking a block of the selected page selects it. Without an `update` listener the page reloads after each save; with one, it shows edits as they are typed: ```html ``` Inside the editor the page is loaded with `?cms_preview=1&cms_token=…`. On your server, fetch with `cms_token` as the bearer token then, with your space's id in `Obelism-Space` and the environment in `Obelism-Environment`: it is made for the person editing and reads drafts. A made-up or expired one, or one for another space, gets a `401`, so fall back to your delivery token. Never show drafts because of `cms_preview` alone: anyone can add it to a URL. See [the canvas](/docs/guides/canvas) for the rest. # Getting started (https://content.obelism.studio/docs/getting-started) Sign in at [content.obelism.studio](https://content.obelism.studio/dashboard) with your Obelism account. Content lives in a **space** (one site or project) of your **organization**, in one of three [environments](/docs/guides/environments). Work in develop until the site is ready. 1. **Create a space** on the dashboard, and set its [locales](/docs/guides/locales) under **Settings**. 2. **Model the content** under **Content model**: a page type (with a title and a `blocks` field), and a few block types for the parts pages are built from. See [the content model](/docs/guides/content-model). 3. **Write a page** under **Content**, with the slug `home`, and publish it. 4. **Create [tokens](/docs/guides/api-tokens)** under **Settings → API tokens**: a delivery token for the site, a preview token for drafts, and a management token for the CLI. 5. **Connect your site**, following the guide for your stack: Then, when the site runs: - Set the environment's **preview URL** (Settings) to where it runs, and editors can [edit it in place](/docs/guides/canvas). - Add a [webhook](/docs/guides/webhooks), so the site rebuilds or revalidates when something is published. - [Promote](/docs/guides/environments#promoting) the model and content to production, and repeat steps 4 and 5 with production's tokens. # Next.js (https://content.obelism.studio/docs/getting-started/nextjs) This sets up a Next.js App Router site that serves every page entry at its path, shows drafts in the canvas, and lets editors sign in on the site to change it. The [example in the SDK repository](https://github.com/Obelism/sdk/tree/main/examples/content-nextjs) is the finished version. ## 1. Start the repository on a space [#1-start-the-repository-on-a-space] Create a **management token** under **Settings → API tokens**, then run in the site's root: ```sh export CONTENT_MANAGEMENT_TOKEN=cms_m_… npx @obelism/content init ``` `init` writes the model file (`content/content-model.json`), an example of what the delivery API answers for every content type (`content/examples/`), installs `@obelism/content`, and adds the token variables to `.env.example`. Add `@obelism/content-react` for the React helpers: ```sh npm i @obelism/content-react ``` Put a delivery token in `.env.local` (never commit it): ```sh CONTENT_DELIVERY_TOKEN=cms_d_… # The space and environment it reads (Space settings), so the canvas's # token for another space reads nothing here. CONTENT_SPACE_ID=space_… CONTENT_ENVIRONMENT=production ``` ## 2. The clients [#2-the-clients] One client for published content, cached by Next. Drafts come with a token for the person editing: in the canvas the page's URL carries `cms_token`, and an editor [signed in on the site](/docs/guides/site-editing) has a site token while draft mode is on. Those requests get a client of their own, never cached. [Previews and draft mode](/docs/guides/preview) explains the order and why. ```ts title="src/lib/content.ts" import 'server-only' import { ContentClient, readPreviewParams } from '@obelism/content' import { readSiteToken } from '@obelism/content/site' import { cookies, draftMode } from 'next/headers' export const delivery = new ContentClient({ token: process.env.CONTENT_DELIVERY_TOKEN!, init: { next: { revalidate: 60, tags: ['content'] } }, }) // Naming the space and environment makes a token someone made for another // space read nothing here. const drafts = (token: string) => new ContentClient({ token, space: process.env.CONTENT_SPACE_ID!, environment: process.env.CONTENT_ENVIRONMENT!, init: { cache: 'no-store' }, }) // `cms_preview` alone proves nothing: anyone can add it to a URL. export const getContent = async ( searchParams: Record, ) => { const preview = readPreviewParams(searchParams) if (preview?.token) return drafts(preview.token) if ((await draftMode()).isEnabled) { const token = readSiteToken(await cookies()) if (token) return drafts(token) } return delivery } ``` ## 3. Every page by path [#3-every-page-by-path] ```tsx title="src/app/[[...slug]]/page.tsx" import { ContentApiError } from '@obelism/content' import { notFound } from 'next/navigation' import { Page, type PageEntry } from '@/components/Page' import { delivery, getContent } from '@/lib/content' type Props = { params: Promise<{ slug?: string[] }> searchParams: Promise> } export default async function CatchAll({ params, searchParams }: Props) { const { slug } = await params const path = slug?.join('/') || 'home' const content = await getContent(await searchParams) const entry = await content .getPage(path, { depth: 2 }) .catch((error: unknown) => { // A made-up, expired or other space's `cms_token`: show what's published. if (error instanceof ContentApiError && error.status === 401) { // Says why, when the canvas shows published content (guides/canvas). console.warn('Content refused the draft token:', error.body) return delivery.getPage(path, { depth: 2 }) } throw error }) if (!entry) notFound() return } ``` ## 4. Render the entry [#4-render-the-entry] `useLiveEntry` returns the editor's unsaved version while the page is open in the canvas, and `entry` everywhere else. `editable` marks the entry and its blocks, so editors can click them. ```tsx title="src/components/Page.tsx" 'use client' import { type Block, editable, type Entry, RichText, type RichTextNode, useLiveEntry, } from '@obelism/content-react' type HeroBlock = Block<{ heading: string | null }, 'hero'> type TextBlock = Block<{ text: RichTextNode | null }, 'text'> export type PageEntry = Entry< { title: string | null; body: (HeroBlock | TextBlock)[] | null }, 'page' > export const Page = ({ entry }: { entry: PageEntry }) => { const { fields } = useLiveEntry(entry) return (

{fields.title}

{fields.body?.map((block) => block._type === 'hero' ? (

{block.heading}

) : (
), )}
) } ``` The types in `content/examples/` show what each of your content types looks like; adjust `PageEntry` to your own model. ## 5. Start the bridge [#5-start-the-bridge] ```tsx title="src/app/layout.tsx" import { ContentBridge } from '@obelism/content-react' export default function RootLayout({ children, }: { children: React.ReactNode }) { return ( {children} ) } ``` It does nothing outside the editor. ## 6. Let the CMS frame the site [#6-let-the-cms-frame-the-site] ```ts title="next.config.ts" const nextConfig = { async headers() { return [ { source: '/:path*', headers: [ { key: 'Content-Security-Policy', value: "frame-ancestors 'self' https://content.obelism.studio", }, ], }, ] }, } export default nextConfig ``` Then set the environment's **preview URL** (Settings) to where the site runs, such as `http://localhost:3000`, and open the **Canvas**. ## Next steps [#next-steps] - [Revalidate on publish](/docs/guides/webhooks): call `revalidateTag('content')` from a webhook route. - [Editing on the site](/docs/guides/site-editing): one route handler, and editors sign in on the site itself. - [Images](/docs/api/images): resized and re-encoded copies with a query on the URL. - [A/B tests in content](/docs/guides/ab-tests), with Obelism Improve. # Shopify Oxygen (https://content.obelism.studio/docs/getting-started/oxygen) [Oxygen](https://shopify.dev/docs/storefronts/hydrogen/deploy/oxygen) is Shopify's host for Hydrogen storefronts: a React Router 7 app as a module Worker on `workerd`, with its environments, previews and variables in the Shopify admin. A site there reads Content over HTTPS like any other site, but a few things differ from Vercel and from a Node server. Start with the [React Router quick start](/docs/getting-started/react-router); this page is what changes when that site runs on Oxygen. A worked example is in the SDK: [`examples/content-react-router-oxygen`](https://github.com/Obelism/sdk/tree/main/examples/content-react-router-oxygen). It runs every step below, with the Content model, the assets and the Improve test the checklist needs. ## 1. Store the token in the environment [#1-store-the-token-in-the-environment] Oxygen gives the Worker an `env` argument instead of `process.env`, and issues no OIDC token of its own, so a [connected deployment](/docs/guides/api-tokens#without-a-stored-token) can't cover it: the site reads with a stored token. Create a **delivery token** under **Settings → API tokens** of the environment the site shows, then add the three variables under **Storefront settings → Environments and variables** in the Shopify admin, the token marked secret. **Copy as .env** next to the token list copies the same names: | Variable | Value | | ------------------------ | ------------------------------------------------------ | | `CONTENT_DELIVERY_TOKEN` | `cms_d_…` | | `CONTENT_SPACE_ID` | `space_…` | | `CONTENT_ENVIRONMENT` | `production`, or the environment this deployment shows | A deployment keeps the variables it was deployed with, so a new or rotated token reaches the site only after a redeploy. A **preview token** (`cms_p_…`) goes in the same place, for a deployment that always shows drafts. Shopify's React Router template already puts the Worker's `env` in the route context (`app/lib/env.ts`, set in `server.ts`). Add the three fields to its `Env` type, then read them with `context.get(envContext)`: ```ts title="app/lib/env.ts" export type Env = { // … CONTENT_DELIVERY_TOKEN?: string CONTENT_SPACE_ID?: string CONTENT_ENVIRONMENT?: string } ``` ```ts title="app/lib/content.server.ts" import { ContentClient, readPreviewParams } from '@obelism/content' import { readSiteToken } from '@obelism/content/site' import type { Env } from './env' // Naming the space and environment makes a token someone made for another // space read nothing here. const drafts = (token: string, env: Env) => new ContentClient({ token, space: env.CONTENT_SPACE_ID, environment: env.CONTENT_ENVIRONMENT, init: { cache: 'no-store' }, }) export const createDelivery = (env: Env) => new ContentClient({ token: env.CONTENT_DELIVERY_TOKEN!, space: env.CONTENT_SPACE_ID, environment: env.CONTENT_ENVIRONMENT, }) export const contentFor = (request: Request, env: Env) => { // `cms_preview` alone proves nothing: anyone can add it to a URL. const preview = readPreviewParams(new URL(request.url)) if (preview?.token) return drafts(preview.token, env) const siteToken = readSiteToken(request.headers.get('cookie')) if (siteToken) return drafts(siteToken, env) return createDelivery(env) } ``` ```tsx title="app/routes/page.tsx" import { data } from 'react-router' import { contentFor } from '../lib/content.server' import { envContext } from '../lib/env' import type { Route } from './+types/page' export async function loader({ context, params, request }: Route.LoaderArgs) { const env = context.get(envContext) const entry = await contentFor(request, env).getPage( params['*'] || 'home', { depth: 2 }, ) if (!entry) throw data(null, { status: 404 }) return { entry } } ``` The three clients are the [one-client-per-request](/docs/guides/preview#one-client-per-request) order from the quick start, with the variables read from the binding instead of `process.env`, and the quick start's `401` fallback: an expired site token, a `cms_token` from another space, or a deployment that names another environment than its token belongs to all show the published page. ## 2. The preview environment must be public [#2-the-preview-environment-must-be-public] The [canvas](/docs/guides/canvas) frames your site in the editor's browser, and that browser is signed in to Obelism Content, not to Shopify. A private Oxygen environment asks for a Shopify staff login on every request, so the frame shows that login page instead of your site: the environment whose **preview URL** the canvas opens has to be **public**. Oxygen allows one public environment on the single-store plans, 25 on Plus, and none on development stores and trial plans. A development store gets none even when its plan shows Plus: the store type is checked as well as the plan. Two ways to fit: - **Plus**: production public, and a second public environment on its own branch for editors. Point each Content environment's preview URL at its Oxygen environment URL. - **Otherwise**: set production's preview URL to the one public environment and let the canvas show drafts there through `cms_token`; visitors without a token still get published content. [Editing on the site](/docs/guides/site-editing) works there too. Point the preview URL at an **environment** URL, not at a deployment's own preview URL: that one changes with every deploy. ## 3. Send the headers the editor needs [#3-send-the-headers-the-editor-needs] The shortest policy that works for Content: the canvas frames the site (`frame-ancestors`), the prompt bar's form is an iframe of the CMS (`frame-src`), and Content's assets come from its own host (`img-src`, and `connect-src` if a browser reads the delivery API). ```tsx title="app/root.tsx" import type { Route } from './+types/root' export function headers(_: Route.HeadersArgs) { return { 'Content-Security-Policy': "frame-ancestors 'self' https://content.obelism.studio; frame-src 'self' https://content.obelism.studio; img-src 'self' data: https://content.obelism.studio", } } ``` React Router sends the headers of the deepest matching route, so a child route that exports its own `headers` has to merge `parentHeaders` or this one is gone: keep the CSP here, in the root route or in `entry.server.tsx`. If you already send a policy, add these directives to it rather than replacing it. Drop `X-Frame-Options: DENY` for preview requests, and leave analytics out when the URL carries `cms_token` (the last [rule for drafts](/docs/guides/preview#rules-for-drafts)); numbers' and improve's origins go in `connect-src` next to Content's. ## 4. Cache with a TTL, not with tags [#4-cache-with-a-ttl-not-with-tags] Oxygen has no tag cache, no way to purge the cache of the live deployment, and a Cache API that is per data centre. That fits Content: it purges its own edge cache when something is published, so a read you don't cache is never stale, and the only staleness left is the one you add. - **Cache published reads for a short while.** Hydrogen's `createFetchWithCache` (the 2026-10 API; earlier versions have `createWithCache`) and a custom strategy are made for this: pass it as the client's `fetch`, with the space and environment in the key (they travel in headers, so the URL alone doesn't identify the answer). - **Never cache a draft.** Read drafts with `cache: 'no-store'`, and answer the request with `Cache-Control: private, no-store` and `X-Robots-Tag: noindex` (the [rules for drafts](/docs/guides/preview#rules-for-drafts)). - **Keep drafts out of Oxygen's full-page cache.** A page sent with `Oxygen-Cache-Control` stays cached until its `max-age`, since that cache can't be purged. ```ts title="app/lib/content.server.ts" import { Cache, type CacheInstance, createFetchWithCache, } from '@shopify/hydrogen' import { ContentClient } from '@obelism/content' import type { Env } from './env' // The createDelivery above, with a cache in front of the published reads. export const createCachedDelivery = ( env: Env, cache: CacheInstance, waitUntil: ExecutionContext['waitUntil'], ) => { const fetchWithCache = createFetchWithCache({ cache, waitUntil }) const key = (input: Parameters[0]) => `${env.CONTENT_SPACE_ID}:${env.CONTENT_ENVIRONMENT}:${ input instanceof Request ? input.url : String(input) }` return new ContentClient({ token: env.CONTENT_DELIVERY_TOKEN!, space: env.CONTENT_SPACE_ID, environment: env.CONTENT_ENVIRONMENT, // A draft client passes `init.cache: 'no-store'` and skips this. fetch: (input, init) => init?.cache === 'no-store' ? fetch(input, init) : fetchWithCache(input, init, { key: key(input), strategy: Cache({ maxAge: 60, staleWhileRevalidate: 600 }), }), }) } ``` Use it where `contentFor` uses `createDelivery(env)`, with `context.cache` and `context.waitUntil`: the Worker entry that Shopify's template ships sets both on the route context. ## 5. Know what a webhook can't do [#5-know-what-a-webhook-cant-do] A [webhook](/docs/guides/webhooks) still reaches the site when something is published, but on Oxygen it can't revalidate by tag: it can warm a page, or delete one key in the data centre that happens to receive it, not everywhere. With the TTL above, most sites don't need more; a site that must show a publish at once either keeps its TTLs very short or rebuilds on the webhook. ## Checklist [#checklist] - [ ] `CONTENT_DELIVERY_TOKEN`, `CONTENT_SPACE_ID` and `CONTENT_ENVIRONMENT` in the storefront's variables, the token secret. - [ ] The environment the deployment shows is the one `CONTENT_ENVIRONMENT` names. - [ ] The preview URL is the deployment's Oxygen **environment** URL, and that environment is public. - [ ] `frame-ancestors` and `frame-src` allow `https://content.obelism.studio`. - [ ] Published reads cached for seconds to a minute; drafts uncached, `private, no-store` and `noindex`. - [ ] A redeploy after rotating the token. - [ ] No `process.env`, `Buffer` or `node:crypto` in server code: the `env` binding, Web APIs, and `headers.getSetCookie()` for several `Set-Cookie` values. # React Router (https://content.obelism.studio/docs/getting-started/react-router) This sets up a React Router v7 site in framework mode (Remix's successor) that renders every page entry at its path on the server, shows drafts in the canvas, and lets editors sign in on the site. It runs on any host that runs a Worker or a Node server; [Shopify Oxygen](/docs/getting-started/oxygen) has what changes on Shopify's. The SDK's [`examples/content-react-router-oxygen`](https://github.com/Obelism/sdk/tree/main/examples/content-react-router-oxygen) is the same setup on Oxygen, deployed. ## 1. Start the repository on a space [#1-start-the-repository-on-a-space] Create a **management token** under **Settings → API tokens**, then run in the site's root: ```sh export CONTENT_MANAGEMENT_TOKEN=cms_m_… npx @obelism/content init npm i @obelism/content-react ``` `init` writes the model file (`content/content-model.json`), an example of what the delivery API answers for every content type (`content/examples/`), installs `@obelism/content`, and adds the token variables to `.env.example`. Put a delivery token in `.env` (never commit it): ```sh CONTENT_DELIVERY_TOKEN=cms_d_… # The space and environment it reads (Space settings), so the canvas's # token for another space reads nothing here. CONTENT_SPACE_ID=space_… CONTENT_ENVIRONMENT=production ``` On Shopify Oxygen these arrive in the Worker's `env` binding instead of `process.env`: [Shopify Oxygen](/docs/getting-started/oxygen#1-store-the-token-in-the-environment) shows how to read them. ## 2. The clients [#2-the-clients] One client for published content, and one per request for drafts: the canvas's `cms_token`, then the site token of an editor who signed in on the site, then the published client. This module only runs in loaders, on the server. ```ts title="app/lib/content.server.ts" import { ContentClient, readPreviewParams } from '@obelism/content' import { readSiteToken } from '@obelism/content/site' export const delivery = new ContentClient({ token: process.env.CONTENT_DELIVERY_TOKEN!, }) // Naming the space and environment makes a token someone made for another // space read nothing here. const drafts = (token: string) => new ContentClient({ token, space: process.env.CONTENT_SPACE_ID!, environment: process.env.CONTENT_ENVIRONMENT!, init: { cache: 'no-store' }, }) export const contentFor = (request: Request) => { // `cms_preview` alone proves nothing: anyone can add it to a URL. const preview = readPreviewParams(new URL(request.url)) if (preview?.token) return drafts(preview.token) const siteToken = readSiteToken(request.headers.get('cookie')) if (siteToken) return drafts(siteToken) return delivery } ``` These reads are uncached. Cache published ones for a short while where the host has a cache; Oxygen's way is in [its caching step](/docs/getting-started/oxygen#4-cache-with-a-ttl-not-with-tags). ## 3. Every page by path [#3-every-page-by-path] One splat route serves every path: ```ts title="app/routes.ts" import { type RouteConfig, route } from '@react-router/dev/routes' export default [ route('cms/*', 'routes/cms.tsx'), route('*', 'routes/page.tsx'), ] satisfies RouteConfig ``` The loader fetches the page; the component renders it with `useLiveEntry`, which returns the editor's unsaved version while the page is open in the canvas, and the loaded entry everywhere else. `editable` marks the entry and its blocks, so editors can click them. ```tsx title="app/routes/page.tsx" import { ContentApiError } from '@obelism/content' import { type Block, editable, type Entry, RichText, type RichTextNode, useLiveEntry, } from '@obelism/content-react' import { data } from 'react-router' import { contentFor, delivery } from '../lib/content.server' import type { Route } from './+types/page' type HeroBlock = Block<{ heading: string | null }, 'hero'> type TextBlock = Block<{ text: RichTextNode | null }, 'text'> type PageEntry = Entry< { title: string | null; body: (HeroBlock | TextBlock)[] | null }, 'page' > export async function loader({ params, request }: Route.LoaderArgs) { const path = params['*'] || 'home' const entry = await contentFor(request) .getPage(path, { depth: 2 }) .catch((error: unknown) => { // A made-up, expired or other space's `cms_token`: show what's published. if (error instanceof ContentApiError && error.status === 401) { return delivery.getPage(path, { depth: 2 }) } throw error }) if (!entry) throw data(null, { status: 404 }) return { entry } } export const meta = ({ data }: Route.MetaArgs) => [ { title: data?.entry.fields.title ?? '' }, ] export default function Page({ loaderData }: Route.ComponentProps) { const entry = loaderData.entry const { fields } = useLiveEntry(entry) return (

{fields.title}

{fields.body?.map((block) => block._type === 'hero' ? (

{block.heading}

) : (
), )}
) } ``` `content/examples/` shows what each of your content types looks like; adjust `PageEntry` to your own model. ## 4. Start the bridge [#4-start-the-bridge] ```tsx title="app/root.tsx" import { ContentBridge } from '@obelism/content-react' import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router' export function Layout({ children }: { children: React.ReactNode }) { return ( {children} ) } export default function App() { return } ``` It does nothing outside the editor. ## 5. Let editors sign in on the site [#5-let-editors-sign-in-on-the-site] Editors can work on the site itself, at `/cms/login`: drafts, and the prompt bar to change the page by prompt or form. One resource route serves all of it, with the same handler Next.js mounts: ```ts title="app/routes/cms.tsx" import { createSiteEditing, type SiteEnvironment } from '@obelism/content/site' import type { Route } from './+types/cms' const handler = createSiteEditing({ spaceId: process.env.CONTENT_SPACE_ID!, environment: (process.env.CONTENT_ENVIRONMENT as SiteEnvironment | undefined) ?? 'production', }) export const loader = ({ request }: Route.LoaderArgs) => handler(request) export const action = ({ request }: Route.ActionArgs) => handler(request) ``` Return its `Response` as it is: it sets several cookies, and rebuilding the response loses them. React Router has no draft mode, so `contentFor` above reads the site token from the cookie on every request; answer draft responses with `Cache-Control: private, no-store` and `X-Robots-Tag: noindex`. On Oxygen, `process.env` doesn't exist: read `spaceId` and `environment` with `context.get(envContext)` in `loader` and `action`, as the clients above do. A route's `headers` export gets no `request`, so decide the draft headers in a root middleware that sees the `cms_token` or the site token, not in `headers`. The exit link goes to `/cms/logout` as a plain ``, or with `prefetch="none"`: a prefetch would end the session before anyone clicks. [Editing on your site](/docs/guides/site-editing) has the flow and what editors get. ## 6. Let the CMS frame the site [#6-let-the-cms-frame-the-site] ```tsx title="app/root.tsx" import type { Route } from './+types/root' export function headers(_: Route.HeadersArgs) { return { 'Content-Security-Policy': "frame-ancestors 'self' https://content.obelism.studio; frame-src 'self' https://content.obelism.studio", } } ``` `frame-ancestors` lets the canvas show the site, `frame-src` lets the prompt bar's form open. React Router sends the headers of the deepest matching route, so a child route that exports its own `headers` has to merge `parentHeaders` to keep this: set it here, or in `entry.server.tsx`. If your site sends a stricter policy, add both directives to it, and drop `X-Frame-Options: DENY` for preview requests. Then set the environment's **preview URL** (Settings) to where the site runs, such as `http://localhost:5173`, and open the **Canvas**. ## Next steps [#next-steps] - [Shopify Oxygen](/docs/getting-started/oxygen): a stored token, a public preview environment, and caching on Shopify's host. - [Webhooks](/docs/guides/webhooks): purge your CDN or rebuild when something is published. - [Previews and draft mode](/docs/guides/preview): the order the tokens are read in, and the rules that keep drafts off your public pages. - [Paths per locale](/docs/sdk/javascript#paths-per-locale) and [sitemaps](/docs/sdk/javascript#sitemaps). - [Images](/docs/api/images): resized and re-encoded copies with a query on the URL. - [A/B tests in content](/docs/guides/ab-tests), with Obelism Improve. # A/B tests and flags (https://content.obelism.studio/docs/guides/ab-tests) Sites built on Content can run their A/B tests and feature flags on [Obelism Improve](https://improve.obelism.studio/docs). Instead of hard-coding which content each variant shows, editors write each variant's content in Content, with the built-in **improve block**. ## In the editor [#in-the-editor] Every blocks field takes it, whatever its allowed blocks, with no content type to create. Under **Add block**, choose **A/B test or flag**: it lists the tests and flags of the same environment in Improve. The block then has a tab per variant, each a list of blocks like the field it is in. - Each variant takes the blocks its list allows, so a test can only show what the page could show anyway. A variant can't hold another improve block. - It is offered while the organization has Improve enabled. - A draft may name a test that is still to come. **Publishing** checks the test or flag exists in that environment, by slug, and that every variant is one of its options. **Promoting** checks the same in the target, so sync the test there in Improve first. ## In content [#in-content] ```json { "_uid": "b_…", "_type": "_improve", "kind": "test", "slug": "homepage-hero", "variants": { "control": [{ "_uid": "b_…", "_type": "hero", "heading": "Welcome" }], "variation": [{ "_uid": "b_…", "_type": "hero", "heading": "Hello there" }] } } ``` `kind` is `test` or `flag`, `slug` the test's or flag's slug, and `variants` one list of blocks per option slug. A multi-variant test's options are its combinations (`red-small`). Agents list what a block can name, with each one's option slugs, with `GET …/improve` (`listImproveSources`). ## On the site [#on-the-site] The delivery API returns the block as stored, with each variant projected like any other blocks. Ask Improve's SDK for the visitor's option and render that variant: ```ts import { isImproveBlock, pickImproveVariant } from '@obelism/content' // `improve` is Improve's SDK (@obelism/improve-sdk), set up for the visitor. const blocks = fields.body.flatMap((block) => { if (!isImproveBlock(block)) return [block] const option = block.kind === 'flag' ? improve.getFlagValue(block.slug) : improve.getTestValue(block.slug) return pickImproveVariant(block, option) }) ``` Improve returns `null` when the test or flag isn't active or the visitor isn't in it. `pickImproveVariant(block, option, fallback?)` then takes the `fallback` option's variant, else the first one. `npx @obelism/content init` writes an example of the block to `content/examples/` while the organization has Improve on. # Activity (https://content.obelism.studio/docs/guides/activity) **Activity** (in the sidebar, for admins and developers) lists who did what in the space, newest first and by day: the environment you're in, plus what applies to the whole space. The pills above it show **People**, **Obelism AI** (AI translation) or **Coding agents** (the agent API, MCP and management tokens) alone; the menus beside them pick a time range, a kind of change, or **Every environment**. **Show older activity** at the bottom pages back. Saves are listed too, as "changed", from each entry's history (its last 100 versions). A save doesn't record how it was made, so saves show under **Everyone** and **Entries** only, never under People, Obelism AI or Coding agents. A save and a publish show the first text field they changed, before and after: a save against the version before it, a publish against the entry's previous publish. A row in another environment opens the entry there. What is recorded: | Kind | Changes | | ------------- | ------------------------------------------------------------------------------------------------------------ | | Entries | Created, published, unpublished, moved, scheduled, deleted, restored, deleted for good, a version restored | | Content model | Every change to the content types, with which ones | | Promotions | Content promoted into an environment, or a file imported: how many entries, and what was removed | | Media | Files uploaded, edited (alt text, title, focal point) and deleted | | Redirects | Added by hand, deleted | | API tokens | Created (kind, scopes and expiry) and revoked; never the token itself | | Webhooks | Added, changed, given a new secret, deleted | | Plugins | Registered, updated and removed (the Organization's, in every Space's log); turned on, off, settings changed | | Settings | The preview URL and product path, the prompt endpoint, the locales, the space's name, deleting the space | Saving a draft isn't recorded in the audit log itself (agents reading `…/audit-log` don't get saves): every save is in the entry's own **History**, which Activity shows beside it. A [bulk action](/docs/guides/publishing#several-entries-at-once) is recorded entry by entry, and a promotion or import as one row. Each row says how the change was made when it wasn't in the dashboard: through the agent API, MCP, a [management token](/docs/api/management#content-with-a-management-token), on schedule (as the person who scheduled it), or on the site. Rows are kept for a year. A deleted space's history stays until then; a deleted organization's goes with its spaces. Agents read it with `GET …/audit-log` (`environment` or `all`, `action` as a kind like `entry` or one action like `entry.published`, `userId`, `limit`, `skip`) in the [agent API](/docs/api/agents). # API tokens (https://content.obelism.studio/docs/guides/api-tokens) Your site, your build and your scripts reach Content with a token. A token belongs to one space and one environment, so a production site uses a production token, and a develop token sees nothing of production. ## Kinds [#kinds] You create three kinds under **Settings → API tokens**: | Kind | Starts with | May | Belongs to | | ---------- | ----------- | -------------------------------------------------------------------------------------------------------------- | ------------------------- | | Delivery | `cms_d_` | Read published content from the [delivery API](/docs/api/delivery) | The environment | | Preview | `cms_p_` | Read drafts too | The environment | | Management | `cms_m_` | With the **model** scope, sync the [content model](/docs/guides/model-in-git); with **content**, write content | The person who created it | - **Delivery tokens** are made for being seen: CORS is open, so a page can read published content with one from the browser. - **Preview tokens** see what isn't published yet. Keep them on your server: from a browser they only work on the environment's [preview URL](/docs/guides/spaces#settings). - **Management tokens** act as the person who created them, with that person's [role](/docs/guides/roles) at the time of each request: they stop working when the person leaves the organization, and can't do more than the person may. Pick what one may do under **What it may do**: the **content model** (the [CLI](/docs/sdk/cli) and the [management API](/docs/api/management)) and **content** (the agent API's [content routes](/docs/api/management#content-with-a-management-token), for migrations, imports and backups). The delivery API refuses them. Content makes more tokens itself, which you never create or copy: | Token | Starts with | Made when | Lasts | | ------ | ----------- | ------------------------------------------------------------------------------- | ----------------- | | Canvas | `cms_v_` | The [canvas](/docs/guides/canvas) opens your site, for the person looking at it | 8 hours | | Site | `cms_s_` | An editor [signs in on your site](/docs/guides/site-editing) | 8 hours | | Form | `cms_f_` | The prompt bar's form opens on your site | As its site token | Canvas and site tokens read drafts as their person, like a preview token with that person's role, and are refused from a browser on any origin but the preview URL. ## Creating and revoking [#creating-and-revoking] Admins and developers create tokens under **Settings → API tokens**: a name (what it is for, like "Vercel production"), a kind and, for a management token, its scopes. The token is shown **once**, right after it is created: copy it into your host's secrets or an untracked `.env` file. Only a hash of it is stored, so a lost token can't be shown again; create a new one. Every token expires: in 30 days, 90 days, 1 year or never, picked under **Expires** when you create it. 1 year is the default, and creating a token without picking asks you to confirm that date first. After it, the token is refused with `401` and `token_expired`: create a new one, put it where the old one was, and revoke the old one. The list marks a token that expires within 14 days, and one unused for 90 days, which is likely forgotten and safe to revoke. The organization's admins and the person who created the token get an email 14 and 3 days before it expires. Each token lists when it was last used. **Revoke** stops it on the next request, or within 60 seconds for a delivery token answered from the [edge cache](/docs/api/delivery#caching). Revoking is recorded in [Activity](/docs/guides/activity), never the token itself. Agents list, create and revoke tokens with `listApiTokens`, `createApiToken` and `revokeApiToken`, in develop and staging. Production tokens are made by a person in the dashboard. ## Without a stored token [#without-a-stored-token] A deployment on Vercel, or a GitHub Actions workflow, can use the OIDC token its host gives it instead of an API token: nothing to store, rotate or let expire. Connect it under **Connected deployments** on the space's page on [www.obelism.studio](https://www.obelism.studio): its Vercel team and project ids, or its GitHub repository id, and a rule per environment of the deployment. Each rule maps that environment (a Vercel environment like `production` or `preview`, a GitHub environment or ref, or `any` for the rest) to one environment of the space, with what Content allows there: | Content | Reads as | | -------------------- | -------------------------------------------------------------------- | | Off | refused | | Published | a delivery token | | Published and drafts | a delivery token, or a preview token on requests that ask for drafts | | Management | the above, and a management token with its scopes, acting as you | Oxygen issues no OIDC token, so a storefront on [Shopify Oxygen](/docs/getting-started/oxygen) uses a stored delivery or preview token instead. Then pass the [SDK](/docs/sdk/javascript) a token source: ```ts import { ContentClient } from '@obelism/content' import { vercelOidc } from '@obelism/content/oidc' // needs @vercel/oidc installed const content = new ContentClient({ token: vercelOidc(), // or githubOidc() in a workflow with `permissions: id-token: write` space: process.env.CONTENT_SPACE_ID, environment: process.env.CONTENT_ENVIRONMENT, }) const draft = await content.getPage(path, { drafts: true }) // a preview route ``` Without the SDK, request the host's token for `https://content.obelism.studio` and send it as the bearer token. The token's environment picks the rule, so a site's preview deployments read the environment their rule maps them to. A site that names its space and environment (`Obelism-Space` and `Obelism-Environment`, the SDK's `space` and `environment`) gets `401` with `token_place_mismatch` from a deployment whose rule maps it elsewhere. Drafts are only read when a request asks for them with `Obelism-Drafts: true` (the SDK's `drafts: true`), so a site's pages and its preview can share one connection without a page showing a draft. A token no connection lets in gets `401` with `no_connection`; one whose environment has no rule, `no_rule`; one whose rule has Content off, `product_off`. Asking for drafts with a rule that reads published content only gets `403` with `drafts_not_allowed`. A project connected to more than one space names the one it means with the `Obelism-Space` header, or gets `401` with `ambiguous_space`. Delivery requests with a host token skip the edge cache, since the token changes every hour or two; they are still answered from the content cache. ### Local development [#local-development] `vercelOidc()` works on your own machine too. Link the repository to the Vercel project with `vercel link` and sign in with `vercel login`: the SDK takes the token `vercel env pull` wrote to your `.env`, or asks Vercel for a new one with your sign-in when there is none or it expired. That token's environment is `development`, so the connection needs a rule for `development` (its template maps it to develop), and `CONTENT_ENVIRONMENT` names the same environment. Only members of the Vercel team get a token: anyone else, and CI outside Vercel and GitHub Actions, uses a delivery token. ## Sending one [#sending-one] ```sh curl -H "Obelism-Token: cms_d_…" \ "https://content.obelism.studio/api/delivery/v1/pages?path=home" ``` The delivery API takes a token in `Obelism-Token` or as `Authorization: Bearer …`; only `Obelism-Token` lets a delivery token be answered from the edge cache. The management, agent and site APIs take `Authorization: Bearer …`. The [SDK](/docs/sdk/javascript) sends the right one. ## Environment variables [#environment-variables] **Settings → API tokens → Copy as .env** copies what a site needs to reach the space in that environment, for every product it uses, grouped per product: ```bash # Improve IMPROVE_ORGANIZATION_ID=org_… IMPROVE_SPACE_ID=space_… IMPROVE_ENVIRONMENT=production IMPROVE_API_TOKEN="" # Content CONTENT_SPACE_ID=space_… CONTENT_ENVIRONMENT=production CONTENT_DELIVERY_TOKEN="" # Numbers NUMBERS_ORGANIZATION_ID=org_… NUMBERS_SPACE_ID=space_… NUMBERS_ENVIRONMENT=production NUMBERS_API_TOKEN="" ``` A token line names the page to make one on, since a token is only shown when it is created. When a [connected deployment](#without-a-stored-token) reaches the environment, **Copy for connected deployments** next to it copies the same lines without the token lines of the products it reaches, since the deployment sends its own. With a [Shopify store](/docs/guides/shopify#on-the-site) connected, it adds `SHOPIFY_STORE_DOMAIN` and `SHOPIFY_STOREFRONT_TOKEN` under Content. The SDK and `npx @obelism/content init` use the same names, plus `CONTENT_PREVIEW_TOKEN` for drafts and `CONTENT_MANAGEMENT_TOKEN` for the CLI. # Artboards (https://content.obelism.studio/docs/guides/artboards) Artboards are off until you turn them on under **Account → Preferences** on [www.obelism.studio](https://www.obelism.studio/account/preferences). Then **Artboards** (in the sidebar) shows the pages on the environment's board — or, more precisely, on the active **view** of it: your site, rendered at the environment's **preview URL** (Settings) in a frame per page, with the page's path appended (with `{locale}` in the preview URL, like `https://example.com/{locale}`, the locale chosen at the top goes there and the path is the one in that locale). A [product page](/docs/guides/shopify#product-pages) opens at the environment's product path instead (`/products/` by default). Each page is a frame in a slot on a grid: a page stays where you put it, left to right and then top to bottom, and each frame names its page type in the type's colour. A page type filter shows only that type's pages in their slots, fitted to the screen. **Edit on page** on an entry's page opens just that page (`/artboards?entry=`), when artboards are on: the breadcrumb goes back to **Everything** or to every page of its type, and **Show entire board** fits the whole board. A new board is empty: the empty state offers the first page. Once a page is there, its empty neighbouring slots offer the next one. ## Views [#views] A board has named views: a Black Friday view of the home page and the collection pages, a Stories view of the last few journal posts and their overview. The active view's name is the button at the top right; its menu lists the views with the active one ticked, then **New view**, **Rename view** and **Delete view**. It also holds the board's pages, a new page and the page type filter. Creating a view opens it empty; deleting one leaves its pages in Content. The views are shared: everyone in the Space sees the same names and the same pages in each. The active view is yours — `?layout=` in the URL, or the one this browser last chose, so the sidebar and **Edit on page** return to it — and so is each view's arrangement: the slots are remembered in this browser, per Space environment and per view. ## Adding pages [#adding-pages] Hover an empty slot beside a page: an orange plus and a faint dashed outline show the page that would appear there. A click opens a picker with every page of the environment that is not on the view yet, grouped by type, and — with a store connected — the store's products and collections. Pick one and it takes that slot. A page of another type added while a type filter is on clears the filter, so the new page is in view. The four slots around a page are all offered: adding above the first row or left of the first column moves the board's origin, so the whole board shifts. The empty state's **Add your first page** opens the same picker; **New page** writes one by prompt instead. Picking a product or collection that already has a page adds that page, so you edit it like any other. One without a page adds the store's route for it, which previews the site and has no form; take it off the view with its ×. ## Choosing pages [#choosing-pages] The view's name (top right) → **Pages** lists every page of the environment by type, with the ones on the view ticked. Tick another to add it, untick one to take it off; **Show all pages** fills the view and **Remove all** empties it. The pages save as you change them, and everyone in the Space sees the same views. A page that is not on the view can still be opened: **Edit on page** and `/artboards?entry=` show it for that visit. ## Arranging the board [#arranging-the-board] Drag a page by the grip in its caption to a slot: the slot under the pointer is outlined, and the page goes where you let it go. An empty slot is left empty where it was; dropping on another page swaps the two, and nothing else moves. The grid has no edge: a drag can start a row above the first or a column left of the first, and the board grows there. The arrow keys on the grip move it to the slot beside it, and **Home** and **End** to the ends of its row. The slots are yours: they are remembered in this browser, per Space environment and per view, while the views and their pages stay shared. While you drag a frame, a **Remove** zone appears at the board's bottom right. Drop the frame there to take it off the view; a page with unsaved changes asks first. Store frames can be dragged and removed the same way. ## Making a site editable [#making-a-site-editable] 1. **Include the bridge** on every page. With the [React SDK](/docs/sdk/react) that is `` in the root layout; with a bundler, `getContentBridge()` from `@obelism/content/bridge`; without one, a script tag: ```html ``` It does nothing outside the artboards. Inside it, it only talks to the CMS origin it was loaded from. The script tag puts it on `window.ObelismBridge`. Options go on the tag: `data-prompt="false"` leaves the prompt box of [editing on your site](/docs/guides/site-editing) out, and `data-url` names the CMS when the file comes from an npm CDN. 2. **Mark blocks** with their uid, which every block in a delivery response has: ```jsx
…
// or, without the SDK:
…
``` 3. **Show drafts in preview.** Every frame's URL carries `cms_preview=1`, `cms_entry=`, `cms_environment=` and `cms_token`: a token made for the person editing, each time the artboards open. Read the page with `cms_token` as the bearer token, on your server, naming your space and environment (`space` and `environment` on `ContentClient`, the `Obelism-Space` and `Obelism-Environment` headers otherwise), and the delivery API answers drafts. Without them, a `cms_token` someone made for their own space would put that space's drafts on your site. It only reads, acts as that person (with their role at the time) and expires after 8 hours. A missing, made-up or expired token gets a `401`: show the published page then. [Previews and draft mode](/docs/guides/preview) has the code, and the rules that keep drafts off your public pages. Decide on drafts by `cms_token`, never by `cms_preview` alone with a preview token of your own: anyone can add `cms_preview=1` to a URL. 4. **Re-render on live updates** (optional, recommended): `useLiveEntry(entry)` in React, or ```js ObelismBridge.on('update', ({ entry }) => render(entry)) ``` `entry` has exactly the shape of a delivery API entry for a preview token, built from the unsaved draft. Without an `update` listener the page reloads after every save instead. 5. **Allow framing** by the CMS: if your site sends `Content-Security-Policy: frame-ancestors`, include `https://content.obelism.studio`, and drop `X-Frame-Options: DENY` for preview requests. The preview URL has to be reachable by the editor's browser too: a private host — Vercel deployment protection, Cloudflare Access, a private Oxygen environment — shows its login page in the frame instead of your site ([Shopify Oxygen](/docs/getting-started/oxygen#2-the-preview-environment-must-be-public)). ## Products [#products] With a [Shopify store](/docs/guides/shopify) connected to the space, the artboards add **Products** and **Collections** groups after the view's pages, each in its type's colour. They hold the [product pages](/docs/guides/shopify#product-pages) and [collection pages](/docs/guides/shopify#collection-pages) on the view, grouped by kind. A product or collection comes onto a view through the same add picker as a page (a page's plus, or **Add your first page**): search the store's products and collections there and pick one. - A product (collection) with a page: the picker adds that page, so you edit it like any other. - A product (collection) without one: the picker adds the store's route for it (the environment's product or collection path, `/products/` and `/collections/` by default), a preview with no form. Drag it by its grip to move it; its × takes it off the view. The space needs a product page type to edit a product's page (Settings → Shopify). Products without a page are shown as visitors see them; there is nothing to edit on them in Content. ## Moving around [#moving-around] The board fills the page beside the dashboard's navigation. Its controls are at the top right: **Go to page** (any page of the view, shown once the view holds more than five pages), the view's name (the views, a new view, pages, a new page, and the page type filter), **Save all** and **Publish all** while pages have changes, then the screen, locale and zoom controls. The page breadcrumb and **Show entire board** appear there while a type or a page is in view: - **Scroll** or **drag** to move the board, **⌘ or Ctrl and scroll** (or pinch on a trackpad) to zoom, or **−** and **+**. The board fits the screen when it opens. On the artboards, pinching zooms the board, not the browser. - **Desktop** and **Mobile** render every frame 1440 or 390 pixels wide, so your site's own breakpoints apply. With more than one locale, the locale select shows every page in that one. - Only the frames near what you're looking at load your site, at most eight at a time, each marked **Rendering** until it has loaded. The last dozen you've seen keep theirs when you move on; the others show an outline of a page until you move to them. A frame outside the visible area stops drawing until you bring it back (in Chromium, its animation frames stop too), so a board of open frames doesn't keep painting in the background; its document, scripts and unsaved changes stay as they were. ## Editing a page [#editing-a-page] Click a page to select it. Its form opens in a panel over the right of the board, the same fields and blocks as on the entry page, with **Save draft** and **Publish** at its end and whether the draft has changes beside them. It shows the page type's groups marked for the artboards, in their order: a group [set to **Open**](/docs/guides/content-model#groups-accent-and-code) is there from the start, **Closed** is an accordion you open, and **Hidden** stays on the entry page — SEO and the like. A closed group holding the block you click on the page opens by itself. A page type starts with its fields closed and its content open. Clicks inside the panel now reach your site: clicking a block on the page outlines its row in the form, without opening rows or moving the panel; for a block inside another, the row holding it is outlined until you open it. Clicking a row in the form opens or closes that block, without outlining it; closing the outlined row, or the row holding the outlined block, deselects it. Hold **Space** to move the board over the selected page, and press **Esc** or the panel's close button to clear the selection. **Shift**-click (or **⌘**/**Ctrl**-click) adds pages to the selection or takes them out, and **⌘A** (**Ctrl A**) selects every page of the last clicked page's type. The prompt bar shows what it will change: the selected pages as chips, which you can take out one by one or with **Clear selection**. A store frame has no page of its own and can't be selected; select a page to edit. - Edits show on the page as you type, before they are saved. - The prompt bar acts on the selected page, or the selected block (a chip of its own): type what to change and it changes it [by prompt](/docs/guides/prompt-editing). The change shows on the page at once, unsaved, and **Suggested by Obelism** above the bar says what changed: **Keep it**, **Try another** (undo it and ask again) or **Undo**, with the first text it changed before and after. Until you decide, the changed pages carry a **Preview** chip. - With several pages selected, each gets the same prompt, one after another; the panel lists them as they land. - **New page** in the view menu asks for a page type, a path and what the page should say, and the model writes it as a draft of that type (with no page selected, the board's first gives an example). The page goes on the board and opens selected (save or undo other changes first). - Every page keeps its unsaved changes while you edit others; a page with changes is marked **Unsaved**. The panel's **Save draft** saves the selected page and **Publish** saves and publishes it; **Save all** and **Publish all** do the same for every changed page, each with its own check that nobody saved it in the meantime. A page that can't be saved or published is named. Closing the tab with unsaved changes asks first. - Where publishing is reviewed and you may not publish, **Publish** and **Publish all** ask for review instead. ## Adding blocks [#adding-blocks] The page's form lists its blocks as rows, with **Add block** under them. A row's chevron opens its fields; **Add block** offers the types the blocks field allows (with more than one blocks field, each has its own list). A full field, or one that allows no type, says so. **Shift**- or **⌘**/**Ctrl**-click rows to pick several: a bar above the list moves them up or down together, or copies them, and dragging any picked row's grip moves the group. A plain click on a row opens or closes it and clears the picks. ## Reusing a block on another page [#reusing-a-block-on-another-page] Open a block's row and choose **Copy block** in its ⋯ menu — or pick several rows and use **Copy** in the bar above them. Select another page: every blocks field shows **Paste block** beside **Add block**, so paste it into the field it belongs in, or use **Paste above** / **Paste below** in a block's ⋯ menu to put it above, between or below the blocks there. With a copy held, pointing between two rows shows an orange line where it will go; a click pastes it there. The copies keep the order they had on the page, whatever order you picked them in, with new ids throughout, and stay closed, so you see where they landed. A block type the field doesn't allow, or a field without room for all of them, is refused. A paste leaves nothing picked or outlined. The copy is a single paste: pasting empties the clipboard, so the paste buttons go with it, and a reload empties it too. References and files in it are the same Space's, so they keep working on the new page. ## Variants side by side [#variants-side-by-side] A page with an [A/B test or flag block](/docs/guides/ab-tests) lists its tests and flags under **Variants** in the panel. **Side by side** adds a frame per option of that test or flag after the board's pages, each showing the page as it is with that option's content, while you edit the page in its own frame. **Hide** takes those frames away again. Each variant frame gets the page's unsaved content with every block of that test or flag holding only its option's blocks, so whatever option your site picks for the visitor, it can only render that one. This needs a site that re-renders on live updates (`useLiveEntry`, or `ObelismBridge.on('update', …)`); a site that only reloads after saving shows its own pick in every frame. ## Changing the whole site [#changing-the-whole-site] With no page selected, the prompt bar takes a request about the **whole site**, several pages at once: "make every hero shorter", "end each journal post with a call to action". It works in two steps: 1. A plan: the model reads an outline of the pages on the board (their names, paths, types and the start of each block) and answers which of them the request is about, and what to ask about each, at most 12. 2. Every step then runs as that page's own [prompt](/docs/guides/prompt-editing), one after another, with the same checks as a prompt on one page. The panel lists the steps as they land; click one to go to its page. The changes are unsaved, marked on their frames: look them over, **Undo** them all from the suggestion above the bar, and **Save all** or **Publish all** when they're right. A request reaches every page on the board. ## Whole pages [#whole-pages] A frame is as tall as its page. The bridge tells the artboards the page's height, and the artboards tell the bridge the screen height the frame stands for (900 pixels on desktop, 844 on mobile). The bridge then rewrites `vh`, `svh`, `lvh` and `dvh` sizes in your stylesheets as pixels of that height, so a `100vh` hero stays one screen high instead of growing with the frame. Stylesheets from another origin can't be read and keep their units. Frames as tall as their page need `@obelism/content` 0.20 or later (or Content's own `/bridge.js`). With an older bridge, frames are one screen high and scroll inside. ## Click rates [#click-rates] With **Show click rates on the page** in the panel, the selected page shows how often each of its blocks is clicked, as a label on each block. The switch shows once the page has click rates. The switch is remembered in your browser. The numbers come from [Obelism Numbers](https://numbers.obelism.studio), the events service every Obelism product shares: 1. Load Numbers' script on every page of the site (its Setup page has the tag for your Organization and environment). 2. Tag a block's clickable element with its uid, in production too: ```tsx
{block.label} ``` The script sends a `click` event with that uid for every click on (or inside) the element. A block's click rate is the share of the visitors who viewed a page it was clicked on who clicked it, over the last 30 days, in the environment you're editing. Blocks nobody clicked show nothing. In the artboards and the on-site editor (`@obelism/content` 0.28.2 and up), a block in the draft that is a copy of a block on the live page (pasted, or moved into a test's variants, so it has a new uid) shows the live block's click rate once the draft is saved, when it has the same type and the same links. A rate is only borrowed when exactly one live block, no longer on the draft, matches. ## When a frame shows published content [#when-a-frame-shows-published-content] A frame that shows the published page, or no page, instead of the draft almost always comes from the `401` fallback in step 3 of [making a site editable](#making-a-site-editable). Log the response's body before falling back (`error.body` on the SDK's `ContentApiError`): it says which of these it is. - **The entry was never published.** The fallback has nothing to show, so the frame shows the page as if it had no entry: on a product route, the product without its blocks. - **`cms_token` doesn't reach the client**, and the error is `Invalid or revoked token`. Check what your server actually reads, especially after a rewrite or a redirect. A Next.js rewrite that captures the query with `has` and a bare `key` names the value by its letters only (`cms_token` is `:cmstoken` in the destination). Name it with a group instead: ```ts title="next.config.ts" { source: '/products/:handle', has: [{ type: 'query', key: 'cms_token', value: '(?.+)' }], destination: '/preview/products/:handle?cms_token=:cmsToken', } ``` - **The space or environment doesn't match** (`token_place_mismatch`). The token is for the environment you're editing, and the site names its own (`CONTENT_SPACE_ID`, `CONTENT_ENVIRONMENT`). Point each environment's preview URL at a deployment configured for that environment. With a [connected deployment](/docs/guides/api-tokens#without-a-stored-token), that is the host environment whose rule leads there, such as a preview deployment for staging. ## Bridge reference [#bridge-reference] `window.ObelismBridge`, or what `getContentBridge()` returns: - `isEditing`: true inside the artboards. - `entryId`: the entry being edited. - `on(event, listener)`: `update` (`{ entry }`), `saved`, `published`, and `change` for the prompt state. - `prompt(text)`, `undo()`, `createPage(path, prompt)`: when [editing on your site](/docs/guides/site-editing); see [prompt editing](/docs/guides/prompt-editing). The bridge and the artboards talk over `postMessage`. The site sends `{ source: 'obelism-content-bridge', type }` with `ready` (with the page's `height`), `size` (`{ height }`, whenever it changes) and `select`; the artboards send `{ source: 'obelism-content-editor', type }` with `connect`, `viewport` (`{ height }`, the screen height the frame stands for), `update`, `select`, `saved`, `published` or `stats`. `stats` carries the blocks' click rates and the switch, `{ stats: { [uid]: { clicks, clickers, viewers, rate } }, visible }`, which the bridge shows on the page next to each `data-cms-uid` while `visible` is true (`@obelism/content` 0.7 and up; see the SDK's README, "Click rates"). Both sides check the other's origin. Editors can also work on the site itself, outside the dashboard: see [editing on your site](/docs/guides/site-editing). # Assets (https://content.obelism.studio/docs/guides/assets) Assets are uploaded files: images, video, documents. They belong to a space and are shared by all its environments, so promoting content never copies files. ## Uploading [#uploading] Upload in the **Media** library (media.obelism.studio, under Products in the sidebar), or from an asset field's picker in the entry editor. Files live in **folders** there; the picker opens the Space's folders too and uploads into the one it shows. Both list files newest first, a page at a time, and a search by file name looks in every folder. Agents upload with `POST …/assets` (multipart with a `file` field, or base64 JSON) through the [agent API](/docs/api/agents) or the `uploadAsset` MCP tool; a file too big for the call goes to `POST …/uploads/assets`, whose `uploadUrl` takes the bytes with a `PUT`, then `POST …/assets/from-upload`. - Files uploaded from Content or by an agent are at most 50 MB; over 25 MB, Content warns that the file adds to your storage quota. The Media library uploads larger files in parts ([uploads](https://media.obelism.studio/docs/library/uploads)). - The type is read from the file's bytes, not from its name or what the upload claims. A file claiming to be a browser-renderable image (PNG, JPEG, GIF, WebP, AVIF) whose bytes prove nothing image-like is refused. - Images get their `width` and `height` measured — inline, multipart and signed uploads alike — for the `width` and `height` attributes on the site. - Each asset has an **alt text** and a **title** per [locale](/docs/guides/locales). The delivery API answers them in the requested locale. ## Tags [#tags] Give files **tags** in the **Media** library (comma separated, up to 20 per file) to find them again: the tags above the library filter it, with how many files have each. Tags are only for finding files; they aren't part of delivery responses. Agents set `tags` with `PATCH …/assets/{id}` and filter with `GET …/assets?tag=…`. ## Replacing a file [#replacing-a-file] **Media → Replace file** puts a new file under an existing asset, so every entry using it shows the new one without being edited: a new logo, a corrected PDF. The new file must be the same kind (an image for an image, a PDF stays an application file), since asset fields may only allow some types. Alt text, title, focal point and tags stay; size and dimensions follow the new file. The asset's `url` changes (`/assets/{id}/v2/{filename}`), because files are cached for a year: environments whose content uses the asset get a new content version and an `asset.updated` [webhook](/docs/guides/webhooks), so sites pick up the new URL. Old URLs keep working and serve the new file. Agents use `PUT …/assets/{id}/file` with the same body as uploading (multipart or base64 JSON; a `filename` field names the file). ## Using them [#using-them] An `asset` field stores an asset's id (a list with `multiple`), optionally limited to some types with `mimeTypes` (`["image/"]`). Rich text embeds assets too. In delivery responses the id is replaced by the asset: ```json { "id": "asset_…", "url": "https://content.obelism.studio/assets/asset_…/photo.jpg?w=1600&q=80", "filename": "photo.jpg", "mimeType": "image/jpeg", "size": 482113, "width": 1600, "height": 900, "title": "Our office", "alt": "The office from the canal side", "focalPoint": { "x": 0.42, "y": 0.3 } } ``` `focalPoint` is where crops should keep the image (fractions of its width and height from the top left), or `null`. Set it by clicking the image in its details: open the file in the **Media** library, or click a picked file in the entry form, and crop with it as `focus` (see [images](/docs/api/images)). ### Alt text and focal point for one use [#alt-text-and-focal-point-for-one-use] A file's alt text and focal point are its own, wherever it is used. One use can have its own instead: **For this use** on a picked file in the entry form, or in an asset field's value `{ "asset": "asset_…", "alt": { "en": "…" }, "focalPoint": { "x": 0.2, "y": 0.8 } }` (either key may be left out, and `alt` falls back to the file's in a language it doesn't name). In rich text, select an embedded file and choose **Alt** for its alt text there, or send `alt` and `focalPoint` on the `embeddedAsset` node. In Markdown, an image's alt text is the use's: `![Two people on a beach](asset:asset_…)`. Under the alt text, in the media picker's pane, **For this use** and a file's details, **Suggest alt text** asks Obelism for one plain sentence describing the image in the language shown. **Keep it** puts it in the field, still unsaved; **Try another** asks again; **Dismiss** drops it. It takes an editor role and counts towards the same per-person limit as [prompt editing](/docs/guides/prompt-editing) (20 requests a minute). Media's library has the same button: see [a file's details](https://media.obelism.studio/docs/library/details#suggest-alt-text). Delivery answers the same asset object with the use's alt text and focal point in place, and its `url` carries the focal point as `focus`, so the crop follows it. The file and its other uses keep their own. ### Products in a file [#products-in-a-file] With a Shopify store connected to the space, files in **Media** can be tagged with its products: **Products** on a file adds them, and clicking the image places each one where it is. The asset picker's product filter finds a product's files. A use shows them only when you ask: **Show its products** in **For this use**, 🛍 on a file selected in rich text, or `products: true` in the value (`{ "asset": "asset_…", "products": true }`, or the `embeddedAsset` node's attribute). Delivery then adds them to that asset, in the order they were tagged: ```json "products": [ { "id": "gid://shopify/Product/1001", "handle": "denim-jacket", "position": { "x": 0.42, "y": 0.6 } } ] ``` `position` is the product's point in the picture (fractions of its width and height from the top left, as `focalPoint`), or `null` when it was tagged without one. How products are shown is up to your site: hotspots on the points, a list next to the image, or both. Read prices and stock from the Storefront API by `id`, as for a `shopify` field. Other uses of the file, and uses without the switch, have no `products`; while the space has no store connected, the list is empty. ## Private files [#private-files] A file only some visitors should get (a members' PDF, a price list) can be made private under **Media → Make private**. Its file is then only served with a **signed URL**: the delivery API, the agent API and the dashboard hand out its `url` with `?expires=…&signature=…`, valid until the end of the current hour plus a day. Without a valid signature, or after it expires, the file answers `404`. So keep the delivery token on your server, and only put the URL in pages the right visitors see. - Signed URLs work with [image transforms](/docs/api/images): add the query to them (the SDK's `imageUrl` does). - Private files are never kept in the shared edge cache; browsers may keep one privately until its link expires. - **Revoke links** gives the file a new key: every signed URL handed out so far stops working at once, and new ones are signed with the new key. - Making a file private moves it to a new URL and announces it like an edit (`asset.updated`). The old public URL stops within a day, once the edge's copy expires, like a deleted file. **Make public** serves it without a signature again. - Agents use `PATCH …/assets/{id}` with `{ "private": true }`, or `{ "revokeLinks": true }`. ## Serving [#serving] Sites consume **`/assets/{id}/{filename}`**: the URL the delivery API hands out. An image's URL names its width and quality (`?w=1600&q=80`), which the route forces to what Content decides a site should get: a modern format chosen from `Accept`, metadata stripped, the asset's focal point for crops. Both are optional: without `w` (or `h`) an image gets its own width, and without `q` a quality of 80. `w` is never past 3840 pixels and nothing is enlarged past the original. An AVIF upload is served as it is, already small. Files that aren't images answer as they are, so they can be linked and seeked like a raw file would be. The image query parameters [images](/docs/api/images) documents (`h`, `fit`, `format`, `focus`) work here too. The other files are answered from the same place as they always were: - **`/files/{id}/{filename}`** (and `/files/{id}/v{n}/{filename}`) serves the raw file, public and immutable, cached for a year. Files that existed before media took over keep answering raw here exactly as they did; files uploaded after get the forced optimizations above, on this host too. **`/files` is the raw file: don't link it for images — the delivery URL is what Content decides a site should consume.** - Both carry an `ETag`; a request with a matching `If-None-Match` gets `304 Not Modified`. - A `Range: bytes=…` request for one part of a file gets `206 Partial Content`, so video can be seeked in Safari and on iOS. Optimized images ignore `Range`. - Files are kept at Cloudflare's edge for a day; `X-Cache: HIT | MISS` shows whether a response came from there. Only types a browser shows passively are served inline when opened directly: PNG, JPEG, GIF, WebP and AVIF images, PDF, and common video and audio. Everything else (SVG included) downloads, so nothing uploaded can run as a page. Embedding is unaffected: an SVG still shows in an ``. ## In a storefront [#in-a-storefront] Delivery hands out absolute URLs on `https://content.obelism.studio`, so any host uses them as they come: no proxy, no client SDK, and no image service of your own. Content does the resizing and re-encoding, on its own edge. - **Images:** use the `url` as it comes, and build a `srcset` from it with [`imageSrcSet`](/docs/api/images) or by replacing its `w` and `q` (the first `w` in the URL wins). In a React Router or Hydrogen storefront that is a plain ``: Hydrogen's `` only transforms Shopify CDN URLs with its default loader, so a Content URL gains nothing from it. - **Allow the host** in the CSP: `img-src https://content.obelism.studio`. Only add `connect-src` for it if a browser reads the delivery API ([the headers on Oxygen](/docs/getting-started/oxygen#3-send-the-headers-the-editor-needs)). - **Private files:** the signed URL lasts until the end of the current hour plus a day, so don't cache a page that carries one for longer, and keep the delivery token on the server ([private files](#private-files)). - **Products:** prices and stock come from the Storefront API by `id`, as for a `shopify` field; the points are in the asset when the use asks for them with `products: true` ([products in a file](#products-in-a-file)). ## Deleting [#deleting] An asset can be deleted while no entry uses it, in any environment, as a draft or published. The dashboard says which entries still do. Its URL stops working within a day, once the edge's copy expires; browsers that already loaded it may keep their own copy. # Canvas (https://content.obelism.studio/docs/guides/canvas) The **Canvas** (in the sidebar) shows every page of the environment you're editing on one board: your site, rendered at the environment's **preview URL** (Settings) in a frame per page, with the page's path appended (with `{locale}` in the preview URL, like `https://example.com/{locale}`, the locale chosen at the top goes there and the path is the one in that locale). A [product page](/docs/guides/shopify#product-pages) opens at the environment's product path instead (`/products/` by default). Each page type is a column, with the home page's first. A column with more than six pages shows five of them and a sixth frame with a page switcher over it. **Edit on page** on an entry's page opens the board with that page selected and in view (`/canvas?entry=`). ## Making a site editable [#making-a-site-editable] 1. **Include the bridge** on every page. With the [React SDK](/docs/sdk/react) that is `` in the root layout; with a bundler, `getContentBridge()` from `@obelism/content/bridge`; without one, a script tag: ```html ``` It does nothing outside the canvas. Inside it, it only talks to the CMS origin it was loaded from. The script tag puts it on `window.ObelismBridge`. Options go on the tag: `data-prompt="false"` leaves the prompt box of [editing on your site](/docs/guides/site-editing) out, and `data-url` names the CMS when the file comes from an npm CDN. 2. **Mark blocks** with their uid, which every block in a delivery response has: ```jsx
…
// or, without the SDK:
…
``` 3. **Show drafts in preview.** Every frame's URL carries `cms_preview=1`, `cms_entry=`, `cms_environment=` and `cms_token`: a token made for the person editing, each time the canvas opens. Read the page with `cms_token` as the bearer token, on your server, naming your space and environment (`space` and `environment` on `ContentClient`, the `Obelism-Space` and `Obelism-Environment` headers otherwise), and the delivery API answers drafts. Without them, a `cms_token` someone made for their own space would put that space's drafts on your site. It only reads, acts as that person (with their role at the time) and expires after 8 hours. A missing, made-up or expired token gets a `401`: show the published page then. [Previews and draft mode](/docs/guides/preview) has the code, and the rules that keep drafts off your public pages. Decide on drafts by `cms_token`, never by `cms_preview` alone with a preview token of your own: anyone can add `cms_preview=1` to a URL. 4. **Re-render on live updates** (optional, recommended): `useLiveEntry(entry)` in React, or ```js ObelismBridge.on('update', ({ entry }) => render(entry)) ``` `entry` has exactly the shape of a delivery API entry for a preview token, built from the unsaved draft. Without an `update` listener the page reloads after every save instead. 5. **Allow framing** by the CMS: if your site sends `Content-Security-Policy: frame-ancestors`, include `https://content.obelism.studio`, and drop `X-Frame-Options: DENY` for preview requests. The preview URL has to be reachable by the editor's browser too: a private host — Vercel deployment protection, Cloudflare Access, a private Oxygen environment — shows its login page in the frame instead of your site ([Shopify Oxygen](/docs/getting-started/oxygen#2-the-preview-environment-must-be-public)). ## Products [#products] With a [Shopify store](/docs/guides/shopify) connected to the space, the canvas has a **Products** and a **Collections** column after the others. **Products** holds the [product pages](/docs/guides/shopify#product-pages) of every product page type and ends with **Any product**: your site's product route (the environment's product path, `/products/` by default) for a product of the store, the store's first to begin with. Select it and **Choose a product** to see another. **Collections** does the same for [collection pages](/docs/guides/shopify#collection-pages) and **Any collection**, at the collection path (`/collections/` by default). For a product (and a collection the same way): - A product with a page: **Show its page** selects that page's frame, where you edit it like any other page. Choosing a product that has one goes straight there. - A product without one: **Add a product page** makes a draft page of the product page type for it, with the product filled in and its handle as slug, and opens it. The space needs a product page type for this (Settings → Shopify). Products without a page are shown as visitors see them; there is nothing to edit on them in Content. ## Moving around [#moving-around] The board fills the page beside the navigation, which shrinks to a rail of icons there. Each column is headed by its page type in the type's colour, with how many pages it has. At the bottom is the prompt bar, **Ask Obelism**. At the top left, the environment, a page type filter (**All page types**, or one), **Go to page** (any page of the board), **New page**, and **Save all** and **Publish all** while pages have changes. At the top right are the board's controls, and a pointer button whose tip sums them up: - **Scroll** or **drag** to move the board, **⌘ or Ctrl and scroll** (or pinch on a trackpad) to zoom, or **−** and **+**. **Fit** shows the whole board. On the canvas, pinching zooms the board, not the browser. - **Desktop** and **Mobile** render every frame 1440 or 390 pixels wide, so your site's own breakpoints apply. With more than one locale, the locale select shows every page in that one. - Only the frames near what you're looking at load your site, at most eight at a time, each marked **Rendering** until it has loaded. The last dozen you've seen keep theirs when you move on; the others show an outline of a page until you move to them. ## Editing a page [#editing-a-page] Click a page to select it. Its fields show in a panel floating on the right, in the same form as on the entry page, and clicks inside it now reach your site: clicking a block selects it. Hold **Space** to move the board over the selected page, and press **Esc** to clear the selection. **Shift**-click (or **⌘**/**Ctrl**-click) adds pages to the selection or takes them out, and **⌘A** (**Ctrl A**) selects every page of the last clicked page's column. The prompt bar shows what it will change: the selected pages as chips, which you can take out one by one or with **Clear selection**. A store column's last frame (**Any product**, **Any collection**) has no page of its own, so the bar asks you to select a page while it's selected. - Edits show on the page as you type, before they are saved. - The prompt bar acts on the selected page, or the selected block (a chip of its own): type what to change and it changes it [by prompt](/docs/guides/prompt-editing). The change shows on the page at once, unsaved, and **Suggested by Obelism** above the bar says what changed: **Keep it**, **Try another** (undo it and ask again) or **Undo**, with the first text it changed before and after. Until you decide, the changed pages carry a **Preview** chip. - With several pages selected, each gets the same prompt, one after another; the panel lists them as they land. - **New page** asks for a path and what the page should say, and the model writes it as a draft of the selected page's type (with no page selected, the first page's of the board), which then opens selected (save or undo other changes first). - With a block selected, **Entry fields** at the top of the panel goes back to the page's own fields. - Every page keeps its unsaved changes while you edit others; a page with changes is marked **Unsaved**. The panel's **Save** saves the selected page and **Publish** saves and publishes it; **Save all** and **Publish all** do the same for every changed page, each with its own check that nobody saved it in the meantime. A page that can't be saved or published is named. Closing the tab with unsaved changes asks first. - Where publishing is reviewed and you may not publish, **Publish** and **Publish all** ask for review instead. ## Variants side by side [#variants-side-by-side] A page with an [A/B test or flag block](/docs/guides/ab-tests) lists its tests and flags under **Variants** in the panel. **Side by side** adds a column after the others with a frame per option of that test or flag, each showing the page as it is with that option's content, while you edit the page in its own frame. **Hide** takes the column away again. Each variant frame gets the page's unsaved content with every block of that test or flag holding only its option's blocks, so whatever option your site picks for the visitor, it can only render that one. This needs a site that re-renders on live updates (`useLiveEntry`, or `ObelismBridge.on('update', …)`); a site that only reloads after saving shows its own pick in every frame. ## Changing the whole site [#changing-the-whole-site] With no page selected, the prompt bar takes a request about the **whole site**, several pages at once: "make every hero shorter", "end each journal post with a call to action". It works in two steps: 1. A plan: the model reads an outline of the pages on the board (their names, paths, types and the start of each block) and answers which of them the request is about, and what to ask about each, at most 12. 2. Every step then runs as that page's own [prompt](/docs/guides/prompt-editing), one after another, with the same checks as a prompt on one page. The panel lists the steps as they land; click one to go to its page. The changes are unsaved, marked on their frames: look them over, **Undo** them all from the suggestion above the bar, and **Save all** or **Publish all** when they're right. A request reaches the pages on the board, so in a column with a switcher only the page it shows. ## Whole pages [#whole-pages] A frame is as tall as its page. The bridge tells the canvas the page's height, and the canvas tells the bridge the screen height the frame stands for (900 pixels on desktop, 844 on mobile). The bridge then rewrites `vh`, `svh`, `lvh` and `dvh` sizes in your stylesheets as pixels of that height, so a `100vh` hero stays one screen high instead of growing with the frame. Stylesheets from another origin can't be read and keep their units. This needs `@obelism/content` 0.20 or later (or Content's own `/bridge.js`); with an older bridge, frames are one screen high and scroll inside. ## Click rates [#click-rates] With **Show click rates on the page** in the panel, the selected page shows how often each of its blocks is clicked, as a label on each block. The switch shows once the page has click rates. The switch is remembered in your browser. The numbers come from [Obelism numbers](https://numbers.obelism.studio), the events service every Obelism product shares: 1. Load numbers' script on every page of the site (its Setup page has the tag for your Organization and environment). 2. Tag a block's clickable element with its uid, in production too: ```tsx {block.label} ``` The script sends a `click` event with that uid for every click on (or inside) the element. A block's click rate is the share of the visitors who viewed a page it was clicked on who clicked it, over the last 30 days, in the environment you're editing. Blocks nobody clicked show nothing. ## When a frame shows published content [#when-a-frame-shows-published-content] A frame that shows the published page, or no page, instead of the draft almost always comes from the `401` fallback in step 3 of [making a site editable](#making-a-site-editable). Log the response's body before falling back (`error.body` on the SDK's `ContentApiError`): it says which of these it is. - **The entry was never published.** The fallback has nothing to show, so the frame shows the page as if it had no entry: on a product route, the product without its blocks. - **`cms_token` doesn't reach the client**, and the error is `Invalid or revoked token`. Check what your server actually reads, especially after a rewrite or a redirect. A Next.js rewrite that captures the query with `has` and a bare `key` names the value by its letters only (`cms_token` is `:cmstoken` in the destination). Name it with a group instead: ```ts title="next.config.ts" { source: '/products/:handle', has: [{ type: 'query', key: 'cms_token', value: '(?.+)' }], destination: '/preview/products/:handle?cms_token=:cmsToken', } ``` - **The space or environment doesn't match** (`token_place_mismatch`). The token is for the environment you're editing, and the site names its own (`CONTENT_SPACE_ID`, `CONTENT_ENVIRONMENT`). Point each environment's preview URL at a deployment configured for that environment. With a [connected deployment](/docs/guides/api-tokens#without-a-stored-token), that is the host environment whose rule leads there, such as a preview deployment for staging. ## Bridge reference [#bridge-reference] `window.ObelismBridge`, or what `getContentBridge()` returns: - `isEditing`: true inside the canvas. - `entryId`: the entry being edited. - `on(event, listener)`: `update` (`{ entry }`), `saved`, `published`, and `change` for the prompt state. - `prompt(text)`, `undo()`, `createPage(path, prompt)`: when [editing on your site](/docs/guides/site-editing); see [prompt editing](/docs/guides/prompt-editing). The bridge and the canvas talk over `postMessage`. The site sends `{ source: 'obelism-content-bridge', type }` with `ready` (with the page's `height`), `size` (`{ height }`, whenever it changes) and `select`; the canvas sends `{ source: 'obelism-content-editor', type }` with `connect`, `viewport` (`{ height }`, the screen height the frame stands for), `update`, `select`, `saved`, `published` or `stats`. `stats` carries the blocks' click rates and the switch, `{ stats: { [uid]: { clicks, clickers, viewers, rate } }, visible }`, which the bridge shows on the page next to each `data-cms-uid` while `visible` is true (`@obelism/content` 0.7 and up; see the SDK's README, "Click rates"). Both sides check the other's origin. Editors can also work on the site itself, outside the dashboard: see [editing on your site](/docs/guides/site-editing). # Pages from Claude Design (https://content.obelism.studio/docs/guides/claude-design) A page made in [Claude Design](https://claude.ai) can become a page of your site: Claude reads the design, builds a draft from the block types your space already has, and stores its images as assets. Your site shows it with its own components, and editors change it like any other page. ## How [#how] 1. Install the [Obelism plugin for Claude](/docs/getting-started/agents#the-obelism-plugin-for-claude) in Claude Code. 2. In Claude Design, choose **Handoff to Claude Code**, or **Export as standalone HTML** and give Claude the file. 3. Ask Claude to import it into Content, and say where the page goes (`/spring-sale`). It asks which space and environment if you have more than one. Claude answers with the draft's path, what it built each section from, and the sections it left out. ## What you get [#what-you-get] - **A draft**, never published: you check it on the [canvas](/docs/guides/canvas) and publish it yourself. - **Only your blocks.** Each section of the design becomes the block type whose [description](/docs/guides/content-model) fits it best. An import never changes the content model: a section no block fits is left out and named, for you to add by hand or for a developer to make a block for. - **The site's look**, not the design's. Colours, spacing and fonts come from your components; the copy, structure and images come from the design. - **Images as assets**, tagged `design:` in the media library. The same image twice is one asset. - **Importing again updates the page.** Content remembers which page a design became, so a new version of the design replaces that page's draft, after Claude asks you. Edits made in Content since are replaced too. ## For agents [#for-agents] The plugin's `import-design` skill follows these steps; any agent with the [MCP server](/docs/getting-started/agents) can: 1. `createUpload`: a URL that takes the file with a `PUT` (`curl -T page.html ""`), once, within an hour. The file never goes through a tool call. 2. `importDesign` with the `uploadId` (or an `https` `url`) and a `designId`: it stores the images and answers an outline of the page in Markdown, sections separated by `---`, images as `asset:`. 3. `listContentTypes`, map each section onto a block type, then `createEntry` with the same `designId`. When `importDesign` answers an `entry`, the design was imported before: `saveEntry` over it instead. The steps, limits and errors are in [`/agent.md`](https://content.obelism.studio/agent.md#a-design-as-a-page). # Comments and presence (https://content.obelism.studio/docs/guides/comments) ## Comments [#comments] The entry editor's sidebar has the entry's **comments**, for the people working on it: a question about the intro, a note for the translator, feedback before publishing. - A comment is about the **whole entry** or **one field** (pick it under **About**), and others **reply** to it. Replies stay one level deep: replying to a reply joins its thread. - **Resolve** a thread once it's dealt with; it folds away (**Show resolved** brings it back) and can be reopened. Its author and anyone who can edit the entry can resolve it. - Everyone who can read the entry can comment, readers included, so a reviewer doesn't need edit rights. A comment can be deleted by its author and by admins; deleting a thread deletes its replies. - Comments belong to the entry in its environment. They are never part of its content, its history, promotion or the delivery API, and they go when the entry is deleted (also to the trash). - New comments from others show up within half a minute while the entry is open. Agents read and write them with `GET` and `POST …/entries/{id}/comments` (`{ "body", "field"?, "parentId"? }`), resolve with `PATCH …/comments/{commentId}` (`{ "resolved": true }`) and delete with `DELETE …/comments/{commentId}`. ## Who else is here [#who-else-is-here] While you have an entry open, the toolbar shows who else has it open too. When one of them has **unsaved changes**, it says so: whoever saves second gets a [conflict](/docs/guides/publishing) and has to reload, so agree on who edits, or save often. The editor checks in every 20 seconds; someone who closed the entry or went quiet for about a minute is gone from the list. # The content model (https://content.obelism.studio/docs/guides/content-model) Every space defines its own content model: the **content types** its entries are made of. Each [environment](/docs/guides/environments) has its own copy, so a change can be tried in develop before it reaches production. Edit it under **Content model** in the dashboard, which lists every type with what uses it — open one to change its fields and settings — or keep it in a repository ([the model in Git](/docs/guides/model-in-git)). ## Content types [#content-types] A content type has an **API id** (`blogPost`: a lowercase letter, then letters, digits and underscores), a name, a description, a kind and a list of fields. The **description** says what the type is for, in a sentence: "The large opening section of a page: a heading, text and calls to action." Editors see it when they choose a type, and [prompt editing](/docs/guides/prompt-editing) reads it to pick the block type for "add a section about pricing", so say what the type is for rather than what it holds. Every type needs one. Types from before it was required have none: the model can't change until each of them is given one (the dashboard marks them under **Content model**). | Kind | Its entries | | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `page` | Have a slug and a place in the space's tree, so a URL path (`blog/hello`). Fetch one by path from the delivery API. | | `data` | Have no URL: authors, settings, navigation. They can sit in folders. | | `block` | Are the building blocks of a page (a hero, a quote, a call to action), inside a `blocks` field. A block type can also have **shared blocks**: entries made on their own and reused across pages. | **Folders** are entries without a content type that group pages and data entries in the tree. ### Shared blocks [#shared-blocks] A block type can have entries of its own, **shared blocks**: a block made once under Content and shown on every page that uses it — a banner, a footer call to action. They have no URL and no place in the tree; **New entry** offers block types after pages and data types. A blocks field holds a reference to one: ```json { "_uid": "b7", "_type": "_shared", "entry": "entry_…" } ``` It takes a type the field's `allowedBlocks` allows. Delivery answers the block itself, `{ "_uid", "_type": "hero", "_sharedFrom": { "id", "name" }, …fields }`, so a site renders it as any block. A shared block can't contain another shared block, and it can't be put in a data entry's blocks field. Editing a shared block changes every page that shows it. **Edit shared block** on its card opens it, and the publish options list the live pages, all ticked: untick one to keep it on the version it shows now; it gets the new one the next time it is published. A block can't be deleted or unpublished while a page's draft or published copy uses it. ### Groups, accent and code [#groups-accent-and-code] Three settings only change how the dashboard shows a type; delivery and the SDK ignore them: - **Groups** (`groups`) are the sections of the entry editor: `[{ "id": "hero", "label": "Hero", "fieldIds": ["heroTitle", "heroImage"] }]`, in order. A group without a `label` is named by the editor in its own language: `fields` "Fields", `seo` "Search and sharing" (with a preview of the search result), `blocks-` after its blocks field. Every field is in one group, and within a group the fields keep the order of `fields`. A field in no group joins the group of the field before it. On **Content model**, drag a field by its grip (or press Space on it, move it with the arrow keys and press Space again) to move it within or between groups. - **Accent** (`accent`) is the tint of the type's tile in the content list, ⌘K and the editor: `coral`, `butter`, `lilac`, `sky` or `mint`. A new type gets its kind's: butter for pages, sky for data, lilac for blocks. - **Code** (`code`) is the one or two characters on the tile. Empty shows the first letters of the name ("Home page" HP). The agent API, MCP and model files keep these as they are when a type is saved without them, so an older model file or script never undoes them. A new type starts with its plain fields in **Fields**, each blocks field in a group of its own and the SEO fields in **Search and sharing**. ### Preview images [#preview-images] A block type can have a **preview image**: a picture of what the block looks like on the site, shown above its name and description when editors add a block, in the entry form, the canvas and on the site. With twenty blocks to choose from, "Hero (split)" and "Hero (centered)" are told apart at a glance. Set it under **Preview image** in the block type's settings: **Upload** a screenshot (it goes to the space's [media](/docs/guides/assets), and the type stores its id), or paste an `https://` URL of a picture your site or design system already hosts. In the model file and the APIs it is `previewImage`: an `https://` URL or a media asset id (`asset_…`), which must be an image in this space. - A 16:9 screenshot works best: the picker shows the top of the picture at that ratio. - An uploaded file is served like any other media file, with a signed URL when it is [private](/docs/guides/assets#private-files). A URL is loaded by editors' browsers as it is. - A model file with `previewImage` URLs moves between spaces as it is; asset ids only mean something in the space whose media holds the file. ### Starting a space from a model [#starting-a-space-from-a-model] A new space can start with a content model instead of an empty one (**Start with** when creating it, or `starter` in the agent API), in every environment: - **Blog**: blog posts (date, author, categories, cover image), authors, categories and pages. - **Marketing site**: pages built from hero, features, testimonial, text and image blocks, buttons, and navigation entries. - **Docs**: docs pages with text, image, callout and code blocks. It is only a start: change it like any model. ### New types start with the usual fields [#new-types-start-with-the-usual-fields] A new content type in the dashboard isn't empty: it starts with the usual fields for its kind. Change or remove any of them before you save. Switching the kind swaps them while you haven't changed them yet. The agent API and MCP do the same: creating a type adds the template fields it doesn't name, so asking an agent for "a blog post type" gives you these fields as well as the blog post's own. Send `"template": false` to save exactly the fields you send. Replacing an existing type and applying a model file never add fields. | Kind | Starts with | | ----- | --------------------------------------------------------------------------------- | | Page | `title` (required, translated), `body` (blocks) and the SEO fields below | | Data | `title` (required, translated) | | Block | `heading` (translated), `anchor` (for links to the block on the page, `#pricing`) | A page type's SEO fields. None of them is required: | Field | Type | For | | ---------------- | ---------- | ------------------------------------------------------------------------------------------------------------- | | `seoTitle` | `text` | `` and `og:title`. Falls back to the title. | | `seoDescription` | `textarea` | `<meta name="description">` and `og:description`. | | `shareImage` | `asset` | `og:image`, 1200 × 630. | | `canonicalUrl` | `text` | `<link rel="canonical">` when the content was published somewhere else first. Empty means the page's own URL. | | `noIndex` | `boolean` | `<meta name="robots" content="noindex">`. Also leave the page out of the sitemap. | Image alt text belongs to the asset (and is translated), so image fields need no alt field of their own. ## Fields [#fields] Every field has an `apiId`, a `name`, and optionally a `description`, `required`, `localized` and [`when`](#fields-shown-only-sometimes). | Type | Options | An entry stores | | ----------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | | `text` | `minLength`, `maxLength`, `pattern` (a regular expression), `format` (`url`, `email`, `color` or `slug`), `unique`, `defaultValue` | A single-line string | | `textarea` | `minLength`, `maxLength`, `pattern`, `defaultValue` | A string | | `richtext` | | A [rich text document](/docs/api/rich-text) | | `number` | `integer`, `min`, `max`, `unique`, `defaultValue` | A number | | `boolean` | `defaultValue` | `true` or `false` | | `datetime` | `withTime` | `YYYY-MM-DD`, or an ISO 8601 date-time with `withTime` | | `select` | `options` (`[{ value, label }]`), `multiple`, `defaultValue` | An option's value, or a list of them | | `asset` | `mimeTypes` (prefixes, e.g. `["image/"]` or `["image/*"]`), `multiple`, `min`, `max` | An [asset](/docs/guides/assets) id, or a list | | `reference` | `contentTypes` (the page or data types allowed; any when left out), `multiple`, `min`, `max` | An entry id, or a list | | `link` | `contentTypes` (the page types allowed; any when left out), `products` (it may also go to a Shopify product) | A [link](#links): `{ entry }`, `{ url }` or `{ product }`, with `query`, `hash` and `target: "_blank"` | | `blocks` | `allowedBlocks` (block types; any when left out), `min`, `max` | A list of blocks: `{ _uid, _type, ...fields }` | | `shopify` | `resource` (`collection` or `product`), `multiple`, `min`, `max`, `unique` (one item, on a page type) | `{ id, handle }` of an item in the space's Shopify store, or a list | | `json` | | Any JSON | - **Required** fields may be empty in a draft; publishing checks them, as it checks `min` for blocks and for `multiple` assets and references. `max` is checked on every save. - **`shopify`** fields ([Shopify](/docs/guides/shopify)) pick from the Shopify store connected to the space on www (the same store in every environment). An item is stored as its GID (`gid://shopify/Product/123`) and the handle it had when picked, and delivered as is: the site reads prices, stock and images from Shopify's Storefront API by the id (Settings' "Copy as .env" has the store and a Storefront token). A handle changed in Shopify later stays as picked, so link by handle but fetch by id. - **`format`** checks a text field's value: `url` (`https://…`, or a path starting with `/`), `email`, `color` (a hex color, `#rgb` to `#rrggbbaa`) or `slug` (lowercase letters, digits and dashes). The value stays a string. An empty value passes, so combine it with `required`. A `color` field stores the value normalized: lowercase, and `#rgb`/`#rgba` expanded to `#rrggbb`/`#rrggbbaa`, so whatever an editor or an API call sends, the site reads one spelling. - **`unique`** text and number fields (on page and data types) may not have the same value as another published entry of the type, per locale when localized. Publishing checks it; drafts may hold anything meanwhile. A unique **Product** field (`shopify`, one product, not localized, on a page type) makes the type a [product page](/docs/guides/shopify#product-pages) type, and a unique **Collection** field a [collection page](/docs/guides/shopify#collection-pages) type; a type has at most one. - **`defaultValue`** is what a new entry starts with (in the default locale, for a localized field) and a block added in the editor (in the locale being edited). It has to be a valid value of the field. - Publishing also needs every entry the content references or embeds to be published, so published content never points at a draft. Unpublishing is refused the other way round, while published content uses the entry. - A **localized** field stores one value per locale, `{ "en": …, "nl": … }`, also inside blocks. The delivery API resolves it to one locale, falling back along the space's [locale chain](/docs/guides/locales). - Every block has a `_uid`, unique within its entry, assigned on save. Keep it when sending blocks back: the canvas finds blocks on the page by it. - Rich text may be written as Markdown; it is stored as a document. - Every blocks field also takes the built-in [improve block](/docs/guides/ab-tests), different blocks per variant of an A/B test or feature flag, with no content type to create. ### Fields shown only sometimes [#fields-shown-only-sometimes] A block often comes in a few shapes: a hero with or without a call to action, a media block that is an image or a video. Instead of a block type per shape, give the fields of one shape a **Show only when** (`when`), and they are only there while an earlier field holds a value: ```json [ { "apiId": "hasCta", "name": "Has CTA", "type": "boolean" }, { "apiId": "ctaLabel", "name": "CTA label", "type": "text", "required": true, "localized": true, "when": { "field": "hasCta", "equals": true } }, { "apiId": "videoUrl", "name": "Video URL", "type": "text", "when": { "field": "media", "in": ["video", "embed"] } }, { "apiId": "caption", "name": "Caption", "type": "text", "when": { "field": "image", "filled": true } } ] ``` | Condition | Shows the field while the earlier field | | ----------------- | ---------------------------------------------------------------------------------- | | `equals: true` | An on/off field is on (`false`: off) | | `equals: "split"` | A select has that option, or a text field has that text (spaces around it ignored) | | `in: [ … ]` | A select has one of these options (with `multiple`: any of them), or a text is one | | `filled: true` | Any field has a value | - The field it depends on comes **earlier** in the same type (or the same block), and isn't localized: a field is there or not in every language alike. A localized field can depend on one, like `ctaLabel` above. - A field whose own controller is hidden is hidden too, so a chain of them is "and". - **Hidden values are kept.** Turning `hasCta` off and on again gives the label back. A site never gets them: the delivery API answers `null` for a hidden field, for delivery and preview tokens alike. - **Required only while shown.** Publishing skips `required` and `min` on a hidden field. Its stored value is still checked for its shape, and the entry form shows a problem in one on the field it depends on. - A hidden field can't be [filtered or ordered on](/docs/api/delivery#filtering-and-ordering) (`400`): its stored value isn't what sites get. A `unique` field can't be shown only sometimes. - Removing, renaming or translating the field a condition names, or removing an option it names, is refused until the condition changes too, in the same save or model file. - Prompts don't see hidden fields (and keep them), and translating skips them. ## Links [#links] A `link` field is a link on the site, for buttons, cards and menus. It goes to one of three places: - **A page** of the space, `{ "entry": "entry_…" }`: chosen in the editor like a reference, so it follows the page when it moves or is renamed. Like a reference, publishing needs the page to be published, and the page can't be unpublished while published content links to it. Delivery names the page without inlining its fields. - **An address**, `{ "url": "https://…" }`: another site, but also a path of the site the space doesn't have a page for (`/search`), an anchor (`#faq`), `mailto:` or `tel:`. - **A product** of the space's [Shopify store](/docs/guides/shopify#links-to-products), `{ "product": { "id": "gid://shopify/Product/…", "handle": "…" } }`: when the field allows **Shopify products** (`products: true`) and the space has a store, picked with the store's product picker. Nothing is imported; delivery adds the product's [product page](/docs/guides/shopify#product-pages) when it has one. Each one takes a `query` (`utm_source=newsletter`, without `?`) and a `hash` (`pricing`, without `#`), and may hold `target: "_blank"` to ask the browser to open the link in a new tab. Localize the field to link somewhere else per language. ```json { "cta": { "entry": "entry_…", "query": "plan=pro", "hash": "pricing" }, "footer": { "url": "https://github.com/Obelism" } } ``` Rich text has links of its own (a mark on the text); a link field is for a link that stands on its own. The [delivery API](/docs/api/delivery#links) answers a link with the page's `id`, `title`, `slug` and `path`, or `null` while the page isn't published, and a product with its product page or `null`; the site turns the path into an address with its own routing, or with the SDK's [`linkHref`](/docs/sdk/javascript#links). ## Changing the model [#changing-the-model] Admins and developers change the model; editors and readers don't see **Content model**, only the entry form it makes. Under **Content model**, the types are listed by kind, each with its description, its fields and how many entries use it: filter by kind or search, then open one. Its **Fields** tab shows the fields in their groups; click one to change it in the drawer beside them (a sheet on a small screen), with **Done** to close it and **Save content type** to save the whole type. **Settings** has the name, description, kind, colour and code, **Editor preview** what an editor sees on an entry of the type. The breadcrumb goes back to the list. When the model is [managed in code](/docs/guides/model-in-git#locking-the-model-to-code), everything there is read-only; **Download model** is still on the list. **Duplicate content type** (on a type's **Settings** tab) makes a copy with all its fields as `<apiId>Copy`, " (copy)" after its name. No entries come along. Adding fields and types is always possible. Some changes are refused while content would break: - a field's type can change only when the values still fit: `text`, `textarea`, `number`, `boolean`, `datetime` and `select` to `json`, `json` to `text` or `textarea`, and `text` and `textarea` between them (the values are converted); on those types and `json`, whether it is localized can switch either way, keeping the default locale's value (turning localization off drops the other locales' values). Fields that point at entries or files keep their localization. Other type changes need the field removed and added; - a content type can't change kind while it has entries (or, for a block, while content contains it or it has shared blocks); - a type can't be deleted while it has entries (a block type: its shared blocks, or content that contains it), or while other types use it in a `reference`, `link` or `blocks` field. Removing a field deletes its values from every entry that has them, drafts and published content alike; the plan says how many entries that is before anything happens. Every change to the model sends a `model.changed` [webhook](/docs/guides/webhooks). ## The model file [#the-model-file] **Content model → Download model** saves the environment's model as one JSON file, and **Upload model** (in the ⋯ menu beside it) plans and applies one. Its `$schema` gives editors validation and autocomplete. ```json { "$schema": "https://content.obelism.studio/schema/content-model.v1.json", "version": 1, "contentTypes": [ { "apiId": "page", "name": "Page", "kind": "page", "description": "A page of the site, built from hero sections.", "fields": [ { "apiId": "title", "name": "Title", "type": "text", "required": true, "localized": true }, { "apiId": "body", "name": "Body", "type": "blocks", "allowedBlocks": ["hero"] } ] } ] } ``` Applying a file creates and updates the types in it, all or nothing. Types the file leaves out stay, unless it is applied with `prune`. Keep it in the site's repository with [the CLI](/docs/sdk/cli). # Editing entries (https://content.obelism.studio/docs/guides/entries) Entries are the pages and data of a space: a page has a URL, a data entry (an author, the navigation, the site's settings) doesn't. Each is made from a [content type](/docs/guides/content-model) and edited in a form with one input per field. Editors, developers and admins edit them; readers see them and can [comment](/docs/guides/comments). ## The content list [#the-content-list] **Content** in the sidebar lists the entries of the environment you're in, the latest edits first, 50 at a time. Each row shows the entry's type, its status and who edited it last. - The **views** over the list pick what it shows: **All**, **Changed** and **Drafts**, then your own saved views. Each shows how many entries are in it. **Changed** means the entry is live with changes that aren't published yet. Saved views that don't fit in the row are under **N more views**. - **Type** narrows the list to one or more content types, grouped as pages, data and blocks like the content model, each with how many entries it has. Tick the types and **Show N entries** applies them; the filter's **×** clears it. - **Filter** narrows it by status: published, changed, draft, scheduled, **In review** (someone [asked for review](/docs/guides/publishing#review-before-publishing)) or **Schedule failed** (a [scheduled publish](/docs/guides/publishing) was refused, with why on the entry), and **Created by me**. **Search** finds entries by name or path, and **Sort** puts the latest edits first, or sorts by name or path. A filtered list says how many entries match and what it's filtered by. - **Show as**: **Structure** (one folder at a time, folders first, with a breadcrumb back up), **List** (every entry), **By content type** (every entry under a heading per type) or **Tree by path** (pages and folders by path, with data entries after them). - The view, type, search, filter, sort and layout are in the page's address, so a list can be shared or bookmarked, and the browser's back button goes back to it. - **Save as view** appears once the list is filtered or searched in a way none of your views is: it keeps the list as it is (types, status, search, sort and layout) under a name, as a view of your own after **Drafts**. Only you see your views, in every environment of the space; anyone who can read the content can keep up to 30. On one of your views, its **⋯** menu renames it, moves it left or right, or deletes it (the entries in it stay). Tick entries to [publish, unpublish, move, delete](/docs/guides/publishing#several-entries-at-once) or [translate](/docs/guides/locales#many-entries-at-once) them together. Deleted entries are under **Trash**, linked from the list. ## A new entry [#a-new-entry] **New entry** asks what you're making first: pages (they get their own URL) as cards, data, shared blocks and folders below them, with a search. **Create** then asks for a name and where it goes; everything else is filled in afterwards. With a page type chosen, **Describe it** takes a path and a description instead, and Obelism writes the draft of that page type [by prompt](/docs/guides/prompt-editing). - **Content type**: one of the space's page, data or block types, or **Folder**. - **Name**: what the dashboard calls it. It is not a field of the content: a page's title is its own `title` field. - **Slug** (pages and folders): the entry's own URL segment, lowercase words joined by hyphens. Its path is the slugs of the folders it is in and its own, `blog/hello`. - **Inside**: the folder or page it goes in, or **Root**. A page whose type is a [product page or collection page](/docs/guides/shopify#product-pages) type starts from a product or collection of the space's Shopify store instead: it is named after it, takes its handle as slug and goes in the `products` or `collections` folder. **Folders** group pages and data entries under one path, and have no content of their own. A page can hold pages too: `/solutions/agencies` is the page `agencies` inside the page `solutions`. New entries are drafts. They also come from [prompts](/docs/guides/prompt-editing) (**New page**), [duplicating](/docs/guides/publishing#duplicating), a [design import](/docs/guides/claude-design) or an agent, as drafts too. ## The form [#the-form] Opening an entry shows its form: every field of its content type, in the order of the model, each with its name, description and whether it is **Required**. The same form is in the [canvas](/docs/guides/canvas)'s panel and in the prompt bar on [your own site](/docs/guides/site-editing). On the entry's page the fields come in groups: **Fields** for the type's own fields, one group per blocks field, and **Search and sharing** for the SEO fields, which also shows how the page would look in search results. Every group folds with its chevron. The row of pills above them jumps to a group and follows as you scroll; a group with something to look at (an SEO title longer than search results show) is marked. | Field | Its input | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Text, long text | A text box, counting characters against `maxLength`; the SEO title and description count against what search shows (60, 155) and warn past it | | Rich text | A toolbar: bold, italic, underline, strikethrough, code, heading, subheading, lists, quote, code block, links and embeds, undo | | Number, date, yes/no | A number box, a date (and time) picker, a switch | | Select | A dropdown, or with `multiple` a field of tags: search the options, Enter adds one | | Asset | One file: a slot that opens **Choose from Media** (the library by folder, searched by name, narrowed to images or videos, or an upload; the current file marked **In use**) or takes a dropped file. Picking a file shows its focal point and alt text for this use beside the grid, with a watch-out for a heavy image, until **Use this image**; then **Replace** and **For this use**. Several: **Choose file**, ticking files on and off. A picked file's name opens its details | | Reference | **Choose entry**: the entries of the allowed types, searched by name or path; a chosen one shows **Open** and **Change** | | Link | **A page**, **An address** or **A product**, with a query and an anchor; see [links](/docs/guides/content-model#links) | | Blocks | Cards, one per block, each with its own fields | | Shopify | A picker that searches the space's store by title; see [Shopify](/docs/guides/shopify#shopify-fields) | | JSON | A code box; text that isn't valid JSON yet keeps the last valid value | Every list keeps the order you give it. A field holding several files, entries or Shopify items drags a row by its grip, or with Space and the arrow keys; a select field's options move the same way in the [content model](/docs/guides/content-model#fields). ### Blocks [#blocks] A blocks field lists its blocks as rows: the type and the first line of its text; a row opens to its fields. **Add block** offers the first block types the field allows as buttons, and **More…** all of them (and the [A/B test or flag](/docs/guides/ab-tests) block) in a popover at the insert line: search, the kinds (text, media, or layout for blocks that hold blocks), how often the field uses each, and a preview of the highlighted one with its fields; the arrows move and Enter inserts. A new block goes at the line (the end, or below the row whose menu asked), with the field's default values. A row's menu moves it up or down, adds a block below it, duplicates or removes it; drag a row by its handle, or with the keyboard: Space on the handle lifts it, the arrow keys move it, Space drops it and Escape puts it back. Blocks hold blocks of their own when they have a blocks field. A field with a `max` says so once it is full. **Shared blocks…** at the bottom of Add block inserts a block made on its own, of a type the field allows; see [shared blocks](/docs/guides/content-model#shared-blocks). Its card says it is shared and where it lives (_Live on 12 pages_, _Held since 12 Oct_ or _Not on a live page_). **Edit shared block** opens it, where one change reaches every page that shows it; **Detach** copies its content into this page as a local block, so later changes there no longer reach it. A block whose type was deleted from the model shows as JSON, so its content isn't lost. **Write with AI**, beside a text, rich text or blocks field, writes that field from a prompt and shows the result as a suggestion to keep, try again or dismiss: see [prompt editing](/docs/guides/prompt-editing#write-with-ai-in-the-entry-form). ### Languages [#languages] In a space with several [locales](/docs/guides/locales), the **Language** switch at the top picks which language's values the form shows. Translated fields are marked with the language you're editing; the others are shared by every language. The switch shows how far each language is translated (**3/5**), and **Translate from …** fills in what a language is missing. ### Saving [#saving] **Save draft** (or ⌘S / Ctrl+S) stores the form as the entry's draft. Nothing reaches your site until it is [published](/docs/guides/publishing). The bar says whether your work is safe: **Saved 2 min ago**, **Saving…**, **2 changes not saved**, or **Couldn’t save** with **Try again**. - Saving checks that every value has the right shape; publishing also checks required fields and references. When something is wrong, nothing is saved and the form lists the fields to check, in every language. - Leaving the page with unsaved changes asks first. - If someone else saved the entry since you opened it, your save is refused rather than overwriting theirs. The bar shows who else has the entry open, and a warning shows while one of them has [unsaved changes](/docs/guides/comments#who-else-is-here). - Every save is a version in the entry's **History**. ## Publishing from the entry [#publishing-from-the-entry] **Publish** in the bar opens the publish options: **Now** or **Later**, where it goes, and a check before it goes out: whether the required fields are filled (with **Fix** to jump to one), fields longer than search shows, open comments and a schedule already set. Required fields stop **Publish now**; the rest are worth a look but never block. Where publishing is reviewed, **Ask for review** takes Publish's place. **Edit on page** opens the entry in the [canvas](/docs/guides/canvas). ## Beside the form [#beside-the-form] - **Status**: Draft, Published, Changed or when it's scheduled, its revision, **Schedule publish**, **Unpublish** and when it was first published (see [publishing and scheduling](/docs/guides/publishing)). - **URL** (pages and folders): the entry's path in every language. **Edit** moves it: a new slug per language, or another folder. The old path keeps working as a [redirect](/docs/guides/redirects). - **Folder** (data entries): the folder it is filed in. - **Comments**: [threads](/docs/guides/comments) about the entry or one of its fields. - **History**: the latest saves and publishes; **Compare** opens every one, to view or restore. - **Duplicate**, **Promote to** the next environment, **Edit as JSON** and **Copy ID** (the `entry_…` an agent or a reference needs). - **Delete entry**, once nothing published uses it. It goes to the [trash](/docs/guides/publishing#deleting-and-the-trash) for 30 days. ## For agents [#for-agents] Agents create entries with `POST …/entries` (`createEntry`), read them with `GET …/entries/{id}` and save with `PUT …/entries/{id}` and the `baseRevision` they read (`saveEntry`); a `409` means someone saved in between. `GET …/entries` lists them with `search`, `contentType`, `status`, `parentId` and `sort`. See the [agent API](/docs/api/agents#saving-content). # Environments (https://content.obelism.studio/docs/guides/environments) Every space has three environments: **develop**, **staging** and **production**. Each holds its own copy of the content model and of the content, so new content types and new content can be built and checked in develop before anyone sees them in production. ## What each environment has of its own [#what-each-environment-has-of-its-own] - The [content model](/docs/guides/content-model) - Entries, their drafts and their published content - [API tokens](/docs/guides/api-tokens), [webhooks](/docs/guides/webhooks), [redirects](/docs/guides/redirects), the preview URL and the prompt endpoint - Whether publishing is [reviewed](/docs/guides/publishing#review-before-publishing), and whether the model is [managed in code](/docs/guides/model-in-git#locking-the-model-to-code) Shared by all three: the space's [locales](/docs/guides/locales), [assets](/docs/guides/assets), members and [Shopify store](/docs/guides/shopify). An entry keeps the same id in every environment, so references between entries still point at the right entry after they move. A production site uses a production token and gets production's webhooks. Switch environments with the selector in the dashboard's sidebar. Live is the production environment: the dashboard calls it **live**, and the API, the CLI and these docs keep `production`. ## Promoting [#promoting] **Promote** copies content from the environment you are in to another one: every entry, or the ones you pick. An entry's **Promote…** button starts with that entry picked. 1. Choose where to, and whether to bring the content model along (admins and developers). Bring it when the target doesn't have a content type the entries use yet. 2. **Show what would change** lists every entry as new, updated or unchanged, which ones it publishes, and which have unpublished edits made in the target that it would replace (a draft that came with an earlier promotion doesn't count). Folders and parent pages the target is missing, and entries that promoted entries reference, are added for you ("Needed by …"). 3. **Publish live entries on … too** (named after the target, like **Publish live entries on staging too**) is on to start with. It only publishes what is published where you are, with its published version: drafts stay drafts, and newer unpublished edits arrive as a draft on top. Untick it to bring drafts only: nothing is published in the target, so entries arrive there as drafts and changes for someone to review and publish. An entry already published in the target also keeps its name and place there (parent, slug, order), since those reach the site at once; a new name or place comes with the next promotion that publishes, or by moving it there. Entries not published in the target take the new name and place. 4. **Promote** does it all in one go, or nothing at all when anything stands in the way: content the target's model refuses, a path another entry already uses there, a reference to an entry that doesn't exist. What a promotion does to each entry: - its **draft** replaces the target's draft; - if it is **published**, the same published content is published in the target, sending `entry.published` webhooks there (unless **Publish live entries on … too** is off); - nothing is ever **unpublished** in the target; - the entry's status carries over: one with unpublished edits stays "changed". The content model is applied first and never prunes: types that only the target has are kept. The plan also lists the entries **only the target has**: ones created there, or deleted where you are promoting from. They are left alone unless you tick them; ticked entries go to the target's [trash](/docs/guides/publishing), restorable for 30 days, and published ones send `entry.deleted`. A folder can only go with everything in it, and an entry only when nothing staying in the target uses it. Promoting drafts only needs no publish rights: into an environment where publishing is [reviewed](/docs/guides/publishing#review-before-publishing), anyone who edits content can, and the entries wait there for review. Agents promote through `POST …/promotion/plan` and `POST …/promotion/apply` in the [agent API](/docs/api/agents); `"publish": false` brings drafts only. ## Production is set up by a person [#production-is-set-up-by-a-person] Agents can do most things in every environment, but in production they can't create or revoke API tokens, add or delete webhooks, or change the preview URL or prompt endpoint. Those hand out credentials or send content to new URLs, so someone with the admin or developer [role](/docs/guides/roles) does them in the dashboard. ## Export and import [#export-and-import] **Settings → Export and import** has two parts. **Export** downloads the environment you are editing, **Import** puts a content export back into it. **Export → Content export** downloads one JSON file: the content model, every entry with its draft, published content, place in the tree and slugs per locale, and which assets the content uses. Keep it as a backup, or import it into an environment of the same space. **Export → Plain SQL** is a zip of plain SQL for another tool, described under [taking your content elsewhere](#taking-your-content-elsewhere); Obelism never imports it back. **Import** reads one file: a content export from an Obelism environment (`"format": "obelism-content-export"`, `"version": 1`), at most 10,000 entries and 32 MB. Not SQL, not a zip, and not another tool's export. **Download a template** gives the same shape with this environment's content model and one example entry, to fill in or hand to a tool. Importing a file works like [promoting](#promoting) from it: **Show what the file would change** plans it, entries are created or updated by id, published ones are published, nothing is unpublished, and nothing happens at all when something stands in the way. Entries the environment has but the file doesn't are listed; tick them to move them to the trash, which restores a backup exactly. Tick **Include the file's content model** to apply its model first. Assets aren't in the file, only their ids: they belong to the space, so a file restores into the space it came from. Content that uses an asset the space doesn't have is reported, and nothing is imported. Agents and scripts use `GET …/export`, `POST …/import/plan` and `POST …/import/apply` (`{ "file", "includeModel"?, "remove"? }`) in the [agent API](/docs/api/agents). A [management token with the content scope](/docs/api/management#content-with-a-management-token) can export and import content (not the model), for scheduled backups. ## Taking your content elsewhere [#taking-your-content-elsewhere] There is no lock-in: **Settings → Export and import → Export → Download … as SQL** downloads a zip of plain SQL, independent of Obelism: - `schema.sql` creates four tables: `content_types`, `entries`, `entry_paths` and `redirects`. - `content_types.sql`, `entries.sql`, `entry_paths.sql` and `redirects.sql` hold the rows as `INSERT` statements, and `import.sql` has all of them in dependency order inside a transaction. - `README.md` explains the columns and the JSON in them: localized values keyed by locale, rich text as ProseMirror, blocks as `_type`, references and assets as ids. This is a way out, not a backup: Obelism doesn't import it back. Assets are files in the [Media library](/docs/guides/assets), so download the ones your content uses separately; the export names their ids. `GET …/export/sql` in the [agent API](/docs/api/agents) answers the same files as `{ name, content }` entries. # Locales (https://content.obelism.studio/docs/guides/locales) A space publishes in one or more **locales**, set under **Settings → Languages**. They apply to every environment of the space. - A locale has a **code** (`en`, `nl`, `en-GB`, `zh-Hant-TW`), a name, and optionally a locale it **falls back to**. - One is the **default locale**. Required fields must be filled in for it before an entry can be published, and every fallback chain ends there. - A space has up to 100 locales. ## Localized fields [#localized-fields] A field marked **localized** in the [content model](/docs/guides/content-model) stores one value per locale: ```json { "title": { "en": "About us", "nl": "Over ons" } } ``` Any other field holds one value shared by all locales. Asset alt text and titles are always per locale. In the entry editor, the language switch at the top picks which locale's values you edit. ## Translating [#translating] The language switch shows how far each locale is: **3/5** means the entry has 5 localized values in the default locale, and 3 of them in this one (inside blocks too). A locale without a count has everything. In another locale than the default, **Translate from English** (the default's name) fills in what that locale is missing by machine translation, with the same model as [editing by prompt](/docs/guides/prompt-editing) (Workers AI, or your space's prompt endpoint): - Text, long text and rich text are translated; rich text keeps its formatting, links and embeds. Other localized values (an image, an option, a number) are copied from the default locale. - Only empty values are filled. Once a locale is complete, **Translate everything again** overwrites it. - Nothing is saved: the translation lands in the form, to check and save like any edit. Fields, blocks and the other locales are never changed. ### AI translation settings [#ai-translation-settings] Under **Settings → Languages → AI translation**, admins and developers decide for the whole space: - **Allow AI translation**: off removes the translate buttons, and the API refuses it (`403 forbidden`). - **Into these languages**: the locales it may write (all by default). - **Instructions**: house style the model follows on top of its own rules, such as “Use informal _je_ in Dutch” or “Keep product names in English”. At most 2,000 characters. - **Translate when the default language is saved**: see below. ### Many entries at once [#many-entries-at-once] Tick entries in the content list, press **Translate**, pick a language (or **Every language**) under **Translate into** and press **Translate** again. The translations run in the background, as you, within a few minutes; each is saved as the entry's **draft**, never published. The list shows how many are waiting, and what failed with why (a failure is retried three times). An entry someone has open with unsaved changes waits for them; an editor who has it open without changes gets the new draft right away. ### Translating on save [#translating-on-save] With **Translate when the default language is saved** on, every save that changes the default language queues the same background translation for that entry: what changed is translated again in every other language, and whatever a language still lacks is filled in. Values edited by hand in another language stay as they are until their default-language text changes. Translations are saved as drafts, so review and publish them as usual ([some languages at a time](/docs/guides/publishing#publishing-some-languages), if you like). ### Agents [#agents] - `POST …/entries/{id}/translate` with `{ "to": "nl", "overwrite"?, "save"? }` translates one entry and returns its content; `save: true` saves it as the draft. - `POST …/translations` with `{ "entryIds": [...], "locales"? }` queues many (`202`); `GET …/translations` lists what is waiting. - `PATCH …/settings` with `{ "translation": { "enabled"?, "locales"?, "instructions"?, "autoTranslate"? } }` changes the settings. ## Fallbacks [#fallbacks] The delivery API answers one locale (`?locale=nl`; the default locale when left out). When a localized field is empty in that locale, it takes the value of the locale it falls back to, then that one's fallback, ending at the default locale. With `en` as the default, `nl-BE` falling back to `nl` and `nl` to `en`: | Requested | Tried, in order | | --------- | ------------------- | | `nl-BE` | `nl-BE`, `nl`, `en` | | `nl` | `nl`, `en` | | `en` | `en` | `?locale=*` skips fallbacks and answers every localized field as `{ [locale]: value }`. ## URLs per locale [#urls-per-locale] A page or folder has a slug per locale: the default locale's is required, the others are optional and fall back like field values, so every page has a path in every locale. Set them under **Move** in the entry's sidebar. With `en` as the default and a Dutch slug `over-ons` on `/about-us`, the delivery API answers `/pages?path=over-ons&locale=nl`, and every entry lists its `paths`. The locale itself is never part of the path: your site decides whether that is `/nl/over-ons` or `nl.example.com/over-ons`. See [paths per locale](/docs/api/delivery#paths-per-locale). Changing a slug keeps the old path as a [redirect](/docs/guides/redirects) in that locale. ## Changing locales [#changing-locales] Adding locales, renaming them, and changing fallbacks or the default are possible at any time, and send a `locales.changed` [webhook](/docs/guides/webhooks) in every environment. A new default locale takes over its slugs: each page keeps its paths in the old and the new default. Paths that do change (a locale that fell back to the old default now falls back to the new one) become redirects. ### Removing a locale [#removing-a-locale] **Remove…** next to a saved locale (not the default: make another one the default first) deletes everything keyed by it, in every environment: its value in every localized field (drafts, published content and the trash), asset titles and alt text, slugs, paths and redirects. The dialog says how many entries, assets and slugs that touches, and asks you to type the code. Published content changes at once, sites asking for the locale get a 400 from then on, and a `locales.changed` webhook (with `removed`) goes out. Locales that fell back to it fall back to what it fell back to. Version history keeps what it had, so adding the locale again and restoring versions brings content back. The prompt bar on [your own site](/docs/guides/site-editing) edits in the page's language, read from `<html lang>` when the space has that locale. # The model in Git (https://content.obelism.studio/docs/guides/model-in-git) A site's content model can live next to its code as one JSON file, reviewed in pull requests and moved from develop to production like the site itself. The file is the one **Content model → Download model** saves; its `$schema` gives editors validation and autocomplete. See [the model file](/docs/guides/content-model#the-model-file). ## Management tokens [#management-tokens] Create one under **Settings → API tokens** with the kind **Management**, once per environment the repository syncs (agents can create one with `createApiToken`, outside production). A management token: - belongs to one space and one environment; - acts as the person who created it, with their current [role](/docs/guides/roles): it can change the model only while they are an admin or developer, and stops working when they leave the organization; - only works on the [management API](/docs/api/management). The delivery API refuses it. Keep it in your CI's secrets. Revoke it in Settings. ## The CLI [#the-cli] ```sh export CONTENT_MANAGEMENT_TOKEN=cms_m_… npx @obelism/content model pull # write the environment's model to content/content-model.json npx @obelism/content model plan # what applying the file would change, and what stands in the way npx @obelism/content model apply # apply it in one transaction, or change nothing ``` `plan` and `apply` exit with 1 when the file has problems (a content type without a description, a field whose type or localization changes, a content type in use changing kind, deleting a type other types use, and so on), listing each one; `apply` then changes nothing. In GitHub Actions they also write the plan to the job summary. See [the CLI](/docs/sdk/cli) for the options. ## Locking the model to code [#locking-the-model-to-code] Once an environment's model lives in Git, a change made in the dashboard would be undone, or deleted, by the next `apply`. Mark the environment's model as **managed in code** under **Settings → Publishing → Where the content model is changed**, or apply with `--lock` (`npx @obelism/content model apply --lock`, `--unlock` gives it back): - the content model pages show every type read-only, with a note, and keep **Export**; - the dashboard, the agent API and MCP can't create, change, duplicate, import or delete content types there (`409 model_managed_by_code`); - `content model apply` (the management API) and promoting the model from another environment still work, so code-managed models move through develop, staging and production as before; - content is never affected: editors keep editing. It is set per environment: a common setup edits the model in the dashboard on develop and keeps staging and production locked to the file. ## A workflow [#a-workflow] This workflow plans on pull requests (against develop) and applies on merge: `develop` to the develop environment, `main` to production. A change that removes fields says how many entries lose values before you merge it. It needs two repository secrets, a management token per environment: `CONTENT_TOKEN_DEVELOP` and `CONTENT_TOKEN_PRODUCTION`. ```yaml title=".github/workflows/content-model.yml" name: Content model on: pull_request: paths: [content/content-model.json] push: branches: [develop, main] paths: [content/content-model.json] jobs: content-model: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 with: node-version: 24 - name: Plan if: github.event_name == 'pull_request' run: npx --yes @obelism/content model plan env: CONTENT_MANAGEMENT_TOKEN: ${{ secrets.CONTENT_TOKEN_DEVELOP }} - name: Apply if: github.event_name == 'push' run: npx --yes @obelism/content model apply env: CONTENT_MANAGEMENT_TOKEN: ${{ github.ref_name == 'main' && secrets.CONTENT_TOKEN_PRODUCTION || secrets.CONTENT_TOKEN_DEVELOP }} ``` A typical loop: 1. Change the model in the dashboard on develop, then `model pull` and commit the file; or edit the file directly. 2. Open a pull request: the plan shows what will change. 3. Merge: the workflow applies it. Applying sends `model.changed` [webhooks](/docs/guides/webhooks). # Plugins (https://content.obelism.studio/docs/guides/plugins) Content has an input for every field type, but not for your brand palette, a map pin or a product from your PIM. A **plugin** is a web page you build and host that Content loads in a frame: as the input of a field, or as a page of its own in the dashboard. The value it sets is the field's, checked like any other, so sites and the API see nothing different. ## A plugin is a manifest [#a-plugin-is-a-manifest] A plugin describes itself in a JSON manifest at a URL of yours: ```json title="https://plugins.example.com/brand-colour/manifest.json" { "id": "brand-colour", "name": "Brand colour", "url": "https://plugins.example.com/brand-colour/", "locations": [ { "type": "field", "fieldTypes": ["text", "json"] }, { "type": "page", "title": "Palette" } ], "permissions": ["user", "token"], "config": { "type": "object", "properties": { "palette": { "type": "string", "title": "Palette", "enum": ["web", "print"] } }, "required": ["palette"] } } ``` | Key | What it is | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | A lowercase slug, unique in your Organization. Fields name it. | | `url` | The page Content frames: `https`, never on obelism.studio. | | `locations` | `field`: the input of fields of the `fieldTypes` it lists (any type but `blocks`). `page`: a page in the dashboard, under **Plugins**. At most one of each. | | `permissions` | What it gets beyond its own field's value: `entry:read` (the entry it is in, as it is edited), `user` (the editor's id, name and role), `token` (a [context token](#context-tokens)). Empty by default. | | `config` | The settings each environment gives it: string, number and boolean settings with `title`, `description`, `enum` and `required`. Never secrets: they are handed to the frame. | The entry's sidebar isn't extendible: it holds what Content manages about the entry. ## Registering and turning it on [#registering-and-turning-it-on] 1. An **admin** opens **Plugins**, clicks **Register plugin**, pastes the manifest URL and clicks **Check manifest**. Content fetches the manifest (https only, to a public hostname, no redirects, 5 seconds, 64 KB) and shows what it adds, what it can read and change, and the host it loads from. **Register** registers exactly that; it is on nowhere yet. 2. A **developer** turns it on per environment under **Settings → Preview and site → Plugins**, with that environment's settings. Turn it on in develop first. An environment may load another URL of the same plugin on the registered origin (a version or branch), or `http://localhost:…` in develop, to build against real content. 3. A developer picks it as a field's **Input** in the content model. The field stores `editor: { "plugin": "brand-colour", "config": { … } }`, with up to 8 KB of settings for that field. In a plugin's ⋯ menu, **Check for updates** fetches the manifest again and lists what changed (origin, locations, permissions, settings) before **Update** saves it. **Remove…** turns it off in every Space; fields that used it get their own input back, with their values. `editor` is part of the model: in model files, promotions and the agent API. Whether a plugin is on isn't promoted, like tokens and webhooks. A plan for an environment where the plugin is off says so as a **warning** and applies anyway; the field has its own input there until the plugin is turned on. ## In the dashboard [#in-the-dashboard] - A field whose plugin is on here gets the plugin's frame instead of its own input, wherever the entry form is: the entry page, the canvas's panel and [editing on your site](/docs/guides/site-editing). - A frame loads when it comes into view (block cards start closed). After five plugin frames in one form, the next ones show a **Load** button with the plugin's name, and load when clicked. - A plugin that is off, removed, or doesn't answer within 10 seconds leaves the field's own input, with a line saying so. The value stays as it is. - Agents and [MCP](/docs/getting-started/agents) write the field's value directly. `GET …/plugins` (`listPlugins`) lists the plugins on in an environment. - The [activity log](/docs/guides/activity) records plugins registered, updated, removed, turned on and off, and their settings changed. ## Building one [#building-one] The frame is sandboxed (`allow-scripts allow-same-origin allow-forms allow-popups`) on your own origin, so it never sees Content's cookies or page. Your page must allow being framed by Content, and, for editing on your site, by your site too, since browsers check every ancestor: ```http Content-Security-Policy: frame-ancestors https://content.obelism.studio https://www.example.com ``` [`@obelism/content-plugin`](https://github.com/Obelism/sdk/tree/main/packages/content-plugin) does the protocol: ```ts import { connect } from '@obelism/content-plugin' const plugin = await connect() // plugin.context: the field, its config, the value, locale, Space, environment // and theme; the entry and user when it may have them. input.value = String(plugin.context.value ?? '') input.oninput = () => plugin.setValue(input.value) plugin.onValue((value) => (input.value = String(value ?? ''))) ``` Under the hood, Content posts `{ type: 'obelism:init', protocol: 1 }` to your page's origin once, with a `MessagePort`; everything after goes over that port. The plugin sends `ready`, then `setValue` (a field's value, JSON, up to 1 MB), `height`, `openLink` and `toast`; Content sends `context`, `value` when the form changes it, and `entry` updates with `entry:read`. A plugin never saves or publishes: what it sets is an unsaved change the editor reviews and saves. ## Context tokens [#context-tokens] With the `token` permission, `await plugin.token()` gives a five-minute JWT (ES256, `typ: obelism-plugin+jwt`) for your backend: `aud` is the plugin id, `sub` the user, with `org`, `space`, `env` and `role`, and `entry: { id, contentType }` with `entry:read`. Verify it against `https://content.obelism.studio/.well-known/plugin-jwks.json`: ```ts import { verifyContextToken } from '@obelism/content-plugin/server' const claims = await verifyContextToken(token, { pluginId: 'brand-colour' }) ``` The token also reads the [delivery API](/docs/api/delivery) of its Space environment, drafts included, as a bearer token: while the person may read there and the plugin is still on. Turning the plugin off ends every token it was given. # Previews and draft mode (https://content.obelism.studio/docs/guides/preview) A delivery token reads what is published. Everything else a site shows of a draft goes through a token that reads drafts, and there are four ways one reaches your server: | Way | Token | Reaches your server as | Lasts | For | | ---------------------------------------------------------------------- | ------------------------------------------ | ----------------------------------------------- | ------------------------------ | -------------------------------------------------------- | | [Canvas](/docs/guides/canvas) | Canvas, `cms_v_…` | `cms_token` in the query of every frame's URL | 8 hours | Editors working in the dashboard | | [Editing on your site](/docs/guides/site-editing) | Site, `cms_s_…` | The httpOnly cookie `obelism_content_token` | 8 hours, or until sign-out | Editors working on the site itself, with the prompt bar | | [Preview token](/docs/guides/api-tokens) | Preview, `cms_p_…` | Your server's environment variable | Until it expires or is revoked | A staging or preview deployment that always shows drafts | | [Connected deployment](/docs/guides/api-tokens#without-a-stored-token) | The host's OIDC token, with `drafts: true` | Asked from Vercel or GitHub Actions per request | Per request | The same, without a token to store | The first two act as the person editing, with their [role](/docs/guides/roles) at the time of each request: they stop working when that person leaves the organization. The last two show drafts to anyone who can open the deployment, so put that deployment behind your host's protection (Vercel's deployment protection, Cloudflare Access, basic auth). Oxygen issues no OIDC token, so a storefront on [Shopify Oxygen](/docs/getting-started/oxygen) uses a stored token: a delivery token for its public pages, a preview token for a deployment that always shows drafts. All four are refused by the delivery API from a browser on any origin but the environment's **preview URL**: read drafts on your server. ## One client per request [#one-client-per-request] Decide per request which token reads the page, in this order: the canvas's `cms_token`, then a signed-in editor's site token, then your delivery token. Everything a page reads (the page, its navigation, its settings) goes through that one client, so a draft page doesn't sit inside a published navigation. ```ts title="lib/content.ts" import { ContentClient, readPreviewParams } from '@obelism/content' import { readSiteToken } from '@obelism/content/site' export const delivery = new ContentClient({ token: process.env.CONTENT_DELIVERY_TOKEN!, }) // Naming your space and environment makes a token someone made for another // space read nothing here. const drafts = (token: string) => new ContentClient({ token, space: process.env.CONTENT_SPACE_ID!, environment: process.env.CONTENT_ENVIRONMENT!, init: { cache: 'no-store' }, }) export const contentFor = (request: Request) => { const preview = readPreviewParams(new URL(request.url)) if (preview?.token) return drafts(preview.token) const siteToken = readSiteToken(request.headers.get('cookie')) if (siteToken) return drafts(siteToken) return delivery } ``` In Next.js the site token is read in [draft mode](#nextjs-draft-mode), and the canvas's token from `searchParams`; the [Next.js quick start](/docs/getting-started/nextjs) has the same function written that way. ### When the token is refused [#when-the-token-is-refused] A canvas or site token that expired, was revoked, belongs to another space, or was made up gets a `401`. Show the published page then, and log why: ```ts import { ContentApiError } from '@obelism/content' const content = contentFor(request) const page = await content.getPage(path).catch((error: unknown) => { if (error instanceof ContentApiError && error.status === 401) { console.warn('Content refused the draft token:', error.body) return delivery.getPage(path) } throw error }) ``` `error.body` says why: `token_place_mismatch` as its `code` for a token of another space or environment, otherwise a message such as `Invalid or expired token` (see the delivery API's [errors](/docs/api/delivery#errors)). An editor who sees published content in the canvas, or on the site after a working day, has a token that ran out: opening the canvas again, or `/cms/login`, makes a new one. ## Rules for drafts [#rules-for-drafts] 1. **Decide by the token, never by `cms_preview`.** The canvas adds `cms_preview=1`, `cms_entry` and `cms_environment` to its URLs, but anyone can type those into a browser. Read drafts only with the token the request brings (`cms_token`, the site cookie), never with a preview token of your own because a parameter is there. `readPreviewParams` answers `{ entryId, environment, token }`; use `token`. 2. **Name your space and environment** on every client that reads with a token from the request (`space` and `environment` on `ContentClient`, the `Obelism-Space` and `Obelism-Environment` headers without the SDK). Anyone with a space of their own can make a canvas token for it. Without these, such a token would put their drafts on your site; with them, it gets `401` with `token_place_mismatch`. 3. **Keep draft-reading tokens on your server.** Preview tokens never go in client bundles or public environment variables (`NEXT_PUBLIC_…`, `PUBLIC_…`, `VITE_…`). The site token lives in an httpOnly cookie for this reason, and the prompt bar reaches Content through your server's `/cms/api/…`. 4. **Never cache a draft.** Fetch drafts with `cache: 'no-store'` (as above), and keep the page itself out of shared caches: a site token arrives on the same URL as the public page, so a CDN that caches by URL would hand an editor's draft to the next visitor. Answer draft requests with `Cache-Control: private, no-store`. Next.js does this itself in draft mode; elsewhere, set it wherever you pick the drafts client. 5. **Keep drafts out of search engines and analytics.** Send `X-Robots-Tag: noindex` with draft responses. The canvas's token is in the frame's URL, and analytics tools record full URLs: leave your analytics out when `readPreviewParams` answers, so neither the token nor editors' visits reach them. ## Live updates [#live-updates] A token shows what is **saved**. The canvas and the prompt bar also send what an editor types before it is saved, through the [bridge](/docs/sdk/javascript#bridge): - React: `useLiveEntry(entry)` returns the unsaved version while editing, and `entry` everywhere else. See [the React SDK](/docs/sdk/react). - Anything else: `bridge.on('update', ({ entry }) => render(entry))`, with `entry` in exactly the shape the delivery API answers for a draft token. Without an `update` listener, the page reloads after every save, which shows the saved draft through the token. ## A preview deployment [#a-preview-deployment] A staging site, or a preview deployment per pull request, can show drafts to everyone who opens it, with no sign-in: give that deployment a **preview token** instead of a delivery token, or, on Vercel or GitHub Actions, a [connected deployment](/docs/guides/api-tokens#without-a-stored-token) whose rule reads drafts: ```ts import { ContentClient } from '@obelism/content' import { vercelOidc } from '@obelism/content/oidc' export const delivery = new ContentClient({ token: vercelOidc(), space: process.env.CONTENT_SPACE_ID, environment: process.env.CONTENT_ENVIRONMENT, drafts: process.env.VERCEL_ENV === 'preview', init: { cache: 'no-store' }, }) ``` - Point the deployment at the environment it shows. A staging deployment reads staging, with staging's token: tokens belong to one environment. - Draft responses are never cached by Content, so such a deployment renders every page on request. Turn its own caches off, as for any draft (`cache: 'no-store'`). - Protect it. It shows everything that isn't published yet to anyone with the URL. - It doesn't replace the canvas: it shows saved drafts, not what an editor is typing, and has no prompt bar. Set the environment's **preview URL** to it, include the bridge, and editors get both. ## Next.js draft mode [#nextjs-draft-mode] [Draft mode](https://nextjs.org/docs/app/guides/draft-mode) is Next's switch for one browser to skip the caches: while its cookie is set, `fetch` and `'use cache'` skip the cache, static pages render on request, and responses are sent with `Cache-Control: private, no-cache, no-store`. Editing on the site turns it on at sign-in and off at sign-out: ```ts title="app/cms/[...path]/route.ts" import { createSiteEditing } from '@obelism/content/site' import { draftMode } from 'next/headers' const handler = createSiteEditing({ spaceId: process.env.CONTENT_SPACE_ID!, environment: 'production', onSignIn: async () => (await draftMode()).enable(), onSignOut: async () => (await draftMode()).disable(), }) export { handler as GET, handler as POST } ``` Read the site token only while draft mode is on, so published pages never look at cookies: ```ts import { readSiteToken } from '@obelism/content/site' import { cookies, draftMode } from 'next/headers' export const getContent = async () => { if ((await draftMode()).isEnabled) { const token = readSiteToken(await cookies()) if (token) return drafts(token) } return delivery } ``` Next can't read cookies inside `'use cache'`, so call this outside any cached function and pass the client in. Draft mode and the site token end together on sign-out, but not otherwise: the token's cookie expires with the token after 8 hours, while Next's draft mode cookie lasts until the browser closes. The page then shows published content, rendered per request, until the editor signs in again or leaves through `/cms/logout`. A token revoked before then (the person left the organization) gets a `401`, and the fallback above shows published content too. An exit link of your own goes to `/cms/logout` as a plain `<a>` or a form, or with `prefetch={false}` on `next/link`: Next prefetches links, and a prefetch of `/cms/logout` would end the session before anyone clicks. The canvas doesn't use draft mode: its token arrives on every frame's URL, so a page that reads it from `searchParams` works without cookies inside the dashboard's frame. ## React Router: no draft mode [#react-router-no-draft-mode] React Router has no switch to flip. The site token's cookie is the switch: read it from the request on every request (the `contentFor` function above), fetch drafts with `cache: 'no-store'`, and answer those responses with `Cache-Control: private, no-store` and `X-Robots-Tag: noindex` yourself. Keep them out of your own loader caches and out of Oxygen's full-page cache: the token arrives on the same URL as the public page. The cookie expires after 8 hours and on sign-out, and a refused token gets a `401`, so the fallback above shows the published page; there is nothing else to turn off. The exit link goes to `/cms/logout` as a plain `<a>`, or with `prefetch="none"`: a prefetch would end the session before anyone clicks. [Shopify Oxygen](/docs/getting-started/oxygen) has the host's side: a public environment for the canvas, and the headers the editor needs. ## Troubleshooting [#troubleshooting] - **The canvas shows the published page.** See [when a frame shows published content](/docs/guides/canvas#when-a-frame-shows-published-content). - **The canvas shows a Shopify login.** The environment is private, and the frame is loaded by the editor's browser, which isn't signed in to your store. Make that environment public, or point the preview URL at one that is ([Shopify Oxygen](/docs/getting-started/oxygen#2-the-preview-environment-must-be-public)). - **Signed in on the site, but the page is published content.** Your page doesn't read the site token: check that the code above runs on the route (in Next.js, that draft mode is on and the page reads `cookies()` only then), and that your CDN didn't answer from its cache (rule 4). - **`403` reading drafts.** A draft token from a browser on another origin than the preview URL: read on the server, or set the preview URL to the origin you are on. With a connected deployment and `code: drafts_not_allowed`, its connection's rule reads published content only: allow drafts in that rule, or leave `drafts` off. - **`401` with `token_place_mismatch`.** The token is for another space or environment than your site names. Check `CONTENT_SPACE_ID` and `CONTENT_ENVIRONMENT` against the environment whose preview URL points here. - **Drafts show to visitors.** A preview token in a deployment the public reaches, or a draft response cached under the public URL. Swap the token for a delivery token and purge that cache. # Prompt editing (https://content.obelism.studio/docs/guides/prompt-editing) Editors who may edit content can change a page by typing what they want, in a box at the bottom of your site: "make the hero heading shorter", "translate this block's text into plain English", "add a call to action under the pricing table". It works on the [canvas](/docs/guides/canvas), where [a request can also cover the whole site](/docs/guides/canvas#changing-the-whole-site), and when [editing on your site](/docs/guides/site-editing). ## What happens to a prompt [#what-happens-to-a-prompt] - It is about the selected block, or the whole entry when none is selected, in the locale chosen in the editor (on the site: the page's `<html lang>`). With a block selected, only that block can change; "add a section" puts new blocks right after it (or before, when you say so), and nothing else on the page is touched. On the whole entry, an answer that would drop blocks you didn't ask to remove is refused. - New sections are blocks: the model picks the block type whose description fits the request best. Every content type has a description for this; write it as what the block is for ("A list of questions with answers"). - A [shared block](/docs/guides/content-model#shared-blocks) is one line in what the model sees, never its fields: a prompt can insert a shared block the page's field allows ("add the promo banner"), keep or remove one, but never change its content, which would land on every page that shows it. Publishing the block is a separate decision, not a side effect of a page prompt. - The change lands in the **unsaved draft**, so it shows on your site straight away; **Undo** puts back what was there before. Nothing is stored until the editor presses Save, and closing the tab with unsaved changes asks first. - The model sees and edits only the current locale, and can't point at entries or assets the content didn't already use. Its answer is checked like any save; if it isn't valid after a second try, nothing changes. - On the site, **New page** writes a whole page from a path and a prompt and stores it as a draft, and **JSON** mode shows what the model sees so you can change it by hand. - Each person can prompt (or make a new page) 20 times a minute; past that, prompts are refused (`429`) until the minute is up. ## Write with AI in the entry form [#write-with-ai-in-the-entry-form] On an entry's page, text, long text, rich text and blocks fields have **Write with AI**: the same prompt for that one field (or blocks list) instead of the whole page. "A warmer intro", "three questions about delivery", "shorter, for mobile". - The model sees and may change only that field, in the language you are editing, written against your unsaved form, so it picks up what you just typed. A field inside a block is that block's. - Its answer is checked like any save, and like on the canvas a blocks list may not lose blocks you didn't ask to remove; if it isn't valid after a second try, nothing changes. - The suggestion shows the field before and after. **Keep it** puts it in the form as an unsaved change, saved with **Save** like your own edits (and recorded in [activity](/docs/guides/activity) as your save); **Try another** asks again with the same prompt; **Dismiss** drops it. - It runs on the environment's [own model](#your-own-model) when one is set, and counts towards the same 20 prompts a minute. ## The prompt box [#the-prompt-box] Every bridge comes with it: the `bridge.js` script tag, `getContentBridge()` from `@obelism/content/bridge` and `<ContentBridge />` from `@obelism/content-react`. It is plain DOM in a shadow root, no framework. - Leave it out with `data-prompt="false"` on the tag, `getContentBridge({ prompt: false })` or `<ContentBridge prompt={false} />`. - Change its labels: `getContentBridge({ prompt: { labels: { open: 'Bewerk met een prompt' } } })`. - Mount it yourself later with `mountPromptBox(document, bridge)`. - Or build your own UI with `bridge.prompt(text)`, `bridge.undo()` and the `change` event, or `useContentPrompt()` in React. ## Your own model [#your-own-model] By default, prompts run on Workers AI. To use your own model, set a **prompt endpoint** under **Settings → Preview and site → Prompt editing**. Each prompt is then a `POST` to it, signed like a [webhook](/docs/guides/webhooks) (Standard Webhooks) with the secret shown when you save the URL: ```json { "type": "content.prompt", "system": "You edit content in Obelism Content…", "messages": [{ "role": "user", "content": "Request: …" }], "schema": { "type": "object", "properties": { "intent": {}, "changes": {}, "summary": {} } } } ``` Answer `{ "output": … }` within 60 seconds, where `output` is the model's answer, `{ "intent": "add", "changes": { … }, "summary": "…" }`, as an object or as JSON text. `intent` (`add`, `change`, `remove` or `none`) is what the model took the request to be: an `add` that adds no block is sent back once. Answers without it are still accepted. `createPromptHandler` from `@obelism/content/prompt` verifies the request and shapes the response; you supply the model call: ```ts title="app/api/content-prompt/route.ts" import { createPromptHandler } from '@obelism/content/prompt' export const POST = createPromptHandler({ secret: process.env.CONTENT_PROMPT_SECRET!, // whsec_… from Settings generate: async ({ system, messages, schema }) => { // call any model; return its JSON answer (object or text) }, }) ``` Like the preview URL, the prompt endpoint belongs to one environment, and agents can set it outside production with `PATCH …/settings`. # Publishing and scheduling (https://content.obelism.studio/docs/guides/publishing) Every entry has a **draft**, which is all editing changes, and once published a **published** copy, which is all the [delivery API](/docs/api/delivery) serves to delivery tokens. An entry has unpublished changes while its draft is ahead of what's published. Preview tokens and the [canvas](/docs/guides/canvas) see drafts. ## What publishing checks [#what-publishing-checks] Saving only checks that values have the right shape. Publishing also needs: - every **required** field filled in for the default locale, and `min` blocks in a blocks field; - every entry the content **references** or embeds to be published itself, so published content never points at a draft; - every [improve block](/docs/guides/ab-tests) to name a test or flag that exists in the environment. The other way round, an entry can't be unpublished or deleted while published content still uses it. ## Publishing some languages [#publishing-some-languages] In a space with several [locales](/docs/guides/locales), **Publish languages…** (under **Now** in the publish options) publishes the changes of only the languages you pick: say the English text is ready and the Dutch translation isn't. - The picked languages' values go live from the draft. The others keep what was published before, block by block; where nothing was published in a language, the delivery API falls back along the [locale chain](/docs/guides/locales#fallbacks), as for any empty value. - What isn't per language (the order of blocks, a new block, a shared field) is the draft's, since every language shares it. - An entry is first published in its default locale; after that any language can go on its own. - While some of the draft is unpublished, the entry's status is **Changed**. Publishing the remaining languages (or everything) makes it **Published** again. Agents send `{ "locales": ["en"] }` with `POST …/entries/{id}/publish`. Scheduled publishing, bulk publishing and approving a review publish every language. ## Shared blocks [#shared-blocks] Publishing a [shared block](/docs/guides/content-model#shared-blocks) updates every live page that shows it, without republishing each page. Its publish options list them: untick a page to keep it on the version it shows now, which gets the new one the next time that page is published (or **Detach** on its card to opt out for good). The reverse warning names the blocks a held page catches up on when it is published. Approving a shared block in a [reviewed environment](#review-before-publishing) shows the same list. A schedule keeps the pages picked when it was set. The publish sends one `block.published` [webhook](/docs/guides/webhooks) with the pages it updated. ## Scheduling [#scheduling] **Publish** in the entry bar opens its options: **Now**, or **Later** with a date and a time in your own time zone (**Schedule for Thu 09:00**). **Schedule publish** under Status beside the form sets both when the draft goes live and when it comes off your site, or clears them. Agents use `POST …/entries/{id}/schedule` with ISO 8601 times (the `scheduleEntry` MCP tool). - At the publish time, the draft **as it is then** is published, not as it was when you scheduled it. Edits until then go live with it. - Where publishing is [reviewed](#review-before-publishing), only the draft as it was when the admin scheduled it goes live. If anyone saves it after that, the run is refused; schedule it again once the new draft is reviewed. - It runs as the person who scheduled it, with their role at that moment, and checks everything a publish does. It sends the same [webhooks](/docs/guides/webhooks). - If it is refused (a required field is empty, or the person lost their role), nothing changes and the reason shows under Status beside the entry's form. - Schedules are checked every minute, so allow up to a minute. - An unpublish time has to be after the publish time. Leave a time empty, or send `null`, to clear it. A schedule belongs to one environment. Promoting an entry doesn't carry its schedule along. ## History [#history] Every save, every publish and every draft a [promotion](/docs/guides/environments#promoting) brings (**Promoted**) is kept as a version: **History** beside the entry's form, or `GET …/entries/{id}/versions`. Restoring a version makes it the draft again; publish to roll back. An entry keeps its newest 100 versions. ## Deleting and the trash [#deleting-and-the-trash] Deleting an entry (once nothing uses it) moves it to the **Trash**, linked under the entries list, for 30 days. **Restore** brings it back as a draft with its version history, at its old place when that is still free (at the root when its folder is gone); publish it again when ready. **Delete for good** removes it straight away. Agents use `GET …/trash`, `POST …/trash/{id}/restore` and `DELETE …/trash/{id}`. Deleting a published entry takes it off the delivery API at once and sends `entry.deleted`; restoring doesn't publish it. ## Moving and redirects [#moving-and-redirects] **Edit** under URL beside the entry's form gives a page or folder a new slug (per locale) or another folder. Its old path, and those of the pages below it, keep pointing at it as **redirects**: the delivery API answers an old path with where the page is now, so your site can answer with a `301` instead of a 404. Moving sends `entry.moved` with the previous paths. See [Redirects](/docs/guides/redirects) for how they behave, adding one by hand, and answering them on your site. ## Duplicating [#duplicating] **Duplicate** beside the entry's form (or `POST …/entries/{id}/duplicate`) makes a new draft next to the entry, with the same content type, folder and saved draft, " (copy)" after the name and, for a page, the first free `<slug>-copy`. Nothing is published. ## Review before publishing [#review-before-publishing] An admin can make an environment's publishing **reviewed** under **Settings → Publishing**, typically production only. There, only admins publish, unpublish, schedule, delete published entries, and promote or import published content. Everyone else who edits sees **Ask for review** where Publish was: 1. **Ask for review** saves the draft and asks an admin to publish that revision, with an optional note. The entry shows "In review", the content list can filter on it, and an `entry.review_requested` [webhook](/docs/guides/webhooks) goes out, for a Slack message or an email from your own tooling. 2. Editing goes on as usual. If the draft changes after the ask, the request says so, and it can't be approved until it is asked for again: nobody publishes text they haven't seen. 3. An admin opens the entry and either **approves**, which publishes exactly the revision that was asked about, or **sends it back** with a reason, which the editor sees on the entry. Either way `entry.review_closed` goes out, and the [activity](/docs/guides/activity) log has it. The person who asked can withdraw the request. An admin publishing an entry with an open request also closes it. Bulk actions, the canvas, editing on the site, the agent API, MCP and management tokens all follow the setting: whoever may not publish there asks for review (`POST …/entries/{id}/review`, and `request_review` in bulk), and publishing answers `403 review_required`. ## Several entries at once [#several-entries-at-once] Tick entries in the **Content** list to publish, unpublish, move, delete or promote them together (**Promote** opens [promoting](/docs/guides/environments#promoting) with them picked), up to 100 at a time (the box in the header ticks the whole page). Each entry goes through exactly what it would on its own: the same checks, history, webhooks and redirects, and deleted entries go to the trash. The order you tick them in doesn't matter. A page that references an author publishes once the author has, and a folder is deleted after what is in it. Entries that wait on each other (each only names the others) publish together in one transaction: drafts like two articles linking to each other go live as a set, or not at all. That set is the whole cycle, even when it is larger than the 100 a call may name — a ring of 137 articles split over two calls still publishes from the half the call names (up to 500; a larger cycle says so), and the cycle's other entries go live with it. Whatever still can't be done is listed with the reason and stays ticked; the rest is done. Agents use `POST …/entries/bulk` with `{ "action": "publish" | "unpublish" | "move" | "delete", "entryIds": [...], "parentId"? }` (`parentId` for `move`, `null` for the root), which answers a result per entry. # Redirects (https://content.obelism.studio/docs/guides/redirects) When a page gets another path, its old path becomes a **redirect** to the page. The delivery API answers the old path with where the page is now, so your site sends visitors and search engines on with a `301` instead of a 404. Content records the redirects; your site answers them. ## Where they come from [#where-they-come-from] - **Moving a page or folder.** **Move** in the entry's sidebar (a new slug, or another folder) keeps the old path of the page and of every page below it. See [moving](/docs/guides/publishing#moving-and-redirects). - **Changing a slug in one locale.** Slugs are [per locale](/docs/guides/locales#urls-per-locale), and so are redirects: a new Dutch slug keeps the old Dutch path, the English one doesn't change. - **Changing locales.** A new default locale or another fallback can change the paths of locales without their own slug; the paths that change become redirects. - **By hand.** For an old path no page here ever had, such as the URLs of a site you import from another content system. See [adding one by hand](#adding-one-by-hand). ## How they behave [#how-they-behave] - **A redirect follows its page.** One recorded by a move points at the entry, not at a path: moving the page again changes where all its old paths go, without chains. One [added by hand](#adding-one-by-hand) points at a fixed path or URL, and stays there when a page moves or is deleted. - **A page placed on an old path takes it over.** Creating or moving a page onto a path that was a redirect removes the redirect, in that locale. - **Deleting the page removes the redirects its moves recorded.** Its old paths are 404s from then on. Restoring it from the trash doesn't bring them back. - **Only published pages count on a live site.** A delivery token doesn't see redirects to entries that aren't published; a preview token does. Redirects made by hand are always visible. - **Every environment has its own.** Redirects are recorded where the page moved and aren't [promoted](/docs/guides/environments#promoting). A page moved in staging and promoted to production gets its new path there without a redirect from the old one: move published pages in production, or add the redirect there by hand. ## Managing them [#managing-them] **Settings → Preview and site → Redirects** lists every old path with where it goes now, per locale (one row when all locales go to the same page), and the page's name. **Delete** removes one: its old path is a 404 from then on. Editors and above can delete them. The [activity log](/docs/guides/activity) records redirects added by hand and deleted ones. Moving sends the `entry.moved` [webhook](/docs/guides/webhooks) with the previous paths. ## Adding one by hand [#adding-one-by-hand] The dashboard doesn't add redirects; the [agent API](/docs/api/agents), [MCP](/docs/getting-started/agents) and [management tokens](/docs/api/management) with the `content` scope do. Use them when importing a site, for its old URLs, in production too (MCP's `createRedirect`, `listRedirects` and `deleteRedirect`): ```http POST https://content.obelism.studio/api/agent/organizations/{org}/spaces/{space}/{environment}/redirects/create Authorization: Bearer cms_m_… Content-Type: application/json { "from": "/news/2019/our-new-office", "to": "/blog/our-new-office" } ``` | Field | Value | | -------- | ------------------------------------------------------------------------------------------------------------------- | | `from` | The old path, with or without a leading or trailing `/`; no spaces, `?` or `#`, at most 2048 characters | | `to` | A path in this space, with or without a leading `/`, or an absolute `http(s)` URL (`https://shop.example.com/sale`) | | `locale` | Optional. Without it, the redirect is added in every locale | A path a page has in that locale, or one that already redirects there, answers `409`: delete the old redirect first to change it. The target path isn't checked against your pages: it can be a route of your site that isn't in Content. `GET …/redirects` lists them with their entry, and `POST …/redirects/delete` with `{ "from", "locale"? }` removes one. ## On your site [#on-your-site] There are two ways to answer them, and most sites use the first. ### When a page isn't found [#when-a-page-isnt-found] Look the path up with `findPage` instead of `getPage`. It answers `{ entry }` for a page, `{ redirect: { path } }` for an old path, and `null` for neither: ```tsx title="src/app/[[...slug]]/page.tsx" import { notFound, permanentRedirect } from 'next/navigation' export default async function CatchAll({ params }: Props) { const { slug } = await params const found = await content.findPage<PageEntry>(slug?.join('/') || 'home', { depth: 2, }) if (found?.redirect) { const to = found.redirect.path permanentRedirect(/^https?:\/\//.test(to) ? to : `/${to}`) } if (!found) notFound() return <Page entry={found.entry} /> } ``` Paths come without a leading `/` and without the locale. With several locales, pass `locale` to `findPage` and put it back where your routes have it (`/${locale}/${to}`). A `to` that starts with `http` is an absolute URL someone added by hand; send it as it is. Without the SDK, `GET /api/delivery/v1/pages?path=…` answers `404` with `redirect: { path }` (see [moved pages](/docs/api/delivery#moved-pages)). This costs nothing extra for pages that exist, and a redirect works the moment a page moves. ### As rules built ahead of time [#as-rules-built-ahead-of-time] To answer old paths before your app runs (at the CDN, or in `next.config`), list them all with `getAllRedirects` and turn them into your host's rules: ```ts title="next.config.ts" import { ContentClient } from '@obelism/content' import type { NextConfig } from 'next' const content = new ContentClient({ token: process.env.CONTENT_TOKEN! }) const config: NextConfig = { redirects: async () => { const rules = [] for await (const { from, to } of content.getAllRedirects({ locale: 'en' })) rules.push({ source: `/${from}`, destination: /^https?:\/\//.test(to) ? to : `/${to}`, permanent: true, }) return rules }, } export default config ``` Each item is `{ locale, from, to }`; leave `locale` out for every locale's. `GET /api/delivery/v1/redirects` answers up to 1000 at a time, with `limit` and `skip`. Rules like these are only as new as your last build: rebuild on the `entry.moved` webhook, or keep `findPage` in the catch-all route as well, so a page moved since still redirects. # Roles (https://content.obelism.studio/docs/guides/roles) People belong to an **organization** with one role, managed on [www.obelism.studio](https://www.obelism.studio) for every Obelism product. The role applies to every space of the organization. | May… | Admin | Developer | Editor | Reader | | ------------------------------------------------------------- | :---: | :-------: | :----: | :----: | | Read content, the model, assets, locales and the preview URL | ✓ | ✓ | ✓ | ✓ | | Comment on entries | ✓ | ✓ | ✓ | ✓ | | Create, save, publish, move and delete entries; upload assets | ✓ | ✓ | ✓ | | | Prompt, save and publish on the site | ✓ | ✓ | ✓ | | | Promote content between environments | ✓ | ✓ | ✓ | | | Promote the content model along | ✓ | ✓ | | | | Export and import content (backups, moving content) | ✓ | ✓ | | | | Change the content model | ✓ | ✓ | | | | Create spaces; change locales, preview URL, prompt endpoint | ✓ | ✓ | | | | Open Settings and Activity | ✓ | ✓ | | | | See, create and revoke API tokens and webhooks | ✓ | ✓ | | | | Invite and remove members, rename the organization | ✓ | | | | | Roles per space; reviewed publishing, and approving reviews | ✓ | | | | | Transfer or delete a space | ✓ | | | | ## Roles per space [#roles-per-space] By default a member has their organization role in every space. An admin can give someone a role in some spaces only, under **Settings → People**: from then on they only see the spaces they have a role in, with that role in each (developer, editor or reader; it can be more than their organization role, say a client's editor who is a developer in their own space). Agencies use it to give a client's people their own space and nothing else. - **Every space, as …** puts someone back in every space with their organization role. - **Not in this space** takes one space away, but not their last: choose every space, or remove them from the organization on www. - Admins are always in every space; to restrict an admin, change their role on www first. - Everything follows the role in the space: the dashboard, the agent API and MCP, management and site tokens (as their creator) and editing on the site. Spaces someone wasn't given don't show up anywhere, as if they didn't exist. Agents use `GET …/spaces/{space}/members` and `PUT …/spaces/{space}/members/{userId}` with `{ "access" }` (admins). In an environment whose publishing is **reviewed** (Settings → Publishing), only admins publish, unpublish, schedule, delete published entries and promote or import published content there; developers and editors [ask for review](/docs/guides/publishing#review-before-publishing) instead, and an admin approves or sends it back. Changing the setting is for admins too. ## Access per product [#access-per-product] An admin can keep a member out of Content (or out of Improve) on www, under the organization's **Product access**. For Content they are then no member at all: the organization doesn't show up, and its spaces answer as if they didn't exist, in the dashboard, the agent API, MCP and their tokens. Their role and access to the other products stay as they are. Agents use `PATCH https://www.obelism.studio/api/agent/organizations/{org}/members/{membershipId}` with `{ "products": ["improve"] }`, or `null` for every product (admins). An admin can also turn Content off for the organization on www. The dashboard, the agent API, MCP and the management API then refuse it (`product_disabled`), while delivery keeps serving your sites. ## Tokens and agents act as a person [#tokens-and-agents-act-as-a-person] - **Management tokens** and **site tokens** act as the person who created them, with that person's role at the time of each request: they stop working when the person leaves, and lose rights when the role changes. - **MCP** and the **agent API** run as the person who signed in. - **Delivery** and **preview tokens** only read, and belong to the space environment, not to a person. # Shopify (https://content.obelism.studio/docs/guides/shopify) Connect a space's Shopify store, and editors pick its collections and products in content: a **Collection** field in a product grid, a **Product** field in a feature block. Content stores which item was picked; the site reads its prices, stock and images live from Shopify. ## Connecting a store [#connecting-a-store] The connection goes through the **Obelism** app in Shopify, and is made by an admin or developer of the space. 1. On www, open the space’s page and under **Shopify** enter the store’s address (`acme`, or `acme.myshopify.com`) and choose **Connect store**. Or install the Obelism app from the store’s Shopify admin. 2. Shopify asks to allow the app's access to the store. Allow it. 3. Back on Obelism, sign in if needed, and choose the **space** the store belongs to and the **environment** its storefront uses (usually production). - **One store per space, in every environment.** Develop, staging and production pick from the same store, so a product picked in develop is the same product after [promotion](/docs/guides/environments). - A store belongs to one space, and a space has one store. Connecting another store to a space means disconnecting the first. - Content only reads products and collections. The app can also add files, their alt texts and their translations to products, which [Obelism media](https://media.obelism.studio/docs/shopify) does when you ask it to. It never sees orders, customers or payments. - **Moving or disconnecting**: on the space’s page on www, under **Shopify → Move or disconnect**, or open the Obelism app from the store’s Shopify admin. Content already pointing at the store’s items keeps them. - **Uninstalling** the app in Shopify disconnects the store, and Obelism forgets its tokens; 48 hours later Shopify asks us to delete the store's data, and we do. ## The product grid block [#the-product-grid-block] Once the space has a store, **Settings → Shopify** offers **Add a product grid block**: a block type, `productGrid`, with a heading, a collection and a number of columns (2 to 6, 4 by default). It is added to the environment you're in, like any change to the [content model](/docs/guides/content-model); [promote](/docs/guides/environments) the model to bring it to the next environment. After that it's a type like any other: rename it, add fields, or delete it. Where the model is [managed in code](/docs/guides/model-in-git#locking-the-model-to-code), Settings show the block's definition to add to the model file instead. ## Shopify fields [#shopify-fields] Any content type can have a field of type **Shopify**, holding a **collection** or a **product** (chosen when the field is created, and fixed after it is saved), one or, with **multiple**, a list with an optional minimum and maximum. See [fields](/docs/guides/content-model#fields). A multiple field's picked items drag into the order you want, by their grip or with Space and the arrow keys, and delivery keeps that order. Editors choose items with a picker that searches the store by title and shows each item's image: in the entry editor, the [canvas](/docs/guides/canvas) and the form in the [site's prompt bar](/docs/guides/site-editing). A space without a store says so and links to www, and hides the Shopify features until a store is connected. [Editing by prompt](/docs/guides/prompt-editing) keeps or removes the items an entry has, but never picks new ones: a model can't know a store's products. ## Links to products [#links-to-products] Tick **Shopify products** on a [link field](/docs/guides/content-model#links) (`products: true` in the model) and it offers **A product** once the space has a store: a button or card that goes to a product, picked with the same picker. Nothing is imported, and no page is needed for it: ```json { "cta": { "product": { "id": "gid://shopify/Product/42", "handle": "mug" }, "hash": "reviews" } } ``` The [delivery API](/docs/api/delivery#links) answers it as `type: "product"`, with the product and its product page (below) in `entry`, or `null` when it has none. Link to the product page's path, or to your own product route by handle (`/products/mug`). Prompts keep or remove a linked product, but never pick a new one. ## Product pages [#product-pages] For the products you want to say more about than the store does (a story, a video, a comparison), make a page per product. A product page is an ordinary page whose type has a **Product** field marked **unique** (one product, not translated): each published page of that type is the page of its product, and no two can have the same one. Once the space has a store, **Settings → Shopify** offers **Add a product page type**: a page type, `productPage`, with the product, a blocks field and the SEO fields (no title: the product's comes from the store), and a `products` folder for its pages, as in the store's own `/products/<handle>`. Change it like any other type afterwards, or give one of your own page types a unique product field instead. A new product page starts from a product: under **Content → New entry**, choose the product page type and pick the product; it names the page, gives it the product's handle as slug and puts it in the `products` folder. Where your site shows a product is set per environment under **Settings → Preview and site → Preview URL → Product pages at**: a path after the preview URL with `{handle}`, `/products/{handle}` by default (Shopify's own). The canvas opens a product page there (and shows any product of the store there, in its [Products](/docs/guides/canvas#products) column), and the prompt bar on your site opens product pages there from its page menu. Pages are only made for the products that need one; the rest keep their store page. A link to a product with a product page is delivered with that page. On the site's product route, look the page up by the product's id rather than by path, since a handle can change in Shopify after the page was made: ```sh GET /api/delivery/v1/entries?contentType=productPage&fields.product=gid://shopify/Product/42 ``` No page found means the product has no marketing content: render the store's product alone. Mark the product on that route with the SDK's `editableProduct(product)`, next to `editable(page)`. When someone [edits on the site](/docs/guides/site-editing), a product without a page then offers **Add a product page** in the prompt bar, which makes the draft for that product and shows it: ```tsx <main {...editableProduct(product)} {...editable(productPage)}> … </main> ``` ## Collection pages [#collection-pages] Collections get pages the same way, for what goes around a collection's products on your site: an introduction, a banner, a buying guide. A collection page's type has a **Collection** field marked **unique** (one collection, not translated); a type has at most one unique Shopify field, so a page is about a product or a collection, never both. - **Settings → Shopify** offers **Add a collection page type**: `collectionPage`, with the collection, a blocks field and the SEO fields (no title: the collection's comes from the store), and a `collections` folder for its pages. - A new collection page starts from a collection under **Content → New entry**, named after it, with its handle as slug, in the `collections` folder. - **Settings → Preview and site → Preview URL → Collection pages at** sets where your site shows a collection, `/collections/{handle}` by default. The canvas opens collection pages there, and shows any collection of the store there in its [Collections](/docs/guides/canvas#products) column. - On your collection route, look the page up by the collection's id, and render the store's collection alone without one: ```sh GET /api/delivery/v1/entries?contentType=collectionPage&fields.collection=gid://shopify/Collection/7 ``` - Mark the collection with the SDK's `editableCollection(collection)` (`@obelism/content` 0.20 or later), next to `editable(page)`, and the prompt bar on your site offers **Add a collection page** on a collection without one. ## In content [#in-content] A Shopify field stores the item's id, the same in every environment, and its handle when it was picked: ```json { "collection": { "id": "gid://shopify/Collection/451234", "handle": "new-arrivals" } } ``` With **multiple**, a list of those. The [delivery API](/docs/api/delivery#shopify-fields) returns it as stored. Content never copies prices, stock or images, so they are never out of date. If the handle is changed in Shopify later, the stored one stays as it was until the item is picked again. Fetch by `id`, and use the handle only for links. ## On the site [#on-the-site] **Settings → API tokens → Copy as .env** gives the store's domain and a Storefront API token, made when the store was connected: ```bash SHOPIFY_STORE_DOMAIN=acme.myshopify.com SHOPIFY_STOREFRONT_TOKEN=… ``` The Storefront token only reads what the store sells, so it is safe in the browser. With the SDK (`@obelism/content` 0.17.0 and later): ```ts import { fetchShopifyCollection } from '@obelism/content' // A productGrid block: { heading, collection, columns }. const collection = await fetchShopifyCollection(block.collection, { shop: process.env.SHOPIFY_STORE_DOMAIN!, storefrontToken: process.env.SHOPIFY_STOREFRONT_TOKEN!, first: 12, }) const products = collection?.products ?? [] // Each: id, handle, title, availableForSale, featuredImage, // priceRange.minVariantPrice ({ amount, currencyCode }) ``` `fetchShopifyProducts(items, options)` does the same for a product field, in the field's order. Both leave out what the store no longer has, and take `country` and `language` for a market's prices. See [the JavaScript SDK](/docs/sdk/javascript#shopify). A Hydrogen or Liquid storefront already queries Shopify: use the `id` in its own queries instead (`collection(id: …)` in the Storefront API). `npx @obelism/content types` types the fields as `ShopifyItem` (`{ id, handle }`), and `npx @obelism/content init` writes an example item for each. ## On a Hydrogen storefront [#on-a-hydrogen-storefront] A Hydrogen storefront on [Shopify Oxygen](/docs/getting-started/oxygen) queries the Storefront API itself; the `{ id, handle }` Content stores are all it needs. Use the two values from **Copy as .env**, or Hydrogen's own `PUBLIC_STORE_DOMAIN` and `PRIVATE_STOREFRONT_API_TOKEN`: ```ts import { gql } from '@shopify/hydrogen' const PRODUCT = gql(`#graphql query Product($id: ID!) { product(id: $id) { title priceRange { minVariantPrice { amount currencyCode } } } } `) const { data } = await storefront.graphql(PRODUCT, { variables: { id: item.id }, // gid://shopify/Product/… }) const product = data?.product ``` `storefront` is Hydrogen's client (`createStorefrontClient`), and `graphql` answers `{ data, errors, headers }`. `fetchShopifyCollection` and `fetchShopifyProducts` work there too when you'd rather not write the query; they take the same two values. - **Prices and stock are the store's**, read at render time. Cache the queries with Hydrogen's `Cache.short()` or `Cache.long()`, and let the [webhooks](#keeping-your-site-in-step) tell you to refresh what you keep yourself; on Oxygen the TTL is what bounds staleness. - **On Oxygen there is no tag cache and no purging of the live deployment's cache**, so the TTL is what bounds how stale a price can be ([caching on Oxygen](/docs/getting-started/oxygen#4-cache-with-a-ttl-not-with-tags)). - **Images come from Content**, not from the Storefront API: use the asset's `url` as it is ([assets](/docs/guides/assets)). - **Shoppable images**: the delivery asset's `products` carry each product's point (`position`) when the use asks for them, so a Hydrogen storefront draws the hotspots from that ([products in a file](/docs/guides/assets#products-in-a-file)). The theme's **Shoppable image** app block instead reads the product's `hotspots` metafield, which the Storefront API doesn't expose. ## Keeping your site in step [#keeping-your-site-in-step] Prices, stock and images come from the Storefront API when your site renders, so a site that caches its pages needs to hear when the store changes. Add a webhook under **Settings → Webhooks** with `product.updated`, `product.deleted`, `collection.updated` and `collection.deleted`, and revalidate the pages and cache tags each event names. See [Webhooks](/docs/guides/webhooks#shopify-changes). ## Media in Shopify [#media-in-shopify] Files in **Media** tagged with a product can go onto that product in Shopify, without downloading and uploading them again. In the Shopify admin, a product's page has an **Obelism media** block (add it with the page's ⋯ menu) that lists the files tagged with the product, and **More actions → Add from Obelism media** opens the whole library. **Add to product** puts the file on the product with its alt text, and in the store's other languages when the file has alt text in them. A file is uploaded to the store once: adding it to another product uses the same Shopify file. When the file changes in Media (a new version or new alt text), the block shows it and **Update** replaces Shopify's copy in place, keeping its URL and products. Nothing goes to Shopify unless someone there asks for it. The products tagged on a file with a point become hotspots in the theme: add the **Shoppable image** app block to the product template in the theme editor, and it shows the product's images that have hotspots, each linking to its product. Images (JPEG, PNG, GIF and WebP) and videos (MP4, MOV and WebM, up to 1 GB and 10 minutes) can be added; a video uploads in the background and shows as processing until Shopify has it ready, and only its alt text updates afterwards. # Editing on your site (https://content.obelism.studio/docs/guides/site-editing) The [canvas](/docs/guides/canvas) shows your site inside the dashboard. Editors can also work on the site itself: they open `/cms/login` on your site, sign in to Obelism Content, and are sent back to the page in **draft mode**. The page then shows drafts, and a prompt bar at the bottom lets them change it by typing ("make the hero heading shorter"), then **Save** or **Publish**. ``` yoursite.com/cms/login?return_to=/pricing → content.obelism.studio/site/connect?… (signed out? → www.obelism.studio/login, and back; then Continue) → yoursite.com/cms/callback?code=…&state=… (code → site token, draft mode on) → yoursite.com/pricing (drafts + prompt bar) ``` ## Setting it up [#setting-it-up] With the SDK it is one route and a few lines in the code that already reads content. 1. **Set the preview URL** of the environment (Settings) to your site. Sign-in only sends people back to that origin. 2. **Mount the handler** on `/cms/*`. In Next.js (App Router): ```ts title="app/cms/[...path]/route.ts" import { createSiteEditing } from '@obelism/content/site' import { draftMode } from 'next/headers' const handler = createSiteEditing({ spaceId: process.env.CONTENT_SPACE_ID!, environment: 'production', onSignIn: async () => (await draftMode()).enable(), onSignOut: async () => (await draftMode()).disable(), }) export { handler as GET, handler as POST } ``` In React Router (framework mode) it is one resource route, and no draft mode: the site token's cookie is the switch, so there is nothing to turn on. ```ts title="app/routes/cms.tsx" import { createSiteEditing } from '@obelism/content/site' import type { Route } from './+types/cms' const handler = createSiteEditing({ spaceId: process.env.CONTENT_SPACE_ID!, environment: 'production', }) export const loader = ({ request }: Route.LoaderArgs) => handler(request) export const action = ({ request }: Route.ActionArgs) => handler(request) ``` Add `route('cms/*', 'routes/cms.tsx')` to `app/routes.ts`: it ranks above the splat that serves your pages. Return its `Response` as it is: it sets several cookies, and rebuilding the response loses them. On Oxygen the two settings come from the Worker's `env` binding ([how](/docs/getting-started/oxygen#1-store-the-token-in-the-environment)). The handler takes a standard `Request` and returns a `Response`, so it works the same in any framework that has those. 3. **Read drafts** with the session's token, which works with the delivery API like a preview token. In Next.js, while draft mode is on: ```ts import { ContentClient } from '@obelism/content' import { readSiteToken } from '@obelism/content/site' import { cookies, draftMode } from 'next/headers' const token = (await draftMode()).isEnabled ? readSiteToken(await cookies()) : null const content = token ? new ContentClient({ token, space: process.env.CONTENT_SPACE_ID!, environment: process.env.CONTENT_ENVIRONMENT!, init: { cache: 'no-store' }, }) : delivery ``` In React Router there is no draft mode: read the token from the request's cookie on every request (`readSiteToken(request.headers.get('cookie'))`), and answer draft responses with `Cache-Control: private, no-store` and `X-Robots-Tag: noindex` yourself. [Previews and draft mode](/docs/guides/preview#react-router-no-draft-mode) has the function. Once the token stops working (sign-out, 8 hours, or the person leaving the organization), the API answers 401. Show the published page then. After 8 hours the token's cookie is gone while Next.js keeps draft mode on; see [Next.js draft mode](/docs/guides/preview#nextjs-draft-mode). 4. **Mark the page's entry** next to its blocks, so the prompt bar knows what it is editing: ```jsx <main {...editable(entry)}> <section {...editable(block)}>…</section> </main> ``` A block inside another marked entry (a navigation, say) is edited in that entry. 5. **Render live changes** with `useLiveEntry(entry)` (React) or `bridge.on('update', …)`, as for the canvas. Prompts change content in the browser's memory, and `update` shows it without saving. A page without an `update` listener shows the change once it is saved, after reloading. `<ContentBridge />` (or `getContentBridge()`) starts the prompt bar on its own whenever the canvas isn't around and the editing cookie is set. ## What editors get [#what-editors-get] - **Prompt**: as in the canvas, about the selected block (click one while the bar is open) or the whole entry, in the page's language (`<html lang>`, when the space has it). "Add a section on eligibility" adds a block, of the type whose [description](/docs/guides/content-model) fits best; with a block selected it goes right after it, and only that block can change. **Undo** steps back through the prompts. - **Form**: switch the bar from Prompt to Form to edit the selected block, or the whole entry, with the same form as the dashboard and the canvas: every field, the asset and entry pickers, uploads, and blocks with **+ Add block**, in the page's language. Edits show on the page as you type and stay unsaved until **Save**; a prompt, an undo or another selection shows in the form. It replaces the JSON editor of 0.13 (`bridge.view()` and `bridge.applyJson()` remain). (`@obelism/content` 0.14 and up) - **Pages**: the page menu lists every page, drafts included, and opens one, so a new draft that nothing links to yet can be reached. (`@obelism/content` 0.13 and up) - **New page**: a path (`/pricing`, or `/solutions/agencies` under an existing page) and what the page is for. The model writes the whole page, with the content type of the page the editor is on and that page as an example of tone and blocks, and it is **stored as a draft right away**; the bar then opens it. On a page that isn't an entry, the bar only offers New page, and the space's page content type is used when it has just one. - **Add a product page**: on a product route that marks its product with `editableProduct(product)` and has no [product page](/docs/guides/shopify#product-pages) yet, the bar offers this instead of a prompt. It makes a draft of the space's product page type for the product (titled after it, its handle as slug, under `products` when the space has that page or folder), and the page reloads to show it, ready for a prompt or the form. Product pages open from the page menu at your product route (`productPath`, default `/products/<handle>`). (`@obelism/content` 0.18 and up) - **Add a collection page**: the same on a collection route that marks its collection with `editableCollection(collection)` and has no [collection page](/docs/guides/shopify#collection-pages) yet, under `collections`; collection pages open at `collectionPath` (default `/collections/<handle>`). (`@obelism/content` 0.20 and up) - **Save** stores the changed content as the draft. If someone else saved the entry since it was loaded, the save is refused and nothing is lost: the changes stay in the browser. - **Publish** saves first, then publishes every entry changed here (or the page's entry when nothing was). - **Exit editing** signs out (`/cms/logout`) and turns draft mode off. Leaving the page with unsaved changes asks first. - People who may only read content see drafts and an exit, and no prompt bar. ## How it works [#how-it-works] - `/cms/login` sends the browser to `/site/connect` with the space, the environment, a `redirect_uri` (`/cms/callback` on your site), a random `state` and a PKCE challenge (S256). Signed out, that goes through the Obelism login on www.obelism.studio first. Signed in, Content checks the person may open the space and that `redirect_uri` is on the preview URL's origin, then shows them that origin, the space, the environment and their role. Only when they choose **Continue** does it redirect there with a one-time code, valid for a minute. - `/cms/callback` checks the `state`, and trades the code and the PKCE verifier for a **site token** (`POST /api/site/v1/token`). It keeps the token in an httpOnly cookie (`obelism_content_token`), and sets a readable `obelism_content_editing` cookie that only tells the bridge to start. - The site token acts as that person in that space environment, with their [role](/docs/guides/roles) at the time of each request, like a management token acts as its creator. It expires after 8 hours. - The prompt bar calls `/cms/api/…` on your site. The handler forwards those calls to the [site API](/docs/api/site) with the token, so the token never reaches page scripts. Writes need an `x-obelism-content: 1` header, which a cross-site form can't send. - **Form** is a page of Content (`/site/form` on content.obelism.studio) in an iframe in the bar. To open it, the handler asks the site API for a URL with a one-time code (`POST /cms/api/form` → `POST /api/site/v1/form`); the page trades the code for a **form token** of its own, kept in memory, which only works on the form's routes and ends with the site token (signing out, expiry). The page and the bar exchange the content by `postMessage`, the page only with your site's origin. References and shared blocks offer **Open**; since the dashboard refuses to be framed, that opens the entry in a new tab rather than in the panel. Content lets any site frame that page, so nothing on your side needs changing; if your site sets a `Content-Security-Policy`, its `frame-src` (or `child-src`/`default-src`) has to allow `https://content.obelism.studio`. # Spaces and settings (https://content.obelism.studio/docs/guides/spaces) A **space** is one site or project of your organization: its content model, content, assets, locales and API tokens. An organization with a marketing site and a help centre has two spaces. Spaces are shared by every Obelism product, so [Improve](https://improve.obelism.studio/docs)'s tests and flags and the numbers of a site belong to the same space as its content. Every space has three [environments](/docs/guides/environments), develop, staging and production, each with its own model and content. ## Creating a space [#creating-a-space] **Spaces** in the sidebar lists the organization's spaces with how many content types and entries each has. Admins and developers create one there: - **Name**: what the dashboard calls it; change it later in Settings. - **Default locale**: its code (`en`, `nl-NL`) and name. Add more [locales](/docs/guides/locales) in Settings. - **Start with**: an empty content model, or a [starter model](/docs/guides/content-model#starting-a-space-from-a-model) for a blog, a marketing site or docs, in every environment. A new organization starts with one space, named right after creating the organization on www.obelism.studio, in English. The organization's page there lists its spaces and adds, renames, transfers and deletes them for every Obelism product. Agents create spaces with `POST /api/agent/organizations/{org}/spaces` (`createSpace`, with `starter`), and delete or transfer one with `deleteSpace` and `transferSpace`, which www answers. ## Switching [#switching] The switcher at the top of the sidebar picks the organization, the space and the environment you work in. It is the same switcher in every Obelism product, and they remember the same organization and space: switch in Content, and Improve opens on the same space. A [role per space](/docs/guides/roles#roles-per-space) means someone only sees the spaces they were given. ## Settings [#settings] **Settings** in the sidebar (for admins and developers) holds everything about the space and the environment you're in: **General** (the space itself), **Site** (what the canvas and your site use), **API & webhooks** (how a site reads content) and, once a store is connected, **Shopify** (the content types that use it). The store itself is connected on the space's page on www, since Content and Media share it. Locales, members, Shopify and AI translation apply to the whole space; the others to the current environment only, so production has its own preview URL, tokens and webhooks. | Page | Section | For | Applies to | | -------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------- | | General | Name | Renaming the space | Space | | General | Locales | The [languages](/docs/guides/locales) the space publishes in, the default, fallbacks, and removing one | Space | | General | Members | [Roles per space](/docs/guides/roles#roles-per-space): who may open this space, and as what | Space | | General | Publishing | Open, or [reviewed](/docs/guides/publishing#review-before-publishing) by an admin | Environment | | General | Content model | Maintained in the dashboard, or [in code](/docs/guides/model-in-git#locking-the-model-to-code) | Environment | | General | Transfer this space | [Moving it to another organization](#transferring-a-space), with everything in it | Space | | General | Delete this space | Deleting it, with everything in it | Space | | Site | Preview URL | Where the [canvas](/docs/guides/canvas) loads your site, and where it shows products and collections | Environment | | Site | Prompt editing | Workers AI, or [your own model](/docs/guides/prompt-editing#your-own-model) through a signed endpoint | Environment | | Site | AI translation | Whether AI translates, into which languages, house style, and [translating on save](/docs/guides/locales#translating) | Space | | Site | Redirects | The old paths of [moved pages](/docs/guides/redirects) | Environment | | API & webhooks | Environment variables | **Copy as .env**: every product's ids and environment, and a line for each token a site needs | Environment | | API & webhooks | API tokens | [Delivery, preview and management tokens](/docs/guides/api-tokens) | Environment | | API & webhooks | Webhooks | [Requests to your site](/docs/guides/webhooks) when content changes, and their deliveries | Environment | | API & webhooks | Usage | [Delivery API requests](/docs/api/delivery#rate-limits-and-usage) this month and per day | Space | | Shopify | Shopify | Adding a product grid block and product or collection page types; the [store itself](/docs/guides/shopify) is connected on www | Space | Publishing review, members, transferring and deleting the space are for admins only. In production, agents read settings but don't change the preview URL or prompt endpoint, or create tokens and webhooks: a person does that here. Agents read and change the rest with `GET` and `PATCH …/settings` (`getSpaceSettings`, `updateSpaceSettings`). ## Transferring a space [#transferring-a-space] **Transfer this space**, above deleting it in Settings, moves the space to another organization you're an admin of, in every Obelism product: its content model, content, history and files in Content, its tests, flags and audiences in Improve, its events in numbers. Its id stays the same, and so do its API tokens, webhooks, allowed origins and Shopify store, so sites keep working without a change. Pick the organization, type the space's name, and confirm once more. - Only an admin of both organizations transfers a space, and both have to be active. - An organization's first space can't be transferred: sites that name only the organization, not the space, use it. That includes an organization's last space. - Roles per space don't move along. In the new organization its members have their organization role in the space; members of the old one lose access unless they're in the new one too. - The space keeps its slug, unless the new organization has a space with it: then it gets `-2`. - Usage counted before the move stays with the old organization; from then on the space counts for the new one, under its plan. ## Deleting a space [#deleting-a-space] **Delete this space**, at the bottom of Settings, deletes the space in every environment: its content model, content, history, files, API tokens and webhooks. Type the space's name to confirm. Sites reading it stop getting content at once, and it can't be undone, so [export](/docs/guides/environments#export-and-import) what you want to keep first. An organization's last space can't be deleted. Only admins delete spaces. ## The dashboard [#the-dashboard] - **Home** greets you with what needs you in the space you're in: unpublished changes, drafts, reviews, failed schedules and what is ready to promote, each with a link, then the numbers, the latest edits and where each environment stands. In a new space, developers and admins find the prompt that [connects your coding agent](/docs/getting-started/agents) there, ready to copy. Invites to other organizations show there to accept. - **⌘K** (Ctrl+K) opens one menu for every Obelism product: type to go to a page, or ask about the space in your own words ("which pages are still drafts?", "add an FAQ block to the pricing page"). It reads straight away and asks you to confirm every change before it is made, with your own role. It never creates tokens or webhook secrets, and doesn't upload files. - The dashboard is in English or Dutch: **Nederlands** (or **English**) in the sidebar switches it for this browser. - Organizations, members, invites and your account are managed on [www.obelism.studio](https://www.obelism.studio), for every Obelism product at once. # Webhooks (https://content.obelism.studio/docs/guides/webhooks) A webhook tells your site that what the delivery API answers has changed, so it can rebuild or revalidate. Add one under **Settings → Webhooks**, or, in develop and staging, through the [agent API](/docs/api/agents) (`createWebhook`). Production webhooks are added in the dashboard only. Like an API token, it belongs to one space and one environment, so a production site only hears about production. The signing secret (`whsec_…`) is shown once, when the webhook is added. Keep it server-side: it proves a request came from Obelism Content. ## Events [#events] | Event | Sent when | `data` | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | | `entry.published` | An entry is published (first time or again) | `{ entry }` | | `block.published` | A [shared block](/docs/guides/content-model#shared-blocks) is published: once, for every live page it updated | `{ entry, entries }` | | `entry.unpublished` | An entry is taken off the delivery API | `{ entry }` | | `entry.deleted` | A published entry is deleted | `{ entry }` | | `entry.moved` | An entry's parent, slug or position changes | `{ entry, previousPath, previousPaths }` | | `asset.updated` | An asset's alt text, title or focal point changes, or its file is replaced, in each environment whose content uses it | `{ asset, entries }` | | `model.changed` | Content types are created, changed or deleted | `{ contentTypes: [{ apiId, action }] }` | | `locales.changed` | The space's locales change (every environment) | `{ defaultLocale, locales }` | | `entry.review_requested` | Someone asks for review in a [reviewed environment](/docs/guides/publishing#review-before-publishing) | `{ entry, revision, note }` | | `entry.review_closed` | A review is approved (published) or sent back | `{ entry, outcome, revision, reason? }` | | `product.updated` | A product of the space's [Shopify store](/docs/guides/shopify) is created or changed (price, images, title, variants), or a variant goes in or out of stock | `{ product, collections, entries, tags }` | | `product.deleted` | A product is deleted in the store | `{ product, collections, entries, tags }` | | `collection.updated` | A collection is created or changed, including products added or removed by hand | `{ collection, entries, tags }` | | `collection.deleted` | A collection is deleted in the store | `{ collection, entries, tags }` | | `webhook.test` | **Send test** is pressed in Settings | `{}` | A webhook with no events selected receives all of them. Select **content types** to hear only about entries of those types: entry events, and `asset.updated` and `model.changed` when they involve one of them. Events about no content type (`locales.changed`, `webhook.test`) always go out. `entry` is `{ id, contentType, name, path, paths }`: enough to know what to revalidate, never the content itself; fetch that from the delivery API. `path` is the default locale's and `paths` the path in every locale (`{ [locale]: path }`, see [paths per locale](/docs/api/delivery#paths-per-locale)); `path` is `null` and `paths` `{}` for data entries. When an entry moves, everything below `previousPath` (and, per locale, `previousPaths`) moved with it. `asset.updated` names the asset (`{ id, filename }`) and up to 100 entries that use it, drafts included. A shared block's publish is one `block.published` event, not an `entry.published` per page: `entry` is the block and `entries` the live pages it updated. Pages left on the version they show now are absent. A webhook limited to content types hears it when the block's own type is selected; it names the pages in `entries` so you can rebuild each of them. ### Shopify changes [#shopify-changes] With a Shopify store connected, the store's changes reach your site through the same webhooks, so it can revalidate what it cached of a product or collection: its prices, stock, images and title, the pages that show it, and your feeds. They go to every webhook that selects them, also one limited to some content types, and to every environment. `product` and `collection` are `{ id, handle }` (the GID and the handle). `collections` are the collections the product is in, whose pages and feeds list it. `entries` are the published pages whose content names the item or those collections (its product page, collection pages, grids, links), with their `paths`, up to 100. `tags` are `shopify:product:<number>` and `shopify:collection:<number>`: if your site tags what it fetches from Shopify with them, revalidate by tag and pages Content doesn't know of (a feed, a search) follow too. A deleted product has no `collections` and its `handle` is `null`. ```ts export const POST = createWebhookHandler({ secret: process.env.CONTENT_WEBHOOK_SECRET!, onEvent: (event) => { if (event.type === 'product.updated' || event.type === 'product.deleted') { for (const tag of event.data.tags) revalidateTag(tag) for (const entry of event.data.entries) for (const path of Object.values(entry.paths)) revalidatePath(`/${path}`) } }, }) ``` ## Requests [#requests] Each event is one `POST` with a JSON body: ```json { "type": "entry.published", "timestamp": "2026-10-13T19:30:00.000Z", "space": "space_…", "environment": "production", "data": { "entry": { "id": "entry_…", "contentType": "page", "name": "Hello", "path": "blog/hello", "paths": { "en": "blog/hello", "nl": "blog/hallo" } } } } ``` Requests follow the [Standard Webhooks](https://www.standardwebhooks.com/) spec, so its libraries verify them: | Header | Value | | ------------------- | ----------------------------------------------------------- | | `webhook-id` | The delivery's id; the same on every retry of that delivery | | `webhook-timestamp` | Seconds since the epoch, when this attempt was sent | | `webhook-signature` | `v1,` + base64 HMAC-SHA256 of `{id}.{timestamp}.{body}` | The HMAC key is the base64-decoded part of the secret after `whsec_`. ```ts title="app/api/revalidate/route.ts" import { revalidateTag } from 'next/cache' import { Webhook } from 'standardwebhooks' const webhook = new Webhook(process.env.CONTENT_WEBHOOK_SECRET!) export async function POST(request: Request) { const body = await request.text() // Throws when the signature or timestamp is wrong. webhook.verify(body, Object.fromEntries(request.headers)) revalidateTag('content') return new Response(null, { status: 204 }) } ``` Reject requests whose timestamp is more than a few minutes old, which the libraries do, and use `webhook-id` to ignore a delivery you already handled. ### With the SDK [#with-the-sdk] With `@obelism/content` 0.6.0 or later, `createWebhookHandler` verifies the request and hands you the event, typed per `type`: ```ts title="app/api/revalidate/route.ts" import { createWebhookHandler } from '@obelism/content/webhooks' import { revalidatePath, revalidateTag } from 'next/cache' export const POST = createWebhookHandler({ secret: process.env.CONTENT_WEBHOOK_SECRET!, onEvent: (event, { id }) => { // `id` is the webhook-id: the same on every retry of a delivery. if (event.type === 'entry.published' && event.data.entry.path) { revalidatePath(`/${event.data.entry.path}`) } else { revalidateTag('content') } }, }) ``` It answers `401` to a bad signature or a timestamp more than 5 minutes off (`tolerance`, in seconds), `204` once `onEvent` is done, and `500` when it throws, so the delivery is retried. `verifyWebhook(request, secret)` only verifies and returns the event; see [the JavaScript SDK](/docs/sdk/javascript#webhooks). **On a host without tag revalidation.** Oxygen (Shopify) has no tag cache, can't purge the live deployment's cache, and its Cache API is per data centre: a webhook there can warm a page or clear one key where it lands, not everywhere. Content already purges its own edge cache when something is published, so a short TTL on the site covers most of it ([Shopify Oxygen](/docs/getting-started/oxygen#4-cache-with-a-ttl-not-with-tags)). ## Delivery and retries [#delivery-and-retries] Answer with any `2xx` within 10 seconds. Anything else, a redirect included, counts as a failure and is retried after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 3 hours and 6 hours: eight attempts over about 11 hours. After that the delivery is marked failed. Order isn't guaranteed; use `timestamp` or re-read the delivery API when it matters. **Edit** changes a webhook's name, URL, events and content types. **Pause** stops new events being queued for it until you **Resume**; deliveries already queued still go out, and **Send test** still works. **New secret** replaces the signing secret, shown once; the old one stops working at once, so update your site's secret right after. **Settings → Webhooks** also lists the latest deliveries with their status, attempts and last error, and can send a finished delivery again (same `webhook-id`). Deliveries are kept for 30 days. The agent API has the same list (`GET …/webhooks/deliveries`), test (`POST …/webhooks/{id}/test`) and redelivery (`POST …/webhooks/deliveries/{id}/redeliver`), so an agent that sets up a webhook can check it gets a 2xx. Outside production, agents can also edit, pause and rotate webhooks (`PATCH …/webhooks/{id}`, `POST …/webhooks/{id}/rotate-secret`). URLs must use `https`. # Obelism Content Docs (https://content.obelism.studio/docs) Obelism Content is a headless CMS. Each organization models its own content per space, edits it in the dashboard, on its own site or through an agent, and its sites read it from the delivery API. These pages are for the people building those sites. ## What can you do with Content? [#what-can-you-do-with-content] ### Model your own content [#model-your-own-content] Page types for anything with a URL, data types for everything else, and block types for the parts pages are built from. Every space has its own [content model](/docs/guides/content-model), and you can keep it in your repository, reviewed in pull requests like your code. ### Edit on the page [#edit-on-the-page] Editors see every page of your site on the [canvas](/docs/guides/canvas) and click a block to change it, or sign in on [the site itself](/docs/guides/site-editing) and change it by [prompt](/docs/guides/prompt-editing): "make the hero heading shorter". ### Read it from any stack [#read-it-from-any-stack] The [delivery API](/docs/api/delivery) answers JSON for a path, an id or a content type, in one locale or all of them, with references and assets inlined. The [SDK](/docs/sdk/javascript) wraps it for JavaScript and React. ### Develop, staging and production [#develop-staging-and-production] Every space has [three environments](/docs/guides/environments), each with its own model and content. Build in develop, check on staging, then promote to production. ### A/B tests in your content [#ab-tests-in-your-content] Any blocks field takes the built-in [improve block](/docs/guides/ab-tests): different blocks per variant of an Obelism Improve test or flag. ### Shopify products and collections [#shopify-products-and-collections] Connect a space's [Shopify store](/docs/guides/shopify) and editors pick its products and collections in content, link to them, and give the ones that need it a page of their own. Your site reads live prices and stock from Shopify. ### Work as a team [#work-as-a-team] Editors [comment](/docs/guides/comments) on entries and see who else has one open. Production can be [reviewed](/docs/guides/publishing#review-before-publishing), so an admin approves what goes live, and anything can be [scheduled](/docs/guides/publishing#scheduling). People get a [role](/docs/guides/roles) per space, and [Activity](/docs/guides/activity) shows who changed what. ### Every language [#every-language] A space publishes in as many [locales](/docs/guides/locales) as it needs, with fallbacks, URLs per language, and AI translation for one entry or the whole space. ### From a design or an agent [#from-a-design-or-an-agent] Turn a [Claude Design](/docs/guides/claude-design) page into a draft built from your own blocks, or let [an agent](/docs/getting-started/agents) model, write and publish content through MCP. ## How it works [#how-it-works] 1. **Create a space** for the site, and set its [locales](/docs/guides/locales). 2. **Model the content** under **Content model**, or keep the model [in Git](/docs/guides/model-in-git). 3. **Write content** in the [dashboard](/docs/guides/entries), on the [canvas](/docs/guides/canvas), or through an [agent](/docs/getting-started/agents). 4. **Read it from the site** with a [delivery token](/docs/guides/api-tokens): `GET /api/delivery/v1/pages?path=…`. 5. **Rebuild on publish** with a [webhook](/docs/guides/webhooks). 6. **Promote** content and the model from develop to production. ## Get started [#get-started] <Cards> <Card title="Next.js" href="/docs/getting-started/nextjs" description="Start a site's repository on a space and render its pages" /> <Card title="Plain HTML" href="/docs/getting-started/html" description="Read content with fetch, and make the page editable with one script tag" /> <Card title="Connect an agent" href="/docs/getting-started/agents" description="Model and write content from Claude Code, Codex, Cursor and others" /> <Card title="Delivery API" href="/docs/api/delivery" description="Every endpoint and parameter a site reads content with" /> </Cards> # CLI (https://content.obelism.studio/docs/sdk/cli) The CLI ships with `@obelism/content` and needs a **management token** of the environment it works on (Settings → API tokens, kind Management) in `CONTENT_MANAGEMENT_TOKEN`; only `types --file` works without one. ## init [#init] Start a site's repository on a space, in its root: ```sh export CONTENT_MANAGEMENT_TOKEN=cms_m_… npx @obelism/content init ``` It: 1. writes the model file, `content/content-model.json`; 2. writes an example of what the delivery API answers for every content type to `content/examples/`, with the [improve block](/docs/guides/ab-tests) while your organization has Improve on; 3. installs `@obelism/content` with your package manager; 4. adds the token variables (`CONTENT_DELIVERY_TOKEN`, `CONTENT_PREVIEW_TOKEN`, `CONTENT_MANAGEMENT_TOKEN`) to `.env.example`, empty. | Option | | | ---------------- | ------------------------------------------------------------- | | `--dir <path>` | Where the files go (default `content`) | | `--url <origin>` | The CMS (default `$CONTENT_URL`, else content.obelism.studio) | | `--skip-install` | Don't install the SDK | Agents get the same steps from the `getProjectSetup` MCP tool, and run `init` rather than writing the files themselves. ## model [#model] Keep the model file in Git in step with an environment ([the model in Git](/docs/guides/model-in-git)): ```sh npx @obelism/content model pull # write content/content-model.json npx @obelism/content model plan # what applying would change, and what stands in the way npx @obelism/content model apply # apply it, all or nothing ``` | Option | | | ---------------- | ---------------------------------------------------------------- | | `--file <path>` | The model file (default `content/content-model.json`) | | `--url <origin>` | The CMS (default `$CONTENT_URL`, else content.obelism.studio) | | `--prune` | `plan` and `apply` also delete content types the file leaves out | `plan` and `apply` exit with 1 when the file has problems, listing each one; `apply` then changes nothing. In GitHub Actions they also write the plan to the job summary. ## types [#types] Since `@obelism/content` 0.6.0. Write a TypeScript type per content type to `content/content-types.ts`: ```sh npx @obelism/content types # from the environment's model npx @obelism/content types --file content/content-model.json # from the model file, no token needed ``` Page and data types become `Entry<{ …fields }, 'apiId'>`, block types `Block<{ …fields }, 'apiId'>`, named after their API id (`blogPost` → `BlogPost`), with `ContentEntry` and `ContentBlock` as the unions of them. Each field has the type the delivery API answers with its defaults (one locale, rich text as a document), or `null` when it is empty: | Field | Type | | ------------------------ | -------------------------------------------------------------------- | | text, textarea, datetime | `string` | | number, boolean | `number`, `boolean` | | select | its options' values (a list when multiple) | | asset | `Asset` (a list when multiple) | | reference | the allowed entry types or their `EntryStub`s (a list when multiple) | | link | `Link` | | blocks | a list of the allowed block types and the improve block | | rich text | `RichTextDocument` | | JSON | `unknown` | A localized field is typed as one locale's value. Pass the types to the client, e.g. `client.getPage<BlogPost>('blog/hello')`, and run `types` again when the model changes. | Option | | | ---------------- | ------------------------------------------------------------- | | `--dir <path>` | Where `content-types.ts` goes (default `content`) | | `--file <path>` | Read this model file instead of the environment's model | | `--url <origin>` | The CMS (default `$CONTENT_URL`, else content.obelism.studio) | # JavaScript SDK (https://content.obelism.studio/docs/sdk/javascript) ```sh npm i @obelism/content ``` `@obelism/content` runs anywhere with `fetch`: Node, Bun, Deno, Workers and the browser. Its source is `packages/content` in [Obelism/sdk](https://github.com/Obelism/sdk/tree/main/packages/content). | Import | Has | | --------------------------- | ----------------------------------------------------------------------------------- | | `@obelism/content` | `ContentClient`, `readPreviewParams`, `editable`, rich text, images, types | | `@obelism/content/bridge` | `getContentBridge`, `mountPromptBox` | | `@obelism/content/site` | `createSiteEditing`, `readSiteToken` | | `@obelism/content/prompt` | `createPromptHandler`, `verifyPromptRequest` | | `@obelism/content/webhooks` | `createWebhookHandler`, `verifyWebhook`, the `WebhookEvent` types (0.6.0 and later) | | `npx @obelism/content` | [the CLI](/docs/sdk/cli) | ## ContentClient [#contentclient] Reads one space environment through the [delivery API](/docs/api/delivery). The token decides the space, the environment and whether drafts are visible: a delivery token (`cms_d_…`) sees published content, a preview token (`cms_p_…`) drafts too. Keep preview tokens server-side. ```ts import { ContentClient } from '@obelism/content' const content = new ContentClient({ token: process.env.CONTENT_DELIVERY_TOKEN!, // url: 'https://content.obelism.studio', // locale: 'nl', depth: 1, richText: 'json', timeout: 10_000, // init: { next: { revalidate: 60 } }, // passed to every fetch }) await content.getPage('blog/hello') // Entry | null await content.findPage('blog/old') // { entry } | { redirect: { path } } | null await content.getAllRedirects() // every redirect, per locale (async iterator) await content.getEntry('entry_…') // Entry | null await content.getEntries({ contentType: 'post', limit: 20, skip: 0 }) // { items, total, limit, skip } for await (const post of content.getAllEntries({ contentType: 'post' })) { // every entry, fetched 100 at a time } await content.getFirstEntry({ contentType: 'navigation', fields: { key: 'main' }, }) // Entry | null await content.getContentModel() // the model file ``` | Option | Default | | | ------------- | -------------------------------- | ------------------------------------------------------------------------------- | | `token` | | A delivery, preview or site token, or a [token source](#without-a-stored-token) | | `space` | | Your space: a token for another one is refused | | `environment` | | Your environment: a token for another one is refused | | `drafts` | `false` | Ask for drafts, with a host token | | `url` | `https://content.obelism.studio` | | | `locale` | the space's default | A locale code, or `*` for every locale | | `depth` | `1` | Levels of references to inline, 0–5 | | `richText` | `'json'` | `'html'` for rendered HTML strings | | `timeout` | `10000` | Milliseconds before a request is aborted | | `init` | | Passed to every `fetch` | | `fetch` | global `fetch` | Your own implementation | Each method takes `{ locale, depth, richText, drafts, init }` to override the defaults; `getEntries` and `getAllEntries` also `contentType`, `parentPath`, `fields`, `order`, `limit` (1–100) and `skip`. Missing pages and entries are `null`; any other failure throws a `ContentApiError` with `status` and the API's `message`. ### Without a stored token [#without-a-stored-token] On Vercel, or in GitHub Actions, pass a token source instead of a token: the client sends the identity token the host gives the deployment, so there is nothing to store or rotate. Connect the deployment first, under **Connected deployments** on the space's page on [www.obelism.studio](https://www.obelism.studio) ([how](/docs/guides/api-tokens#without-a-stored-token)). ```ts import { ContentClient } from '@obelism/content' import { vercelOidc } from '@obelism/content/oidc' // needs @vercel/oidc installed const content = new ContentClient({ token: vercelOidc(), // or githubOidc() in a workflow with `permissions: id-token: write` space: process.env.CONTENT_SPACE_ID, environment: process.env.CONTENT_ENVIRONMENT, }) const draft = await content.getPage(path, { drafts: true }) // a preview route ``` A host token reads published content unless the client or the request sets `drafts: true`, which needs a rule that reads drafts. `token` takes any function that gives a token per request. Locally, `vercelOidc()` gets a token after `vercel link` and `vercel login`, for the connection's `development` rule: see [local development](/docs/guides/api-tokens#local-development). ### Paths per locale [#paths-per-locale] Since `@obelism/content` 0.8.0. A page or folder can have a slug per locale (see [Locales](/docs/guides/locales#urls-per-locale)). `getPage(path, { locale })` looks the path up in that locale's paths, `entry.path` is the path in that locale and `entry.paths` the path in every locale, for `hreflang` links and language switchers: ```ts import { alternateLinks } from '@obelism/content' const page = await content.getPage(slug.join('/'), { locale }) // [{ hreflang: 'en', href: '…' }, { hreflang: 'nl', href: '…' }, { hreflang: 'x-default', href: '…' }] const alternates = page ? alternateLinks(page, { url: (path, code) => `https://example.com/${code}/${path}`, defaultLocale: 'en', }) : [] ``` `url` turns a path and a locale into an absolute URL, however your site lays them out; `defaultLocale` adds an `x-default` link (0.9.0 and later). The locale is never part of a path; your routes decide where it goes. A page that moved keeps its old path as a redirect in each locale. `findPage` answers it, so the route can send a `301`, and `getRedirects({ locale, limit, skip })` / `getAllRedirects({ locale })` list them (`{ locale, from, to }`) for redirect rules built ahead of time: ```ts const found = await content.findPage(path, { locale }) if (found?.redirect) return Response.redirect(`${origin}/${locale}/${found.redirect.path}`, 301) if (!found) return notFound() ``` ### Sitemaps [#sitemaps] `sitemapXml(entries, { url, defaultLocale, exclude? })` builds a `sitemap.xml`: one `<url>` per page and locale, each listing the page in every locale (`xhtml:link`, the hreflang alternates Google reads), with `lastmod` from `updatedAt`. Data entries (no paths) are skipped, and so are pages with the SEO template's **Hide from search engines** (`noIndex`) on, unless you pass your own `exclude`. Fetch the pages with a delivery token, so drafts stay out: ```ts import { sitemapXml } from '@obelism/content' export const GET = async () => { const pages = [] for await (const page of content.getAllEntries({ contentType: 'page' })) pages.push(page) return new Response( sitemapXml(pages, { url: (path, locale) => `https://example.com/${locale}/${path}`, defaultLocale: 'en', }), { headers: { 'Content-Type': 'application/xml' } }, ) } ``` ### Links [#links] A [link field](/docs/guides/content-model#links) arrives as `Link`: the page it goes to (`entry`: `id`, `title`, `slug`, `path`) or its `url`, plus `query` and `hash`, or `null` when the page isn't published. Where a page's path lives on your site is your routing, so `linkHref(link, { url })` takes it as `url` (default `/<path>`) and adds the query and hash (0.17.0 and later): ```tsx import { linkHref } from '@obelism/content' ;<a href={ linkHref(block.link, { url: (path) => `/${locale}/${path}` }) ?? undefined } > {block.label} </a> ``` A link to a [Shopify product](/docs/guides/shopify#links-to-products) is `type: "product"`, with the `product` (`{ id, handle }`) and its product page as `entry` (or `null`); `linkHref` puts it at your product route, `product` (default `/products/<handle>`; 0.18.0 and later): ```ts linkHref(block.link, { product: ({ handle }, page) => `/${locale}/products/${handle}`, }) ``` ### Filtering and ordering [#filtering-and-ordering] `fields` filters on `contentType`'s fields, keyed by apiId with an optional operator; `order` sorts. They are the delivery API's [filters and order](/docs/api/delivery#filtering-and-ordering): ```ts // The 10 newest news posts from this year await content.getEntries({ contentType: 'post', fields: { category: 'news', 'date[gte]': '2026-01-01' }, order: '-fields.date', limit: 10, }) // A list means one of them (`[in]`) await content.getEntries({ contentType: 'post', fields: { tags: ['design', 'code'] }, }) ``` Operators: `ne`, `in`, `gt`, `gte`, `lt`, `lte` (number and date fields) and `exists` (`true` or `false`). `getFirstEntry(query)` answers the first match or `null`: a settings or navigation entry, found without its id. Type your content with generics: ```ts import type { Block, Entry } from '@obelism/content' type Hero = Block<{ heading: string | null }, 'hero'> type Page = Entry<{ title: string | null; body: Hero[] | null }, 'page'> const page = await content.getPage<Page>('home') ``` The types `npx @obelism/content init` writes to `content/examples/` show each content type's shape. ### Shopify [#shopify] A [Shopify field](/docs/guides/shopify) arrives as `ShopifyItem` (`{ id, handle }`, a list when `multiple`). `fetchShopifyCollection(item, options)` reads the collection with its first `first` products (default 12, at most 250) from Shopify's Storefront API, and `fetchShopifyProducts(items, options)` a product field's products, in the field's order (0.17.0 and later): ```ts import { fetchShopifyCollection } from '@obelism/content' const collection = await fetchShopifyCollection(block.collection, { shop: process.env.SHOPIFY_STORE_DOMAIN!, storefrontToken: process.env.SHOPIFY_STOREFRONT_TOKEN!, first: 12, country: 'NL', // optional: the market's prices and currency language: 'NL', // optional: translated titles init: { next: { revalidate: 60 } }, // optional: passed to fetch }) ``` Each product has `id`, `handle`, `title`, `availableForSale`, `featuredImage` (`url`, `altText`, `width`, `height`) and `priceRange.minVariantPrice` (`amount`, `currencyCode`). A collection or product the store no longer has (or doesn't sell through the token's channel) is `null`, or left out of the list. Shopify's errors throw `ShopifyStorefrontError`. The store's domain and token are in Settings → Copy as .env. ## Preview [#preview] The canvas loads your site with `?cms_preview=1&cms_entry=…&cms_environment=…&cms_token=…`. `cms_token` is a token for the person editing that reads drafts for 8 hours: read the page with it. ```ts import { ContentClient } from '@obelism/content' const token = new URL(request.url).searchParams.get('cms_token') const client = token ? new ContentClient({ token, space: process.env.CONTENT_SPACE_ID, environment: process.env.CONTENT_ENVIRONMENT, init: { cache: 'no-store' }, }) : deliveryClient ``` Pass your site's `space` and `environment`: anyone with a space of their own can make a `cms_token` for it, and the delivery API only refuses one for another space when the request names yours. A made-up, expired or other space's token throws a `ContentApiError` with status `401`; fall back to `deliveryClient` then. `readPreviewParams` takes a `URL`, `URLSearchParams`, a string or a framework's search params object, and answers `{ entryId, environment }` on a request from the canvas, else `null`. Don't choose a preview token of your own by it: anyone can add `cms_preview=1` to a URL. `editable(entryOrBlock)` returns the attribute that lets editors select it: `data-cms-uid` for a block, `data-cms-entry` for an entry. `editableProduct(product)` marks the Shopify product a product route shows (`{ id, handle }`), so [editing on the site](/docs/guides/site-editing) can add its product page (0.18.0 and later). `editableCollection(collection)` does the same for a collection route and its collection page (0.20.0 and later); `collectionPath` on the bridge, like `productPath`, says where collection pages are on your site. ## Bridge [#bridge] ```ts import { getContentBridge } from '@obelism/content/bridge' const bridge = getContentBridge() bridge.on('update', ({ entry }) => render(entry)) // unsaved edits, as typed ``` | Option | | | ------------- | ----------------------------------------------------------------------------------------------------------------- | | `url` | The CMS, when it isn't content.obelism.studio | | `prompt` | `false` to leave the prompt box out, or `{ labels }` | | `pagePath` | `(path, locale)`: maps a CMS path in the page's locale to the site's URL for New page (default `/` + the path) | | `productPath` | `(handle, locale)`: the site's URL of a product page, by its product's handle (default `/products/` + the handle) | Events: `update` (`{ entry }`), `saved`, `published`, and `change` (`{ features, prompting, canUndo, selectedUid }`) for your own prompt UI. Methods: `prompt(text)`, `undo()`, `createPage(path, prompt)`, `createProductPage()` (for `bridge.product`, the product the page marks). Without an `update` listener the page reloads after each save. See [the canvas](/docs/guides/canvas) and [prompt editing](/docs/guides/prompt-editing). `mountPromptBox(document, bridge, { labels })` mounts the prompt box yourself when the bridge was started with `prompt: false`, and returns a function that removes it. ## Editing on the site [#editing-on-the-site] `createSiteEditing({ spaceId, environment, onSignIn, onSignOut })` returns a handler for everything under `/cms` (login, callback, logout and the bridge's API calls); `readSiteToken(cookies)` reads the signed-in editor's token. Other options: `url`, `basePath` (default `/cms`), `siteUrl` (the public origin when behind a proxy), `timeout`. See [editing on your site](/docs/guides/site-editing). ## Your own prompt model [#your-own-prompt-model] `createPromptHandler({ secret, generate })` answers the space's prompt endpoint: it verifies the signed request and returns what `generate` gives. `verifyPromptRequest` does only the verification. See [prompt editing](/docs/guides/prompt-editing#your-own-model). ## Webhooks [#webhooks] Since `@obelism/content` 0.6.0. `createWebhookHandler({ secret, onEvent })` answers a [webhook](/docs/guides/webhooks)'s URL: it verifies the signature and timestamp (401 when either is wrong), calls `onEvent(event, { id })` with the typed event and answers 204, or 500 when `onEvent` throws so the delivery is retried. `id` is the delivery's `webhook-id`, the same on every retry. `verifyWebhook(request, secret, { tolerance? })` does only the verification and returns the event, or throws `WebhookVerificationError`; it also takes `{ body, headers }` for frameworks without a fetch `Request`. `tolerance` is in seconds (default 5 minutes). `WebhookEvent` is a union on `type`, so `event.data` is typed per event: `entry.published`, `entry.unpublished`, `entry.deleted`, `entry.moved`, `asset.updated`, `model.changed`, `locales.changed` and `webhook.test`. Entries carry `paths` (every locale's path) and `entry.moved` carries `previousPaths` (0.8.0 and later). ## Helpers [#helpers] - `richTextToHtml(doc, { renderEntry?, renderAsset? })`: [rich text](/docs/api/rich-text) to safe HTML. Assets default to an image in a `<figure>`, or a link; entries to nothing. - `richTextToPlainText(doc)` - `imageUrl(asset, { w, h, fit, format, q })` and `imageSrcSet(asset, [480, 960], transform?)`: see [images](/docs/api/images). - `alternateLinks(entry, { url, defaultLocale })` and `sitemapXml(entries, { url, defaultLocale })`: see [sitemaps](#sitemaps). - `linkHref(link, { url?, product? })`: the address of a link field, see [links](#links). - `isImproveBlock(block)` and `pickImproveVariant(block, option, fallback?)`: see [A/B tests and flags](/docs/guides/ab-tests). - `fetchShopifyCollection(item, options)` and `fetchShopifyProducts(items, options)`: see [Shopify](#shopify). # React SDK (https://content.obelism.studio/docs/sdk/react) ```sh npm i @obelism/content @obelism/content-react ``` `@obelism/content-react` re-exports `editable` and the types of `@obelism/content`. Everything is a client component except `RichText`. ## Live preview [#live-preview] ```tsx title="app/layout.tsx" import { ContentBridge } from '@obelism/content-react' export default function RootLayout({ children }) { return ( <html lang="en"> <body> {/* Starts the bridge, so blocks are selectable on every page. */} <ContentBridge /> {children} </body> </html> ) } ``` ```tsx title="components/Page.tsx" 'use client' import { editable, useLiveEntry } from '@obelism/content-react' export const Page = ({ entry }) => { // The editor's unsaved version while editing; `entry` otherwise. const { fields } = useLiveEntry(entry) return ( <main {...editable(entry)}> {fields.body.map((block) => ( <section key={block._uid} {...editable(block)}> … </section> ))} </main> ) } ``` ## Reference [#reference] - `<ContentBridge url? prompt? pagePath? />`: starts the bridge. Pass `url` only when the CMS isn't content.obelism.studio; `prompt={false}` leaves the prompt box out; `pagePath(path, locale)` maps a CMS path in the page's locale to the site's URL for pages made with New page (default `/` + the path). - `useLiveEntry(entry, { url? })`: `entry`, or the editor's live version of it. - `useContentBridge()`: `{ isEditing, entryId, environment }`, after hydration. - `useContentBridgeEvent('saved' | 'published' | 'update', listener)`: e.g. `router.refresh()` on publish. - `useContentPrompt()`: `{ available, prompting, canUndo, selectedUid, prompt, createPage, undo, select }`, for your own prompt UI with `<ContentBridge prompt={false} />`. ## Rich text [#rich-text] ```tsx import { RichText } from '@obelism/content-react' export const Body = ({ entry }) => ( <RichText document={entry.fields.body} embeddedEntry={({ entry }) => <Card entry={entry} />} nodes={{ heading: ({ node, children }) => <h2>{children}</h2> }} marks={{ link: ({ mark, children }) => ( <Link href={mark.attrs.href}>{children}</Link> ), }} /> ) ``` It isn't a client component, so it works in server components too. Links are only rendered for safe URLs (`http(s):`, `mailto:`, `tel:`, `#` and paths). See [rich text](/docs/api/rich-text) for the nodes and marks. # Terminology (https://content.obelism.studio/docs/terminology) ## Where content lives [#where-content-lives] **Organization**: the customer. Owns spaces and the people who work in them, with one [role](/docs/guides/roles) each. Shared by every Obelism product and managed on www.obelism.studio. **Space**: one site or project of an organization, with its own content model, content, assets, locales and API tokens; the same space in every Obelism product. See [spaces and settings](/docs/guides/spaces). An organization with a marketing site and a help centre has two spaces. Every organization has at least one: a new one starts with a space called Default, in English. **Environment**: `develop`, `staging` or `production`, which every space has. Each holds its own copy of the content model and the content. Moving them from one environment to the next is **promotion**. See [environments](/docs/guides/environments). **Shopify store**: the Shopify store connected to a space, the same in every environment. Its collections and products are picked in [Shopify fields](/docs/guides/shopify). A space has at most one. ## The content model [#the-content-model] **Content model**: the content types of one space environment, defined by you. **Content type**: a schema identified by its **API id** (`blogPost`), the same in every environment. Its kind is **page** (entries with a URL), **data** (entries without one) or **block** (parts of a page, inside a blocks field; a block type can also have entries of its own, **shared blocks**). **Field**: one named, typed value on a content type. A **localized** field holds one value per locale. **Model file**: the content model as one JSON file, kept in a repository and synced by **plan** (what would change, and what stands in the way) and **apply** (make it so, all or nothing). See [the model in Git](/docs/guides/model-in-git). ## Content [#content] **Entry**: an instance of a page, data or block content type. Keeps the same id in every environment. A **folder** is an entry without a content type that groups entries in the tree. **Block**: an instance of a block content type inside an entry, with a `_uid` that is unique within the entry. A **shared block** is a block type's entry of its own, reused across pages; a page's blocks field references it with `_type: "_shared"`. The **improve block** (`_improve`) is the built-in block with different blocks per variant of an A/B test or flag. **Draft and published**: every entry has a draft, which is all editing changes, and, once published, a published copy, which is all the delivery API serves. An entry has **unpublished changes** (status **Changed**) while its draft is ahead. Saving checks value shapes; publishing also enforces required fields. See [publishing](/docs/guides/publishing). **Version**: every save and publish of an entry, kept in its **history** (the newest 100) and restorable as the draft. **Review**: in an environment whose publishing is **reviewed**, editors and developers **ask for review** and an admin approves (publishes) or sends it back. See [review before publishing](/docs/guides/publishing#review-before-publishing). **Schedule**: a set time at which an entry publishes or unpublishes, as the person who scheduled it. **Trash**: where deleted entries stay for 30 days, restorable as drafts. **Redirect**: the old path of a page that moved, still pointing at it. See [redirects](/docs/guides/redirects). **Slug and path**: a page's slug is its own URL segment; its path is the slugs of its ancestors and itself joined with `/` (`blog/hello`). Paths are unique within an environment. A page has a slug, and so a path, per locale. **Locale**: a language a space publishes in. The **default locale** must be filled in before publishing, and every **fallback** chain ends there. See [locales](/docs/guides/locales). **Asset**: an uploaded file, belonging to a space and shared by its environments, with alt text and a title per locale and a **focal point** that crops keep. A **private** asset is only served with a signed URL. See [assets](/docs/guides/assets). **Link**: a field value that goes to a page of the space, any address or a Shopify product, with an optional query and anchor. See [links](/docs/guides/content-model#links). **Product page** and **collection page**: a page about one product or collection of the space's Shopify store, for what the store doesn't hold. See [Shopify](/docs/guides/shopify#product-pages). ## Delivery [#delivery] **Delivery API**: read-only access to one space environment's content, for your site, with a **delivery token** (published content) or **preview token** (drafts too). See [API tokens](/docs/guides/api-tokens). **Management token**: acts as the person who created it, from a repository or a script: with the **model** scope it syncs the content model, with the **content** scope it writes content. See [API tokens](/docs/guides/api-tokens). **Webhook**: a request sent to your site when what the delivery API answers changes. Each send is a **delivery**. **Promotion**: copying content, and optionally the model, from one environment to another, after a **plan** of what will change. See [promoting](/docs/guides/environments#promoting). **Canvas**: the dashboard view that shows every page of your site side by side, at the environment's **preview URL**, with the selected page's fields next to it. **Site session**: an editor signed in to edit on your own site, with a **site token**. Drafts show (draft mode), and they can prompt, save and publish. Lasts a working day. # Obelism Improve docs (https://improve.obelism.studio/docs) # Audiences (https://improve.obelism.studio/docs/api/agents/audiences) An audience is a set of targeting rules. A visitor is in the audience when every rule matches, and an audience without rules matches everyone. Audiences belong to the organization and are shared by all environments. An audience belongs to a space, like the tests and flags that use it. Paths start with `/api/agent/organizations/{organizationId}/spaces/{spaceId}/audiences`. Reading needs any role; creating, changing and deleting need `editor` or higher. ## Rules [#rules] | Rule | Values | | --------- | ---------------------------------------------------------------------------------- | | `country` | ISO 3166-1 alpha-2 code, from IP geolocation, e.g. `NL` | | `device` | `wearable`, `mobile`, `tablet`, `console`, `smarttv`, `embedded`, `desktop` | | `browser` | `chrome`, `safari`, `firefox`, `edge`, `ie`, `samsung internet`, `social`, `other` | | `os` | `mac os`, `ios`, `android`, `windows`, `unix` | | `pointer` | `coarse` (touch), `fine` (mouse) | ## Create an audience [#create-an-audience] ```bash curl -X POST https://improve.obelism.studio/api/agent/organizations/$ORG/audiences \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"Dutch mobile","rules":{"country":"NL","device":"mobile"}}' ``` ```json { "audience": { "audienceId": "aud_01H8XYZ", "name": "Dutch mobile", "slug": "dutch-mobile", "rules": { "country": "NL", "device": "mobile" }, "createdAt": "2026-10-08T12:00:00.000Z", "updatedAt": "2026-10-08T12:00:00.000Z" } } ``` `slug` is optional and defaults to the slugified name. A slug you send must already be lowercase letters, digits and `-`. ## Other operations [#other-operations] | Method | Path | Does | | -------- | ------------------------- | ----------------------------------------------------- | | `GET` | `/audiences` | List the audiences | | `GET` | `/audiences/{audienceId}` | Get one audience | | `PATCH` | `/audiences/{audienceId}` | Change the fields you send; `rules` is replaced whole | | `DELETE` | `/audiences/{audienceId}` | Delete the audience (`204`) | Tests and flags refer to an audience by its `audienceId`. Deleting an audience doesn't update the tests and flags that use it, so point them at another audience first. # Flags (https://improve.obelism.studio/docs/api/agents/flags) A flag lives in one environment of a space. Paths start with `/api/agent/organizations/{organizationId}/spaces/{spaceId}/{environment}/flags`. Reading needs any role; creating, changing and deleting need `editor` or higher. ## Create a flag [#create-a-flag] ```bash curl -X POST https://improve.obelism.studio/api/agent/organizations/$ORG/staging/flags \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "New checkout", "options": [ { "name": "Off", "split": 100 }, { "name": "On", "split": 0 } ] }' ``` ```json { "flag": { "flagId": "feat_01H8XYZ", "name": "New checkout", "slug": "new-checkout", "icon": "⛳️", "state": "draft", "audienceId": null, "options": [ { "id": 1, "name": "Off", "slug": "off", "value": "", "split": 100 }, { "id": 2, "name": "On", "slug": "on", "value": "", "split": 0 } ], "createdAt": "2026-10-08T12:00:00.000Z", "updatedAt": "2026-10-08T12:00:00.000Z" } } ``` | Field | Default | | ------------ | ----------------------------------------------------------------- | | `name` | Required | | `slug` | The slugified name. The SDK looks flags up by it | | `icon` | A random emoji | | `state` | `draft`. Only `active` flags are served | | `audienceId` | `null`, everyone. Or an [audience](/docs/api/agents/audiences) id | | `options` | Original (split 100) and Alteration (split 0) | Each option has a `name`, an optional `slug`, an optional `value` your code can read, and a `split`: its relative weight, from 0 to 100. ## Change a flag [#change-a-flag] `PATCH .../flags/{flagId}` changes the fields you send. To turn a flag on, set its state: ```bash curl -X PATCH https://improve.obelism.studio/api/agent/organizations/$ORG/staging/flags/$FLAG \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"state":"active"}' ``` `options` is always the full new list. An option with an `id` updates that option, one without an `id` is added, and options you leave out are deleted. ## Other operations [#other-operations] | Method | Path | Does | | -------- | ----------------- | ------------------------------------- | | `GET` | `/flags` | List the flags, without their options | | `GET` | `/flags/{flagId}` | Get one flag with its options | | `DELETE` | `/flags/{flagId}` | Delete the flag (`204`) | A flag change reaches SDKs within about 30 seconds, when the edge cache of the [datafile](/docs/api/datafile) expires. # Agents (https://improve.obelism.studio/docs/api/agents) The agent API does everything a person can do in the dashboard: apply for access, create an account, sign in, and then manage organizations, audiences, flags and tests and read their results. The endpoints live under `/api/agent/*`, take JSON and return JSON. Errors are [problem details](/docs/api/errors) with a stable `code` and a `hint`. Every endpoint is in the [OpenAPI spec](/openapi.json), which is the full reference. These pages walk through them: <Cards> <Card title="Organizations" href="/docs/api/agents/organizations" description="Roles, API tokens, allowed origins, members, usage" /> <Card title="Audiences" href="/docs/api/agents/audiences" description="Targeting rules for tests and flags" /> <Card title="Flags" href="/docs/api/agents/flags" description="Feature flags and their options" /> <Card title="Tests" href="/docs/api/agents/tests" description="A/B and multi-variant tests" /> <Card title="Results" href="/docs/api/agents/results" description="Test results, events and visitor analytics" /> </Cards> ## Getting started [#getting-started] 1. [Sign in](#sign-in), or [create an account](#create-an-account), for a bearer token. 2. List your [organizations](/docs/api/agents/organizations) to get an `organizationId`. Without one, [apply for access](#apply-for-access): the register code that is emailed on approval creates an organization. 3. Work in an environment of it: `develop`, `staging` or `production`. The account and organization endpoints are served by `www.obelism.studio` for every Obelism product, since one account, one token and one set of organizations work everywhere: creating an account, verifying, signing in and out, resetting the password, the profile, invites, applying for access, and creating, deleting and staffing organizations. improve answers those paths with a `308` redirect there, so a client that follows redirects can keep calling improve's host. Listing your organizations, reading one, and everything about tests, flags, audiences and results stay on `improve.obelism.studio`. ## Apply for access [#apply-for-access] Joins the waitlist, the same as the form on the homepage. The applicant gets a confirmation email, and a register code by email once the application is approved. ```bash curl -X POST https://www.obelism.studio/api/agent/apply \ -H "Content-Type: application/json" \ -d '{"email":"jane@example.com","name":"Jane","company":"Acme"}' ``` ```json { "status": "pending" } ``` ## Create an account [#create-an-account] ```bash curl -X POST https://www.obelism.studio/api/agent/auth/sign-up \ -H "Content-Type: application/json" \ -d '{"email":"jane@example.com","password":"a-long-Passw0rd!","firstName":"Jane","lastName":"Doe"}' ``` ```json { "token": "Zm9vYmFyYmF6cXV4cXV1eGNvcmdlZ3JhdWx0", "tokenType": "Bearer", "expiresAt": "2026-11-07T12:00:00.000Z", "userId": "user_01H8XYZ", "verificationRequired": true } ``` The password needs at least 8 characters, including a number and a special character. A six-digit code is emailed to the address. ## Verify the email [#verify-the-email] Send the code from the email with the token from sign-up or sign-in: ```bash curl -X POST https://www.obelism.studio/api/agent/auth/verify-email \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"code":"123456"}' ``` Codes are valid for 4 hours. Signing in with an unverified account emails a new one and returns `verificationRequired: true`. ## Sign in [#sign-in] ```bash curl -X POST https://www.obelism.studio/api/agent/auth/sign-in \ -H "Content-Type: application/json" \ -d '{"email":"jane@example.com","password":"a-long-Passw0rd!"}' ``` The response has the same shape as sign-up. The response is the same `401 invalid_credentials` whether the email is unknown or the password is wrong. With two-factor authentication on (turned on under [Security](https://www.obelism.studio/account/security) on your Obelism account), the answer is `401 two_factor_required`: send the same request with `code`, the current 6-digit code from the authenticator app. The recovery code works there too, and turns two-factor authentication off. ## Forgot your password [#forgot-your-password] Request a reset email: ```bash curl -X POST https://www.obelism.studio/api/agent/auth/forgot-password \ -H "Content-Type: application/json" \ -d '{"email":"jane@example.com"}' ``` The answer is `202` whether or not the email has an account. The email links to `/reset-password?token=…`. That token is valid for 1 hour, and a newer request replaces it. Set the new password with it: ```bash curl -X POST https://www.obelism.studio/api/agent/auth/reset-password \ -H "Content-Type: application/json" \ -d '{"token":"…","newPassword":"an-even-longer-Passw0rd!"}' ``` This ends every session of the account and returns a new token, like sign-in. With two-factor authentication on, send `code` as well, as for sign-in: the reset email alone doesn't change the password. ## Use the token [#use-the-token] Send the token on every request that needs an account: ```bash curl https://improve.obelism.studio/api/agent/me \ -H "Authorization: Bearer $TOKEN" ``` A token is valid for 30 days and is extended while it is in use. `GET /api/agent/me` returns the account and the current expiry, so it doubles as a check that a stored token still works. A `401 unauthorized` means the token is missing, invalid or expired: sign in again. The token is a full login session for the account. Store it like a password and never put it in client-side code. ## Update your profile [#update-your-profile] `PATCH /api/agent/me` on `www.obelism.studio` changes the name. Fields you leave out keep their value. The email address can't be changed, since invites to organizations are matched by it. ```bash curl -X PATCH https://www.obelism.studio/api/agent/me \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"firstName":"Jane","lastName":"Doe"}' ``` ## Change your password [#change-your-password] ```bash curl -X POST https://www.obelism.studio/api/agent/me/password \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"currentPassword":"a-long-Passw0rd!","newPassword":"an-even-longer-Passw0rd!"}' ``` This ends every session of the account, including the token you sent. The response has a new token to continue with, in the same shape as sign-in. ## Sign out [#sign-out] ```bash curl -X POST https://www.obelism.studio/api/agent/auth/sign-out \ -H "Authorization: Bearer $TOKEN" ``` The token stops working straight away. ## Rate limits [#rate-limits] | Endpoint | Limit | | ----------------- | --------------------------- | | `apply` | 3 per email per hour | | `sign-up` | 3 per email per hour | | `sign-in` | 5 per email per minute | | `forgot-password` | 3 per email per hour | | `reset-password` | 5 per reset token per 5 min | | `verify-email` | 5 per account per 15 min | Every response from these endpoints states its limit in a `RateLimit-Policy` header, such as `"sign-in";q=5;w=60`. Over the limit the response is `429 rate_limited` with `RateLimit` and a `Retry-After` header in seconds. See [Versioning](/docs/api/versioning#agent-account-endpoints). # MCP server (https://improve.obelism.studio/docs/api/agents/mcp) Improve runs a [Model Context Protocol](https://modelcontextprotocol.io) server at `https://improve.obelism.studio/mcp`. Its tools are the [agent API](/docs/api/agents): list organizations, create audiences, flags and tests, read results, and manage tokens, origins and members. A tool runs exactly like the matching API call, with the same checks and errors. ## Connect [#connect] Add the server URL to your MCP client. The client opens Improve in your browser, you sign in with your Improve account and allow the connection, and the client is connected. There is nothing to copy. The dashboard's **Agents** page has the address and the Claude Code command, what an agent may do, and the apps connected to Improve. In Claude Code: ```bash claude mcp add --transport http improve https://improve.obelism.studio/mcp ``` In Claude or other apps with connectors, add a custom connector with the same URL. Or install the Obelism plugin for Claude, which connects improve, Content and numbers at once and adds skills for the jobs that span them, like running an A/B test on a block of a Content page: ```text /plugin marketplace add Obelism/sdk /plugin install obelism@obelism ``` Or let your agent do the setup. Paste this into Claude Code, Codex, Cursor or another coding agent: ```text Fetch and follow the instructions to set me up for Obelism Improve from https://improve.obelism.studio/agent-setup.md ``` It connects this server for that agent and, if you agree, points the agent at the Improve docs. The sign-in uses OAuth 2.1 as the [MCP authorization spec](https://modelcontextprotocol.io/specification/latest/basic/authorization) describes: the server's `401` points to `/.well-known/oauth-protected-resource/mcp`, the authorization server metadata is at `/.well-known/oauth-authorization-server`, and clients register with a Client ID Metadata Document or, for older clients, Dynamic Client Registration. PKCE is required. ## Access [#access] When you allow a connection, the app gets access to every organization you belong to, as far as your role in each allows. It can ask for one of two scopes: | Scope | Tools | | --------------- | --------------------------------------------------------------------------- | | `improve:read` | Only the read-only tools: listing and reading organizations, tests, results | | `improve:write` | Every tool, including creating, changing and deleting | Access tokens last an hour and the app renews them on its own. The connection ends after 30 days without use, and always after 90 days. To disconnect an app sooner, open [Connected apps](https://www.obelism.studio/account/connected-apps) on your Obelism account, which lists the apps connected to every Obelism product. The agent API's own bearer tokens don't work on `/mcp`: it only accepts the access tokens its sign-in issues. ## Tools [#tools] There is one tool per agent API operation, named after its `operationId` in the [OpenAPI spec](/openapi.json): `listOrganizations`, `createTest`, `getTestResults` and so on. Path parameters such as `organizationId` and `environment` are tool arguments next to the body fields. Signing up, signing in and out, password changes and resets, and applying for access are not tools: they happen in the browser, and signing out or changing the password would end the session the connection belongs to. Read-only tools are marked `readOnlyHint`, and deleting or regenerating a token is marked `destructiveHint`, so clients can ask before running them. A failed call returns the API's [problem details](/docs/api/errors) as the tool result, with a `code` and a `hint`. # Organizations (https://improve.obelism.studio/docs/api/agents/organizations) Everything in Improve belongs to an organization, which has one or more [spaces](/docs/terminology#space): one site or project each. Tests and flags belong to one of a space's three environments, `develop`, `staging` and `production`; audiences and API tokens to the space. Its allowed origins are shared with every product and managed on www. The dashboard remembers which space and environment you are editing. The agent API names them in the path instead: ``` /api/agent/organizations/{organizationId}/spaces/{spaceId}/{environment}/... ``` Things of the whole space, such as audiences and API tokens, leave out the environment, and things of the organization, such as its members, the space too: ``` /api/agent/organizations/{organizationId}/spaces/{spaceId}/... /api/agent/organizations/{organizationId}/... ``` The paths from before spaces, without `/spaces/{spaceId}`, still work: they use the organization's first space. All of these need a [bearer token](/docs/api/agents#use-the-token). ## List your organizations [#list-your-organizations] ```bash curl https://improve.obelism.studio/api/agent/organizations \ -H "Authorization: Bearer $TOKEN" ``` ```json { "organizations": [ { "organizationId": "org_01H8XYZ", "name": "Acme", "slug": "acme", "plan": "pro", "role": "editor" } ] } ``` `GET /api/agent/organizations/{organizationId}` returns one organization, plus the `environments` every space has. ## List its spaces [#list-its-spaces] `GET /api/agent/organizations/{organizationId}/spaces` returns the spaces you can work in, with the `spaceId` the other paths take. `first` marks the space that paths and SDK calls naming only the organization use. ```json { "spaces": [ { "spaceId": "space_01H8XYZ", "name": "Website", "slug": "website", "first": true } ] } ``` ## Create, rename, delete and transfer spaces [#create-rename-delete-and-transfer-spaces] Spaces are shared by every Obelism product, so they are managed on `www.obelism.studio`, like the organization's page (improve redirects these requests there with a `308`). Each answers the space as `{ "space": { "spaceId", "organizationId", "name", "slug", "first" } }`. - `POST /api/agent/organizations/{organizationId}/spaces` with `{"name":"Help centre"}` creates one. Its slug follows the name, with `-2`, `-3`… when the organization already has it. Needs `developer` or `admin`. - `PATCH /api/agent/organizations/{organizationId}/spaces/{spaceId}` with `{"name":"…"}` renames it. The slug stays, so links and sites keep working. Needs `developer` or `admin` in the space. - `DELETE /api/agent/organizations/{organizationId}/spaces/{spaceId}?confirm={slug}` deletes it with everything in it, in every product: tests, flags, audiences, API tokens, events, and its content and files in Obelism Content. Needs `admin`. The last space of an organization can't be deleted. This cannot be undone. - `POST /api/agent/organizations/{organizationId}/spaces/{spaceId}/transfer` with `{"targetOrganizationId":"org_…","confirm":"{slug}"}` moves it with everything in it to another organization. API tokens, allowed origins, webhooks and the Shopify store stay connected; roles per space don't. Needs `admin` in both organizations. The first space can't be transferred, since paths naming only the organization use it. ```bash curl -X POST https://www.obelism.studio/api/agent/organizations/$ORG/spaces \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"Help centre"}' ``` ## Create an organization [#create-an-organization] Creating an organization needs the register code that is emailed when an [application for access](/docs/api/agents#apply-for-access) is approved. Your account becomes its admin. Organizations are shared by every Obelism product, so creating, deleting and staffing them happens on `www.obelism.studio` (improve redirects these requests there with a `308`). ```bash curl -X POST https://www.obelism.studio/api/agent/organizations \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"Acme","registerCode":"…"}' ``` A new organization starts with no space. Whatever works in a space, create its first one through `POST /api/agent/organizations/{organizationId}/spaces` before using it. To delete one, an admin sends `DELETE https://www.obelism.studio/api/agent/organizations/{organizationId}?confirm={slug}`. The slug is the confirmation, like typing it in the dashboard. This deletes everything in it and cannot be undone. ## API tokens and allowed origins [#api-tokens-and-allowed-origins] These decide who may fetch a space's [datafile](/docs/api/datafile) and post [analytics](/docs/api/analytics) for it: servers send an API token, browsers must come from an allowed origin. Each space has its own tokens; its allowed origins are shared with every product and managed on www. They need the `developer` or `admin` role, including to read them. `GET .../security` returns, per environment, whether it has an API token (`hasApiToken`), its allowed origins and whether it accepts any origin (`allowAnyOrigin`). Tokens are stored hashed, so they can't be read back. `PATCH .../security` replaces the allowed origins of the environments you send, and can turn `allowAnyOrigin` on or off; it is served by www, where the shared list lives, and returns the origins without the token state: ```bash curl -X PATCH https://www.obelism.studio/api/agent/organizations/$ORG/spaces/$SPACE/security \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"staging":{"allowAnyOrigin":true},"production":{"allowedOrigins":["https://shop.example.com","https://www.example.com"]}}' ``` Each origin must be exact, such as `https://shop.example.com`, with no path or trailing slash, because browsers send it that way. `allowAnyOrigin` is the dashboard's switch: handy for develop and staging, where every preview deployment has its own URL. The agent API only turns it on for those two; production's switch stays in the dashboard, behind its confirmation. API tokens belong to an environment, under `/api/agent/organizations/{organizationId}/spaces/{spaceId}/{environment}`: | Method | Path | Does | | ------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/api-tokens` | The tokens, newest first: name, last characters (`hint`), last used, expiry, revoked | | `POST` | `/api-tokens` | Create one with `{"name":"Website","expiresIn":"90d"}` (`30d`, `90d`, `1y` the default, `never`); the response has its value, once | | `POST` | `/api-tokens/{tokenId}/revoke` | Revoke one | | `POST` | `/api-token` | Revoke every active token and create one, from when there was one per environment | Several tokens can be active, so a server can switch to a new one before the old one is revoked. A new or revoked token takes effect within about a minute. Connected deployments reach Improve and Content with their host's OIDC token instead of an API token (see [API](/docs/api#without-a-stored-token)). They are served by `www.obelism.studio`, under `/api/agent/organizations/{organizationId}/spaces/{spaceId}/connections`, and need the `developer` or `admin` role: | Method | Path | What it does | | -------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `GET` | `/connections` | The connections, newest first, each with its ids, `rules` and `revision` (`listConnections`) | | `POST` | `/connections` | Save a whole connection (`saveConnection`): `name`, `issuer` (`vercel` with `ownerId` and `projectId`, or `github` with `repositoryId`) and `rules`; `connectionId` and `baseRevision` replace one, else it is created (`201`). A stale revision is `409 conflict` | | `DELETE` | `/connections/{connectionId}` | Disconnect one (`deleteConnection`) | A rule maps a host environment (`host`: a Vercel environment, a GitHub environment or full ref, or `any` for the rest) to one space environment (`environment`), with `content` (`off`, `published`, `drafts`, or `management` with `scopes`) and `improve` (`true` also lets it send numbers events): ```json { "name": "Website on Vercel", "issuer": "vercel", "ownerId": "team_AbC123", "projectId": "prj_XyZ789", "rules": [ { "host": "production", "environment": "production", "content": "published", "improve": true }, { "host": "preview", "environment": "staging", "content": "drafts", "improve": true } ] } ``` ## Members and invites [#members-and-invites] Managing members needs the `admin` role. These endpoints are on `www.obelism.studio`, where the organization's page is too. | Method | Path | Does | | -------- | -------------------------------------------- | ------------------------------------- | | `GET` | `/organizations/{id}/members` | Members, and invites not accepted yet | | `POST` | `/organizations/{id}/members` | Invite people by email | | `PATCH` | `/organizations/{id}/members/{membershipId}` | Change a role or products | | `DELETE` | `/organizations/{id}/members/{membershipId}` | Remove a member or withdraw an invite | ```bash curl -X POST https://www.obelism.studio/api/agent/organizations/$ORG/members \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"invites":[{"email":"sam@example.com","role":"editor"}]}' ``` `PATCH` takes `{ "role" }`, `{ "products" }` or both. `products` lists the products the member may use (`improve`, `content`, which includes media, and `numbers`), or is `null` for every product; in a product they are left out of, they aren't a member at all. Each person gets an email with a link to join, valid for 72 hours. Emails that are already a member or invited come back under `skipped`. A change that would leave the organization without an admin who has joined is refused. To join an organization yourself, `GET /api/agent/me/invites` (on `www.obelism.studio`) lists the organizations that invited your email, and `POST /api/agent/me/invites/accept` joins one, with either its `organizationId` or the `token` from the link in the invite email. Your email has to be verified first, since invites are matched by it. ## Usage [#usage] `GET /api/agent/organizations/{organizationId}/spaces/{spaceId}/{environment}/usage` returns this month's datafile and analytics requests against the plan. A quota covers the whole organization, so the numbers are all three environments combined whichever environment is in the path. A limit of `-1` is unlimited. Past the analytics limit, [results](/docs/api/agents/results) freeze until the next month or an upgrade. Past the datafile limit, the datafile is frozen at its last version. Plans and pricing are paused, so the limit is `-1` (unlimited) for every organization and neither freeze currently happens; they return with the plans. ## Roles [#roles] Your role decides what you can do in an organization, the same as in the dashboard: | Role | Can | | ----------- | ------------------------------------------------------- | | `reader` | View tests, flags, audiences and results | | `editor` | Also create, edit and delete tests, flags and audiences | | `developer` | Also manage API tokens and allowed origins | | `admin` | Everything, including members and deleting the org | An action your role doesn't allow answers `403 insufficient_role`. An organization you are not a member of answers `404 unknown_organization`, the same as one that doesn't exist. # Results (https://improve.obelism.studio/docs/api/agents/results) Everything here is read-only and works for any role. Paths start with `/api/agent/organizations/{organizationId}/spaces/{spaceId}/{environment}`. ## Test results [#test-results] ```bash curl https://improve.obelism.studio/api/agent/organizations/$ORG/production/tests/$TEST/results \ -H "Authorization: Bearer $TOKEN" ``` ```json { "testId": "test_01H8XYZ", "status": "ready", "usageLimitReached": false, "results": { "summary": { "dateStart": "…", "dateUpdated": "…", "totalSessions": 2000 }, "result": { "control": { "page_view": 1000, "sign_up": 100 }, "bold": { "page_view": 1000, "sign_up": 140 } }, "resultsByDate": [] }, "significance": { "confidence": 95, "control": "control", "significantVariants": ["bold"], "variants": { "bold": { "significantResult": true, "direction": "better", "percentile": 99, "zScore": 2.78, "standardErrorDifference": 0.014 } } } } ``` - `result` has the unique visitors per variant for each event of the test. The first event is the start, the last is the conversion. - `value` and `orders` appear when events carry a `value`, such as an order total. They add up every event, including repeat purchases. - `significance` compares each variant with the control, the first variant, at 95% confidence. A variant is significant when it is clearly better **or** clearly worse. `direction` (`better`, `worse` or `none`) says which, so check it before calling a variant a winner. - `status` is `pending`, and `results` is `null`, until the test has exposures. Results are cached. A cached result comes back straight away and is recalculated in the background when it is older than 5 minutes. `POST` to the same path recalculates now. It answers `202` while there is nothing to calculate yet or a calculation is already running. When the organization is over its analytics usage limit, results freeze at the last calculation: `usageLimitReached` is `true`, and a recalculation answers `402 usage_limit_reached`. Plans and pricing are paused, so `usageLimitReached` is currently always `false`; this returns with the plans. ## Events [#events] `GET .../events` lists every event and scope the environment has received, or a test refers to, with a `count` and `lastReceivedAt`. Use it to choose a test's [events](/docs/api/agents/tests). ## Visitor analytics [#visitor-analytics] `GET .../analytics?timezone=Europe/Amsterdam` returns visitor charts for the environment ([Obelism numbers](https://numbers.obelism.studio) shows the same and more in its dashboard): - `visitors`: unique visitors per time bucket over the last `24h`, `7d`, `30d` and `90d` - `popularPages`: the most viewed pages - `countries`: visitors per country `timezone` is an IANA time zone for the buckets, and defaults to `UTC`. # Tests (https://improve.obelism.studio/docs/api/agents/tests) A test lives in one environment of a space. Paths start with `/api/agent/organizations/{organizationId}/spaces/{spaceId}/{environment}/tests`. Reading needs any role; creating, changing, deleting and copying need `editor` or higher. ## Create an A/B test [#create-an-ab-test] ```bash curl -X POST https://improve.obelism.studio/api/agent/organizations/$ORG/staging/tests \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Hero copy", "options": [ { "name": "Control", "split": 50 }, { "name": "Bold", "value": "Try it free", "split": 50 } ], "events": { "start": { "event": "page_view", "scope": "/" }, "conversion": { "event": "sign_up", "scope": "hero" } } }' ``` | Field | Default | | -------------- | ----------------------------------------------------------------- | | `name` | Required | | `slug` | The slugified name. The SDK looks tests up by it | | `type` | `ab-test` | | `state` | `draft`. Only `active` tests are served | | `audienceId` | `null`, everyone. Or an [audience](/docs/api/agents/audiences) id | | `allocation` | `100`, the percentage of matching visitors in the test | | `options` | Control and Variation, 50/50 | | `defaultValue` | The first option's slug, served to visitors outside the test | | `events` | None. Set them before going live, or there are no results | `events` says what counts: `start` is the event that enters a visitor in the funnel, `conversion` is the goal, and `metrics` are optional steps in between. Each is an `{ "event", "scope" }` pair, the same names your code [tracks](/docs/api/analytics). ## Create a multi-variant test [#create-a-multi-variant-test] A multi-variant test has `variants` instead of `options`: dimensions whose values are combined. Every combination is an option, with an equal split. ```json { "name": "Buy button", "type": "multi-variant-test", "variants": [ { "name": "Colour", "values": ["red", "blue"] }, { "name": "Size", "values": ["small", "large"] } ] } ``` This serves `red-small`, `red-large`, `blue-small` and `blue-large`. Its `defaultValue` is the first combination. ## Change a test [#change-a-test] `PATCH .../tests/{testId}` changes the fields you send. To start a test: ```bash curl -X PATCH https://improve.obelism.studio/api/agent/organizations/$ORG/staging/tests/$TEST \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"state":"active"}' ``` - `options` and `variants` are the full new list. An item with an `id` updates it, one without is added, and items you leave out are deleted. - Changing `type` needs the new list, for example `variants` to switch to a multi-variant test. - `events` merges: `start`, `metrics` and `conversion` you leave out keep their value. Changing events recalculates the results. The whole body is checked before anything is saved, so an invalid field changes nothing. ## Copy tests to another environment [#copy-tests-to-another-environment] Copies tests with their state, options and events, like Sync in the dashboard. Results are not copied. ```bash curl -X POST https://improve.obelism.studio/api/agent/organizations/$ORG/staging/tests/sync \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"testIds":["test_01H8XYZ"],"targetEnvironment":"production"}' ``` ```json { "created": 1, "overwritten": 0 } ``` When a test with the same slug already exists in the target, nothing is copied and the answer is `409 slug_conflict`, with a `conflicts` list. Send `"overwrite": true` to replace them. ## Other operations [#other-operations] | Method | Path | Does | | -------- | ----------------- | -------------------------------------------- | | `GET` | `/tests` | List the tests, without options and variants | | `GET` | `/tests/{testId}` | Get one test with its options or variants | | `DELETE` | `/tests/{testId}` | Delete the test and its exposures (`204`) | # Analytics (https://improve.obelism.studio/docs/api/analytics) Endpoint that receives post messages to keep track of visitors and their progress. Used to calculate the conversion of tests. Both **events** and **exposures** are posted here. The body is discriminated by a `type` field: - `type: 'event'`: a real-world tracked action (a page view, click, purchase…). Events are **test-independent** and carry no `testId`/`testValue`; the server attributes them to a test/flag by joining against the visitor's exposures. - `type: 'exposure'`: a visitor's assignment to a test/flag variant, emitted by the SDK the first time it resolves a non-holdout value for that visitor. This is what events are joined against. ## Url [#url] `https://improve.obelism.studio/api/log` - Method: `POST` ## Type [#type] ```ts // Fields common to both bodies. type BeaconCommon = { organizationId: string // Optional: the space whose allowed origins and API tokens apply. Without // it, the organization's first space. spaceId?: string environment: 'develop' | 'staging' | 'production' visitorId: string pointer: 'coarse' | 'fine' device: | 'wearable' | 'mobile' | 'tablet' | 'console' | 'smarttv' | 'embedded' | 'desktop' browser: | 'chrome' | 'safari' | 'firefox' | 'edge' | 'ie' | 'samsung internet' | 'social' | 'other' os: 'mac os' | 'ios' | 'android' | 'windows' | 'unix' screen: 'small' | 'medium' | 'large' | 'huge' visitor: 'recurring' | 'new' } // A real-world tracked event, test-independent (no testId/testValue). type CreateEvent = BeaconCommon & { type: 'event' event: string // Required. The stable, structured second half of the event's identity — // e.g. "homepage_hero_login" — not display text. See postAnalytic. scope: string // GA4-aligned structured fields (all optional). // `value` (+ `currency`) is aggregated into revenue / average order value // per variant; `params` is stored and mirrored to the GTM dataLayer. value?: number | undefined currency?: string | undefined params?: Record<string, unknown> | undefined } // A visitor's assignment to a test/flag variant. type CreateExposure = BeaconCommon & { type: 'exposure' subjectKind: 'test' | 'flag' subjectId: string variant: string } type createAnalytics = CreateEvent | CreateExposure ``` <Callout type="warn"> This is a hard cutover: event bodies no longer carry `testId`/`testValue`, and older beacons that include them are rejected. Attribution is now derived server-side from exposures. </Callout> <Callout type="warn"> Also a hard cutover: event bodies previously carried an optional `message` field, used inconsistently (a URL path on one event, a display label on another, often empty). It's replaced by a required `scope` — a short, stable identifier for where/what the event refers to, not display text. Beacons missing `scope` are rejected with `400`. </Callout> ## Location [#location] Both events and exposures are stored with a coarse location the server derives from the request's edge geo headers: Vercel's `x-vercel-ip-country` / `x-vercel-ip-country-region`, falling back to Cloudflare's `cf-ipcountry`: - `country`: ISO 3166-1 alpha-2 (e.g. `NL`). - `region`: subdivision code. Both are **optional/nullable**: they're absent in local development and on hosts without a geo edge. The SDK never sends them, and the raw IP is **never stored**. Only these coarse values, computed at ingestion, are kept. `country` can be [targeted by audiences](/docs/api/datafile) (server-side only); `region` is captured for analytics but is **not** targetable. ## Authentication & limits [#authentication--limits] Like the datafile, requests are authenticated by CORS (a browser request's `Origin` must be in the environment's Allowed origins) or by a `token` header for server requests; otherwise `403` (see [Security](/docs/api)). The endpoint also validates and bounds the payload: - Requests larger than **8KB** are rejected with `413`. - Malformed JSON, a missing/unknown `type`, missing `organizationId`/`environment`, a value outside the enums above, a non-numeric `value`, a missing or empty `scope` on an event body, an event body that still carries `testId`/`testValue`, or an `event` name in the reserved `gtm.*` namespace returns `400`. - Passing your plan's monthly quota does **not** affect this endpoint. Events are accepted and stored exactly as before — no event is ever dropped for being over quota. What pauses is the dashboard: test results stop being recalculated until the quota clears, at which point they catch up with no gap in the underlying data. See [Rate limits & quotas](/docs/api#rate-limits--quotas). Every error response is JSON [problem details](/docs/api/errors) with a stable `code` and a `hint`, and still carries `success: false` for older clients. The client SDK's `postAnalytic` fires these requests without awaiting the response, so these statuses are not surfaced to your app. Events rejected with a `4xx` are silently discarded. # Datafile (https://improve.obelism.studio/docs/api/datafile) The datafile contains the complete configuration of an environment for one [space](/docs/terminology#space). It contains all flags and tests that are active and links the audiences required for those entries. ## Url [#url] `https://improve.obelism.studio/config/:spaceId/:environment/:status?` - Method: `GET` - `spaceId` - Unique identifier of the space; `space_XXX`. Copy it from Implementation on the [Settings page](/settings). An organization id (`org_XXX`) works too, and loads the organization's first space: that is what the SDK sends. - `environment` - environment to load; `'develop' | 'staging' | 'production'` - `status` - _(optional)_ which test state to return; `'draft' | 'active' | 'finished' | 'archived'`. When omitted it defaults to `active`, so `/config/:spaceId/:environment` returns the same as `/config/:spaceId/:environment/active`. The `status` segment lets you preview tests in another state before they go live: for example, request `draft` to validate a test you're still setting up. Production traffic should keep loading `active` (the default). A space or organization id that doesn't exist, or belongs to an Organization that was deleted, gets `404` with the code [`unknown_organization`](/docs/api/errors#unknown_organization). The SDK doesn't retry it. Path parameters that aren't valid at all (an id without the `space_` or `org_` prefix, an unknown environment or status) still get an empty datafile, `{}`, so the SDK falls back to its defaults. ## Authentication [#authentication] How a request is checked depends on whether it sends an `Origin` (see [Security](/docs/api)): - **Browser (client) requests** send an `Origin`, which must be in the environment's **Allowed origins** (on the space’s page on [www.obelism.studio](https://www.obelism.studio); an entry ending in `:*`, like develop's default `http://localhost:*`, matches any port). This is enforced via CORS: an origin that isn't allow-listed receives a `403`. - **Server requests** don't send an `Origin` and are served without a token, rate limited per IP (see below). Send the environment's API token in a `token` header to lift that limit (the edge limit still applies). A token that isn't valid isn't refused: the request is served and rate limited like one without a token. Allowed origins decide which sites can load the datafile in a browser; they don't make it secret. Anyone who knows the Organization id can fetch it without an `Origin`, so don't put anything in a test or flag that visitors shouldn't see. ## Rate limiting [#rate-limiting] The browser (Origin) path is rate-limited per IP (currently 600 requests/minute); exceeding it returns `429`. Browser responses carry `RateLimit-Policy: "browser";q=600;w=60`, and a `429` adds `RateLimit: "browser";r=0;t=60` and `Retry-After: 60` (see [Versioning](/docs/api/versioning#rate-limit-headers)). Requests with a valid `token` are exempt, so server-side rendering behind a shared egress IP is only held to the edge limit below. A `token` that isn't one of the environment's API tokens is rate limited like a request without one. Every datafile request, with or without a token and cached or not, is also limited at Cloudflare's edge to 100 per 10 seconds per IP. Past it, the address gets a `429` `rate_limited` for 10 seconds. That response comes from the edge, not the API: it is `application/json` (the same problem fields) without `RateLimit` or `Retry-After` headers, so wait 10 seconds before retrying. Responses are also served with a short CDN cache (30 seconds, then served stale for up to 60 more while it refreshes in the background, with `Vary: Origin`), which absorbs repeated identical requests before they reach the origin. Browsers don't cache it (`Cache-Control: public, max-age=0`). Saving a test, flag or audience clears the cached datafile, so the change usually shows up on the next request; in the rare case that fails, it takes up to about 90 seconds. Changes to API tokens and allowed origins take up to about two and a half minutes. ## Caching [#caching] Loading the datafile is one of the metrics on Improve that could increase costs. If you'd run a test on the server and want to post analytics from the client, this could mean two requests to the datafile per visitor. To make sure you don't rapidly chew through your usage plan you can cache this file in a few ways; ### Add a caching layer [#add-a-caching-layer] Instead of requesting the file from Improve for every visitor you could place it in your own key/value storage (like Redis). Here you can configure a time-to-live, ttl for short, and load it once every 5 minutes or per hour. This can be a great solution if you're not making changes on the fly. However, keep in mind that disabling all traffic from a flag back to the original will have the caching time as a delay. ### Statically generate [#statically-generate] If you hardly make any changes in your config but you have one AB test running for a few weeks, or you're a solo developer that can easily deploy when you make a change, it could be an idea to grab the file on deploy and host it yourself in your project. A way of doing this is with pre-deploy scripts. For example, if you host using GitHub Actions, you could add a task to grab it then. For hosting providers like Vercel and Netlify you utilize a _prebuild_ script to download the latest datafile at that point. ## Type [#type] Audiences match against parameters derived from the visitor. Most (`pointer`, `device`, `browser`, `os`) come from the user agent and resolve anywhere. `country` (ISO 3166-1 alpha-2, e.g. `NL`) is the exception: the browser can't determine it from the user agent, so it only resolves during **server-side / middleware bucketing**: the [Next.js middleware](/docs/sdk/nextjs) supplies it automatically from the edge, or you pass it into the server SDK's [getTestValue](/docs/sdk/javascript#gettestvalue) / [getFlagValue](/docs/sdk/javascript#getflagvalue) `geo` argument. A `country` audience never matches client-side. ```ts type AudienceParamKey = 'country' | 'pointer' | 'device' | 'browser' | 'os' export type Configuration = { name: string version: number flags: { [flagSlug in string]: { id: string name: string audience: string options: { name: string slug: string value: string | undefined split: number }[] } } tests: { [testSlug in string]: { id: string name: string defaultValue: string audience: string allocation: number options: { name: string slug: string value: string | undefined split: number }[] events: Events } } audience: { [audienceSlug in string]: { [Key in AudienceParamKey]: string } } } ``` ## Example [#example] ```ts { "name": "obelism", "version": 1, "flags": { "feature-test": { "id": "feat_VGEFKIJEXBLBRB5KUCZJSTNMD7", "name": "Feature test", "audience": "cooler-users", "options": [ { "name": "Original", "slug": "original", "value": "a", "split": 80 }, { "name": "Alteration", "slug": "alteration", "value": "b", "split": 20 } ] } }, "tests": { "duo-test": { "id": "test_AM1TCFPYQW08EQFNWHL5ZSP8VB", "name": "Duo test", "defaultValue": "control", "allocation": 100, "audience": "", "options": [ { "name": "Control", "slug": "control", "value": "a", "split": 50 }, { "name": "Variation", "slug": "variation", "value": "b", "split": 50 } ], "events": { "start": "page_view", "metrics": [], "conversion": "sign_up" } } }, "audience": { "cooler-users": { "device": "tablet", "os": "ios" }, "dutch-visitors": { "country": "NL" } } } ``` # Errors (https://improve.obelism.studio/docs/api/errors) Every non-`2xx` response from `/api/*` and `/config/*` is JSON in the [RFC 9457 problem details](https://www.rfc-editor.org/rfc/rfc9457) format, with `Content-Type: application/problem+json`. That includes paths that don't exist and unsupported HTTP methods, so a client never has to parse an HTML error page. ```json { "type": "https://improve.obelism.studio/docs/api/errors#forbidden", "title": "The request origin is not allow-listed and no valid token was sent.", "status": 403, "detail": "This origin or token is not allowed for this environment.", "code": "forbidden", "hint": "Add the site origin to Allowed origins for this environment, or send a valid `token` header from a server." } ``` | Field | Meaning | | -------- | ------------------------------------------------------- | | `type` | Link to the section on this page for the error code | | `title` | Short, fixed description of the error code | | `status` | The HTTP status code, repeated | | `detail` | What went wrong with this specific request | | `code` | Stable identifier to branch on; one of the codes below | | `hint` | What to change before retrying (present on most errors) | Errors from `POST /api/log` also include `success: false`, which older clients check. A `429` also sends `Retry-After` and `RateLimit` headers. The datafile endpoint keeps one exception for SDK compatibility: when the `organizationId`, `environment` or `status` path segment is invalid, it answers `200` with an empty datafile `{}`, so SDKs fall back to their default values instead of throwing. ## Error codes [#error-codes] ### invalid_json [#invalid_json] `400`. The request body is not valid JSON. Send a JSON object with `Content-Type: application/json`. ### payload_too_large [#payload_too_large] `413`. The request body exceeds the size limit (8 KB for `/api/log`). Send one analytic per request and keep `params` small. ### invalid_request [#invalid_request] `400`. A required field is missing, has the wrong type, or is outside its allowed values. `detail` names the field. Check the body against the [OpenAPI spec](/openapi.json). ### unsupported_api_version [#unsupported_api_version] `400`. The request sent an `Api-Version` header naming a version this API doesn't serve. The only version is `1`. Send `Api-Version: 1` or leave the header out. See [Versioning](/docs/api/versioning). ### reserved_event_name [#reserved_event_name] `400`. Event names starting with `gtm.` are Google Tag Manager internals and are not stored. Rename the event, or filter `gtm.*` events out before sending. ### unknown_organization [#unknown_organization] `404`. The `organizationId` (or `spaceId`) could not be resolved, or the space belongs to another organization. Copy them from Implementation on the Settings page in the dashboard. `/api/log` and the datafile also answer it for an Organization that was deleted (`detail`: "This organization was deleted."): nothing more is stored or served for it. The datafile caches this answer at the edge for 30 seconds. ### unauthorized [#unauthorized] `401`. An [agent API](/docs/api/agents) endpoint needs a bearer token and none was sent, or it is invalid or expired. Sign in again with `POST /api/agent/auth/sign-in` and send `Authorization: Bearer <token>`. ### invalid_credentials [#invalid_credentials] `401`. The email and password do not match an account. The response is the same whether the email is unknown or the password is wrong. ### password_reset_required [#password_reset_required] `401`. The account's password was stored in a format that is no longer supported. Reset it at [www.obelism.studio/forgot-password](https://www.obelism.studio/forgot-password), then sign in again. ### two_factor_required [#two_factor_required] `401` from sign-in, `400` from reset-password. The account has two-factor authentication on, and the request had no `code`. Send it again with `code`: the current 6-digit code from the authenticator app, or the recovery code. ### invalid_two_factor_code [#invalid_two_factor_code] `401` from sign-in, `400` from reset-password. The `code` is not the authenticator app's current code, that code was already used, or it is not the recovery code. Wait for the next code. Five codes per account per 15 minutes. ### invalid_verification_code [#invalid_verification_code] `400`. The email verification code is wrong or has expired. Codes are valid for 4 hours. Sign in again to get a new one by email. ### invalid_reset_token [#invalid_reset_token] `400`. The password reset token is wrong, has expired or was already used. A token is valid for 1 hour, and requesting a new reset email replaces the previous token. Request a new one with `POST /api/agent/auth/forgot-password`. ### insufficient_role [#insufficient_role] `403`. Your role in the organization doesn't allow this action, for example a `reader` creating a test. See [roles](/docs/api/agents/organizations#roles). Ask an admin of the organization for more access. ### product_disabled [#product_disabled] `403`. An admin of the organization has disabled improve for it, in the organization's settings on [www.obelism.studio](https://www.obelism.studio). The dashboard, MCP and this API refuse it, whatever your role. Tests and flags keep being served to visitors, and analytics keep being collected. Ask an admin to enable improve again. ### resource_not_found [#resource_not_found] `404`. The test, flag, audience or other resource in the path doesn't exist in this organization and environment. Tests, flags and audiences belong to one environment, so check you are in the right one. ### slug_conflict [#slug_conflict] `409`. Copying tests to another environment found tests with the same slug there, so nothing was copied. The problem has a `conflicts` array with the `testId`, `name` and `slug` of each. Send the request again with `"overwrite": true` to replace them, or leave them out. ### conflict [#conflict] `409`. The resource changed since it was read (a save with an old `baseRevision`), or one like it already exists (a second connection for the same deployment in a space). Read it again, apply the change to what is there now, and send it with its current revision. ### usage_limit_reached [#usage_limit_reached] `402`. The organization used more analytics requests than its plan includes, so test results are frozen at their last calculation. Analytics keep being recorded, and results catch up once the plan is upgraded or the next period starts. Reading results still works. Plans and pricing are paused, so this error does not currently occur. ### forbidden [#forbidden] `403`. The request's `Origin` is not in the environment's Allowed origins (on the space’s page on [www.obelism.studio](https://www.obelism.studio)), or the `token` header is not a valid API token for the environment. See [Security](/docs/api#security). ### rate_limited [#rate_limited] `429`. Too many requests in the current window. Wait for the `Retry-After` seconds before retrying. Browser (Origin) requests to the datafile are limited to 600 per minute per IP, and the `RateLimit-Policy` header on every browser response states the limit: cache the datafile, or fetch it server-side with a valid `token` header, which is exempt. Every datafile request is also limited at the edge to 100 per 10 seconds per IP; that `429` is `application/json` without `Retry-After`, so wait 10 seconds. The [agent API](/docs/api/agents)'s account endpoints limit attempts per email address, reset token or user, and state the limit in the same `RateLimit-Policy` header. ### ingest_failed [#ingest_failed] `400`. The analytic was valid but could not be queued. Retry the request later. ### not_found [#not_found] `404`. No API endpoint exists at this path. See the [OpenAPI spec](/openapi.json) for the available endpoints. ### method_not_allowed [#method_not_allowed] `405`. The endpoint exists but not for this HTTP method, for example `GET /api/log`. See the [OpenAPI spec](/openapi.json). ### internal_error [#internal_error] `500`. An unexpected server error. Retry later; if it keeps failing, email [improve@obelism.studio](mailto:improve@obelism.studio). # Improve API (https://improve.obelism.studio/docs/api) If you're running Improve on a Javascript server environment it's recommended to use the [Javascript SDK](/docs/sdk/javascript) which interfaces with the API for you. If that doesn't work for you because of alternative server languages you can interface directly with the API. The Improve API is split up in two parts; <Cards> <Card title="Datafile" href="/docs/api/datafile" /> <Card title="Analytics" href="/docs/api/analytics" /> <Card title="Agents" href="/docs/api/agents" /> <Card title="Errors" href="/docs/api/errors" /> <Card title="Versioning" href="/docs/api/versioning" /> </Cards> ## OpenAPI spec [#openapi-spec] The public API is described as OpenAPI 3.1 at [`/openapi.json`](/openapi.json): every operation has an `operationId`, typed parameters, request bodies and response schemas. Load it into an HTTP client, a code generator or an LLM tool-calling setup instead of hand-writing requests. ```bash curl https://improve.obelism.studio/openapi.json ``` ## Security [#security] To make sure your usage is actually from you we protect endpoints in two different ways for server and client. ### Server side [#server-side] Server requests send an API token (`imp_…`) in a `token` header. It belongs to one space and environment, and is for servers only. Create one under **API tokens** on the [Settings page](/settings), for the environment you are editing. Only a hash of it is stored, so it is shown once, when created: copy it into your server's environment right away. Every token expires: in 30 days, 90 days, 1 year (the default) or never, picked when you create it. After that it is refused like a revoked one, and the list marks a token that expires within 14 days. The organization's admins and the person who created it get an email 14 and 3 days before. An environment can have several tokens, so to replace one, create the new token, deploy it, then revoke the old one. If a token leaks, revoke it. - **Analytics** (`/api/log`) needs the token: a server request without a valid one gets `403`. - **The datafile** (`/config`) is also served without a token, rate limited per IP. A valid token lifts that limit, so send it from servers that render many pages. Every request is still held to 100 per 10 seconds per IP at the edge. ```bash curl --header "token: imp_…" https://improve.obelism.studio/config/ORG/ENV/STATE ``` ### Without a stored token [#without-a-stored-token] A deployment on Vercel, or a GitHub Actions workflow, can send the OIDC token its host gives it instead of an API token: nothing to store or rotate. Connect it first, under **Connected deployments** on the space's page on [www.obelism.studio](https://www.obelism.studio): its Vercel team and project ids or its GitHub repository id, and a rule per environment of the deployment mapping it to one environment of the space, with Improve on (which also lets it send numbers events). Then pass the server SDK a token source, which sends the host's token in the same `token` header: ```ts import { ImproveServerSDK } from '@obelism/improve-sdk/server' import { vercelOidc } from '@obelism/improve-sdk/oidc' // needs @vercel/oidc installed const improve = new ImproveServerSDK({ organizationId: 'org_…', environment: 'production', token: vercelOidc(), // or githubOidc() in a workflow with `permissions: id-token: write` }) ``` Shopify Oxygen issues no OIDC token, so a storefront there uses an API token (`imp_…`) instead; see [React Router](/docs/getting-started/react-router). Without the SDK, request the host's token for `https://improve.obelism.studio` (the datafile) or `https://numbers.obelism.studio` (analytics) and send it yourself. A token from another team, project or repository, for another audience, expired, or whose rule has Improve off, counts as no token. A token whose rule maps it to another environment than the one in the URL (or the event) is refused with `403`: a preview deployment reads the environment its rule names, never production by asking for it. When a connected deployment reaches the environment you are editing, the [Settings page](/settings) has **Copy for connected deployments** next to **Copy as .env**: the same variables without a token line. #### Local development [#local-development] `vercelOidc()` works on your own machine too. Link the repository to the Vercel project with `vercel link` and sign in with `vercel login`: the SDK takes the token `vercel env pull` wrote to your `.env`, or asks Vercel for a new one with your sign-in when there is none or it expired. That token's environment is `development`, so the connection needs a rule for `development` with Improve on (its template maps it to develop), and the SDK's `environment` names the same environment. Only members of the Vercel team get a token: anyone else, and CI outside Vercel and GitHub Actions, uses an API token. ### Client side [#client-side] Client side validation is done by CORS; the space’s allowed origins, on [www.obelism.studio](https://www.obelism.studio), say which origin may make requests per environment. Requests from an origin that isn't allow-listed are rejected with `403`, so make sure every environment lists the origins your site actually serves from before going live; otherwise the SDK falls back to default values and analytics are dropped. ### Rate limits & quotas [#rate-limits--quotas] Plans and pricing are paused: every organization currently has unlimited datafile and analytics usage, so the quotas below do not apply. They return with the plans. The datafile endpoint rate-limits the browser path per IP (`429` when exceeded); server requests with a valid `token` are exempt. On top of that, every datafile request is limited at Cloudflare's edge to 100 per 10 seconds per IP, with or without a token. Analytics requests are bounded too: oversized bodies return `413` and invalid payloads `400`. The agent API's account endpoints are limited per email address. Rate limited endpoints state their policy in a `RateLimit-Policy` header, and a `429` adds `RateLimit` and `Retry-After`. Your plan's monthly quota works differently, and deliberately never returns an error. Quotas are measured per organization — all three environments draw on one allowance — and cover two meters: datafile requests and analytics events. Going over either one **never affects your visitors**: the datafile keeps being served and every analytics event is still accepted and stored. Instead the platform pauses its own work on your behalf: - Past your **analytics** quota, test results stop being recalculated, so the dashboard stops moving. Nothing is lost — the data is still being recorded, and results catch up with no gap once the quota clears. - Past your **datafile** quota, your datafile is frozen at its last version. Visitors keep getting your tests and flags exactly as they were configured at that moment, but changes you make in the dashboard won't reach them. Every datafile request counts, including those answered from Cloudflare's cache, and usage is updated hourly. Both lift automatically when the new month starts, or immediately on moving to a bigger plan, which an admin does on the organization's page on [obelism.studio](https://www.obelism.studio/dashboard). Admins are emailed at 80% and at 100% of each quota. See the [Datafile](/docs/api/datafile), [Analytics](/docs/api/analytics) and [Versioning](/docs/api/versioning) pages for specifics. ## Versioning [#versioning] The public API is version `1`, sent on every response as `Api-Version: 1`. Send the same header on a request to pin the version: one that isn't served gets a `400`. Breaking changes ship under a new path prefix, never in place, and a retiring endpoint sends `Deprecation` and `Sunset` headers at least 6 months ahead. See [Versioning](/docs/api/versioning). # Versioning (https://improve.obelism.studio/docs/api/versioning) The public API is the datafile (`GET /config/**`), the analytics endpoint (`POST /api/log`) and the [agent API](/docs/api/agents) (`/api/agent/*`). It is version `1`. Every response from it carries the version in a header: ```http Api-Version: 1 ``` To pin the version your client was written against, send the same header on the request. It's optional: without it, a request gets the version of its path. A version the API doesn't serve is refused with a `400` [`unsupported_api_version`](/docs/api/errors#unsupported_api_version) problem rather than answered with another version. ```http GET /config/org_01H8XYZ/production HTTP/1.1 Api-Version: 1 token: <environment API token> ``` `Api-Version` isn't a CORS-safelisted header, so browsers that send it cross-origin trigger a preflight. `POST /api/log` allows it; the datafile answers no preflight, so don't send it to the datafile from a browser. The other `/api/*` routes belong to the dashboard. They are not versioned and can change at any time, so don't integrate against them. ## Stable paths [#stable-paths] The current paths have no version prefix, and they are version 1. A breaking change ships under a new path prefix (`/v2/config/...`) next to the current paths, never in place. Both run side by side until the old version's sunset date. ## What can change without notice [#what-can-change-without-notice] Additive changes can ship at any time and are not breaking: - new endpoints - new optional request fields - new response fields, headers or error codes - new values for `params` and other open-ended fields Clients should ignore fields and headers they don't recognize, and treat an unknown error `code` like its HTTP status. ## What counts as breaking [#what-counts-as-breaking] Removing or renaming an endpoint, a field or a header, making an optional field required, or changing the meaning or type of an existing field. These only ship in a new version. ## Deprecation [#deprecation] When an endpoint is going away, its responses start sending two headers, and its operation is marked `deprecated` in the [OpenAPI spec](/openapi.json): | Header | Standard | Value | | ------------- | -------------------------------------------------- | ----------------------------------------------------------- | | `Deprecation` | [RFC 9745](https://www.rfc-editor.org/rfc/rfc9745) | When the endpoint was deprecated, e.g. `@1767225600` | | `Sunset` | [RFC 8594](https://www.rfc-editor.org/rfc/rfc8594) | When it stops working, e.g. `Thu, 01 Jul 2027 00:00:00 GMT` | | `Link` | [RFC 8288](https://www.rfc-editor.org/rfc/rfc8288) | `rel="deprecation"` pointing at the migration guide | The sunset date is at least 6 months after the deprecation date. Deprecations are also listed in the [change log](/docs/change-log). Browser clients can read all of these cross-origin: they are listed in `Access-Control-Expose-Headers`. ## Rate limit headers [#rate-limit-headers] Rate limits follow the IETF [RateLimit header fields draft](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/). ### Datafile [#datafile] The datafile's browser (Origin) path sends: ```http RateLimit-Policy: "browser";q=600;w=60 ``` Every browser response says the quota (`q`, requests) per window (`w`, seconds) per IP. A `429` adds the current state and when to retry: ```http RateLimit: "browser";r=0;t=60 Retry-After: 60 ``` Successful responses don't carry `RateLimit`: they are cached at the edge for 30 seconds and shared between clients, so a per-IP count on them would be wrong for most readers. Server requests with a valid `token` header are not held to this policy and get no rate limit headers. The edge limit of 100 requests per 10 seconds per IP ([Datafile](/docs/api/datafile#rate-limiting)) applies to every request and answers its `429` without these headers. ### Agent account endpoints [#agent-account-endpoints] The [agent API](/docs/api/agents)'s account endpoints limit attempts per email address, reset token or user, and state the policy on every response. The `/api/agent/auth/*` ones are served by `www.obelism.studio` (improve redirects them there) with the same policies: | Endpoint | Policy | Counted per | | -------------------------------------- | ------------------------------ | ----------- | | `POST /api/agent/apply` | `"apply";q=3;w=3600` | email | | `POST /api/agent/auth/sign-up` | `"sign-up";q=3;w=3600` | email | | `POST /api/agent/auth/sign-in` | `"sign-in";q=5;w=60` | email | | `POST /api/agent/auth/forgot-password` | `"forgot-password";q=3;w=3600` | email | | `POST /api/agent/auth/reset-password` | `"reset-password";q=5;w=300` | reset token | | `POST /api/agent/auth/verify-email` | `"verify-email";q=5;w=900` | user | Every attempt restarts the window, so a `429` always resets in the full window: ```http RateLimit-Policy: "sign-in";q=5;w=60 RateLimit: "sign-in";r=0;t=60 Retry-After: 60 ``` Other responses carry the policy only, not a remaining count. # Change log (https://improve.obelism.studio/docs/change-log) Notable changes to the Improve SDK packages (`@obelism/improve-sdk`, `@obelism/improve-sdk-react`, `@obelism/improve-sdk-next`). Full release notes live in the [SDK repository](https://github.com/Obelism/sdk/blob/main/packages/improve/CHANGELOG.md), one changelog per package. ## Core SDK (`@obelism/improve-sdk`) [#core-sdk-obelismimprove-sdk] ### 2.0.0 [#200] - **Breaking:** analytics are decoupled from tests. Events are now **test-independent** and attribution happens server-side by joining a visitor's **exposures** against the event stream. - `postAnalytic` dropped its leading `testSlug` argument: `postAnalytic(event, payload?)`. An event is recorded once per visitor per page/session, independent of any test. - **Exposures are automatic.** `getTestValue` / `getFlagValue` now emit an exposure beacon the first time they resolve a non-holdout variant for a visitor. A visitor must read a test/flag's variant for their subsequent events to be attributed to it; audience-excluded and holdout visitors are never exposed. The old implicit "first tracked event = exposure" behaviour is gone. - `POST /api/log` bodies are now discriminated by a `type: 'event' | 'exposure'` field, and event bodies no longer carry `testId`/`testValue`. This is a hard cutover: old beacons are rejected. - The GTM `dataLayer` `improve` block no longer contains `test` / `variant`, only `visitorId`. Compare variants in the test Monitor, not via a GTM/Google Ads split. ### 1.0.0 [#100] - **Breaking:** requires `ua-parser-js` **v2** as a peer dependency (was v1). The SDK now uses the named `{ UAParser }` export. Run `npm i ua-parser-js@^2` when upgrading. ### 0.5.0 [#050] - Added [Google Tag Manager / Google Ads `dataLayer` integration](/docs/integrations/gtm). `postAnalytic` now mirrors each event onto `window.dataLayer` with experiment dimensions. Enabled by default; opt out with `dataLayer: false`. ### 0.4.0 [#040] - Reliability, security and performance improvements. ### 0.3.x [#03x] - `0.3.2`: Exposed `organizationId`, `environment` and `state` properties; `fetchConfig` now returns the loaded config. - `0.3.1`: Added the `baseUrl` setup arg (point the SDK at your Improve instance). - `0.3.0`: Server API authentication via the `token` setup arg. ## React & Next SDKs (`@obelism/improve-sdk-react`, `@obelism/improve-sdk-next`) [#react--next-sdks-obelismimprove-sdk-react-obelismimprove-sdk-next] ### 1.0.0 [#100-1] - **Breaking:** tracks the core SDK `2.0.0` line. `usePostAnalytic()` now takes **no arguments** and returns a global post function `(event, payload?) => …`; it is no longer bound to a test slug. Reading a variant with `useTestValue` / `useFlagValue` records the exposure that events are attributed to. ### 0.5.x [#05x] - Reliability, security and performance improvements; track the core SDK `1.0.0` line. ### 0.4.0 [#040-1] - Added the [`useImproveStatus`](/docs/sdk/react#useimprovestatus) hook for reading the provider lifecycle (`loading` / `setup` / `error`). # Hono (https://improve.obelism.studio/docs/examples/hono) This example runs a server side A/B test on a [Hono](https://hono.dev/) server using the [`ImproveServerSDK`](/docs/sdk/javascript#server) directly. Unlike the [Next.js example](/docs/examples/nextjs) (where the middleware handles cookies for you), here you manage the visitor and test cookies yourself, which makes it a good reference for any server framework that is not Next.js. ## Install [#install] ```bash npm i @obelism/improve-sdk hono ``` ## Handler [#handler] The flow on each request is: 1. [`fetchConfig`](/docs/sdk/javascript#fetchconfig) to load the datafile. 2. Read the visitor id and test value from cookies, using [`getVisitorCookieName`](/docs/sdk/javascript#getvisitorcookiename) for the cookie name. 3. Validate those cookie values with `validateVisitorId` and [`validateTestValue`](/docs/sdk/javascript#validatetestvalue); fall back to [`generateVisitorId`](/docs/sdk/javascript#generatevisitorid) and [`getTestValue`](/docs/sdk/javascript#gettestvalue) when they are missing or invalid. 4. Re-store both values in cookies so the visitor keeps the same variant on future visits. ```ts title="src/index.ts" import { ImproveServerSDK } from '@obelism/improve-sdk/server' import { Hono } from 'hono' import { getCookie, setCookie } from 'hono/cookie' const app = new Hono() const improveSdk = new ImproveServerSDK({ organizationId: 'org_MJFL46Z0WXGQ5OHW1ZXSM3Q88S', environment: 'staging', token: 'xxxx-xxxx-xxxx-xxxx', }) const AB_TEST_SLUG = 'startpage-visual' app.get('/', async (c) => { //? Get config from Improve await improveSdk.fetchConfig() //? Get values from cookie for a consistent browsing experience for users const visitorCookieName = improveSdk.getVisitorCookieName() const visitorIdCookie = getCookie(c, visitorCookieName) const testCookieValue = getCookie(c, AB_TEST_SLUG) //? Validate if the cookie values are valid const validCookieVisitorId = visitorIdCookie && improveSdk.validateVisitorId(visitorIdCookie) const validCookieValue = testCookieValue && improveSdk.validateTestValue(AB_TEST_SLUG, testCookieValue) const visitorId = validCookieVisitorId ? visitorIdCookie : improveSdk.generateVisitorId() const testValue = validCookieValue ? testCookieValue : improveSdk.getTestValue( AB_TEST_SLUG, visitorId, c.req.header('User-Agent') || '', ) //? Re-store values in cookies for one week setCookie(c, visitorCookieName, visitorId, { maxAge: 60 * 60 * 24 * 7, }) setCookie(c, AB_TEST_SLUG, testValue, { maxAge: 60 * 60 * 24 * 7, }) return c.text(`AB Test: ${testValue}`) }) export default app ``` **Note:** the runnable example hardcodes the server `token` for brevity. In your own app load it from an environment variable so it never ends up in version control. ## Source [#source] <Cards> <Card title="View on GitHub" href="https://github.com/Obelism/sdk/tree/main/examples/improve-hono" description="Full source for this example" /> <Card title="View live demo" href="https://hono.improve.obelism.studio" description="This example deployed and running" /> </Cards> # Static HTML (https://improve.obelism.studio/docs/examples/html) This example uses the base [Javascript Client SDK](/docs/sdk/javascript#client) only; there is no server logic. The moment the script mounts in the browser it fetches the config and shows the control or variation. This works great for static files on a CDN without any server side logic. The downside is that there is always layout shift (CLS) when the test is applied to content that is visible above the fold. That is solvable by [running Improve on the server](/docs/examples/nextjs). ## Install [#install] ```bash npm i @obelism/improve-sdk ``` ## Script [#script] The script creates an [`ImproveClientSDK`](/docs/sdk/javascript#client), fetches the config, sets up the visitor and reads the value for the `startpage-visual` test, then writes it into the page. ```ts title="src/index.ts" import { ImproveClientSDK } from '@obelism/improve-sdk/client' const improveClient = new ImproveClientSDK({ organizationId: 'org_MJFL46Z0WXGQ5OHW1ZXSM3Q88S', environment: 'staging', }) improveClient.fetchConfig().then(() => { improveClient.setupVisitor(window.navigator.userAgent) const value = improveClient.getTestValue('startpage-visual') document.querySelector('#output').textContent = value }) ``` ## Markup [#markup] The script is bundled (the example uses [tsup](https://tsup.egoist.dev/)) to `dist/index.mjs` and loaded with a deferred `<script>` tag. The `#output` element holds the control value for the first paint until the SDK swaps it. ```html title="index.html" <!doctype html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Static website

Static website

Your version: control

``` ## Source [#source] # Examples (https://improve.obelism.studio/docs/examples) Each example below is a small, runnable project that wires Improve into a specific framework. They all run the same A/B test (`startpage-visual`) so you can compare how the integration differs between a static site, a client app and a server rendered app. The full source for every example lives in the [public SDK repository](https://github.com/Obelism/sdk/tree/main/examples) (the `improve-*` folders). Clone it, run `npm install` at its root and `npm run dev` in an example folder to try it locally. Each one is also deployed live at `.improve.obelism.studio`; both links are on every example page below. ## Client side [#client-side] Render the control on first paint and swap to the visitor's variant once the SDK mounts in the browser. Simplest to set up, but causes layout shift (CLS) for content above the fold. ## Server side [#server-side] Decide the variant before the response is sent, so the first paint already matches the visitor's variant: no layout shift. For a page you can prerender, see our recommended setup: [Server-side tests on static pages](/docs/guides/static-server-side-tests). # Next.js + Vercel (https://improve.obelism.studio/docs/examples/nextjs-vercel) This is the [Next.js example](/docs/examples/nextjs) with one change: instead of fetching the config over the network inside the middleware, it reads the datafile from [Vercel Edge Config](/docs/integrations/vercel). Edge Config is co-located with the middleware, so there is no network round-trip on every request. The [Vercel integration](/docs/integrations/vercel) keeps your Edge Config in sync with the active tests, flags and audiences. Only the **active** state is synced, so the example falls back to a regular [`fetchConfig`](/docs/sdk/javascript#fetchconfig) for any other state (e.g. when testing a draft). ## Install [#install] ```bash npm i @obelism/improve-sdk @obelism/improve-sdk-next @obelism/improve-sdk-react @vercel/edge-config ``` ## Middleware [#middleware] The only difference from the plain Next.js example is `getImproveConfig`: it loads the datafile from Edge Config with [`loadConfig`](/docs/sdk/javascript#loadconfig) when the state is `active`, and fetches it from the API otherwise. ```ts title="src/middleware.ts" import { type NextRequest } from 'next/server' import { get as getFromEdgeConfig } from '@vercel/edge-config' import { type ImproveConfiguration } from '@obelism/improve-sdk/types' import { ImproveServerSDK } from '@obelism/improve-sdk/server' import { generateImproveNextMiddleware } from '@obelism/improve-sdk-next' import { IMPROVE_CONFIG } from './app/improveConfig' export const config = { matcher: '/', } const improveSdk = new ImproveServerSDK({ ...IMPROVE_CONFIG, token: 'xxxx-xxxx-xxxx-xxxx', }) const improveMiddlewareHandler = generateImproveNextMiddleware({ improveSdk, serverABtests: [ { slug: 'startpage-visual', routeHandler: '/', options: [ { value: 'control', slug: '/' }, { value: 'variation', slug: '/variation' }, ], }, ], }) const getImproveConfig = async () => { if (!!improveSdk.config) return // Only the active state is saved in edge config if (IMPROVE_CONFIG.state !== 'active') { return await improveSdk.fetchConfig() } const config = await getFromEdgeConfig(IMPROVE_CONFIG.environment) improveSdk.loadConfig(config as ImproveConfiguration) } export const middleware = async (request: NextRequest) => { await getImproveConfig() return improveMiddlewareHandler(request) } ``` **Note:** the runnable example hardcodes the server `token` for brevity. In your own app load it from an environment variable so it never ends up in version control. Everything else (the shared config, client provider, routes and analytics tracking) is identical to the [Next.js example](/docs/examples/nextjs). ## Source [#source] # Next.js (https://improve.obelism.studio/docs/examples/nextjs) This example runs a server side A/B test with the [Next.js SDK](/docs/sdk/nextjs). The middleware decides the variant before the page is rendered and rewrites the request to an internal route, so the first paint already matches the visitor's variant: no layout shift. The [React SDK](/docs/sdk/react) is still used on the client to post analytics. ## Install [#install] ```bash npm i @obelism/improve-sdk @obelism/improve-sdk-next @obelism/improve-sdk-react ``` ## Shared config [#shared-config] A small shared config keeps the organization and environment in one place for both the middleware and the client provider. ```ts title="src/app/improveConfig.ts" import { ImproveSetupArgs } from '@obelism/improve-sdk/types' export const IMPROVE_CONFIG: ImproveSetupArgs = { organizationId: 'org_MJFL46Z0WXGQ5OHW1ZXSM3Q88S', environment: 'staging', } as const ``` ## Middleware [#middleware] The middleware creates an [`ImproveServerSDK`](/docs/sdk/javascript#server), fetches the config, and hands the request to [`generateImproveNextMiddleware`](/docs/sdk/nextjs#generator). Each server test maps a route to the internal slugs visitors are rewritten to. ```ts title="src/middleware.ts" import { ImproveServerSDK } from '@obelism/improve-sdk/server' import { generateImproveNextMiddleware } from '@obelism/improve-sdk-next' import { type NextRequest } from 'next/server' import { IMPROVE_CONFIG } from './app/improveConfig' export const config = { matcher: '/', } const improveSdk = new ImproveServerSDK({ ...IMPROVE_CONFIG, token: 'xxxx-xxxx-xxxx-xxxx', }) const improveMiddlewareHandler = generateImproveNextMiddleware({ improveSdk, serverABtests: [ { slug: 'startpage-visual', routeHandler: '/', options: [ { value: 'control', slug: '/', }, { value: 'variation', slug: '/variation', }, ], }, ], }) export const middleware = async (request: NextRequest) => { await improveSdk.fetchConfig() return improveMiddlewareHandler(request) } ``` **Note:** the runnable example hardcodes the server `token` for brevity. In your own app load it from an environment variable so it never ends up in version control. ## Client provider [#client-provider] The provider has to be a client component because it loads the SDK and fetches the config in a `useEffect`. Re-export it from a `'use client'` file. ```ts title="src/app/improve.ts" 'use client' import { generateImproveProvider } from '@obelism/improve-sdk-react' import { IMPROVE_CONFIG } from './improveConfig' const improveReact = generateImproveProvider(IMPROVE_CONFIG) export const ImproveProvider = improveReact.ImproveProvider export const useTestValue = improveReact.useTestValue export const useFlagValue = improveReact.useFlagValue export const usePostAnalytic = improveReact.usePostAnalytic ``` Wrap the app with the provider in the root layout: ```tsx title="src/app/layout.tsx" import { ImproveProvider } from './improve' export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { return ( {children} ) } ``` ## Routes [#routes] Each variant is a normal route. The middleware rewrites `/` to `/variation` for visitors in that variant, so both pages render the same `HomePage` with a different `variant` prop. ```tsx title="src/app/page.tsx" import { HomePage } from './HomePage' export default function Home() { return } ``` ```tsx title="src/app/variation/page.tsx" import { HomePage } from '../HomePage' export default function Home() { return } ``` ## Tracking [#tracking] Client components post analytics with [`usePostAnalytic`](/docs/sdk/react#usepostanalytic). It takes no arguments and events are **test-independent**: call it with an event name and a required `scope` and the server attributes the event to whichever tests/flags the visitor was exposed to. Because this example resolves the variant server-side (via middleware rewrite), read the variant client-side with [`useTestValue`](/docs/sdk/react#usetestvalue) so the SDK records the **exposure** that events are attributed to. `TrackPageView` reports a page load, `Visual` reports a click on the variant. ```tsx title="src/app/TrackPageView.tsx" 'use client' import { useEffect } from 'react' import { usePostAnalytic, useTestValue } from './improve' export const TrackPageView = ({ page }: { page: string }) => { const postAnalytic = usePostAnalytic() // Reading the variant records the exposure events are attributed to. useTestValue('startpage-visual') useEffect(() => { postAnalytic('page_view', page) }, [page, postAnalytic]) return null } ``` ```tsx title="src/app/Visual.tsx" 'use client' import Image from 'next/image' import { usePostAnalytic } from './improve' export const Visual = ({ variant }: { variant: 'control' | 'variation' }) => { const postAnalytic = usePostAnalytic() return ( Next.js Logo { postAnalytic('visual_click') }} /> ) } ``` ## Source [#source] # React (https://improve.obelism.studio/docs/examples/react) This example uses the [React SDK](/docs/sdk/react) to run an A/B test in a client side React app. The provider fetches the config on mount and exposes hooks to read test values down the component tree. Like the [static HTML example](/docs/examples/html) this runs fully on the client, so expect layout shift on content above the fold. For a server rendered setup without CLS see the [Next.js example](/docs/examples/nextjs). ## Install [#install] ```bash npm i @obelism/improve-sdk-react ``` ## App [#app] [`generateImproveProvider`](/docs/sdk/react#generateimproveprovider) returns the `ImproveProvider` plus the hooks. Wrap your app in the provider and read the variant with [`useTestValue`](/docs/sdk/react#usetestvalue). `useImproveStatus` lets you react to the loading state while the config is being fetched. ```tsx title="src/index.tsx" import React from 'react' import { createRoot } from 'react-dom/client' import { generateImproveProvider } from '@obelism/improve-sdk-react' const { ImproveProvider, useTestValue, useImproveStatus } = generateImproveProvider({ organizationId: 'org_MJFL46Z0WXGQ5OHW1ZXSM3Q88S', environment: 'staging', }) const Example = () => { const status = useImproveStatus() const testValue = useTestValue('startpage-visual') return ( <>

Static React website

Your version: {testValue}

) } const rootElement = document.querySelector('#root') const root = createRoot(rootElement) root.render( , ) ``` ## Source [#source] # Privacy & GDPR (https://improve.obelism.studio/docs/gdpr) This page describes, precisely, what data Improve's SDK and dashboard collect about **your website's visitors** when you run tests, flags, and analytics, so you (or your legal/privacy team) can fill in your own privacy policy, cookie notice, and data processing agreement correctly. This is a technical description of data flows, not legal advice. Whether you need consent, a legitimate-interest assessment, or neither depends on your jurisdiction and how you use Improve. Review this with your own counsel before publishing a privacy policy or cookie notice. ## What Improve collects about your visitors [#what-improve-collects-about-your-visitors] | Data | What it is | Source | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Visitor ID** | An opaque random token (`visi_` + 26 random characters), not derived from IP, device fingerprint, or any personal data | Generated client-side or in your server middleware, stored in a cookie | | **Test/flag assignment** | Which variant of which test or flag a visitor was exposed to | Recorded when your code reads a test/flag value | | **Device & browser** | Coarse categories only: device type (mobile/tablet/desktop/…), browser family, OS family, a bucketed screen size, pointer type (coarse/fine) | Parsed from the User-Agent client-side; **the raw User-Agent string and exact screen resolution are never sent or stored**, only these buckets | | **Location** | Country (ISO 3166-1 alpha-2) and, where available, a subdivision/region code | Derived **server-side** from your hosting edge's geo headers (e.g. Vercel's `x-vercel-ip-country`); **the visitor's IP address is never read or stored** | | **Events you send** | Whatever you pass to `postAnalytic()`: an event name, a required `scope`, plus an optional `value`, `currency`, and a free-form `params` object | Entirely under your control: see [below](#events-are-your-data-too) | ## Cookies Improve sets [#cookies-improve-sets] Improve's SDK sets first-party cookies on your own domain (never on `obelism.studio`); Improve's servers don't set cookies directly, since the API only receives POSTed events. | Cookie | Purpose | Lifetime | Notes | | ---------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `obelism_visitor_id` | Identifies a returning visitor so tests/flags/analytics stay consistent across their session | 30 days (client SDK default) or 7 days (Next.js middleware default), both configurable | `Secure`, `SameSite=Lax`. Deliberately **not** `HttpOnly`, since client-side code needs to read it; see the [JavaScript SDK guide](/docs/getting-started/javascript#post-an-analytic-event) | | `` (one per server-side test) | Keeps a visitor on the same variant across repeat visits | Same defaults as `obelism_visitor_id`, configurable per test via the [Next.js middleware options](/docs/sdk/nextjs) | Same attributes as above | The `obelism_visitor_id` cookie isn't strictly required to render your page: it exists to support testing and analytics. Depending on your jurisdiction, that likely means it belongs in the "analytics" or "statistics" category of your cookie notice rather than "strictly necessary," and may require consent before it's set (for example under GDPR/ePrivacy in the EU). Confirm this classification with your own counsel. ## Events are your data too [#events-are-your-data-too] The `scope`, `value`, `currency`, and `params` you pass to `postAnalytic()` (and anything you push through the [GTM integration](/docs/integrations/gtm)) are stored exactly as you send them; Improve doesn't inspect, validate, or filter their contents. **Don't put personal data in them.** A page path (`scope: '/pricing'`) or an order total (`value: 72.05`) is fine; a visitor's name, email address, or order contents with identifying details is not. If your events currently include anything like that, treat it the same as any other personal data your systems store, including for retention and erasure, described below. ## Turning off the Google Tag Manager mirror [#turning-off-the-google-tag-manager-mirror] If you've enabled the [GTM integration](/docs/integrations/gtm) (on by default: `dataLayer: true` in your SDK setup), every event mirrors the visitor ID plus its `value`/`currency`/`params` onto `window.dataLayer`. Whatever GTM tags you've wired up to fire on that data (a GA4 or Google Ads tag, for example) will send it to Google as a sub-processor, including any `params`/`ecommerce` object you supplied. Set `dataLayer: false` in your SDK setup args to disable this mirror entirely if you don't use GTM, or if you want to keep this data out of Google's hands. ## Data retention [#data-retention] Improve does not currently apply an automatic retention limit: analytics and exposure rows persist until you delete them. If your privacy policy commits to a retention period, contact us and we'll help enforce it. ## Right to erasure (data subject requests) [#right-to-erasure-data-subject-requests] There's no self-service "delete this visitor" tool in the dashboard today. If a data subject asks to have their data erased, and you can identify their `obelism_visitor_id` (e.g. from their cookie, if they contact you with it), contact us with it and we'll remove the matching rows for you. numbers' Events page can also delete a range's events for one visitor id. Since the visitor ID has no independent link to a real person (it's not an email, name, or account ID), you'll generally need the visitor to provide their own `obelism_visitor_id`; Improve has no way to look one up from "a person" on its own. ## Sub-processors & data residency [#sub-processors--data-residency] - **Hosting**: Improve runs on Cloudflare Workers; edge geo lookups depend on Cloudflare's request headers. - **Database**: Improve's PostgreSQL database runs on PlanetScale in **Frankfurt, Germany (AWS `eu-central-1`)**, EU-resident by default. For a current, complete sub-processor list or a signed DPA, contact us at [development@obelism.studio](mailto:development@obelism.studio). ## Checklist for your own privacy policy [#checklist-for-your-own-privacy-policy] - [ ] Disclose the `obelism_visitor_id` (and, if applicable, per-test) cookie: likely under "analytics"/"statistics," not "strictly necessary." - [ ] Decide whether you need consent before Improve's SDK sets its cookie, based on your jurisdiction. - [ ] Audit what you're passing into `scope`/`value`/`params`/`ecommerce`: make sure none of it is personal data you haven't accounted for elsewhere. - [ ] If you use the GTM integration, disclose Google as a sub-processor for that data (or disable it with `dataLayer: false`). - [ ] Note that Improve doesn't yet auto-delete old data; factor that into your own retention commitments. - [ ] Know how you'd handle an erasure request today (manual deletion by `obelism_visitor_id`). # Getting Started (https://improve.obelism.studio/docs/getting-started) ## Setup [#setup] ### Create your account [#create-your-account] Go to [the register page](https://www.obelism.studio/register) and fill in your info ### Create an organization [#create-an-organization] After logging in either join or [create a new organization](https://improve.obelism.studio/organization/create). ### Setup a Test [#setup-a-test] Now that you're all set up in the Improve dashboard we can [create our first AB test](https://improve.obelism.studio/tests/create). A few things to watch out for: - **Slug** - This is automatically generated and possible to overwrite; we're going to need this value in the next steps. - **State** - Since we want to integrate this test right away, let's pop this in "active". That means that when we create this test, it's no longer editable but available to be used right away. - **Options** - For setting up our first test, let's add two options and leave the values as is. This will give us a 50/50 AB test. - **Events** - These mark the start and end moment of our AB test. If you're just testing, let's try: `page_view` for the start event and a `cta_click` for the conversion event. ## Integration [#integration] Now that your account and organization are set up, it's time to integrate it into your application. This depends on the tech stack that you're running. Currently there are the following connections: - Javascript SDK - React SDK - NextJS Starter - React Router, including Shopify Oxygen # Javascript (https://improve.obelism.studio/docs/getting-started/javascript) For a complete overview of features in the [Javascript SDK](/docs/sdk/javascript) check out the docs regarding those. For getting started read on. ## Install [#install] ```bash npm i @obelism/improve-sdk ``` ## Setup [#setup] ## Server setup [#server-setup] ### Configure the client [#configure-the-client] ```ts import { ImproveServerSDK } from '@obelism/improve-sdk/server' const improveSdk = new ImproveServerSDK({ organizationId: process.env.IMPROVE_ORGANIZATION_ID, environment: process.env.IMPROVE_ENVIRONMENT, token: process.env.IMPROVE_API_TOKEN, }) ``` On Vercel or in GitHub Actions, `token: vercelOidc()` (or `githubOidc()`) from `@obelism/improve-sdk/oidc` replaces the stored token: see [Without a stored token](/docs/api#without-a-stored-token). ### Get the visitor ID [#get-the-visitor-id] In the request handler it's possible to generate a visitor ID with the function; ```ts improveSdk.generateVisitorId() ``` It's recommended to save this value in a cookie so it's possible to keep track of specific visitors and serve them the same version of the AB test for each request. This could look like this; ```ts export const GET = async (request: Request) => { const visitorCookieName = improveSdk.getVisitorCookieName() const visitorId = request.cookies.get(visitorCookieName)?.value || improveSdk.generateVisitorId() // ... return new Response('...', { headers: { 'Set-Cookie': `${visitorCookieName}=${visitorId}; Secure; Max-Age=2592000`, }, }) } ``` We're not marking the cookie `HttpOnly`, this way we can read the value from the client and use it for analytic tracking. ### Get the visitor value [#get-the-visitor-value] Now that we know who the visitor is on the server we can get them a value for the AB test that we're running; ```ts const userAgent = request.headers.get('user-agent') improveSdk.getTestValue('example-test-slug', visitorId, userAgent) ``` #### Serverless [#serverless] Running serverless you need to also set the test value in a response cookie, since the next request the same user makes to your serverless function might hit a different instance which has no recollection of the given value. ```bash export const GET = async (request: Request) => { // ... const userAgent = request.headers.get('user-agent') const value = request.cookies.get('example-test-slug')?.value || improveSdk.getTestValue('example-test-slug', visitorId, userAgent) // Alter the response based on the value const responseMessage = value === 'b' ? "alteration" : "original" return new Response(responseMessage, { headers: { 'Set-Cookie': `example-test-slug=${value}; Secure; Max-Age=2592000`, }, }) } ``` #### Serverfull [#serverfull] When having a long running server, you can utilize the cache within the SDK, this way setting the response cookie is not required. However, if you want to send analytic events from the client you probably still want to. ```bash export const GET = async (request: Request) => { // ... const userAgent = request.headers.get('user-agent') const value = improveSdk.getTestValue('example-test-slug', visitorId, userAgent) // Alter the response based on the value const responseMessage = value === 'b' ? "alteration" : "original" return new Response(responseMessage) } ``` ## Client setup [#client-setup] ### Configure the client [#configure-the-client-1] #### Sync loading [#sync-loading] ```ts import { ImproveClientSDK } from '@obelism/improve-sdk/client' const client = new ImproveClientSDK({ organizationId: process.env.IMPROVE_ORGANIZATION_ID, environment: process.env.IMPROVE_ENVIRONMENT, }) ``` #### Async loading [#async-loading] ```ts const { ImproveClientSDK } = await import('@obelism/improve-sdk/client') const client = new ImproveClientSDK({ organizationId: process.env.IMPROVE_ORGANIZATION_ID, environment: process.env.IMPROVE_ENVIRONMENT, }) ``` ### Get the visitor ID [#get-the-visitor-id-1] Now that the client is setup it's time to get the config and setup the visitor. This will grab the values from the cookies and use them internally in the client. ```ts await client.fetchConfig() client.setupVisitor(window.navigator.userAgent) ``` ### (optional) Consume the test value [#optional-consume-the-test-value] Getting the value for a test in the client is usually only needed for client side test where you can skip the Server setup. However it can happen that for a server test you want a tiny adjustment in some client side logic. ```ts const value = client.getTestValue('example-test-slug') ``` ### Post an analytic event [#post-an-analytic-event] Last up is pushing analytic events, these are required for the monitoring. Events are test-independent: you post an event name + scope and the server attributes it to whichever tests/flags the visitor was exposed to. ```ts client.postAnalytic('page_view', 'homepage') ``` `scope` (the second argument) is required — a short, stable identifier for where/what the event refers to, not display text. For an event to be attributed to a test, the visitor must have been **exposed** to it first: reading the variant with `getTestValue` (see above) emits the exposure beacon that the server joins events against. Audience-excluded and holdout visitors are never exposed, so their events aren't attributed to the test. Recommended default: fire `page_view` once per page load with the current path as its scope (`client.postAnalytic('page_view', pathname)`). See [Track page views like Google Analytics](/docs/guides/events#default-track-page-views-like-google-analytics); it's the default start event for tests and powers the Events dashboard's Popular pages report. # NextJS (https://improve.obelism.studio/docs/getting-started/nextjs) Within the client side application the [React SDK](/docs/getting-started/react) will do everything you need it to do. If you are only running your AB tests client side, you don't need this package. However NextJS also provides a setup for server routing where you might also want to use AB testing. This package is for Next.js middleware only. On React Router, and on Shopify Oxygen, the loader decides instead: see [React Router](/docs/getting-started/react-router). ## When to use [#when-to-use] A good scenario to use this is when you want to AB test part of a single page, above the fold. Like on [the homepage of Improve](https://improve.obelism.studio). If we'd use a client side test for this there would be layout shift since the first paint would need to be the same. This can have a negative impact on alternative versions. That's why we need a server side AB test, this way the server can detect what version the visitor would get and the first paint will match your version. ## How it works [#how-it-works] To not get in the way of how you render your pages; static/dynamic or somewhere in between. The Improve NextJS SDK will sit in the middleware, this way it can rewrite to different internal routes based on cookie values or query parameters. ## Install [#install] ```bash npm i @obelism/improve-sdk-next ``` ## Middleware [#middleware] Within the middleware you can create the handler by passing it the Improve SDK and a list of the AB tests that you want to server side test. For each server AB test you need to specify the route it needs to run on and the internal slugs you want to rewrite visitors to. For more complicated rewrites it's possible to provide a `formatSlug` function. ```ts import type { NextRequest } from 'next/server' import { ImproveServerSDK } from '@obelism/improve-sdk/server' import { generateImproveNextMiddleware } from '@obelism/improve-sdk-next' export const config = { matcher: '/', } const improveSdk = new ImproveServerSDK({ organizationId: process.env.NEXT_PUBLIC_IMPROVE_ORG_ID, environment: process.env.NEXT_PUBLIC_IMPROVE_ENV, }) const improveMiddlewareHandler = generateImproveNextMiddleware({ improveSdk, serverABtests: [ { slug: 'startpage-visual', routeHandler: '/', options: [ { value: 'control', slug: '/', }, { value: 'variation', slug: '/variation', }, ], }, ], }) export const middleware = async (request: NextRequest) => { await improveSdk.fetchConfig() return improveMiddlewareHandler(request) } ``` ## Example [#example] # React Router (https://improve.obelism.studio/docs/getting-started/react-router) A React Router loader runs before the page renders, which is where a server-side variant belongs: the first paint already has the visitor's version. On Shopify Oxygen there is no middleware helper to install, so the loader is the hook; the [Next.js setup](/docs/getting-started/nextjs) is middleware-based and doesn't apply. This page assumes React Router 7 in framework mode, with the [React SDK](/docs/getting-started/react) already mounted for the browser half. ## 1. One server SDK per isolate [#1-one-server-sdk-per-isolate] A Worker isolate serves one deployment of one storefront, so build the server SDK once and let it keep the datafile in memory between requests. ```ts title="app/lib/improve.server.ts" import { ImproveServerSDK } from '@obelism/improve-sdk/server' import type { Env } from './env' let sdk: ImproveServerSDK | undefined export const getImprove = (env: Env) => (sdk ??= new ImproveServerSDK({ organizationId: env.IMPROVE_ORGANIZATION_ID!, // Only when the test lives in another space than the organization's first. spaceId: env.IMPROVE_SPACE_ID, environment: env.IMPROVE_ENVIRONMENT, // `imp_…` from Settings → API tokens. Without one, the datafile is // rate-limited per IP, and Oxygen's egress IPs are shared. token: env.IMPROVE_API_TOKEN!, })) ``` ```ts title="app/lib/env.ts" export type Env = { IMPROVE_ORGANIZATION_ID?: string IMPROVE_SPACE_ID?: string IMPROVE_ENVIRONMENT?: 'develop' | 'staging' | 'production' IMPROVE_API_TOKEN?: string } ``` Shopify's React Router template already exports `envContext` from `app/lib/env.ts` and sets it in `server.ts`, so add the fields to its type and read them with `context.get(envContext)`. The isolate keeps the first datafile it fetched: `fetchConfig()` returns at once once it has one, so a test created or changed after the isolate started reaches it when the isolate restarts, or through a deploy. For a faster pickup, fetch the datafile yourself on a schedule and pass it with `loadConfig(config)`. Add `IMPROVE_ORGANIZATION_ID`, `IMPROVE_SPACE_ID`, `IMPROVE_ENVIRONMENT` and `IMPROVE_API_TOKEN` to the storefront's variables; **Copy as .env** under Settings copies them next to the other products' (the token line names where to make one), and the token is shown once when you create it. Oxygen issues no OIDC token, so a [connected deployment](/docs/api#without-a-stored-token) can't cover it, and a deployment keeps the variables it was deployed with: a new token needs a redeploy. ## 2. Decide in the loader [#2-decide-in-the-loader] Read the visitor and the stored variant from the cookies, decide only when there is nothing to reuse, and hand back the cookies to set. ```ts title="app/lib/improve.server.ts" const readCookie = (header: string | null, name: string) => header ?.split('; ') .find((row) => row.split('=')[0] === name) ?.split('=')[1] ?? null const cookie = (name: string, value: string) => `${name}=${value}; Path=/; Max-Age=2592000; SameSite=Lax; Secure` // Oxygen sets `oxygen-buyer-country`; other hosts use their own header. const countryOf = (request: Request) => request.headers.get('oxygen-buyer-country') ?? request.headers.get('cf-ipcountry') ?? request.headers.get('x-vercel-ip-country') ?? undefined export type Variant = { value: string; cookies: string[] } export async function variant( request: Request, env: Env, testSlug: string, ): Promise { const improve = getImprove(env) await improve.fetchConfig() const test = improve.getTestConfig(testSlug) const visitorCookie = improve.getVisitorCookieName() const storedVisitor = readCookie(request.headers.get('cookie'), visitorCookie) const visitorId = storedVisitor && improve.validateVisitorId(storedVisitor) ? storedVisitor : improve.generateVisitorId() const stored = readCookie(request.headers.get('cookie'), testSlug) const known = stored && test?.options.some((option) => option.slug === stored) ? stored : null const value = known ?? improve.getTestValue( testSlug, visitorId, request.headers.get('user-agent') ?? '', { country: countryOf(request) }, ) ?? test?.defaultValue ?? 'control' return { value, cookies: [cookie(visitorCookie, visitorId), cookie(testSlug, value)], } } ``` `getTestValue` answers `null` for a slug that isn't in the datafile, so the fallback keeps the page rendering with the control. The cookie is what makes the variant sticky across isolates: the in-memory cache only helps within one, and Oxygen runs many. A flag is the same call: `improve.getFlagValue(flagSlug, visitorId, userAgent, { country })`. ## 3. Render it [#3-render-it] ```tsx title="app/routes/home.tsx" import { data } from 'react-router' import { variant } from '../lib/improve.server' import { envContext } from '../lib/env' import type { Route } from './+types/home' export async function loader({ request, context }: Route.LoaderArgs) { const env = context.get(envContext) const { value, cookies } = await variant(request, env, 'home-hero') const headers = new Headers() for (const setCookie of cookies) headers.append('Set-Cookie', setCookie) return data({ variant: value }, { headers }) } export default function Home({ loaderData }: Route.ComponentProps) { return loaderData.variant === 'b' ? : } ``` React Router sends `Set-Cookie` from a loader on its own. Keep tested routes out of prerendering: a loader that runs at build time bakes in one variant for everyone. ## 4. Record the exposure in the browser [#4-record-the-exposure-in-the-browser] A server-side decision is not an exposure: the count comes from the browser reading the variant. Have the client read the same slug once, which it does from the cookie the loader set, without re-bucketing. ```tsx title="app/routes/home.tsx" import { useTestValue } from '../lib/improve' const value = useTestValue('home-hero', loaderData.variant) ``` `useTestValue` returns `null` until the provider has loaded, so pass the loader's value as the fallback to keep the markup stable. `useFlagValue` does the same for flags, and `usePostAnalytic()` sends the events a test's conversion listens for. The provider is the one from the [React SDK](/docs/getting-started/react), with the same organization and environment as the server SDK. That guide generates `app/lib/improve.client.ts` with `generateImproveProvider`; export its `ImproveProvider`, `useTestValue` and `usePostAnalytic` from there. Name that module without `.client` in React Router, such as `app/lib/improve.tsx`: a `.client` module resolves to an empty module on the server, and the root route has to render `` during SSR too. Read the organization and space ids in the root loader and hand them to a module-level config, rather than a build-time Vite variable the Worker's `env` doesn't fill. ```tsx title="app/root.tsx" import { Outlet } from 'react-router' import { ImproveProvider } from '../lib/improve' export default function App() { return ( ) } ``` ## 5. Allow the storefront's origin [#5-allow-the-storefronts-origin] The datafile and the events reach Improve from the browser, so the storefront's origin has to be in the environment's **allowed origins** (on the space’s page on [www.obelism.studio](https://www.obelism.studio)). Origins match exactly, or by port with `:*`: Oxygen gives **every deployment its own preview URL**, so preview deployments are new origins each time. Add the environment's own URL, or turn on **allow any origin** for develop and staging while you test; production should list its one domain. A server-side `fetchConfig` sends the API token and no `Origin`, so it is not affected. ## Checklist [#checklist] - [ ] `IMPROVE_ORGANIZATION_ID`, `IMPROVE_ENVIRONMENT` and `IMPROVE_API_TOKEN` in the storefront's variables. - [ ] The loader decides once and sets both cookies: `obelism_visitor_id` and the test or flag slug. - [ ] The client reads the same slug, so exposures are recorded. - [ ] Country audiences get `oxygen-buyer-country` (or the host's own header). - [ ] The storefront's origin, and each preview origin you use, is allowed. - [ ] Tested routes aren't prerendered. # React (https://improve.obelism.studio/docs/getting-started/react) For a complete overview of features in the [React SDK](/docs/sdk/react) check out the docs regarding those. For getting started read on. ## Install [#install] ```bash npm i @obelism/improve-sdk-react ``` ## Setup [#setup] ### Configure the client [#configure-the-client] First let's generate our [React handlers](/docs/sdk/react#api) and expose them so we can import them in multiple places; ```tsx import { generateImproveProvider } from '@obelism/improve-sdk-react' export default generateImproveProvider(...) ``` This makes it possible to import all React handlers from that file. First one we need is our provider that sits around your app; ```tsx import { render } from 'react-dom' import { ImproveProvider } from 'utils/improveSdk' const App = () => (