Public API v1

Mentra API Documentation

Programmatic access to structured learning content via our headless CMS. Build your own learning platforms, mobile apps, or content integrations.

Introduction

The Mentra Public API v1 is a RESTful API providing read and write access to learning content. Content is organized in a hierarchical structure (Level 1 → Level 2 → Level 3 → Level 4), plus standalone content items. Tenants can configure custom labels for each hierarchy level.

Key Features

  • Multi-language support - Content available in multiple locales (en-US, nb-NO)
  • AI-generated extensions - Mnemonics, quizzes, and real-world scenarios
  • Headless CMS - Use anywhere: web, mobile, IoT, voice assistants
  • Pagination & filtering - Efficient content discovery and retrieval
  • Cache-friendly - ETag and Last-Modified headers for performance
  • Write API - Create, update, and bulk-import content programmatically with write:content scoped API keys
  • Content Export - Export all content in bulk-import-compatible format for backups, tenant duplication, and round-trip migrations
  • Bulk Operations - Import and delete up to 100 items per request with dry-run validation, cascade deletion, and per-item error reporting
  • Migration-friendly - Idempotent upserts via externalId and Markdown/HTML auto-conversion
  • Migration Guides - Copy-paste Python scripts to import from AWS S3 and GitHub with folder-to-hierarchy mapping and dry-run support
  • Media API - Upload and manage images via API with ImageKit CDN delivery and on-the-fly transforms (resize, crop, auto-WebP)
  • Webhooks - Real-time notifications for content updates
  • OpenAPI Specification - Interactive API Explorer or download raw JSON (for developer tools)

Using this API with an AI assistant?

This page is a JavaScript SPA, so raw page fetches return an empty shell. Point your LLM at one of these plain-text endpoints instead — each contains the full, up-to-date API reference:

Tip: paste https://www.mentra.systems/llms-full.txt into Claude, ChatGPT, or Cursor and it will have everything it needs to write working integration code.

Authentication

All content and data endpoints require authentication via API Keys. You can provide the key using either the standard Authorization header or the custom X-Api-Key header.

Two kinds of endpoints are keyless. GET /health, GET /openapi.json, GET /changelog, and the tenant GET /api/public/{tenant_id}/llms.txt / llms-full.txt feeds take no key by design — the latter are built for LLM/answer-engine crawlers, which don't carry API keys. POST /telemetry/public/track (see Telemetry) also currently accepts calls with no key — but that's a tracked gap, not a stable contract, so don't build an integration that depends on it staying keyless.

How to Get an API Key

  1. Sign up or log in to Mentra
  2. Navigate to the API Keys page
  3. Create a new API key with the appropriate scopes
  4. Copy the key immediately! It is only shown once.
    ⚠️ Important: The key format is mn_live_PREFIX.SECRET. Make sure to copy the entire string, including the dot and the long secret part.

Authentication Header Format

Option 1: Bearer Token (Standard)

Authorization: Bearer mn_live_...

Option 2: X-Api-Key (Simpler)

X-Api-Key: mn_live_...

API Key Scopes

Each API key is granted one or more scopes that control what it can access. See the full Scopes Reference below for endpoint-level detail.

read:content
read:preview
read:analytics
write:content
write:media
auth:headless

Scopes Reference

The table below lists every scope, what it grants, and which endpoint groups require it.

ScopeDescriptionEndpoint Groups
read:content
Read access to published structured and standalone content.Sections (read), Standalone List/Get, Slug Lookup, Content Export
read:preview
Access to draft / unpublished content. Only checked when publishedOnly=false.Same as read:content — additive modifier
read:analytics
Access to usage analytics and learner metrics.Analytics endpoints
future
write:content
Create, update, and delete content (standalone + hierarchy). Also required for Bulk Import and Bulk Delete.Standalone POST/PUT/DELETE, Level 1–4 POST/PUT/DELETE, Bulk Import, Bulk Delete
write:media
Upload, manage, and delete media assets (images, videos).Media Upload (URL), Media Upload (File), Media Delete, Media List
auth:headless
Mint JWT tokens for end-users in headless authentication flows.Headless Token Mint

⚠️ Tip: Grant only the scopes your integration needs. A read-only frontend should only use read:content. Add read:preview only for staging or preview environments.

Base URL

All API requests are made to the following base URL:

Current environment
https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1

💡 Tip: The URL shown here resolves automatically for the environment you're viewing the docs in.

Quick Start

Get started in 60 seconds with these ready-to-use examples:

Pagination

All list endpoints return paginated results. Use the page and pageSize query parameters to navigate through results.

Query Parameters

ParameterTypeDefaultDescription
pageinteger11-based page index. Must be ≥ 1.
pageSizeinteger20Items per page. Range: 1–100.

Response Envelope

Every list response includes the following pagination metadata alongside the items array:

{
  "items": [ ... ],     // Array of content DTOs for the current page
  "page": 1,            // Current page number
  "pageSize": 20,       // Requested page size
  "total": 57,          // Total items matching the query (all pages)
  "hasNext": true,      // Whether a next page exists
  "nextPage": 2         // Next page number, or null if last page
}

Example: Iterating All Pages

import requests

API_KEY = "mn_live_..."
BASE = "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1"
headers = {"X-Api-Key": API_KEY}

page = 1
all_items = []

while True:
    resp = requests.get(
        f"{BASE}/standalone",
        headers=headers,
        params={"locale": "en-US", "page": page, "pageSize": 50},
    )
    data = resp.json()
    all_items.extend(data["items"])

    if not data["hasNext"]:
        break
    page = data["nextPage"]

print(f"Fetched {len(all_items)} of {data['total']} items")

💡 Tip: For bulk operations, prefer the Export endpoint (up to 100 items per page) rather than paginating through individual list endpoints.

Locale Parameter

Multi-Language Support

The locale parameter is REQUIRED for all content endpoints. It determines which language version of the content to return.

Supported Locales

en-US
English (United States)
nb-NO
Norwegian Bokmål (Norway)

Format

Locales follow the BCP-47 standard: language-region

Examples

https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/sections/YOUR_SECTION_KEY/tree?locale=nb-NO
https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/standalone/CONTENT_ID?locale=en-US

API Endpoints

Read Endpoints
read:content

Standalone Content

Independent content items not tied to the hierarchical structure. Perfect for blog posts, articles, or standalone lessons.

GET
/standalone

Returns metadata only for multiple items (title, summary, keywords, slug, timestamps).

Query Parameters

locale (required) - Content language (en-US, nb-NO)
publishedOnly (optional) - Filter published content (default: true)
page (optional) - Page number (default: 1)
pageSize (optional) - Items per page (default: 20, max: 100)
https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/standalone?locale=nb-NO&publishedOnly=true&page=1&pageSize=20
GET
/standalone/{id}

Returns full content including HTML, plain text, media, SEO metadata, and AI-generated extensions.

Query Parameters

locale (required) - Content language (en-US, nb-NO)
include (optional) - Comma-separated list of extra fields (e.g. layout to get resolved page builder blocks)
https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/standalone/{contentId}?locale=nb-NO&include=layout

Structured Content — Sections

Fetch a named structured-content group by its stable key. This replaces the retired per-level read routes (/level1..4, /courses, /journeys, /steps, /lifeskills).

GET
/sections

Discovery — lists each Section by key with its label, template vocabulary, and advisory basePath hint.

https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/sections
GET
/sections/{key}/tree

Returns the Section's ordered node tree in one call — each node carrying its per-locale slug, filtered to the requested locale's published, public content.

Query Parameters

locale (optional) - Content language (en-US, nb-NO)
publishedOnly (optional) - Filter published content (default: true; false requires read:preview)
https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/sections/{key}/tree?locale=nb-NO

Write Endpoints
write:content

Create Content

Create standalone or hierarchy content via the API. Supports Markdown, HTML, or TipTap JSON input with automatic format conversion.

POST
/standalone

Create a standalone content item. Returns 201 Created on success.

POST
/level1|/level2|/level3|/level4

Create hierarchy content at any level. Level 2\u20134 require a parentId referencing the parent item.

curl -X POST "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/standalone" \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Getting Started with AI",
    "locale": "en-US",
    "content": "# Introduction\nAI is transforming...",
    "contentFormat": "markdown",
    "externalId": "wp-post-42",
    "tags": ["ai", "beginner"]
  }'

Request Body Fields

title (required) - Content title
locale (optional, default: en-US) - Content language
content (optional) - Content body (string or JSON)
contentFormat (optional) - One of markdown, html, json. Auto-converts to internal format.
externalId (optional) - Your system\u2019s unique ID. Enables idempotent upsert.
tags (optional) - Array of tag strings
summary (optional) - Short description
coverImageUrl (optional) - Cover image URL
parentId (required for Level 2\u20134) - Parent item\u2019s Firestore ID

Idempotent Upsert via externalId

If you include an externalId and a document with that ID already exists for your tenant, the API will update the existing document instead of creating a duplicate. The response returns 200 OK instead of 201 Created.

This is the recommended pattern for CMS migrations from Contentful, Strapi, Sanity, or WordPress. Use your source system\u2019s ID as the externalId so you can re-run imports safely.

Update Content

Update existing content by its Firestore ID. Supports partial updates \u2014 only provided fields are modified.

PUT
/standalone/{id} |/level1/{id} |... /level4/{id}
curl -X PUT "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/standalone/CONTENT_ID" \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Updated Title",
    "content": "<h1>New Content</h1><p>Updated via API.</p>",
    "contentFormat": "html"
  }'

Delete Content

Permanently delete a content item and all its translations. Hierarchy deletes do not cascade to children.

DELETE
/standalone/{id} |/level1/{id} |... /level4/{id}
curl -X DELETE "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/standalone/CONTENT_ID" \
  -H "X-Api-Key: YOUR_API_KEY"

Bulk Import
write:content

Bulk Import Content

Import up to 100 items in a single request. Supports mixed content types (standalone + hierarchy) with per-item error reporting.

POST
/import

Conflict Resolution Strategies

skip \u2014 Existing items are left unchanged (no update)
update \u2014 Existing items are updated with new data (upsert)
fail \u2014 If ANY item exists, the entire import is aborted with 409
curl -X POST "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/import" \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      {
        "type": "standalone",
        "externalId": "wp-post-1",
        "title": "First Article",
        "content": "# Hello World",
        "contentFormat": "markdown",
        "locale": "en-US"
      },
      {
        "type": "level1",
        "externalId": "cat-101",
        "title": "Programming",
        "locale": "en-US"
      },
      {
        "type": "level2",
        "externalId": "course-201",
        "title": "Python Basics",
        "parentExternalId": "cat-101",
        "locale": "en-US"
      }
    ],
    "onConflict": "update"
  }'

Parent Resolution

Use parentExternalId to reference parent items by their external ID. Parents are resolved within the batch first, then from existing Firestore data. Order items so parents appear before children.

Dry-Run Mode

Set dryRun: true in the request body to validate your import without writing any data to Firestore. The response uses preview actions:

would_create \u2014 Item does not exist, would be created
would_update \u2014 Item exists and would be updated (onConflict: update)
would_skip \u2014 Item exists and would be skipped (onConflict: skip)

Use dry-run to validate data, check for conflicts, and preview the outcome before committing.

{
  "totalItems": 3,
  "created": 2,
  "updated": 1,
  "skipped": 0,
  "failed": 0,
  "results": [
    { "index": 0, "status": "created", "id": "abc123", "externalId": "wp-post-1" },
    { "index": 1, "status": "created", "id": "def456", "externalId": "cat-101" },
    { "index": 2, "status": "updated", "id": "ghi789", "externalId": "course-201" }
  ]
}

Migration Guide

Migrating from another CMS? The Write API is designed for seamless, repeatable imports.

1. Map your content IDs

Use your source CMS\u2019s unique identifiers as externalId values. This makes imports idempotent \u2014 running the same script twice won\u2019t create duplicates.

2. Choose your content format

Export your content as Markdown or HTML and set contentFormat accordingly. Mentra automatically converts it to the internal editor format.

3. Use bulk import with conflict strategy

For initial imports, use onConflict: \"fail\" to catch unexpected duplicates. For incremental syncs, use onConflict: \"update\" to upsert changes.

4. Build hierarchy top-down

Create Level 1 items first, then Level 2 (referencing Level 1 via parentId or parentExternalId), and so on. In bulk imports, order parents before children.

import requests
import json

API_URL = "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1"
API_KEY = "YOUR_API_KEY"
headers = {"X-Api-Key": API_KEY, "Content-Type": "application/json"}

# Export from your CMS (example: WordPress posts)
posts = get_wordpress_posts()  # your export function

# Batch into groups of 100
for batch in chunks(posts, 100):
    payload = {
        "items": [
            {
                "type": "standalone",
                "externalId": f"wp-{post['id']}",
                "title": post["title"],
                "content": post["content"],
                "contentFormat": "html",
                "locale": "en-US",
                "tags": post.get("tags", [])
            }
            for post in batch
        ],
        "onConflict": "update"
    }
    resp = requests.post(f"{API_URL}/import", headers=headers, json=payload)
    result = resp.json()
    print(f"Created: {result['created']}, Updated: {result['updated']}")

Media API
write:media

CDN Image Transforms

All image URLs in API responses (content coverImageUrl, media cdnUrl, and inline content images) are served via ImageKit CDN. You can append transformation parameters to any CDN URL for responsive, optimized delivery.

URL Format

CDN URLs follow the pattern: https://ik.imagekit.io/MentraIntelligence/tr:TRANSFORMS/path/to/image.jpg

To add transforms to a cdnUrl, insert tr:PARAMS after the endpoint base.

# Thumbnail (200x200 square crop)
https://ik.imagekit.io/MentraIntelligence/tr:w-200,h-200,c-at_max,fo-auto/{tenantId}/media/originals/image.jpg

# Card size (600px wide, auto height)
https://ik.imagekit.io/MentraIntelligence/tr:w-600,fo-auto,f-webp,q-85/{tenantId}/media/originals/image.jpg

# Content width (800px, quality optimized)
https://ik.imagekit.io/MentraIntelligence/tr:w-800,f-webp,q-80/{tenantId}/media/originals/image.jpg

