# Kairos Dynamic Data — Integration Guide

schema_version: 1.0.0  
theme: Kairos  
last_updated: 2026-05-26  
audience: app developers, LLM agents

---

## Overview

The Kairos theme ships a **metafield-driven PDP template** (`product.dynamic.json`). An external app writes one JSON metafield per product; the theme renders the full editorial stack below the buy-box with no further theme-editor interaction.

**What this doc covers:**
- The exact metafield definitions your app must create on install
- The `kairos.pdp_layout` JSON payload contract (all 11 section types)
- The GraphQL mutations your app's install/sync flow must implement
- Discovery URLs where this doc and the schema are served without auth
- OAuth scopes required

**What the app must NOT do:** modify any theme file, theme editor setting, or Shopify section/block JSON. The app only writes metafields.

---

## Metafield definitions

Three definitions must exist before the renderer produces any output. Create them once on app install (idempotent — see mutations below).

| Owner | Namespace | Key | Type | Storefront access | Purpose |
|-------|-----------|-----|------|-------------------|---------|
| `PRODUCT` | `kairos` | `pdp_layout` | `json` | `PUBLIC_READ` | Editorial PDP stack — the app writes this per product |
| `PRODUCT` | `kairos` | `pdp_files` | `list.file_reference` | `NONE` | Optional: app-owned file-reference bag; not read by the theme renderer |
| `SHOP` | `kairos` | `faq_default` | `json` | `PUBLIC_READ` | Store-wide FAQ items merged into every `store-faq` section |

---

## Layout payload contract