# Auto-format (browser-negotiated WebP/AVIF)
https://ik.imagekit.io/MentraIntelligence/tr:f-auto,q-80/{tenantId}/media/originals/image.jpg

Transform Parameters

w-{N} \u2014 Width in pixels
h-{N} \u2014 Height in pixels
c-at_max \u2014 Crop mode: fit within bounds
fo-auto \u2014 Smart focus (auto-detect subject)
f-webp / f-auto \u2014 Force WebP or auto-negotiate best format
q-{N} \u2014 Quality (1\u2013100, default 80)

Best Practice: Responsive Images

Use the cdnUrl from API responses as-is for full quality, or append transform parameters for responsive srcset generation. ImageKit automatically negotiates the best format (WebP/AVIF) based on the browser\u2019s Accept header when using f-auto.

Upload Media via URL

Download an image from a public URL and store it in your tenant\u2019s media library. The original file is stored as-is \u2014 optimization and format conversion happens on-the-fly via the CDN.

POST
/media/upload-url

Request Body

sourceUrl (required) - Public HTTP/HTTPS URL of the image to download
filename (optional) - Override the filename (auto-detected from URL if omitted)
alt (optional) - Alt text for accessibility
tags (optional) - Array of tag strings for categorization
curl -X POST "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/media/upload-url" \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "sourceUrl": "https://example.com/hero-image.jpg",
    "alt": "Hero banner for course landing page",
    "tags": ["hero", "banner"]
  }'
{
  "mediaId": "abc123def456",
  "originalUrl": "https://storage.googleapis.com/.../originals/hero-image.jpg",
  "processedUrl": "https://storage.googleapis.com/.../originals/hero-image.jpg",
  "cdnUrl": "https://ik.imagekit.io/MentraIntelligence/tr:f-auto,q-80/{tenantId}/media/originals/hero-image.jpg",
  "filename": "hero-image.jpg",
  "contentType": "image/jpeg",
  "sizeBytes": 245760,
  "alt": "Hero banner for course landing page",
  "tags": ["hero", "banner"],
  "createdAt": "2025-02-24T12:00:00Z"
}

Recommended: Use cdnUrl

Always prefer cdnUrl over originalUrl or processedUrl for displaying images. The CDN URL provides global edge caching, automatic format negotiation, and on-the-fly transforms.

Upload Media File

Upload a binary image file directly via multipart/form-data. Ideal for programmatic uploads from scripts or CI/CD pipelines.

POST
/media/upload

Form Fields

file (required) - Binary image file
alt (optional) - Alt text for accessibility
tags (optional) - Comma-separated tag string (e.g. "hero,banner,course")
curl -X POST "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/media/upload" \
  -H "X-Api-Key: YOUR_API_KEY" \
  -F "file=@./my-image.png" \
  -F "alt=Course thumbnail" \
  -F "tags=thumbnail,course"

Supported Formats & Limits

Formats: JPEG, PNG, GIF, WebP, SVG, BMP, TIFF

Max file size: 20 MB per upload

Optimization: Originals stored as-is. CDN applies on-the-fly format conversion and compression via cdnUrl.

Get Media Metadata

Retrieve metadata and public URLs for a specific media asset.

GET
/media/{mediaId}
curl "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/media/MEDIA_ID" \
  -H "X-Api-Key: YOUR_API_KEY"
{
  "mediaId": "abc123def456",
  "tenantId": "tenant_xyz",
  "originalUrl": "https://storage.googleapis.com/.../originals/image.jpg",
  "processedUrl": "https://storage.googleapis.com/.../originals/image.jpg",
  "cdnUrl": "https://ik.imagekit.io/MentraIntelligence/tr:f-auto,q-80/{tenantId}/media/originals/image.jpg",
  "filename": "image.jpg",
  "contentType": "image/jpeg",
  "sizeBytes": 245760,
  "alt": "Descriptive alt text",
  "tags": ["hero", "banner"],
  "createdAt": "2025-02-24T12:00:00Z",
  "source": "api_url"
}

Response Fields

originalUrl \u2014 Direct Firebase Storage URL (legacy, not CDN-optimized)
processedUrl \u2014 Storage URL (legacy, kept for backward compatibility)
cdnUrl \u2014 Recommended. ImageKit CDN URL with auto-format and quality optimization

List Media Assets

List all media assets for your tenant with pagination and optional tag filtering. Each item includes a cdnUrl for optimized delivery.

GET
/media

Query Parameters

limit (optional) - Number of items per page (1\u2013100, default: 20)
offset (optional) - Number of items to skip (default: 0)
tag (optional) - Filter by tag (e.g. tag=hero)
curl "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/media?limit=10&tag=thumbnail" \
  -H "X-Api-Key: YOUR_API_KEY"
{
  "items": [
    {
      "mediaId": "abc123",
      "filename": "thumbnail.jpg",
      "processedUrl": "https://storage.googleapis.com/.../originals/thumbnail.jpg",
      "cdnUrl": "https://ik.imagekit.io/MentraIntelligence/tr:f-auto,q-80/{tenantId}/media/originals/thumbnail.jpg",
      "sizeBytes": 102400,
      "tags": ["thumbnail"],
      "createdAt": "2025-02-24T12:00:00Z"
    }
  ],
  "total": 1,
  "limit": 10,
  "offset": 0
}

Delete Media Asset

Permanently delete a media asset, including the original file from storage. The freed storage quota is returned to your tenant.

DELETE
/media/{mediaId}
curl -X DELETE "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/media/MEDIA_ID" \
  -H "X-Api-Key: YOUR_API_KEY"
{
  "mediaId": "abc123def456",
  "deleted": true,
  "message": "Media asset deleted successfully",
  "bytesFreed": 348160
}

Surfaces (Placements)

A Surface is a named content slot on your site — a front-page teaser row, a pricing-page highlight strip — whose contents editors curate in Mentra. Each entry inside it is a Placement: a reference to one standalone content item, optionally time-boxed with an active window.

Resolve a Surface by Key

Wire a surface key into a page section once — from then on, editors control the slot entirely from the CMS

read:content

The surface's key (e.g. front-page-teasers) is the contract between Mentra and your site. All curation logic is evaluated server-side at read time: the API returns only the currently-active, published, ordered items — no client-side filtering, window math, or sorting required.

Endpoints

GET /surfaces              — list the tenant's surface keys + labels (discovery)
GET /surfaces/{key}        — resolve a surface: its currently-active content

Paths are relative to the base URL (see Base URLs). The optional locale query parameter serves only items published in that locale, BCP 47 prefix-aware (?locale=nb matches nb-NO).

Example

curl "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/surfaces/front-page-teasers?locale=nb-NO" \
  -H "X-Api-Key: YOUR_API_KEY"

Response

Surface metadata plus the resolved items in curated order. Each item is the reduced content summary shape returned by GET /standalone/summary — not the full detail object (no body content, media, or seoMetadata). For richer rendering, follow up with GET /standalone/{id}?locale={item.locale} per item — pass the item's locale, since the detail endpoint defaults an omitted locale to the document's original language.

{
  "key": "front-page-teasers",
  "label": "Front page teasers",
  "maxItems": 4,
  "ordering": "manual",
  "items": [
    {
      "id": "a1b2c3d4e5f6",
      "slug": "derfor-vinner-radardata",
      "title": "Derfor vinner radardata over magefølelsen",
      "summary": "Kort ingress som brukes i teaser-kortet …",
      "coverImageUrl": "https://ik.imagekit.io/…/cover.webp",
      "locale": "nb-NO",
      "tags": ["innsikt"],
      "publishedAt": "2026-07-10T08:31:12Z",
      "author": { "id": "auth_123", "name": "Kari Nordmann" }
    }
  ]
}

Resolution Rules

The items array is computed fresh on every (non-cached) request, in this order:

  • Active window. A placement with activeFrom/activeUntil is served only while activeFrom <= now < activeUntil (inclusive start, exclusive end; a missing bound is unbounded). Windows take effect on the next request — there is no publish job to wait for.
  • Published in the requested locale. Unpublished, deleted, and visibility-restricted content is silently skipped, never an error.
  • Ordering. manual serves the editor's hand-curated order; newest serves pinned placements first, then the rest by content recency.
  • maxItems is applied last, capping the surviving ordered list.

Empty vs. 404

A known key with zero active items returns 200 with "items": [] (the slot is configured but currently empty — render nothing or a fallback). An unknown key returns 404 with the machine-readable code surface_not_found:

{
  "error": {
    "code": "surface_not_found",
    "message": "Surface not found",
    "request_id": "a1b2c3d4-…"
  }
}

Caching & Revalidation

  • Revalidate with If-None-Match (ETag) only — the resolved item set is time-windowed, so If-Modified-Since is ignored by the endpoint.
  • Subscribe to the surface.updated webhook for push-based invalidation: it fires on every surface mutation and its payload carries the surfaceKey.
  • surface.updated alone is not sufficient invalidation. Publishing or unpublishing a placed item changes the surface response but emits content.published / content.unpublished instead — revalidate on those too. And some changes emit no webhook at all: a scheduled window opening or closing on its own, a visibility-tags-only change to a placed item, and creating a new surface (it starts empty, but a cached discovery list or a cached 404 for its key stays stale). If you cache aggressively, keep time-based revalidation (a modest TTL or ISR-style revalidate) as the safety net for those.
component Surface(key, locale):
    response = GET {base_url}/surfaces/{key}?locale={locale}
        with header X-Api-Key
        with cached ETag via If-None-Match

    if response is 404:          # slot not configured — a wiring problem
        log warning; render nothing
    if response.items is empty:  # configured but currently empty
        render nothing (or a fallback)

    render section:
        heading = response.label
        for item in response.items:      # already filtered, ordered, capped
            render card(item.title, item.summary, item.coverImageUrl,
                        link to /articles/{item.slug})

# Usage — every curated slot is one line:
page "/" renders:            Surface("front-page-teasers", locale)
page "/pricing" renders:     Surface("pricing-highlights", locale)

# Webhook handler — one generic revalidation path for all surfaces:
on webhook "surface.updated" (payload):
    revalidate pages rendering payload.data.surfaceKey

Content Discovery

Resolve content by human-readable slugs instead of Firestore IDs. Perfect for building SEO-friendly URL routing on your frontend.

Slug Lookup

Resolve a slug to a content item across all content types

read:content
GET
/content/by-slug/{slug}

Searches across all content types (standalone, level 1–4) for a document matching the given slug and locale. Returns minimal metadata for routing. A standalone hit can then be fetched in full from /standalone/{id}. A structured hit (level1level4) additionally carries the sectionKey of the Section it belongs to — fetch the node's full body via GET /sections/{sectionKey}/nodes/{id} (or its tree via GET /sections/{sectionKey}/tree). Only content with a public read path is returned: on a tenant not yet on the structured-content model, the search is narrowed to standalone content, so a slug never resolves to a legacy level document that can't be read.

Query Parameters

locale (optional) — Locale code (e.g. en-US). If the slug is not found for the requested locale, falls back to the document's originalLocale. Defaults to en-US.
contentType (optional) — Limit search to a specific type: standalone, level1, level2, level3, level4. When omitted, all collections are searched in order.
publishedOnly (optional) — Filter to published content only. Defaults to true. Set to false with a preview-scoped API key to include drafts.

Locale Fallback Chain

slugByLocale.{requested locale}slug (legacy field)404
GET https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/content/by-slug/getting-started-with-ai?locale=en-US
{
  "id": "abc123def456",
  "contentType": "standalone",
  "slug": "getting-started-with-ai",
  "resolvedLocale": "en-US",
  "title": "Getting Started with AI",
  "summary": "An introduction to artificial intelligence...",
  "coverImageUrl": "https://example.com/cover.jpg",
  "externalId": "wp-post-42",
  "createdAt": "2024-11-21T10:00:00Z",
  "updatedAt": "2025-03-15T14:30:00Z",
  "publishedAt": "2025-01-10T09:00:00Z"
}

SEO-Friendly URL Routing Pattern

Use slug lookup to power clean URLs like /blog/getting-started-with-ai, then fetch full content by the returned ID:

// Step 1: Resolve slug to content metadata
const slug = window.location.pathname.split('/').pop();
const res = await fetch(
  `${BASE_URL}/content/by-slug/${slug}?locale=en-US`,
  { headers: { 'X-Api-Key': API_KEY } }
);
const meta = await res.json();
// e.g. meta.contentType = "standalone", meta.id = "abc123"
//      structured hits also carry meta.sectionKey

// Step 2: Both content types have a per-item body endpoint. Standalone reads
// from /standalone/{id}; a structured node reads from its Section, addressed
// by meta.sectionKey + meta.id.
if (meta.contentType === 'standalone') {
  const full = await fetch(
    `${BASE_URL}/standalone/${meta.id}?locale=${meta.resolvedLocale}`,
    { headers: { 'X-Api-Key': API_KEY } }
  );
  const content = await full.json();
} else {
  const full = await fetch(
    `${BASE_URL}/sections/${meta.sectionKey}/nodes/${meta.id}?locale=${meta.resolvedLocale}`,
    { headers: { 'X-Api-Key': API_KEY } }
  );
  const content = await full.json();
  // content.contentHtml, content.seoMetadata, content.breadcrumbs, …
}

Content Export
read:content

Export All Content

Export your entire tenant's content library in a format that can be re-imported via the Bulk Import endpoint. This enables round-trip migrations, backups, and tenant duplication.

GET
/export

Query Parameters

type (optional) — Filter by content type:standalone, level1, level2, level3, level4, all (default)
locale (optional) — Export specific locale (default: en-US)
page (optional) — Page number (default: 1)
pageSize (optional) — Items per page, 1–100 (default: 50)
curl "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/export?locale=en-US&pageSize=50" \
  -H "X-Api-Key: YOUR_API_KEY"
{
  "totalItems": 42,
  "items": [
    {
      "type": "standalone",
      "id": "abc123",
      "externalId": "wp-post-1",
      "title": "Introduction to Learning",
      "content": "Plain text content...",
      "contentJson": { "type": "doc", "content": [...] },
      "contentHtml": "<h1>Introduction</h1><p>...</p>",
      "contentFormat": "json",
      "summary": "A brief summary...",
      "coverImageUrl": "https://ik.imagekit.io/...",
      "tags": ["intro", "basics"],
      "locale": "en-US"
    },
    {
      "type": "level1",
      "id": "def456",
      "externalId": "cat-101",
      "title": "Programming",
      "locale": "en-US"
    },
    {
      "type": "level2",
      "id": "ghi789",
      "title": "Python Basics",
      "parentId": "def456",
      "order": 0,
      "locale": "en-US"
    }
  ],
  "page": 1,
  "pageSize": 50,
  "hasNext": false,
  "nextPage": null
}

Round-Trip Workflow

The export format mirrors the import format. To migrate content between tenants:

  1. Export from source tenant with GET /export
  2. Validate with POST /import using dryRun: true
  3. Import for real with dryRun: false
import requests

SOURCE_URL = "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1"
TARGET_URL = "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1"  # or different tenant
SOURCE_KEY = "SOURCE_TENANT_API_KEY"
TARGET_KEY = "TARGET_TENANT_API_KEY"

# 1. Export all content
page = 1
all_items = []
while True:
    resp = requests.get(
        f"{SOURCE_URL}/export?page={page}&pageSize=100",
        headers={"X-Api-Key": SOURCE_KEY}
    )
    data = resp.json()
    all_items.extend(data["items"])
    if not data["hasNext"]:
        break
    page = data["nextPage"]

print(f"Exported {len(all_items)} items")

# 2. Dry-run import to target
resp = requests.post(
    f"{TARGET_URL}/import",
    headers={"X-Api-Key": TARGET_KEY, "Content-Type": "application/json"},
    json={"items": all_items[:100], "onConflict": "update", "dryRun": True}
)
result = resp.json()
print(f"Dry-run: {result['created']} would create, {result['updated']} would update")

# 3. Import for real
resp = requests.post(
    f"{TARGET_URL}/import",
    headers={"X-Api-Key": TARGET_KEY, "Content-Type": "application/json"},
    json={"items": all_items[:100], "onConflict": "update", "dryRun": False}
)
result = resp.json()
print(f"Done: {result['created']} created, {result['updated']} updated")

Bulk Delete
write:content

Bulk Delete Content

Delete up to 100 items in a single request. Each item can be identified by its Mentra id (Firestore document ID) or by externalId.

POST
/bulk-delete

Request Body

items (required) — Array of items to delete (max 100)
type — Content type:standalone, level1level4
id — Firestore document ID (one of id or externalId required)
externalId — External ID lookup
cascade (optional, default: false) — For hierarchy items, also delete all descendant items
dryRun (optional, default: false) — Validate without deleting
curl -X POST "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/bulk-delete" \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      {
        "type": "standalone",
        "externalId": "wp-post-1"
      },
      {
        "type": "level1",
        "id": "abc123",
        "cascade": true
      }
    ],
    "dryRun": false
  }'
{
  "totalItems": 2,
  "deleted": 2,
  "notFound": 0,
  "failed": 0,
  "dryRun": false,
  "results": [
    {
      "id": null,
      "externalId": "wp-post-1",
      "type": "standalone",
      "action": "deleted",
      "cascadeDeleted": 0
    },
    {
      "id": "abc123",
      "externalId": null,
      "type": "level1",
      "action": "deleted",
      "cascadeDeleted": 7
    }
  ]
}

Safety: Use Dry-Run First

Set dryRun: true to preview exactly what would be deleted without removing any data. The response will show would_delete actions and cascade counts, letting you validate before committing.

Migration Guides

Import your existing content from AWS S3 buckets or GitHub repositories into Mentra. These guides provide complete, runnable Python scripts with dry-run support.

Import from AWS S3
write:content
write:media

Overview

Migrate content stored as Markdown, HTML, or JSON files in an AWS S3 bucket into Mentra. The script maps your folder structure onto a Section's node types, root to leaf, and uploads media assets via presigned URLs.

Out of the box the script targets the four Curriculum levels. With templated Sections enabled, that lands as nodes inside a Curriculum Section; without it, the import writes the legacy hierarchy. Enable templated Sections before importing — content brought in beforehand stays in the legacy collections and does not move across on its own.

To import into any other shape, set SECTION_KEY and NODE_TYPE_CHAIN at the top of the script. Items then go up as type: "node" with a sectionKey and a nodeTypeKey from that Section's template — so a Knowledge Base, a flat list of Pages, or a template you authored yourself all import, at whatever depth the template permits. Both values come from GET /v1/sections. The bulk import API also still accepts the Curriculum ladder (level1level4) and standalone items, unchanged.

The Section itself — its key, its template, and its node types — is authored in the Mentra app: the structure authoring endpoints need a signed-in user with structured_content:manage, and the public Sections API is read-only. Create the Section first, then import into it.

Folder → Hierarchy Mapping

my-content-bucket/
├── economics/                        → Level 1 (e.g., LifeSkill)
│   ├── meta.json                     → { "title": "Economics", "summary": "..." }
│   ├── cover.jpg                     → Cover image (uploaded via Media API)
│   ├── microeconomics/               → Level 2 (e.g., Course)
│   │   ├── meta.json
│   │   ├── supply-and-demand/         → Level 3 (e.g., Journey)
│   │   │   ├── meta.json
│   │   │   ├── 01-basics.md           → Level 4 (e.g., Step), order = 1
│   │   │   ├── 02-elasticity.md       → Level 4, order = 2
│   │   │   └── images/
│   │   │       └── demand-curve.png   → Media asset
│   │   └── market-structures/
│   │       ├── meta.json
│   │       └── 01-perfect-competition.md
│   └── macroeconomics/
│       └── ...
└── philosophy/
    └── ...
Content Files

.md, .html, or .json files become Steps (Level 4)

Media Assets

Images uploaded via presigned URL → Media API → coverImageUrl

Metadata

meta.json in each folder provides title, summary, keywords

Prerequisites

pip install boto3 requests
Mentra API Key with write:content and write:media scopes
AWS credentials configured (via ~/.aws/credentials, environment variables, or IAM role)
S3 bucket with read access to your content files

Complete Import Script

"""
S3 → Mentra Import Script
=========================
Migrates content from an S3 bucket into any Section, whatever its structure
template. Folder depth maps onto the target's node-type chain, root → leaf;
content files inside the deepest folder level become the leaf nodes.

  Curriculum (default):  bucket/a/b/c/01-step.md → Level 1 > 2 > 3 > Step
  Custom template:       bucket/a/b/01-lesson.md → program > module > lesson

Usage:
  1. Set the configuration variables below
  2. Run with DRY_RUN = True to preview
  3. Review the output
  4. Set DRY_RUN = False to execute
"""
import boto3
import requests
import json
import os
import re
from pathlib import PurePosixPath

# ─── Configuration ───────────────────────────────────────────────
MENTRA_API_KEY = os.environ.get("MENTRA_API_KEY", "your-api-key-here")
MENTRA_BASE_URL = "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1"
S3_BUCKET = "my-content-bucket"
S3_PREFIX = ""  # Optional: only import from a subfolder
AWS_REGION = "eu-west-1"
LOCALE = "en-US"
DRY_RUN = True  # ← Start with True!

# ─── Structure target ────────────────────────────────────────────
# Leave SECTION_KEY = None to import into the Curriculum ladder
# (type level1..4) exactly as before.
#
# To import into any other Section — a Knowledge Base, a flat list of
# Pages, or a template you authored yourself — set SECTION_KEY to that
# Section's key and list its node types root → leaf in NODE_TYPE_CHAIN.
# Both come from GET /v1/sections (`key`, and `vocabulary`'s keys). The
# chain can be any length; there is no four-level cap.
#
#   SECTION_KEY = "program"
#   NODE_TYPE_CHAIN = ["program", "module", "lesson", "activity"]
SECTION_KEY = None
NODE_TYPE_CHAIN = []

SKIP_FOLDERS = {"images", "media", "assets", "_assets"}

# ─── Helpers ─────────────────────────────────────────────────────
HEADERS = {
    "Authorization": f"Bearer {MENTRA_API_KEY}",
    "Content-Type": "application/json",
}

LEVEL_TYPES = ["level1", "level2", "level3", "level4"]

def structure_chain() -> list[str]:
    """The root → leaf chain folder depth maps onto."""
    if SECTION_KEY:
        if not NODE_TYPE_CHAIN:
            raise SystemExit("Set NODE_TYPE_CHAIN when SECTION_KEY is set.")
        return NODE_TYPE_CHAIN
    return LEVEL_TYPES

def address(depth: int) -> dict:
    """The item fields that name a target at this depth in the chain."""
    if SECTION_KEY:
        return {
            "type": "node",
            "sectionKey": SECTION_KEY,
            "nodeTypeKey": NODE_TYPE_CHAIN[depth],
        }
    return {"type": LEVEL_TYPES[depth]}

def slugify(text: str) -> str:
    """Convert text to a URL-safe slug."""
    text = text.lower().strip()
    text = re.sub(r"[^\w\s-]", "", text)
    return re.sub(r"[\s_]+", "-", text).strip("-")

def read_s3_text(s3_client, bucket: str, key: str) -> str:
    """Read a text file from S3."""
    obj = s3_client.get_object(Bucket=bucket, Key=key)
    return obj["Body"].read().decode("utf-8")

def read_s3_json(s3_client, bucket: str, key: str) -> dict:
    """Read and parse a JSON file from S3."""
    return json.loads(read_s3_text(s3_client, bucket, key))

def detect_content_format(filename: str) -> str:
    """Detect content format from file extension."""
    ext = PurePosixPath(filename).suffix.lower()
    return {".md": "markdown", ".html": "html", ".json": "json"}.get(ext, "markdown")

def extract_order(filename: str) -> int | None:
    """Extract numeric order from filename like '01-basics.md' → 1."""
    match = re.match(r"^(\d+)", PurePosixPath(filename).stem)
    return int(match.group(1)) if match else None

def extract_title(filename: str) -> str:
    """Convert filename to title: '01-basics.md' → 'Basics'."""
    stem = PurePosixPath(filename).stem
    # Remove leading numbers and separator
    stem = re.sub(r"^\d+[-_]", "", stem)
    return stem.replace("-", " ").replace("_", " ").title()

def generate_presigned_url(s3_client, bucket: str, key: str, expiry: int = 3600) -> str:
    """Generate a presigned URL for an S3 object (for media upload)."""
    return s3_client.generate_presigned_url(
        "get_object",
        Params={"Bucket": bucket, "Key": key},
        ExpiresIn=expiry,
    )

def upload_media_url(source_url: str, filename: str) -> str | None:
    """Upload a media file to Mentra via presigned URL and return the CDN URL."""
    resp = requests.post(
        f"{MENTRA_BASE_URL}/media/upload-url",
        headers=HEADERS,
        json={"sourceUrl": source_url, "filename": filename},
    )
    if resp.status_code == 200:
        return resp.json().get("cdnUrl") or resp.json().get("originalUrl")
    print(f"  ⚠ Media upload failed for {filename}: {resp.status_code} {resp.text}")
    return None

# ─── S3 Tree Scanner ─────────────────────────────────────────────
def scan_s3_tree(s3_client, bucket: str, prefix: str) -> dict:
    """
    Scan S3 and build a nested dict representing the folder structure.
    Returns: { 'folders': { name: subtree }, 'files': [key1, key2, ...] }
    """
    paginator = s3_client.get_paginator("list_objects_v2")
    tree: dict = {"folders": {}, "files": []}

    for page in paginator.paginate(Bucket=bucket, Prefix=prefix, Delimiter="/"):
        # Files at this level
        for obj in page.get("Contents", []):
            rel = obj["Key"][len(prefix):]
            if rel:  # Skip the prefix itself
                tree["files"].append(obj["Key"])

        # Subfolders
        for cp in page.get("CommonPrefixes", []):
            folder_prefix = cp["Prefix"]
            folder_name = folder_prefix[len(prefix):].rstrip("/")
            tree["folders"][folder_name] = scan_s3_tree(s3_client, bucket, folder_prefix)

    return tree

# ─── Import Logic ────────────────────────────────────────────────
def path_external_id(prefix: str, rel_path: str) -> str:
    """A collision-free externalId derived from the full source path.

    Deriving it from the basename alone is not enough: two folders can each
    hold an `01-introduction.md`, and since both nodes also share a Section and
    a node type, the import treats them as ONE node — the request is rejected
    as a duplicate address, or across separate batches the second silently
    overwrites the first. Slashes are legal in an externalId, so keep the path
    shape and slugify each segment.

    Note: changing how ids are derived changes which nodes a re-run targets. If
    you already imported with an older version of this script, the next run
    creates new nodes rather than updating the old ones.
    """
    stem = re.sub(r"\.[^./]+$", "", rel_path)
    parts = [slugify(part) for part in stem.split("/")]
    return f"{prefix}-{'/'.join(p for p in parts if p)}"

def build_leaf_items(s3_client, bucket: str, tree: dict, leaf_depth: int,
                     parent_external_id: str | None, trail: str = "") -> list[dict]:
    """Content files in this folder → leaf items of the chain.

    ``trail`` is this folder's path relative to S3_PREFIX, so leaf ids carry
    their ancestors and stay unique across identically-named files.
    """
    content_files = [
        f for f in tree["files"]
        if PurePosixPath(f).suffix.lower() in (".md", ".html", ".json")
        and PurePosixPath(f).name != "meta.json"
    ]
    items = []
    for cf in sorted(content_files):
        fname = PurePosixPath(cf).name
        item = {
            **address(leaf_depth),
            "externalId": path_external_id("s3", f"{trail}{fname}"),
            "title": extract_title(fname),
            "content": read_s3_text(s3_client, bucket, cf),
            "contentFormat": detect_content_format(fname),
            "order": extract_order(fname),
            "locale": LOCALE,
        }
        if parent_external_id:
            item["parentExternalId"] = parent_external_id
        items.append(item)
    return items