**Authoritative schema:** `assets/pdp-layout-schema.json` (JSON Schema 2020-12, served from Shopify's CDN — see Discovery surfaces below). This section is a human summary; validate payloads against the JSON Schema.

### Top-level shape

```json
{
  "schema_version": "1.0.0",
  "sections": [
    { "type": "<section_type>", "id": "<optional_stable_id>", "data": { } }
  ]
}
```

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `schema_version` | string (SemVer) | yes | Current: `"1.0.0"`. Renderer matches major version; different major → unknown types skipped gracefully |
| `sections` | array | yes | Render order = array order. Same `type` may repeat N times |
| `sections[].type` | string | yes | One of the 11 types below |
| `sections[].id` | string | no | App-stable key for debugging and partial updates |
| `sections[].data` | object | yes | Type-specific payload — see per-type tables |

### Image reference format

Every `image` / `*_image` field accepts any of these formats:

| Format | Example | Source |
|--------|---------|--------|
| MediaImage GID | `gid://shopify/MediaImage/12345` | Files API response — preferred for app-managed images |
| Shop-image handle | `shopify://shop_images/hero.jpg` | Theme editor image picker |
| Absolute URL | `https://cdn.example.com/img.jpg` | Escape hatch — no Shopify CDN transforms |

Video fields accept `gid://shopify/Video/...`, `gid://shopify/GenericFile/...`, or `https://...`.

### Omission rule

Every section type: if its required content fields are absent or empty, the dispatcher emits **zero DOM** for that entry. A missing `pdp_layout` metafield renders nothing below the buy-box (buy-box + scrolling ticker still render unconditionally from the template JSON).

---

## Section types

### 3.1 `image-with-text`

One image (or video) paired with a text column. Omits if `heading`, `paragraph`, `image`, `video`, and `bullets` are all empty.

| Field | Type | Required | Default |
|-------|------|----------|---------|
| `heading` | string | no | |
| `heading_accent` | string | no | accent fragment appended to heading |
| `subtitle` | string | no | |
| `paragraph` | richtext (HTML) | no | |
| `image` | imageRef | no | |
| `video` | videoRef | no | wins over `image` on media column |
| `image_position` | `"left"` \| `"right"` | no | `"left"` |
| `bullets` | string[] | no | |
| `cta` | `{label: string, url: string}` | no | |

**Minimal example:**
```json
{
  "type": "image-with-text",
  "id": "intro",
  "data": {
    "heading": "Why Most Plans Fail",
    "image": "gid://shopify/MediaImage/123456",
    "image_position": "left"
  }
}
```

---

### 3.2 `statistics-column`

Heading + ordered statistics + optional image and disclaimer. Omits if `stats` is empty.

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `heading` | string | no | |
| `heading_accent` | string | no | |
| `subtitle` | richtext | no | |
| `image` | imageRef | no | |
| `image_position` | `"left"` \| `"right"` | no | `"right"` |
| `disclaimer` | richtext | no | rendered as info-box below stats |
| `stats` | array | **yes**, min 1 | each `{number, title, description?}` |

`stats[].number` — display string e.g. `"95%"`, `"24/7"`. Renderer animates the leading numeric portion.

**Minimal example:**
```json
{
  "type": "statistics-column",
  "id": "science",
  "data": {
    "heading": "The Numbers",
    "stats": [
      { "number": "95%", "title": "of users saw results", "description": "In 12-week trial" }
    ]
  }
}
```

---

### 3.3 `cards`

Grid of cards. Omits if `cards` is empty.

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `heading` | string | no | |
| `heading_accent` | string | no | |
| `subtitle` | richtext | no | |
| `columns_desktop` | integer 2–4 | no | `4` |
| `cards` | array | **yes**, min 1 | each `{image?, title, description?, bullets?}` |

**Minimal example:**
```json
{
  "type": "cards",
  "id": "features",
  "data": {
    "cards": [
      { "title": "No Stimulants", "description": "<p>Zero jitters.</p>" },
      { "title": "Clinically Studied" }
    ]
  }
}
```

---

### 3.4 `roadmap`

Ordered sequence of steps. Omits if `steps` is empty.

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `heading` | string | no | |
| `heading_accent` | string | no | |
| `subtitle` | richtext | no | |
| `steps` | array | **yes**, min 1 | each `{badge?, title, description?, icon_image?, icon_svg?}` |

`steps[].badge` — short label e.g. `"Day 1"` or `"Step 1"`.  
`steps[].icon_svg` — raw inline SVG. Trust boundary is the app; sanitize before writing.

**Minimal example:**
```json
{
  "type": "roadmap",
  "id": "plan",
  "data": {
    "heading": "Your 30-Day Plan",
    "steps": [
      { "badge": "Day 1", "title": "Start Protocol", "description": "<p>Take 2 capsules with water.</p>" }
    ]
  }
}
```

---

### 3.5 `product-benefits`

Icon-bullet list paired with image or video. Omits if `benefits` is empty.

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `heading` | string | no | |
| `heading_accent` | string | no | |
| `subtitle` | richtext | no | |
| `image` | imageRef | no | |
| `video` | videoRef | no | |
| `image_position` | `"left"` \| `"right"` | no | `"left"` |
| `benefits` | array | **yes**, min 1 | each `{title, description?, icon_image?, icon_svg?}` |

**Minimal example:**
```json
{
  "type": "product-benefits",
  "id": "benefits",
  "data": {
    "benefits": [
      { "title": "Suppresses Cravings", "description": "<p>Clinically validated mechanism.</p>" }
    ]
  }
}
```

---

### 3.6 `customer-reviews`

Full-text reviews carousel. Omits if `reviews` is empty.

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `heading` | string | no | |
| `heading_accent` | string | no | |
| `subtitle` | richtext | no | |
| `reviews` | array | **yes**, min 1 | each `{image?, rating?, text, reviewer_name, reviewer_title?}` |

`reviews[].rating` — float 0–5. Default `5`.

**Minimal example:**
```json
{
  "type": "customer-reviews",
  "id": "reviews",
  "data": {
    "reviews": [
      { "text": "<p>Lost 8kg in 6 weeks.</p>", "reviewer_name": "Sarah M.", "rating": 5 }
    ]
  }
}
```

---

### 3.7 `before-after`

Before/after image slider. Omits if `pairs` is empty.

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `heading` | string | no | |
| `heading_accent` | string | no | |
| `subtitle` | richtext | no | |
| `pairs` | array | **yes**, min 1 | each `{category?, before_image, after_image, before_label?, after_label?}` |

**Minimal example:**
```json
{
  "type": "before-after",
  "id": "transformations",
  "data": {
    "pairs": [
      {
        "before_image": "gid://shopify/MediaImage/111",
        "after_image": "gid://shopify/MediaImage/222",
        "before_label": "Week 0",
        "after_label": "Week 12"
      }
    ]
  }
}
```

---

### 3.8 `steps`

Steps / results section. Omits if `steps` is empty.

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `heading` | string | no | |
| `heading_accent` | string | no | |
| `subtitle` | richtext | no | |
| `steps` | array | **yes**, min 1 | each `{image?, icon_svg?, title, description?}` |

**Minimal example:**
```json
{
  "type": "steps",
  "id": "results",
  "data": {
    "steps": [
      { "title": "Week 1", "description": "<p>Appetite noticeably reduced.</p>" },
      { "title": "Week 4", "description": "<p>Consistent energy throughout day.</p>" }
    ]
  }
}
```

---

### 3.9 `product-comparison`

Comparison table. Omits if `columns` or `features` is empty.

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `heading` | string | no | |
| `heading_accent` | string | no | |
| `subtitle` | richtext | no | |
| `columns` | array | **yes**, min 2 | each `{label, image?, highlight?}` |
| `features` | array | **yes**, min 1 | each `{label, cells[]}` |

`columns[].highlight: true` — marks "our product" column for visual emphasis.  
`features[].cells` — length MUST equal `columns` length. Each cell: `{value, icon_svg?}`. `value` may be string, `true` (check), `false` (cross), or `null` (em-dash).

**Minimal example:**
```json
{
  "type": "product-comparison",
  "id": "vs",
  "data": {
    "columns": [
      { "label": "Trim Taste", "highlight": true },
      { "label": "Competitor A" }
    ],
    "features": [
      { "label": "Clinically Studied", "cells": [{ "value": true }, { "value": false }] }
    ]
  }
}
```

---

### 3.10 `store-faq`

FAQ accordion. Merges with `shop.metafields.kairos.faq_default`.

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `heading` | string | no | |
| `heading_accent` | string | no | |
| `subtitle` | richtext | no | |
| `mode` | `"append"` \| `"replace"` | no | `"append"` |
| `items` | array | no | each `{question, answer, image?}` |

**Merge rules (executed in the renderer):**
1. Load `shop.metafields.kairos.faq_default` (array; may be empty)
2. If `mode == "replace"`: final list = `data.items`
3. Else (`"append"`): final list = `shop_faqs + data.items`
4. If final list is empty AND `heading` is blank: omit entire section

**Minimal example:**
```json
{
  "type": "store-faq",
  "id": "faq",
  "data": {
    "mode": "append",
    "items": [
      { "question": "How long until I see results?", "answer": "<p>Most customers report changes within 2 weeks.</p>" }
    ]
  }
}
```

---

### 3.11 `image-reviews`

Image-only review tiles. Omits if `images` is empty.

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `heading` | string | no | |
| `heading_accent` | string | no | |
| `subtitle` | richtext | no | |
| `images` | array | **yes**, min 1 | each `{image, caption?}` |

**Minimal example:**
```json
{
  "type": "image-reviews",
  "id": "photo-reviews",
  "data": {
    "images": [
      { "image": "gid://shopify/MediaImage/555", "caption": "Sarah's 12-week result" }
    ]
  }
}
```

---

## Required GraphQL mutations

Your app's install and product-sync flows must implement the mutations below. All are idempotent — query before mutating, skip if already exists.

### 1. Create metafield definition

**When:** app install (once per store).

```graphql
mutation createDefinition($definition: MetafieldDefinitionInput!) {
  metafieldDefinitionCreate(definition: $definition) {
    createdDefinition { id namespace key name }
    userErrors { field message }
  }
}
```

**Variables (repeat for each of the 3 definitions):**

`kairos.pdp_layout` (PRODUCT):
```json
{
  "definition": {
    "ownerType": "PRODUCT",
    "namespace": "kairos",
    "key": "pdp_layout",
    "name": "PDP Layout",
    "type": "json",
    "description": "Dynamic PDP editorial stack. See /pages/pdp-spec.",
    "access": { "storefront": "PUBLIC_READ" }
  }
}
```

`kairos.pdp_files` (PRODUCT):
```json
{
  "definition": {
    "ownerType": "PRODUCT",
    "namespace": "kairos",
    "key": "pdp_files",
    "name": "PDP Files",
    "type": "list.file_reference",
    "description": "Optional app-owned file bag. Not read by the theme.",
    "access": { "storefront": "NONE" }
  }
}
```

`kairos.faq_default` (SHOP):
```json
{
  "definition": {
    "ownerType": "SHOP",
    "namespace": "kairos",
    "key": "faq_default",
    "name": "Default FAQ",
    "type": "json",
    "description": "Store-wide FAQ merged into store-faq sections.",
    "access": { "storefront": "PUBLIC_READ" }
  }
}
```

If `userErrors` contains `"Key has already been taken"` — definition already exists, continue.

---

### 2. Set metafields (product pdp_layout and shop faq_default)

**When:** product sync (per-product) and store setup.

```graphql
mutation setMetafields($metafields: [MetafieldsSetInput!]!) {
  metafieldsSet(metafields: $metafields) {
    metafields { id namespace key }
    userErrors { field message }
  }
}
```

**Variables for `kairos.pdp_layout` on a product:**
```json
{
  "metafields": [{
    "ownerId": "gid://shopify/Product/987654321",
    "namespace": "kairos",
    "key": "pdp_layout",
    "type": "json",
    "value": "{\"schema_version\":\"1.0.0\",\"sections\":[...]}"
  }]
}
```

Note: `value` must be a JSON-encoded **string** (the JSON object serialized to a string).

**Variables for `kairos.faq_default` on the shop:**
```json
{
  "metafields": [{
    "ownerId": "gid://shopify/Shop/1234567",
    "namespace": "kairos",
    "key": "faq_default",
    "type": "json",
    "value": "[{\"question\":\"...\",\"answer\":\"...\"}]"
  }]
}
```

Get the shop GID first via `{ shop { id } }`.

---

### 3. Assign product template

**When:** product sync — ensure the product uses the dynamic template.

```graphql
mutation updateProduct($input: ProductInput!) {
  productUpdate(input: $input) {
    product { id templateSuffix }
    userErrors { field message }
  }
}
```

**Variables:**
```json
{
  "input": {
    "id": "gid://shopify/Product/987654321",
    "templateSuffix": "dynamic"
  }
}
```

`templateSuffix: "dynamic"` maps to `templates/product.dynamic.json` in the Kairos theme.

---

### 4. Create spec and docs pages

**When:** app install — create merchant-visible pages once.

```graphql
mutation pageCreate($page: PageCreateInput!) {
  pageCreate(page: $page) {
    page { id handle onlineStoreUrl }
    userErrors { field message }
  }
}
```

**Variables for `pdp-spec` page:**
```json
{
  "page": {
    "title": "PDP Spec",
    "handle": "pdp-spec",
    "published": true,
    "templateSuffix": "pdp-spec"
  }
}
```

**Variables for `dynamic-data` docs page:**
```json
{
  "page": {
    "title": "Dynamic Data",
    "handle": "dynamic-data",
    "published": true,
    "templateSuffix": "dynamic-data"
  }
}
```

**Variables for `llms.txt` page:**
```json
{
  "page": {
    "title": "LLMs",
    "handle": "llms.txt",
    "published": true,
    "templateSuffix": "llms"
  }
}
```

---

### 5. Upload images (Files API)

**When:** product sync — upload images, capture GIDs for use in `pdp_layout`.

```graphql
mutation fileCreate($files: [FileCreateInput!]!) {
  fileCreate(files: $files) {
    files {
      ... on MediaImage {
        id
        image { originalSrc }
      }
    }
    userErrors { field message }
  }
}
```

**Variables:**
```json
{
  "files": [{
    "originalSource": "https://your-cdn.com/image.jpg",
    "contentType": "IMAGE"
  }]
}
```

The returned `id` (e.g. `gid://shopify/MediaImage/12345`) is what you store in `pdp_layout` image fields.

---

## Required OAuth scopes

| Scope | Used for |
|-------|---------|
| `read_products` | Query existing products and template suffix |
| `write_products` | Set `templateSuffix`, create draft test product |
| `read_files` | Verify uploaded images exist |
| `write_files` | Upload images via Files API |
| `read_metaobject_definitions` | Query existing metafield definitions |
| `write_metaobject_definitions` | Create the 3 required metafield definitions |
| `read_translations` | (recommended) read locale data |
| `read_publications` | (recommended) read channel assignments |
| `write_publications` | (recommended) publish draft products |
| `read_content` | Query existing pages (pageByHandle) |
| `write_content` | Create `pdp-spec`, `dynamic-data`, `llms.txt` pages |

Minimum for a read/write integration: `read_products write_products read_files write_files read_metaobject_definitions write_metaobject_definitions read_content write_content`.

---

## Discovery surfaces

All URLs below require no authentication.

| URL | Format | Content |
|-----|--------|---------|
| `/cdn/shop/t/{theme_id}/assets/dynamic-data.md` | `text/markdown` | This guide (CDN-served mirror) |
| `/cdn/shop/t/{theme_id}/assets/pdp-layout-schema.json` | `application/json` | JSON Schema 2020-12 — the authoritative field contract |
| `/pages/pdp-spec` | `text/html` | Human-readable spec page with embedded schema + download link |
| `/pages/dynamic-data` | `text/html` | This guide rendered to HTML (merchant must create the page) |
| `/pages/llms.txt` | `text/plain` | Machine-readable index per llmstxt.org convention |

Get `theme_id` via the Admin API: `GET /admin/api/2025-01/themes.json` — use the theme with `role: "main"`.

**Recommended bootstrap sequence for an LLM agent:**
1. Fetch `/pages/llms.txt` for a plain-text index of all docs links
2. Fetch `/cdn/.../assets/dynamic-data.md` for this full guide
3. Fetch `/cdn/.../assets/pdp-layout-schema.json` and validate your payload against it before writing

---

## Versioning

The `schema_version` field at the top of every payload follows SemVer.

| Bump | Trigger |
|------|---------|
| PATCH | Clarifications, doc fixes, new optional fields |
| MINOR | New section types, new optional fields — backwards-compatible |
| MAJOR | Removed or renamed section types or fields |

Renderer behaviour on version mismatch:
- Same major → full render
- Different major → unknown section types skipped; known types render normally
- No payload (`pdp_layout` absent) → no editorial DOM rendered

---

## Worked example

Smallest valid payload that produces two sections:

```json
{
  "schema_version": "1.0.0",
  "sections": [
    {
      "type": "image-with-text",
      "id": "hero-story",
      "data": {
        "heading": "The Science Behind the Formula",
        "paragraph": "<p>12 clinical studies. One breakthrough formula.</p>",
        "image": "gid://shopify/MediaImage/123456789",
        "image_position": "left",
        "cta": { "label": "Read the research", "url": "/pages/research" }
      }
    },
    {
      "type": "store-faq",
      "id": "faq",
      "data": {
        "mode": "append",
        "items": [
          { "question": "Is this safe?", "answer": "<p>Yes — manufactured in FDA-registered facility.</p>" }
        ]
      }
    }
  ]
}
```

A full 15-section example mirroring the trimtaste editorial stack is at `docs/spec/examples/trimtaste-full.json` (also served as `assets/dynamic-data-guide-example.json`).

---

## Error handling

| Scenario | Renderer behaviour |
|----------|-------------------|
| `pdp_layout` metafield absent | No editorial sections rendered; buy-box + ticker render normally |
| `pdp_layout` value is not valid JSON | Liquid `json` filter returns nil; no editorial sections rendered |
| Section entry missing required field (e.g. `stats` on `statistics-column`) | That entry produces no DOM; subsequent entries continue normally |
| Unknown `type` value | HTML comment emitted: `<!-- dpdp: skipped unknown type "xyz" -->` |
| Major version mismatch | Known section types render; unknown types are skipped with HTML comment |
| Image GID references a deleted file | Liquid's `image_url` filter returns nil; image element is omitted, text content still renders |

The renderer is intentionally tolerant. An app that writes partial or evolving payloads will never break the page — it will simply omit the incomplete sections.