def build_import_items(s3_client, bucket: str, tree: dict, depth: int = 0,
                       parent_external_id: str | None = None,
                       trail: str = "") -> list[dict]:
    """
    Recursively walk the S3 tree and build BulkImportItemDto objects.

    Folder depth maps onto structure_chain(): depth 0 → chain[0], depth 1 →
    chain[1], and so on. Content files inside the deepest folder level become
    chain[-1] items. With the default Curriculum chain that is exactly the
    familiar mapping: folders → level1/2/3, files → level4.
    """
    items = []
    chain = structure_chain()
    leaf_depth = len(chain) - 1

    if leaf_depth == 0:
        # A single-type Section (e.g. the Flat 'Pages' template): every
        # content file is a root node, wherever it sits in the bucket.
        items.extend(build_leaf_items(s3_client, bucket, tree, 0, None, trail))
        for folder_name, subtree in sorted(tree["folders"].items()):
            if folder_name.lower() not in SKIP_FOLDERS:
                items.extend(build_import_items(
                    s3_client, bucket, subtree, trail=f"{trail}{folder_name}/"
                ))
        return items

    for folder_name, subtree in sorted(tree["folders"].items()):
        # Skip 'images' folders — those are media, not content
        if folder_name.lower() in SKIP_FOLDERS:
            continue

        if depth > leaf_depth - 1:
            print(f"  ⚠ Skipping deep folder: {folder_name} "
                  f"(structure has {len(chain)} levels)")
            continue

        # Read optional meta.json for this folder
        meta = {}
        meta_files = [f for f in subtree["files"]
                      if PurePosixPath(f).name == "meta.json"]
        if meta_files:
            try:
                meta = read_s3_json(s3_client, bucket, meta_files[0])
            except Exception as e:
                print(f"  ⚠ Could not read {meta_files[0]}: {e}")

        folder_path = f"{trail}{folder_name}"
        external_id = path_external_id("s3", folder_path)
        title = meta.get("title", folder_name.replace("-", " ").replace("_", " ").title())

        # Handle cover image
        cover_url = None
        cover_files = [f for f in subtree["files"]
                       if PurePosixPath(f).name.lower().startswith("cover")]
        if cover_files and not DRY_RUN:
            presigned = generate_presigned_url(s3_client, bucket, cover_files[0])
            cover_url = upload_media_url(presigned, PurePosixPath(cover_files[0]).name)

        folder_item = {
            **address(depth),
            "externalId": external_id,
            "title": title,
            "summary": meta.get("summary"),
            "keywords": meta.get("keywords", []),
            "coverImageUrl": cover_url,
            "locale": LOCALE,
        }
        if parent_external_id:
            folder_item["parentExternalId"] = parent_external_id

        items.append(folder_item)

        if depth < leaf_depth - 1:
            items.extend(build_import_items(
                s3_client, bucket, subtree, depth + 1, external_id,
                trail=f"{folder_path}/",
            ))
        else:
            items.extend(build_leaf_items(
                s3_client, bucket, subtree, leaf_depth, external_id,
                trail=f"{folder_path}/",
            ))

    return items

def chunk_items(items: list[dict], size: int = 100) -> list[list[dict]]:
    """Split into API-sized batches without separating a parent from its subtree.

    The walk emits depth-first, so a root item is followed by its whole subtree.
    Packing whole subtrees keeps `parentExternalId` resolvable *within* each
    request, which is what DRY_RUN needs: a preview writes nothing, so a child
    in a later batch cannot see a parent the earlier batch only pretended to
    create, and would be reported as failed even though the live run succeeds.

    A single subtree larger than `size` still has to be split — the API caps a
    request at 100 items — so only that subtree carries the caveat, and the
    script says so rather than letting it look like a real failure.
    """
    groups: list[list[dict]] = []
    for item in items:
        if not item.get("parentExternalId") or not groups:
            groups.append([item])
        else:
            groups[-1].append(item)

    batches: list[list[dict]] = []
    batch: list[dict] = []
    for group in groups:
        if len(group) > size:
            if batch:
                batches.append(batch)
                batch = []
            for i in range(0, len(group), size):
                batches.append(group[i:i + size])
            if DRY_RUN:
                root = group[0].get("externalId")
                print(f"  ⚠ Subtree '{root}' exceeds {size} items and had to be "
                      f"split. In DRY_RUN its children will report an unresolved "
                      f"parentExternalId; a real run resolves them.")
            continue
        if batch and len(batch) + len(group) > size:
            batches.append(batch)
            batch = []
        batch.extend(group)
    if batch:
        batches.append(batch)
    return batches

def send_import_batch(items: list[dict], dry_run: bool = True) -> dict:
    """Send a batch of items to the Mentra Bulk Import API."""
    payload = {
        "items": items,  # already capped by chunk_items()
        "onConflict": "update",  # Upsert — safe for re-runs
        "dryRun": dry_run,
    }
    resp = requests.post(
        f"{MENTRA_BASE_URL}/import",
        headers=HEADERS,
        json=payload,
    )
    resp.raise_for_status()
    return resp.json()

# ─── Main ────────────────────────────────────────────────────────
def main():
    s3 = boto3.client("s3", region_name=AWS_REGION)

    print(f"Scanning s3://{S3_BUCKET}/{S3_PREFIX} ...")
    tree = scan_s3_tree(s3, S3_BUCKET, S3_PREFIX)

    print("Building import items...")
    items = build_import_items(s3, S3_BUCKET, tree)
    print(f"Found {len(items)} items to import.\n")

    # Batch in groups of 100
    for batch_num, batch in enumerate(chunk_items(items), start=1):
        mode_label = "DRY RUN" if DRY_RUN else "IMPORTING"
        print(f"[{mode_label}] Batch {batch_num} ({len(batch)} items)...")

        result = send_import_batch(batch, dry_run=DRY_RUN)

        for r in result.get("results", []):
            action = r.get("action", "unknown")
            title = r.get("title", r.get("externalId", "?"))
            symbol = {"would_create": "➕", "would_update": "✏️",
                      "would_skip": "⏭️", "created": "✅",
                      "updated": "✏️", "skipped": "⏭️",
                      "failed": "❌"}.get(action, "?")
            print(f"  {symbol} {action}: {title}")

        # Summary
        summary = result.get("summary", {})
        print(f"  → {summary}\n")

    if DRY_RUN:
        print("✅ Dry run complete. Review the output above.")
        print("   Set DRY_RUN = False and run again to import for real.")
    else:
        print("✅ Import complete!")

if __name__ == "__main__":
    main()

meta.json Format

Optional metadata file in each folder. All fields are optional.

{
  "title": "Supply and Demand",
  "summary": "Understanding the fundamental forces of market economics.",
  "keywords": ["economics", "supply", "demand", "market"]
}

Media Handling

The script generates presigned URLs for S3 images and uploads them to Mentra via the POST /media/upload-url endpoint. The returned CDN URL is used as coverImageUrl.

# 1. Generate a temporary URL for the S3 object
presigned = s3.generate_presigned_url(
    "get_object",
    Params={"Bucket": "my-bucket", "Key": "images/cover.jpg"},
    ExpiresIn=3600  # Valid for 1 hour
)

# 2. Pass it to Mentra — Mentra downloads and stores the file
resp = requests.post(
    f"{MENTRA_BASE_URL}/media/upload-url",
    headers=headers,
    json={"sourceUrl": presigned, "filename": "cover.jpg"}
)
cdn_url = resp.json()["cdnUrl"]  # Use this in coverImageUrl

Import from GitHub
write:content

Overview

Migrate documentation or learning content from a GitHub repository into Mentra. The script clones your repo (or uses the GitHub API) and maps the folder structure to Mentra's content hierarchy.

Repo → Hierarchy Mapping

my-docs-repo/
├── content/                          → Root folder (configurable)
│   ├── personal-finance/             → Level 1
│   │   ├── _index.md                 → Level 1 metadata (title, summary)
│   │   ├── budgeting/                → Level 2
│   │   │   ├── _index.md
│   │   │   ├── zero-based/           → Level 3
│   │   │   │   ├── _index.md
│   │   │   │   ├── 01-introduction.md → Level 4 (Step), order = 1
│   │   │   │   └── 02-worksheets.md  → Level 4, order = 2
│   │   │   └── envelope-method/
│   │   │       └── ...
│   │   └── investing/
│   │       └── ...
│   └── career-development/
│       └── ...
├── static/                           → Media assets (optional)
│   └── images/
│       └── budget-chart.png
└── README.md                         → Ignored
Content Convention

_index.md in each folder provides title/summary for that level. Other .md files become Steps.

Media References

Images can reference GitHub raw URLs or be uploaded via the Media API using /media/upload-url

Prerequisites

pip install requests
Mentra API Key with write:content scope (add write:media if uploading images)
GitHub Personal Access Token (for private repos) or public repo URL
Git installed (the script clones the repo locally)

Complete Import Script

"""
GitHub → Mentra Import Script
=============================
Clones a GitHub repo and maps its folder structure onto a Section, whatever
its structure template.

Conventions:
  - _index.md in a folder = metadata for that level (title, summary)
  - Numbered files (01-topic.md) = leaf nodes, sorted by number
  - Subfolders = next level of the target's node-type chain
  - static/ or images/ folders = media assets

Usage:
  1. Set the configuration variables below
  2. Run with DRY_RUN = True to preview
  3. Review the output
  4. Set DRY_RUN = False to execute
"""
import os
import re
import json
import shutil
import subprocess
import requests
from pathlib import Path

# ─── Configuration ───────────────────────────────────────────────
MENTRA_API_KEY = os.environ.get("MENTRA_API_KEY", "your-api-key-here")
MENTRA_BASE_URL = "https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1"

# GitHub settings
GITHUB_REPO = "https://github.com/your-org/your-docs-repo.git"
GITHUB_BRANCH = "main"
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "")  # For private repos
CONTENT_ROOT = "content"  # Subfolder in repo that contains content

LOCALE = "en-US"
DRY_RUN = True  # ← Start with True!
CLONE_DIR = "/tmp/mentra-github-import"  # Temporary clone location

# ─── Structure target ────────────────────────────────────────────
# Leave SECTION_KEY = None to import into the Curriculum ladder
# (type level1..4). Set it to a Section key and list that Section's node
# types root → leaf in NODE_TYPE_CHAIN to import into any other shape —
# both come from GET /v1/sections. The chain can be any length.
#
#   SECTION_KEY = "library"
#   NODE_TYPE_CHAIN = ["category", "article"]
#
# Note: only node types flagged content_bearing accept a body, so a folder
# whose _index.md carries content must map to a content_bearing type — the
# import reports 'not_content_bearing' per row rather than dropping it.
SECTION_KEY = None
NODE_TYPE_CHAIN = []

# ─── Helpers ─────────────────────────────────────────────────────
HEADERS = {
    "Authorization": f"Bearer {MENTRA_API_KEY}",
    "Content-Type": "application/json",
}

LEVEL_TYPES = ["level1", "level2", "level3", "level4"]

def structure_chain() -> list[str]:
    """The root → leaf chain folder depth maps onto."""
    if SECTION_KEY:
        if not NODE_TYPE_CHAIN:
            raise SystemExit("Set NODE_TYPE_CHAIN when SECTION_KEY is set.")
        return NODE_TYPE_CHAIN
    return LEVEL_TYPES

def address(depth: int) -> dict:
    """The item fields that name a target at this depth in the chain."""
    if SECTION_KEY:
        return {
            "type": "node",
            "sectionKey": SECTION_KEY,
            "nodeTypeKey": NODE_TYPE_CHAIN[depth],
        }
    return {"type": LEVEL_TYPES[depth]}

def slugify(text: str) -> str:
    """Convert text to a URL-safe slug."""
    text = text.lower().strip()
    text = re.sub(r"[^\w\s-]", "", text)
    return re.sub(r"[\s_]+", "-", text).strip("-")

def detect_content_format(filepath: Path) -> str:
    """Detect content format from file extension."""
    return {".md": "markdown", ".html": "html", ".json": "json"}.get(
        filepath.suffix.lower(), "markdown"
    )

def extract_order(filepath: Path) -> int | None:
    """Extract numeric order from filename like '01-basics.md' → 1."""
    match = re.match(r"^(\d+)", filepath.stem)
    return int(match.group(1)) if match else None

def extract_title_from_filename(filepath: Path) -> str:
    """Convert filename to title: '01-basics.md' → 'Basics'."""
    stem = re.sub(r"^\d+[-_]", "", filepath.stem)
    return stem.replace("-", " ").replace("_", " ").title()

def parse_frontmatter(content: str) -> tuple[dict, str]:
    """
    Parse YAML frontmatter from Markdown.
    Returns (metadata_dict, content_without_frontmatter).
    """
    if not content.startswith("---"):
        return {}, content
    parts = content.split("---", 2)
    if len(parts) < 3:
        return {}, content
    # Simple key: value parsing (no PyYAML dependency)
    meta = {}
    for line in parts[1].strip().split("\n"):
        if ":" in line:
            key, val = line.split(":", 1)
            val = val.strip().strip('"').strip("'")
            meta[key.strip()] = val
    return meta, parts[2].strip()

def clone_repo():
    """Clone or update the GitHub repository."""
    if Path(CLONE_DIR).exists():
        print(f"Removing existing clone at {CLONE_DIR}...")
        shutil.rmtree(CLONE_DIR)

    clone_url = GITHUB_REPO
    if GITHUB_TOKEN and "github.com" in clone_url:
        # Inject token for private repos
        clone_url = clone_url.replace(
            "https://github.com",
            f"https://{GITHUB_TOKEN}@github.com"
        )

    print(f"Cloning {GITHUB_REPO} (branch: {GITHUB_BRANCH})...")
    subprocess.run(
        ["git", "clone", "--depth", "1", "--branch", GITHUB_BRANCH, clone_url, CLONE_DIR],
        check=True,
        capture_output=True,
    )
    print("Clone complete.\n")

def github_raw_url(repo: str, branch: str, filepath: str) -> str:
    """Build a raw.githubusercontent.com URL for an image."""
    # Extract owner/repo from clone URL
    match = re.search(r"github\.com[/:]([^/]+/[^/.]+)", repo)
    if match:
        return f"https://raw.githubusercontent.com/{match.group(1)}/{branch}/{filepath}"
    return ""

# ─── Import Logic ────────────────────────────────────────────────
SKIP_DIRS = {"images", "media", "assets", "_assets", "static", ".git", "node_modules"}

def path_external_id(prefix: str, rel_path: str) -> str:
    """A collision-free externalId derived from the full source path.

    Deriving it from the basename alone is not enough: two chapters can each
    hold an `01-introduction.md`, and since both nodes also share a Section and
    a node type, the import treats them as ONE node — the request is rejected
    as a duplicate address, or across separate batches the second silently
    overwrites the first. Slashes are legal in an externalId, so keep the path
    shape and slugify each segment.

    Note: changing how ids are derived changes which nodes a re-run targets. If
    you already imported with an older version of this script, the next run
    creates new nodes rather than updating the old ones.
    """
    stem = re.sub(r"\.[^./]+$", "", rel_path)
    parts = [slugify(part) for part in stem.split("/")]
    return f"{prefix}-{'/'.join(p for p in parts if p)}"

def collect_leaf_files(folder: Path, leaf_depth: int,
                       parent_external_id: str | None, base: Path) -> list[dict]:
    """Content files in this folder → leaf items of the chain."""
    content_files = sorted([
        f for f in folder.iterdir()
        if f.is_file()
        and f.suffix.lower() in (".md", ".html", ".json")
        and f.name != "_index.md"
    ])
    items = []
    for cf in content_files:
        fm, content = parse_frontmatter(cf.read_text(encoding="utf-8"))
        item = {
            **address(leaf_depth),
            "externalId": path_external_id("gh", str(cf.relative_to(base))),
            "title": fm.get("title", extract_title_from_filename(cf)),
            "content": content,
            "contentFormat": detect_content_format(cf),
            "order": extract_order(cf),
            "locale": LOCALE,
        }
        if parent_external_id:
            item["parentExternalId"] = parent_external_id
        items.append(item)
    return items

def scan_directory(root: Path, depth: int = 0,
                   parent_external_id: str | None = None,
                   base: Path | None = None) -> list[dict]:
    """
    Recursively walk directory and build BulkImportItemDto objects.

    Folder depth maps onto structure_chain(): depth 0 → chain[0], depth 1 →
    chain[1], and so on. Files inside the deepest folder level become
    chain[-1] items. With the default Curriculum chain that is the familiar
    mapping: folders → level1/2/3, files → level4.
    """
    items = []
    chain = structure_chain()
    leaf_depth = len(chain) - 1
    # externalIds are derived from paths relative to the content root, so the
    # top-level call fixes the base for the whole walk.
    base = base if base is not None else root

    if not root.is_dir():
        return items

    # Sorted for deterministic ordering
    subdirs = sorted([d for d in root.iterdir() if d.is_dir() and d.name not in SKIP_DIRS])

    if leaf_depth == 0:
        # A single-type Section (e.g. the Flat 'Pages' template): every
        # content file is a root node, wherever it sits in the repo.
        items.extend(collect_leaf_files(root, 0, None, base))
        for folder in subdirs:
            items.extend(scan_directory(folder, base=base))
        return items

    for folder in subdirs:
        if depth > leaf_depth - 1:
            print(f"  ⚠ Skipping deep folder: {folder.name} "
                  f"(structure has {len(chain)} levels)")
            continue

        external_id = path_external_id("gh", str(folder.relative_to(base)))

        # Read _index.md for metadata
        meta = {}
        body = None
        index_file = folder / "_index.md"
        if index_file.exists():
            raw = index_file.read_text(encoding="utf-8")
            meta, body = parse_frontmatter(raw)

        title = meta.get("title", folder.name.replace("-", " ").replace("_", " ").title())

        folder_item = {
            **address(depth),
            "externalId": external_id,
            "title": title,
            "summary": meta.get("summary") or meta.get("description"),
            "keywords": [k.strip() for k in meta.get("keywords", "").split(",")] if meta.get("keywords") else [],
            "locale": LOCALE,
        }
        if body:
            folder_item["content"] = body
            folder_item["contentFormat"] = "markdown"
        if parent_external_id:
            folder_item["parentExternalId"] = parent_external_id

        items.append(folder_item)

        # Recurse or collect files
        if depth < leaf_depth - 1:
            items.extend(scan_directory(folder, depth + 1, external_id, base))
        else:
            items.extend(collect_leaf_files(folder, leaf_depth, external_id, base))

    return items

def chunk_items(items: list[dict], size: int = 100) -> list[list[dict]]:
    """Split into API-sized batches without separating a parent from its subtree.

    The walk emits depth-first, so a root item is followed by its whole subtree.
    Packing whole subtrees keeps `parentExternalId` resolvable *within* each
    request, which is what DRY_RUN needs: a preview writes nothing, so a child
    in a later batch cannot see a parent the earlier batch only pretended to
    create, and would be reported as failed even though the live run succeeds.

    A single subtree larger than `size` still has to be split — the API caps a
    request at 100 items — so only that subtree carries the caveat, and the
    script says so rather than letting it look like a real failure.
    """
    groups: list[list[dict]] = []
    for item in items:
        if not item.get("parentExternalId") or not groups:
            groups.append([item])
        else:
            groups[-1].append(item)

    batches: list[list[dict]] = []
    batch: list[dict] = []
    for group in groups:
        if len(group) > size:
            if batch:
                batches.append(batch)
                batch = []
            for i in range(0, len(group), size):
                batches.append(group[i:i + size])
            if DRY_RUN:
                root = group[0].get("externalId")
                print(f"  ⚠ Subtree '{root}' exceeds {size} items and had to be "
                      f"split. In DRY_RUN its children will report an unresolved "
                      f"parentExternalId; a real run resolves them.")
            continue
        if batch and len(batch) + len(group) > size:
            batches.append(batch)
            batch = []
        batch.extend(group)
    if batch:
        batches.append(batch)
    return batches

def send_import_batch(items: list[dict], dry_run: bool = True) -> dict:
    """Send a batch of items to the Mentra Bulk Import API."""
    payload = {
        "items": items,
        "onConflict": "update",
        "dryRun": dry_run,
    }
    resp = requests.post(
        f"{MENTRA_BASE_URL}/import",
        headers=HEADERS,
        json=payload,
    )
    resp.raise_for_status()
    return resp.json()

# ─── Main ────────────────────────────────────────────────────────
def main():
    clone_repo()

    content_root = Path(CLONE_DIR) / CONTENT_ROOT
    if not content_root.exists():
        print(f"ERROR: Content root '{CONTENT_ROOT}' not found in repo.")
        print(f"Available top-level folders: {[d.name for d in Path(CLONE_DIR).iterdir() if d.is_dir()]}")
        return

    print(f"Scanning {content_root}...")
    items = scan_directory(content_root)
    print(f"Found {len(items)} items to import.\n")

    if not items:
        print("No content found. Check your CONTENT_ROOT setting and folder structure.")
        return

    # Batch in groups of 100
    for batch_num, batch in enumerate(chunk_items(items), start=1):
        mode_label = "DRY RUN" if DRY_RUN else "IMPORTING"
        print(f"[{mode_label}] Batch {batch_num} ({len(batch)} items)...")

        result = send_import_batch(batch, dry_run=DRY_RUN)

        for r in result.get("results", []):
            action = r.get("action", "unknown")
            title = r.get("title", r.get("externalId", "?"))
            symbol = {"would_create": "➕", "would_update": "✏️",
                      "would_skip": "⏭️", "created": "✅",
                      "updated": "✏️", "skipped": "⏭️",
                      "failed": "❌"}.get(action, "?")
            print(f"  {symbol} {action}: {title}")

        summary = result.get("summary", {})
        print(f"  → {summary}\n")

    # Cleanup
    print(f"Cleaning up {CLONE_DIR}...")
    shutil.rmtree(CLONE_DIR, ignore_errors=True)

    if DRY_RUN:
        print("✅ Dry run complete. Review the output above.")
        print("   Set DRY_RUN = False and run again to import for real.")
    else:
        print("✅ Import complete!")

if __name__ == "__main__":
    main()

_index.md Frontmatter Format

Each folder can contain an _index.md with YAML frontmatter for metadata. All fields are optional.

---
title: "Zero-Based Budgeting"
summary: "A method where every dollar is assigned a purpose."
keywords: "budgeting, finance, zero-based"
---

Optional body content for this level goes here.
This will be stored as the content of the Level 3 item.

Media Handling for GitHub

For public repos, you can reference images directly using raw.githubusercontent.com URLs as coverImageUrl. For private repos, upload images via the Media API.

# Raw GitHub URL pattern:
https://raw.githubusercontent.com/your-org/your-repo/main/static/images/cover.png

# Use directly as coverImageUrl in the import item:
{
  "type": "level1",
  "externalId": "gh-personal-finance",
  "title": "Personal Finance",
  "coverImageUrl": "https://raw.githubusercontent.com/your-org/your-repo/main/static/images/finance-cover.png"
}
# Read the file from the cloned repo
with open("static/images/cover.png", "rb") as f:
    resp = requests.post(
        f"{MENTRA_BASE_URL}/media/upload",
        headers={"Authorization": f"Bearer {MENTRA_API_KEY}"},
        files={"file": ("cover.png", f, "image/png")},
    )
cdn_url = resp.json()["cdnUrl"]

Recommended Workflow

  1. Run with DRY_RUN = True to validate structure and preview actions
  2. Review the output — check that titles and hierarchy look correct
  3. Fix any folder naming or _index.md frontmatter issues in your repo
  4. Set DRY_RUN = False and run again to import
  5. Verify in the Mentra CMS that your content hierarchy is correct

Response Structure

API responses follow a consistent structure across all endpoints.

1. List Endpoint Response (Metadata Only)

Returned by the standalone content list endpoints (GET /standalone, GET /standalone/summary). Contains paginated metadata to help you discover content. Other list endpoints use a different shape: GET /sections and GET /surfaces return only { items: [...] } (no pagination — a Section or Surface is fetched whole), and GET /media returns { items, total, limit, offset }.

{
"items": [
  {
    "id": "L1_123",
    "title": "Effective Communication",
    "summary": "Master the art of clear communication...",
    "keywords": ["communication", "listening", "speaking"],
    "slug": "effective-communication",
    "author": {
      "id": "auth_123",
      "name": "Dr. Sarah Smith",
      "title": "Cognitive Psychologist",
      "bio": "Expert in memory and learning...",
      "avatarUrl": "https://..."
    },
    "createdAt": "2024-01-15T10:00:00Z",
    "updatedAt": "2024-11-20T14:30:00Z",
    "publishedAt": "2024-01-20T09:00:00Z"
  }
],
"page": 1,
"pageSize": 20,
"total": 42,
"hasNext": true,
"nextPage": 2
}

2. Detail Endpoint Response (Full Content)

Returned by all GET /resource/{id} endpoints. Contains the complete content, including HTML, media assets, and AI-generated extensions.

{
"id": "L4_123",
"title": "Active Listening Techniques",
"summary": "Learn proven techniques for better listening...",
"keywords": ["listening", "empathy", "attention"],
"slug": "active-listening-techniques",

"author": {
  "id": "auth_123",
  "name": "Dr. Sarah Smith",
  "title": "Cognitive Psychologist",
  "bio": "Expert in memory and learning...",
  "avatarUrl": "https://..."
},

// Layout (if include=layout)
"layout": [
  {
    "id": "block_123",
    "type": "hero",
    "props": {
      "heading": "Active Listening Techniques", // Auto-resolved from step.title
      "subheading": "Master the art...",
      "imageUrl": "https://..."
    }
  },
  {
    "type": "richText",
    "props": {
      "content": "<p>...</p>" // Auto-resolved from step.contentHtml
    }
  }
],

// Full Content
"contentHtml": "<h1>Active Listening</h1><p>...</p>",
"contentJson": {...},  // Structured content blocks
"contentPlainText": "Active Listening...",

// Media Assets
"media": {
  "heroImage": "https://...",
  "images": ["https://..."],
  "videos": []
},

// SEO Metadata
"seoMetadata": {
  "metaTitle": "Active Listening Techniques | Mentra",
  "metaDescription": "Learn proven techniques for better listening...",
  "focusKeywords": ["listening", "communication"],
  "canonicalUrl": "https://...",
  "readingTime": 5
},

// AI-Generated Extensions (See "AI Extension Schemas" below for details)
"mnemonicSteps": [...],
"quizData": [...],
"scenarios": [...],

// Timestamps
"createdAt": "2024-01-15T10:00:00Z",
"updatedAt": "2024-11-20T14:30:00Z",
"publishedAt": "2024-01-20T09:00:00Z"
}

📝 Content Format Details (v1.4.0)

contentHtml— Purely semantic HTML5 markup (headings, paragraphs, figures, lists, etc.). No CSS classes or inline styles are included. You are responsible for applying your own styling. Images use CDN URLs with semantic <figure>/<figcaption> tags and alignment/size hint classes (see Rich Text section below).
contentJson— TipTap/ProseMirror JSON document spec. Use this if you need fine-grained control over rendering (e.g., custom block renderers). See the TipTap JSON documentation for the node type reference.
contentPlainText— Pre-computed plain text extraction. Ideal for search indexing, SEO meta descriptions, and AI processing. Whitespace is normalized and image alt text is preserved.

3. Rich Text & Media Handling

The contentHtml field contains purely semantic HTML5 markup with no embedded styles or CSS framework classes. Images with captions are rendered using semantic <figure> and <figcaption> tags.

Image HTML Structure

Images include semantic data-* attributes for alignment and sizing metadata. These are not CSS classes — you should read them and apply your own styles accordingly.

<!-- Image with caption -->
<figure>
  <img src="https://ik.imagekit.io/..." alt="Diagram of active listening" data-size="large" data-align="center">
  <figcaption>Figure 1: The Active Listening Cycle</figcaption>
</figure>

<!-- Image without caption -->
<img src="https://ik.imagekit.io/..." alt="Quick reference chart" data-size="medium" data-align="left">
data-align Values
  • left — Float left
  • center — Centered block
  • right — Float right
data-size Values
  • small — 25% width
  • medium — 50% width
  • large — 75% width
  • xl — 100% width

💡 Styling tip: Use CSS attribute selectors to target these values: img[data-size="large"] or img[data-align="center"]. Images without captions render as standalone <img> tags; images with captions are wrapped in <figure>.

4. AI Extension Schemas

Detailed structure of the AI-generated enrichment fields.

Author Schema (`author`)

Information about the content creator.

{
  "id": "auth_123",
  "name": "Dr. Sarah Smith",
  "title": "Cognitive Psychologist",
  "bio": "Expert in memory and learning retention strategies.",
  "avatarUrl": "https://storage.googleapis.com/.../avatar.jpg"
}

Mnemonic Steps (`mnemonicSteps`)

An array of steps to help memorize the content, often including generated images or videos.

[
{
  "keyword": "LISTEN",
  "description": "Imagine a giant ear walking down the street...",
  "imageUrl": "https://storage.googleapis.com/.../image.png", // Optional
  "videoUrl": "https://storage.googleapis.com/.../video.mp4"  // Optional
}
]

Quiz Data (`quizData`)

Multiple choice questions for testing understanding.

[
  {
    "questionId": "q1",
    "question": "What is active listening?",
    "options": ["Hearing sounds", "Listening to understand", "Ignoring speaker"],
    "correctOptionIndex": 1,
    "explanation": "Active listening is about engagement..."
  }
]

5. Layout Structure (Headless Page Builder)

When requesting include=layout, the API returns a resolved layout array. This array represents the visual structure defined in the Page Builder, with all dynamic data already filled in.

This allows you to render the page exactly as designed without handling data mapping on the client side.

Block Structure

{
  "id": "unique_block_id",
  "type": "hero | richText | features | faq | ...",
  "props": {
    // Properties specific to the block type.
    // Dynamic placeholders (e.g. {{step.title}}) are already replaced with actual content.
    "heading": "Actual Title",
    "content": "<p>Resolved HTML content...</p>",
    "style": "primary"
  }
}

Custom Rendering with contentJson

Every content item includes a contentJson field — the structured TipTap/ProseMirror JSON document that is the source of truth for all content. Use it when you need fine-grained control over rendering instead of consuming the pre-rendered contentHtml.

Use contentHtml when…

  • You want quick integration with minimal code
  • You’re rendering in a non-React environment (server-side, email, etc.)
  • You’re fine applying CSS to semantic HTML

Use contentJson when…

  • You want to map each node to your own React/Vue/Svelte components
  • You need to transform content (e.g. lazy-load images, add analytics)
  • You’re building a native mobile app

Document Structure

The root is always a doc node containing an array of block nodes. Each node has a type, optional attrs, and optional content (child nodes). Text nodes additionally carry a marks array for inline formatting.

{
  "type": "doc",
  "content": [
    {
      "type": "heading",
      "attrs": { "level": 2 },
      "content": [
        {
          "type": "text",
          "text": "Getting Started",
          "marks": []
        }
      ]
    },
    {
      "type": "paragraph",
      "content": [
        {
          "type": "text",
          "text": "Welcome to the course. This section covers "
        },
        {
          "type": "text",
          "text": "key concepts",
          "marks": [{ "type": "bold" }]
        },
        {
          "type": "text",
          "text": " you need to know."
        }
      ]
    }
  ]
}

Node Type Reference

The complete set of node types you may encounter in contentJson. This is the full surface area — there are no other node types.

Node TypeHTML EquivalentAttributesHas Children
doc(root container)
paragraph<p>
heading<h1><h6>level (1–6)
bulletList<ul>
orderedList<ol>
listItem<li>
blockquote<blockquote>
codeBlock<pre><code>language (string)
hardBreak<br>
horizontalRule<hr>
image<img> / <figure>src alt caption size alignment
table<table>
tableRow<tr>
tableHeader / tableCell<th> / <td>

Node Examples

Heading with level

{
  "type": "heading",
  "attrs": { "level": 2 },
  "content": [
    { "type": "text", "text": "Chapter Title" }
  ]
}

Code block with language

{
  "type": "codeBlock",
  "attrs": { "language": "python" },
  "content": [
    { "type": "text", "text": "def hello():\n    print('Hello, world!')" }
  ]
}

Nested list

{
  "type": "bulletList",
  "content": [
    {
      "type": "listItem",
      "content": [
        {
          "type": "paragraph",
          "content": [
            { "type": "text", "text": "First item" }
          ]
        }
      ]
    },
    {
      "type": "listItem",
      "content": [
        {
          "type": "paragraph",
          "content": [
            { "type": "text", "text": "Second item" }
          ]
        }
      ]
    }
  ]
}

Table

{
  "type": "table",
  "content": [
    {
      "type": "tableRow",
      "content": [
        {
          "type": "tableHeader",
          "content": [
            { "type": "paragraph", "content": [{ "type": "text", "text": "Term" }] }
          ]
        },
        {
          "type": "tableHeader",
          "content": [
            { "type": "paragraph", "content": [{ "type": "text", "text": "Definition" }] }
          ]
        }
      ]
    },
    {
      "type": "tableRow",
      "content": [
        {
          "type": "tableCell",
          "content": [
            { "type": "paragraph", "content": [{ "type": "text", "text": "API" }] }
          ]
        },
        {
          "type": "tableCell",
          "content": [
            { "type": "paragraph", "content": [{ "type": "text", "text": "Application Programming Interface" }] }
          ]
        }
      ]
    }
  ]
}

Mark Type Reference

Marks are inline formatting applied to text nodes via the marks array. Multiple marks can be stacked on a single text node.

Mark TypeHTML EquivalentAttributes
bold<strong>
italic<em>
underline<u>
strike<s>
code<code>
link<a>href target

Text node with multiple marks

Marks are composable. A single text node can have bold, italic, and a link simultaneously:

{
  "type": "text",
  "text": "click here",
  "marks": [
    { "type": "bold" },
    { "type": "italic" },
    {
      "type": "link",
      "attrs": {
        "href": "https://example.com",
        "target": "_blank"
      }
    }
  ]
}

Renders as: <a href="https://example.com" target="_blank"><em><strong>click here</strong></em></a>

Image Node Deep Dive

The image node is the most attribute-rich node type. All image URLs are pre-resolved to ImageKit CDN URLs at publish time — you do not need to perform any URL transformation.

{
  "type": "image",
  "attrs": {
    "src": "https://ik.imagekit.io/mentra/content/tr:w-800,q-85,f-webp/image.jpg",
    "alt": "Diagram showing the feedback loop",
    "caption": "Figure 1: The Feedback Loop",
    "size": "large",
    "alignment": "center"
  }
}
AttributeTypeRequiredDescription
srcstringCDN image URL (already optimized)
altstringAlt text for accessibility
captionstring | nullNoIf present, render as <figure> + <figcaption>
sizestring | nullNoLayout hint: small (25%), medium (50%), large (75%), xl (100%)
alignmentstring | nullNoLayout hint: left, center, right

💡 Note: The contentHtml rendering maps sizedata-size and alignmentdata-align as data attributes on the <img> tag. When rendering from contentJson, you read these directly from attrs.

🎨 Ready to render? See the Libraries & SDKs section for the official @mentra/react component that renders contentJson out of the box.

Libraries & SDKs

Official client libraries and rendering tools for integrating Mentra content into your application.

@mentra/react

React
Official

A drop-in React component that recursively renders contentJson from the Mentra API. It handles all standard TipTap/ProseMirror nodes, inline marks, and automatically interprets image sizing and alignment attributes.

Installation

Install via npm. This package is distributed via GitHub Packages.

npm install @mentra/react

Basic Usage

Import MentraContent and pass the contentJson field from any content detail response. Optionally import the companion CSS for default structural styling.

import { MentraContent } from '@mentra/react';
import '@mentra/react/dist/index.css'; // Optional: basic structural styles

export default function MyPage({ contentJson }) {
  return (
    <div className="prose">
      <MentraContent doc={contentJson} />
    </div>
  );
}

Custom Overrides

Customize how any node or mark is rendered by passing overrides. Useful for integrating your design system (e.g., custom links, Next.js routing, optimized images).

import { MentraContent } from '@mentra/react';
import Link from 'next/link';

export default function MyPage({ contentJson }) {
  return (
    <MentraContent
      doc={contentJson}
      nodeOverrides={{
        heading: ({ node, children }) => {
          const Tag = `h${node.attrs?.level ?? 1}` as keyof JSX.IntrinsicElements;
          const classes = node.attrs?.level === 1
            ? 'text-4xl font-bold'
            : 'text-2xl font-semibold';
          return <Tag className={classes}>{children}</Tag>;
        },
        image: ({ node }) => (
          <img
            src={node.attrs?.src}
            alt={node.attrs?.alt}
            loading="lazy"
            className="rounded-xl shadow-lg my-4"
          />
        ),
      }}
      markOverrides={{
        link: ({ mark, children }) => (
          <Link href={mark.attrs?.href || '#'} className="text-blue-600 hover:underline">
            {children}
          </Link>
        ),
      }}
    />
  );
}

Using contentHtml without React?

If you consume contentHtml in a non-React environment, you can still use our structural CSS for image alignments and sizing out of the box:

<link rel="stylesheet" href="https://unpkg.com/@mentra/react/dist/index.css" />

Analytics & Telemetry

Mentra includes a built-in analytics engine to track user engagement and learning progress. Tracking works in two ways:

Automatic Tracking

Interactions with the Mentra API are tracked automatically. You don't need to do anything extra.

  • Quiz Submissions
  • AI Content Generation
  • Mnemonic Creation
Manual View Tracking

To track page views on your frontend, you must manually trigger the tracking endpoint when a user visits a page.

  • Page Views
  • Content Opens
  • Custom UI Events

How to Track Views

Call the /telemetry/public/track endpoint whenever a user views a content item. This allows Mentra to aggregate view counts and engagement metrics. This endpoint currently takes no API key — that's a tracked gap, not a stable contract, so don't build an integration that depends on it staying keyless. It also isn't meant for a direct browser call from your own domain: production CORS defaults to Mentra's own origins plus a wildcard for *.vercel.app and *.run.app deploys — it does not allow an arbitrary custom domain. Call it server-side regardless, same as every other tenant content/data operation in this guide.

POST
/telemetry/public/track
curl -X POST https://api.mentra.systems/api/v1/telemetry/public/track \
  -H "Content-Type: application/json" \
  -d '{
    "tenant_id": "your-tenant-id",
    "event_type": "view",
    "content_id": "content-123",
    "content_type": "course",
    "metadata": {
      "source": "web_client",
      "path": "/courses/content-123"
    }
  }'

Real-World Scenarios

AI Generated

Each Level 4 content item (Step) includes AI-generated Real-World Scenarios. These are practical situations where the learner can apply the knowledge they just acquired.

Scenarios help bridge the gap between theory and practice by providing:

  • Role - The persona the learner should adopt.
  • Setting - The context where the scenario takes place.
  • Challenge - The problem to solve.
  • Solution - The optimal way to apply the knowledge.

Data Structure

Scenarios are returned as part of the scenarios array in the Level 4 content response.

{
  "scenarios": [
    {
      "title": "Handling a Difficult Client",
      "role": "Project Manager",
      "setting": "Weekly Status Meeting",
      "challenge": "The client is demanding features that were out of scope...",
      "solution": "Acknowledge their request, refer back to the SOW, and offer to create a change request...",
      "key_takeaways": ["Boundaries", "Documentation", "Professionalism"]
    }
  ]
}

Adaptive Learning & LMS

⚠️ Not part of the public v1 API. These endpoints live on lms_adaptive and require a signed-in Firebase user — not a mn_live_... API key — so they aren't reachable with the auth scheme the rest of this guide describes. Documented here for completeness; not callable by a public-API integrator today.

Overview

Mentra provides intelligent LMS features to create adaptive learning experiences. You can submit quiz answers to get instant grading and identify knowledge gaps, then request personalized remediation content generated by AI.

POST
/api/v1/adaptive/quiz/submit

Submit answers for a quiz attached to a Step or Standalone Content item. Returns grading results and identifies content blocks that need review.

{
"content_item_id": "step_123",
"content_type": "step", // or "standalone_content"
"locale": "en-US",
"answers": [
  {
    "question_id": "q1",
    "selected_option_index": 1
  },
  {
    "question_id": "q2",
    "selected_option_index": 0
  }
]
}
{
"total_questions": 2,
"correct_answers": 1,
"score_percentage": 50.0,
"passed": false,
"failed_block_ids": ["block_abc", "block_xyz"], // IDs of content blocks related to wrong answers
"feedback": [
  {
    "question_id": "q1",
    "is_correct": true,
    "correct_option_index": 1,
    "explanation": "Correct! Active listening builds trust."
  },
  {
    "question_id": "q2",
    "is_correct": false,
    "correct_option_index": 2,
    "explanation": "Incorrect. Interruption breaks the flow.",
    "related_content_block_ids": ["block_abc"]
  }
]
}
POST
/api/v1/adaptive/remediate

Generate personalized remedial content for failed concepts. Uses AI to rewrite or explain specific content blocks using different strategies (simplification, analogy, etc.).

Strategies

  • simplify - Rewrites content in simpler terms.
  • analogy - Explains concepts using real-world analogies.
  • socratic - Asks guiding questions to help the learner.
  • auto - AI chooses the best strategy (default).
{
"content_item_id": "step_123",
"content_type": "step",
"failed_block_ids": ["block_abc"],
"locale": "en-US",
"strategy": "analogy"
}
{
"original_text_snippet": "Active listening requires...",
"remediated_content": "Think of active listening like being a goalie in soccer...",
"strategy_used": "analogy"
}

Webhooks

Real-time Notifications

Webhooks allow your system to receive real-time notifications when content is published or unpublished in Mentra, or when a Placements surface changes. Instead of polling the API, you can listen for events and update your frontend or database immediately.

Supported Events

content.published

Triggered when any content item (Step, Journey, etc.) is published.

content.unpublished

Triggered when content is unpublished.

surface.updated

Triggered when a Placements surface changes: settings updated, surface deleted, or a placement added, removed, or reordered. The payload carries the surfaceKey — re-fetch the surface and revalidate the page that renders it. Not a complete invalidation signal on its own: publishing/unpublishing a placed item emits the content events instead, and scheduled-window transitions, visibility-tags-only edits, and surface creation emit no webhook — see the Surfaces caching guidance.

There is no per-event subscription to manage: every active endpoint receives every event type above, including event types added after your endpoint was created. Adding an endpoint is the whole opt-in. Switch on X-Mentra-Event (or the payload's type) and ignore event types you don't recognise, rather than assuming the set is fixed or that every delivery carries the same payload shape.

Delivery & Headers

Each webhook request includes several custom headers to help you identify and process the event:

HeaderDescription
X-Mentra-SignatureHMAC-SHA256 signature of the request body (hex digest).
X-Mentra-EventThe event type (e.g., content.published).
X-Mentra-DeliveryUnique UUID for this specific delivery attempt.
User-AgentMentra-Webhook-Client/1.0

Reliability & Retries

Mentra attempts to deliver webhooks with high reliability. If your endpoint is unreachable or returns a non-2xx status code, we will retry the delivery using an exponential backoff strategy.

  • Attempts: Up to 5 retries (6 total attempts).
  • Strategy: Exponential backoff with jitter to prevent thundering herds.
  • Timeout: Each request has a 10-second timeout.

You can view the full history of delivery attempts, including request/response details, in the Developers section of your tenant dashboard.

Payload Structure

For the content events (content.published / content.unpublished), Mentra sends a JSON payload with the following structure. The data.content field contains the full content object.

{
  "id": "evt_507f1f77bcf86cd799439011",
  "type": "content.published",
  "created": 1731671234,
  "tenantId": "your-tenant-id",
  "data": {
    "contentType": "step",
    "contentId": "step_123456",
    "locale": "en-US",
    "timestamp": "2024-11-15T12:00:00Z",
    "content": {
      "id": "step_123456",
      "title": "Introduction to AI",
      "slug": "introduction-to-ai",
      "coverImageUrl": "https://...",
      "seoMetadata": {
        "metaTitle": "Introduction to AI - Mentra",
        "metaDescription": "Learn the basics..."
      }
      // ... full content fields
    }
  }
}

surface.updated payload

The surface.updated event carries a minimal payload — it identifies which surface changed, not what its new contents are. On receipt, re-fetch the surface via GET /surfaces/{key} (see Surfaces) and revalidate the page that renders it. A surface.updated for a surface that no longer exists means the surface was deleted.

{
  "id": "evt_9b2f4a11c3d84e0f8a6b1c2d3e4f5a6b",
  "type": "surface.updated",
  "created": 1731671234,
  "tenantId": "your-tenant-id",
  "data": {
    "surfaceKey": "front-page-hero",
    "surfaceId": "your-tenant-id:front-page-hero",
    "changedAt": "2024-11-15T12:00:00Z"
  }
}

Security & Verification

Requests include the X-Mentra-Signature header. This is an HMAC-SHA256 hex digest of the raw request body, signed with your webhook secret.

Python Verification Example

import hmac
import hashlib

def verify_signature(payload_body: bytes, secret: str, signature_header: str) -> bool:
    """
    Verifies the X-Mentra-Signature header.
    payload_body: The raw bytes of the request body.
    secret: Your webhook secret.
    signature_header: The value of X-Mentra-Signature.
    """
    expected_signature = hmac.new(
        key=secret.encode("utf-8"),
        msg=payload_body,
        digestmod=hashlib.sha256
    ).hexdigest()
    
    return hmac.compare_digest(expected_signature, signature_header)

Error Codes

The API uses standard HTTP status codes to indicate the success or failure of requests.

CodeDescriptionHow to Handle
200
OKStandard response for successful requests, including updates and upserts.
201
CreatedContent created successfully. Returned by POST write endpoints for new items.
304
Not ModifiedContent hasn't changed. Use cached version.
400
Bad RequestCheck your parameters (e.g., missing locale).
401
UnauthorizedMissing or invalid API Key. Common causes:
  • Key is incomplete (missing the secret part after the dot)
  • Key is from the wrong environment (Dev keys don't work in Prod)
  • Header format is incorrect
403
ForbiddenValid key but insufficient permissions (e.g., missing write:content scope for write endpoints).
404
Not FoundResource doesn't exist, hasn't been published, or parent ID is invalid.
409
ConflictContent with the same externalId already exists. Returned by bulk import with onConflict: "fail".
422
Unprocessable EntityRequest is well-formed but semantically invalid (e.g., invalid externalId, missing required parent, or content conversion failure).
429
Too Many RequestsSlow down! Implement exponential backoff.
500
Internal Server ErrorSomething went wrong on our end. Please report it.

Error Response Format

When an error occurs (4xx or 5xx), the API returns a JSON object with a detail field explaining the error.

{
"detail": "Content with ID 'L4_999' not found or not published."
}
{
"detail": "Missing required query parameter: 'locale'"
}

Rate Limits & Pricing

Mentra API enforces rate limits per API key to ensure service quality for all customers.

LimitValueApplies To
Requests per minute
60
All endpoints, per API key
Bulk import items per request
100
POST /import endpoint
Max file size (media upload)
20 MB
POST /media/upload and /media/upload-url
Storage quota per tenant
Plan-based
Total media storage, enforced per tenant
Page size (list endpoints)
100
All GET list endpoints

When Rate Limited

If you exceed the rate limit, the API returns 429 Too Many Requests with a Retry-After: 60 header.

  • Implement exponential backoff when retrying failed requests
  • Respect the Retry-After header value
  • Use bulk import to reduce request count for large imports
  • Cache read responses locally using ETag headers

For higher limits or enterprise plans, please contact our sales team.

Changelog

2026-08-10
Change

A structure node's own title, description and cover image are now published rather than live, on every publishedOnly=true read. This closes the one case the earlier release today deliberately left open. Where a node carries content, its title, summary and coverImageUrl already came from the published translation. Where it does not — a Knowledge Base category, a course or group root, any node created but not yet given a body — those three fields were read from the node document itself, and that document is rewritten the instant an editor saves, with no publish step in between. So renaming a category made the new name public immediately, while renaming an article did not, and nothing in the response told the two apart. The same field behaved differently depending on whether a body happened to be attached. Publishing a node now also freezes its own title, description and cover image, and a published read serves that frozen copy: GET /v1/sections/{key}/tree, GET /v1/sections/{key}/nodes/{id} (including its breadcrumbs and children), the node teasers in GET /v1/surfaces/{key}, GET /v1/content/by-slug/{slug} and GET /v1/export all resolve it the same way, so no two of them can disagree about a node. Renaming a node after publishing it therefore keeps serving the previously published name until you publish again — which is what publishing already meant for every other kind of edit on this API. Three things do not change, and are worth stating because each is the question integrators ask first. Nothing goes blank: a node that has never been published serves its current values, so navigation labels cannot disappear, and existing content was seeded with the values it was already serving, making this release a no-op for every node until its next edit. Preview reads are untouched — publishedOnly=false with a key holding read:preview still shows the working values, which is what that scope is for. And structural edits are still immediate: reordering, creating and deleting nodes take effect without a publish, because navigation housekeeping is not published copy. No request or response field was added, removed or renamed, and no field's type changed. If you cache tree or node responses keyed on title, expect one invalidation as the values settle to their published form; the tree's ETag already covers its resolved titles.

2026-08-10
Change

GET /v1/export now serves published bodies on a publishedOnly=true read, not working ones. Earlier today's release gated the export at item level: an item appears only when it is published, publicly visible and reachable. The body inside each item was left alone, so every item still carried its working translation — a published article whose editor had rewritten it and not republished exported the unreleased content, contentJson, contentHtml, summary, keywords and seoMetadata to a key holding only read:content, while GET /v1/standalone/{id} returned the published text to that same key at the same moment. Two reads of one item at one instant disagreed, which is the boundary the read:preview scope exists to draw and the one already closed for by-slug and surface teasers (title, summary, coverImageUrl) and for standalone SEO. All three builders are now scoped — standalone, level1..4 on either content platform, and Section-native node items — and none of them falls back: a published read reads the published revision or nothing. That covers title as well as the body. title, summary and coverImageUrl were additionally resolved from the item's own document when the translation had no value, and that document is mutable — renaming a node or a standalone item writes it immediately, with no publish step — so the same read could disclose an unpublished rename, description or cover. For any item that can hold a body those three now come from the published translation only — standalone, the Curriculum ladder on either content platform, and Section-native node items alike. The one exception is a structural container that holds no body of its own: a category or group root legitimately stores its title and coverImageUrl on the node itself, there is no translation that could ever carry them, and those still return as before. If you call GET /v1/export to mirror what your site shows, this is what you wanted and there is nothing to do. Two consequences to plan for. First, an item whose ORIGINAL locale has no published revision now exports empty editorial fields — content, contentHtml, summary, keywords, seoMetadata and coverImageUrl come back null, and title comes back an empty string — instead of the draft's values. Note the asymmetry, because it is easy to miss: an item is admitted when any of its locales is published, but its body and metadata are read from the original locale only. So an article authored in en and published solely in nb is listed, correctly, and still comes back with those fields empty. That is a normal multilingual situation rather than a defect, and it has the same shape as an interrupted publish or as content marked live through the publishedLocales index with nothing under the published revision. The item is still listed and still addressable — id, slug, visibilityTags, isPublished and the timestamps are unchanged — so do not treat an empty body as a missing item. Identify items by id, which every item carries: externalId is optional and is commonly null on standalone and level1..4 records, which are the ones most likely to hit this, and only node items are guaranteed one because the export synthesises it. One consequence worth checking against your restore tooling: an item in that state will not re-import from a default export, because import requires a non-blank title. Take it with publishedOnly=false, or publish the item's original locale. Second, an export taken at the default no longer round-trips in-progress edits — re-importing it reconstructs the published text and drops the unreleased revision. That default already omitted unpublished items wholesale, so it was not a faithful backup before this change; the bodies now agree with the items. For a backup that restores exactly what the CMS holds, use publishedOnly=false with a key holding read:preview: that path still returns the working translation for every item, drafts included. One backup payload did change, and it changed by gaining content: level1..4 items belonging to tenants still on the pre-Sections content platform, whose bodies were authored in the CMS, previously exported with an empty body on both settings, because the export looked for the body beneath the level record while that content is stored alongside it instead. It is now found, so those items carry content, contentHtml, summary and the rest where they used to be blank. Nothing was removed or renamed, but a backup taken after this release can be materially larger than one taken before it, and a diff against an older backup will show those fields appearing — worth checking if your restore tooling treats an item gaining a body as a conflict. Every other item type is untouched on that setting. Holding read:preview does not change the default — the parameter does, so a preview-scoped key still reads like production, bodies included, unless it asks for drafts. One field's contents narrowed, on every setting: seoMetadata on standalone and level1..4 items is now limited to the keys the write API accepts (metaTitle, metaDescription, focusKeyword, focusKeywords, slug, canonicalUrl, readingTime), matching what node items have done since the Section export shipped. CMS-authored content also stores generated derivatives — suggestedSlug, structuredData, openGraph, twitterCard — and emitting those verbatim made POST /v1/import reject the entire request, so an export containing one such item could not be restored at all. If you read seoMetadata off an export for something other than re-import, those four keys are no longer present on those item types; the delivery reads still expose them. No request or response field was added, removed or renamed, and the item shape, pagination and type filter are unchanged.

2026-08-10
Change

GET /v1/export now accepts publishedOnly and defaults it to true, so an export returns published, publicly-visible content only — the same contract every other read on this API already has. Until now the export was the one read with no publish filter, no visibility filter and no preview gate: a key holding read:content alone received every draft in the tenant in full — bodies, summaries, keywords and SEO metadata — including items whose visibilityTags is empty and are therefore hidden from everyone, and items never published in any locale, while that same key got 403 Missing scope read:preview for preview access from GET /v1/standalone?publishedOnly=false. isPublished was computed per item and reported, but it filtered nothing. This is a behaviour change for anyone already calling /v1/export. If you export to mirror what your site shows, the new default is what you wanted and there is nothing to do. If you export to take a backup or migrate a tenant, add publishedOnly=false and make sure the key holds read:preview — otherwise the backup silently loses every unpublished item. publishedOnly=false without read:preview is a 403 carrying the same message the standalone and Section reads return; the split is per key, so a tenant can hand its public website a read:content key that cannot see unreleased work and its backup job a read:content + read:preview key that can. Holding read:preview does not change the default: the parameter does, so a preview-scoped key still reads like production unless it asks for drafts. All three item shapes obey the flag — standalone, level1..4 on either content platform, and the Section-native node items. Under publishedOnly=true an item is included only when at least one of its locales is published and it passes the visibility filter — its visibilityTags contains public, or the field is absent entirely, which legacy content predating visibility tagging relies on and which every read on this API treats as public. Only an explicit list without public — including an empty one — is excluded, so do not treat a returned item as invalid because it carries no visibilityTags. And, for level1..4 and node items, it is included only only when every ancestor passes that same test, because a published article under a draft category is not publicly reachable and the Section tree does not serve it either. A consequence worth planning for: pruning an ancestor prunes its whole subtree, so a published item can disappear from the export because something above it was unpublished. totalItems plus the pagination counts describe what you receive rather than what was filtered out. Pruning never leaves a parentExternalId pointing at nothing: across a complete export — every page, no type filter, imported in page order — every parentExternalId resolves to an item the export contains, so a pruned backup still restores cleanly. That guarantee is about pruning only, and it is not page-local. A type-filtered export names parents it does not contain, because you asked for a slice: type=level2 has always referenced the level1 items it omits. Pagination can likewise separate a parent from its child: the export does emit parents before children — standalone, then level1 through level4, then node items in parent-first order — but a page boundary can still fall between the two, so a single page taken on its own may carry a child whose parent arrived on an earlier one. Import the pages in order and that resolves. Both limits were true before this change and are unchanged by it. One operational consequence of guaranteeing that: if a datastore read fails part-way through enumerating your content, the request now returns an error instead of a 200 carrying whatever had loaded. That applies on both publishedOnly settings. A partial export is indistinguishable from a tenant that genuinely holds less, and restoring from one loses content silently, so a backup job that previously recorded a success it should not have will now see a failure it can retry. Under publishedOnly=false nothing is filtered — drafts and hidden items are both included, exactly as the export behaved before — so a preview-scoped key takes the same full backup it always did, and that payload still POSTs back to /v1/import verbatim. One thing the flag did not change, at this release: the exported body was the item's working translation, so a published item edited since it was last published exported the edited text. That was superseded the same day — see the entry above, which scopes bodies to the published revision on publishedOnly=true and describes what changes for backup consumers. The type, page and pageSize parameters and the item shape are unchanged.

2026-08-10
fix

GET /v1/content/by-slug/{slug} and the node teasers in GET /v1/surfaces/{key} now serve published metadata on a publishedOnly read. Both read title, summary and coverImageUrl straight off the mutable content document, which every write updates regardless of publish state — so an item renamed but not republished returned its unpublished title (and summary/cover) while GET /v1/sections/{key}/tree and GET /v1/sections/{key}/nodes/{id} returned the published one for the same item at the same moment. All three fields now come from the same publish-scoped translation those Section routes already use, and they move together: a published translation that carries no summary or cover now returns null rather than falling back to the draft value, and an item whose content has no published translation at all (an interrupted publish, or legacy data) returns null rather than the draft. Preview reads (publishedOnly=false with the read:preview scope) are unchanged and still show drafts. Two related changes: updatedAt on both routes now reports the newer of the item's own timestamp and its content's, so a title-only republish — which touches neither the item document nor the surface — is visible instead of looking unmodified; and for the same reason the surface ETag now covers the resolved teaser metadata, so cached surface ETags are invalidated once by this release and a revalidating client no longer 304s past a republished title. Node teasers also now carry summary, populated from the published translation; the field was already part of the surface item shape and was previously always null for node entries. No request or response field was added, removed or renamed.

2026-08-10
fix

Standalone content SEO is now scoped to publish state and tenant. GET /v1/standalone/{id} (and its /posts and /standalone-content aliases) previously fell back to the draft translation's stored seoMetadata when the requested locale had no published translation — so a publishedOnly=true request made with a key holding only read:content could receive unpublished metaDescription, focusKeywords, suggestedSlug and structuredData. A published read now reads only published translations. The integrator-visible effect: for content published in a locale that has no published translation document (a legacy shape), seoMetadata on a publishedOnly=true response now carries generated values derived from the item's title and summary instead of the draft's authored values; the field is still present and its shape is unchanged. To keep receiving authored draft SEO, request publishedOnly=false with a key holding read:preview, or publish the locale's translation. Preview reads are unchanged.

2026-08-10
Change

Structured content is served from the section/node model for every tenant; the legacy four-level storage behind it is retired. Nothing about the API changes with this: request and response shapes, the level1..4 write routes, bulk import/export, slug lookup, menus and Sections all behave exactly as before, because the last tenants moved to the new model at the 2026-07-17 cutover and this removes the code path none of them was taking. Two clarifications that were previously ambiguous. Re-parenting an item through an update returns 400 on every write surface — the level1..4 routes, the Section node routes, and bulk import alike — and there is no endpoint that moves an item to a different parent; an earlier note here pointed at "the dedicated node routes" for moves, which were never built. To place an item under a different parent: a leaf can be re-created in the new position and the original deleted, but an item with children or attached content must have those moved or removed first, because the delete is refused while they exist — so moving a subtree means rebuilding it bottom-up and deleting the old one leaf-first. And a non-cascade delete of an item that still has children returns 409 rather than orphaning them, which is now the behaviour everywhere rather than only on the new platform.

2026-07-30
fix

GET /v1/export now returns a content body that POST /v1/import can actually read back, for standalone and level1..4 items. Those items paired contentFormat "json" with the plain-text rendering of the body, so re-importing a content-bearing item failed conversion on that row with "Invalid JSON content" — the documented round trip worked only for items with no body, and for the node items added on 2026-07-26. The content field now carries the serialized document that contentFormat describes, so a restore reconstructs it byte-for-byte. If you read the export rather than restore it, nothing you were using has moved: contentJson and contentHtml still carry the structured and rendered forms exactly as before, and every other field is unchanged. If you parsed content expecting prose, read contentHtml (rendered) or keep using the plain text you were reading there before by rendering contentJson — content is the field the import consumes, and it is now labelled honestly. Publication state is still not restored by an import: everything arrives as a draft, so plan a republish step into any migration.

2026-07-27
Change

PUT /v1/level1..4 now enforces externalId uniqueness on update: assigning an externalId already held by another item at the same level returns 409 external_id_conflict and writes nothing, matching the upsert-key contract those routes already advertise. Previously only the create path checked this — an update could silently assign a duplicate externalId, after which the next create-by-externalId upsert picked between the two items arbitrarily. Re-sending an item's own unchanged externalId is unaffected and still succeeds. This mirrors the same 409 the Section node routes (POST/PUT /v1/sections/{key}/nodes) already return for the identical case.

2026-07-26
Feature

Export any Section shape — GET /v1/export now emits Section-native content as items typed "node", carrying sectionKey plus nodeTypeKey, alongside the existing standalone and level1..4 items. Until now the export enumerated only the four-level Curriculum ladder, so a category, an article, or any tenant-authored node type had no slot in the schema and was omitted silently with a 200: a tenant using a branching, flat, deeper-than-four, or tenant-authored Section had no backup path, and the import generalization shipped earlier today was one-way. The type filter gains node; omitting type returns everything, and the standalone and level1..4 items are unchanged. Node items are emitted parents-before-children, because parentExternalId resolves against nodes created earlier in the same import request, and each node appears exactly once — an export naming one node twice would be refused wholesale with 400 duplicate_item_address. parentId is deliberately null on a node item: it out-ranks parentExternalId on import and the exported value is a source-tenant id that names nothing in the target, so parentExternalId is the portable linkage. Two long-standing defects that made the advertised round trip fail for every item type are fixed with it: an export item can now be POSTed to /v1/import verbatim (the response-only fields id, contentJson, contentHtml, isPublished, createdAt and updatedAt are ignored on input instead of rejecting the request), and a node item's content is the serialized document matching its contentFormat, so re-import reconstructs the body rather than failing to parse it. Unknown fields are still rejected, so a misspelled field name is still an error. A node item also carries parentNodeTypeKey, naming the parent's node type: when a template lets two node types hold the same child, one externalId can name a node of each — both legal, since identity is per node type — and a bare parentExternalId would be ambiguous, so the child would fail to import. You may send it on your own node items; omitting it leaves resolution exactly as before. Every node item carries an externalId: it is optional when you create a node and CMS-authored content has none, but import requires one, so a node without one falls back to its Mentra document id, used consistently for its own externalId and its children's parentExternalId — restoring into an empty or different tenant works as intended and repeats idempotently, though re-importing into the tenant you exported from creates a second node rather than updating the original, which never carried that id. Exported seoMetadata is limited to the fields the write API accepts (metaTitle, metaDescription, focusKeyword, focusKeywords, slug, canonicalUrl, readingTime); SEO derivatives generated in the CMS (suggestedSlug, structuredData, openGraph, twitterCard) are omitted because the write API rejects them and including them would fail the whole import request. If two nodes in one Section and node type share a stored externalId, which older data allows because that uniqueness is not enforced by the datastore, both are still exported and the second is given a distinct -2 address so the backup stays importable. Three limitations to plan around: a node item carries one locale, so a multi-locale node exports its original-locale body only; restoring into a new tenant needs the Section to exist first, since a node item addresses a Section by key rather than creating one; and publication state is not restored — the export reports isPublished, but the write API has no publish capability, so everything imports as a draft (true of standalone and level1..4 imports too, and always has been). Because Section delivery defaults to publishedOnly, restored content stays off the public tree until it is republished in the CMS, so plan a republish step into any migration. Note also that the verbatim round trip currently applies to node items: standalone and level1..4 exports still pair contentFormat json with the plain-text rendering, so a content-bearing item of those types fails conversion on re-import until that pairing is corrected.

2026-07-26
Feature

Bulk-import into any Section shape. POST /v1/import items may now be typed "node" and carry a sectionKey plus a nodeTypeKey from that Section's template, alongside the existing standalone and level1..4 types. Previously every structured item resolved onto the four-level Curriculum ladder, so a Section built on a branching, flat, deeper-than-four, or tenant-authored template could be read and written one item at a time but had no batch path in. Node items nest to whatever depth the template permits; parentExternalId resolves within the item's own Section, searching only the node types that template says may parent the child, so a placement the template forbids is reported as a per-item error before anything is written (dryRun reports the same verdicts a live run does). externalId remains the upsert key, scoped per Section and node type — the same externalId may name a node in two Sections without collision, and a result now echoes sectionKey and nodeTypeKey so it correlates back to its request item. Because that scope is the node's identity, two "node" items in one request sharing sectionKey, nodeTypeKey and externalId are rejected up front with 400 duplicate_item_address and nothing is written: under any onConflict strategy one of the two rows would otherwise be silently discarded, so a bad id mapping is reported rather than half-applied. Re-sending the same externalId in a later request is the intended idempotent path and is unaffected. sectionKey/nodeTypeKey are accepted only on a "node" item: sending them on a level1..4 item returns 400 rather than silently importing into the default Curriculum Section. The standalone and level1..4 request shapes are unchanged and keep working. (At the time this shipped GET /v1/export still mirrored only the level1..4 + standalone item shape, so a node from a non-Curriculum Section could not be round-tripped; export gained node items later the same day — see the entry above.)

2026-07-25
Feature

Write structured content into any Section shape: POST /v1/sections/{key}/nodes plus PUT and DELETE on /v1/sections/{key}/nodes/{id} create, update, and remove a node by naming its Section key and its node type from that Section's template. Previously the only structured write path was POST /v1/level1..4, which maps onto the four-level Curriculum ladder and nothing else — so a Section built on a branching template (Knowledge Base), a flat one (Pages), or a tenant-authored template had no write path even though it could already be read via GET /v1/sections/{key}/tree. The new routes place nodes at whatever depth the template permits (no four-level cap), reject placements the template forbids before writing, and treat externalId as an upsert key per Section and node type (an update that would duplicate another node's key returns 409). Only node types flagged content_bearing accept a body: sending content, keywords, or seoMetadata to a container type returns 400 rather than silently dropping it, while title, summary, and coverImageUrl are always accepted so container nodes stay writable. The level1..4 routes are unchanged and keep working. (At the time this shipped GET /v1/export could not represent a node from a non-Curriculum Section and omitted those records, so there was no backup path for such a Section. That gap is now closed — GET /v1/export emits node items; see the 2026-07-26 export entry above.)

2026-07-21
Feature

Read a structured node's full content body: GET /v1/sections/{key}/nodes/{id} returns the rendered content (HTML/JSON/plain text), SEO + schema.org metadata, ancestor breadcrumbs, and the same include= extras as the standalone detail (quiz, scenarios, mnemonics, video, sources) plus include=children for container nodes. Start from a sectionKey + node id or from a by-slug hit's sectionKey. Relatedly, by-slug on a tenant without structured content now resolves only readable (standalone) content instead of returning legacy level hits that had no read path.

2026-07-21
Change

Structured content now reads through a single surface. The legacy per-level and Memolife-named read endpoints (GET /v1/level1..4 and GET /v1/lifeskills, /v1/courses, /v1/journeys, /v1/steps) have been retired; fetch a tenant's structured content as a group via GET /v1/sections and GET /v1/sections/{key}/tree instead. Standalone-content endpoints and the hierarchy write endpoints are unaffected.

2026-07-19
Feature

Placements can now teaser a structured entry node — a Course or Knowledge-Base Category — not only standalone articles. A resolved surface item (GET /v1/surfaces/{key}) carries a targetKind of "standalone" or "node"; a node teaser additionally carries a sectionKey so your site can deep-link into the Section the entry node belongs to. Existing standalone placements are unaffected (targetKind defaults to "standalone"); the item shape is otherwise unchanged.

2026-07-18
Feature

Sections — fetch a named structured-content group as a group: GET /v1/sections lists a tenant's Sections by stable key with their template vocabulary (two same-template Sections are now distinguishable), and GET /v1/sections/{key}/tree returns the whole ordered node tree in one call, each node carrying its per-locale slug, filtered to the requested locale's published, public content. Both payloads echo the Section's advisory basePath "lives at" hint for display — never resolution truth; the key and node slugs stay authoritative.

2026-07-17
Change

Structured content now serves from the generalized section/node model for tenants on the new content platform. Request/response shapes are unchanged across the read, write, bulk-import, bulk-delete, slug-lookup, menus, and export endpoints — content is simply sourced from the new model, and read/slug/menus/export behavior is identical. Writes on the new platform add three stricter guards: re-parenting an item via update returns 400, a non-cascade delete of an item that still has children returns 409 rather than orphaning them, and slugs are unique per-tenant across all levels (a cross-level clash returns 409). Clients not on the new platform are unaffected.

2026-07-17
Feature

Placements — render a curated content slot by key: GET /v1/surfaces/{key} returns the surface's currently-active placements as ordered content summaries (scheduling windows evaluated server-side), and GET /v1/surfaces lists the tenant's surface keys for discovery.

2026-07-16
Feature

Curate by tag — standalone content now returns its editorial tags, and the list/summary endpoints accept a tag= filter (e.g. tag=page:forside) for surface curation.

2026-07-15
Feature

Show your sources — the standalone detail endpoint can now return the research citations (title + URL) behind a piece via include=sources.

2026-03-16
Feature

SEO-friendly content discovery — look up any content item by its human-readable slug instead of internal IDs.

2025-02-27
Feature

Safer imports and full data portability — preview import results before committing, export your entire content library, and batch-delete content in bulk.

2025-02-26
Change

Cleaner content output — HTML responses are now pure semantic markup, giving you full control over styling in your own frontend.

2025-02-24
Feature

Full read-write API and media management — create, update, and delete content programmatically. Upload and manage images with automatic CDN delivery and on-the-fly optimization.

2024-05-20
Feature

Flexible hierarchy naming — access content using generic level-based paths that match your tenant's custom labels.

2024-05-15
Feature

Smarter content queries — filter and selectively include fields to fetch exactly the data you need.

Ready to Get Started?

Create your free account and get your API key in under 2 minutes. Start building amazing learning experiences today.

We'd like to use analytics cookies to understand how Mentra is used, so we can improve it. They're optional — declining changes nothing about how the product works. Read our privacy policy.