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:contentscoped 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
externalIdand 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:
- https://www.mentra.systems/llms-full.txt — full API docs (recommended)
- https://www.mentra.systems/llms.txt — short index per llmstxt.org
- https://mentra-api-4lz2mv64ka-uc.a.run.app/api/v1/openapi.json — machine-readable OpenAPI spec
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
- Sign up or log in to Mentra
- Navigate to the API Keys page
- Create a new API key with the appropriate scopes
- Copy the key immediately! It is only shown once.
⚠️ Important: The key format ismn_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.
Scopes Reference
The table below lists every scope, what it grants, and which endpoint groups require it.
| Scope | Description | Endpoint 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:
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
| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | 1-based page index. Must be ≥ 1. |
pageSize | integer | 20 | Items 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
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.
/standaloneReturns 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
/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=layoutStructured 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).
/sectionsDiscovery — 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
/sections/{key}/treeReturns 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-NOWrite Endpoints write:content
write:content scope. Create one in the API Keys settings page.Create Content
Create standalone or hierarchy content via the API. Supports Markdown, HTML, or TipTap JSON input with automatic format conversion.
/standaloneCreate a standalone content item. Returns 201 Created on success.
/level1|/level2|/level3|/level4Create 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 titlelocale (optional, default: en-US) - Content languagecontent (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 stringssummary (optional) - Short descriptioncoverImageUrl (optional) - Cover image URLparentId (required for Level 2\u20134) - Parent item\u2019s Firestore IDIdempotent 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.
/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.
/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.
/importConflict 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 409curl -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 createdwould_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
write:media scope. Uploaded images are stored as originals and served via ImageKit CDN with on-the-fly optimization and transforms.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.jpgTransform Parameters
w-{N} \u2014 Width in pixelsh-{N} \u2014 Height in pixelsc-at_max \u2014 Crop mode: fit within boundsfo-auto \u2014 Smart focus (auto-detect subject)f-webp / f-auto \u2014 Force WebP or auto-negotiate best formatq-{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.
/media/upload-urlRequest Body
sourceUrl (required) - Public HTTP/HTTPS URL of the image to downloadfilename (optional) - Override the filename (auto-detected from URL if omitted)alt (optional) - Alt text for accessibilitytags (optional) - Array of tag strings for categorizationcurl -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.
/media/uploadForm Fields
file (required) - Binary image filealt (optional) - Alt text for accessibilitytags (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.
/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 optimizationList Media Assets
List all media assets for your tenant with pagination and optional tag filtering. Each item includes a cdnUrl for optimized delivery.
/mediaQuery 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.
/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
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 contentPaths 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/activeUntilis served only whileactiveFrom <= 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.
manualserves the editor's hand-curated order;newestserves pinned placements first, then the rest by content recency. maxItemsis 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, soIf-Modified-Sinceis ignored by the endpoint. - Subscribe to the
surface.updatedwebhook for push-based invalidation: it fires on every surface mutation and its payload carries thesurfaceKey. surface.updatedalone is not sufficient invalidation. Publishing or unpublishing a placed item changes the surface response but emitscontent.published/content.unpublishedinstead — 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-stylerevalidate) as the safety net for those.
<FrontPageTeasers> component — build one <Surface> component that takes a key and reuse it for every slot. Adding a new curated section then becomes a CMS action plus one line of markup, not a development task.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.surfaceKeyContent 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
/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 (level1–level4) 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
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, …
}getting-started-with-ai for en-US, kom-i-gang-med-ki for nb-NO).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.
/exportQuery 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:
- Export from source tenant with
GET /export - Validate with
POST /importusingdryRun: true - 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.
/bulk-deleteRequest Body
items (required) — Array of items to delete (max 100)type — Content type:standalone, level1–level4id — Firestore document ID (one of id or externalId required)externalId — External ID lookupcascade (optional, default: false) — For hierarchy items, also delete all descendant itemsdryRun (optional, default: false) — Validate without deletingcurl -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
}
]
}cascade: true on a Level 1 item will also delete all its Level 2 children, their Level 3 children, and so on down to Level 4. The cascadeDeleted field in the response shows the total number of descendant items removed.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 S3write:contentwrite: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 (level1–level4) 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/
└── ....md, .html, or .json files become Steps (Level 4)
Images uploaded via presigned URL → Media API → coverImageUrl
meta.json in each folder provides title, summary, keywords
Prerequisites
pip install boto3 requests
write:content and write:media scopes~/.aws/credentials, environment variables, or IAM role)Complete Import Script
DRY_RUN = True first. The script will validate your content and show what would be created without writing anything."""
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 coverImageUrlImport from GitHubwrite: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
_index.md in each folder provides title/summary for that level. Other .md files become Steps.
Images can reference GitHub raw URLs or be uploaded via the Media API using /media/upload-url
Prerequisites
pip install requests
write:content scope (add write:media if uploading images)Complete Import Script
DRY_RUN = True first. Review the output before importing."""
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
- Run with
DRY_RUN = Trueto validate structure and preview actions - Review the output — check that titles and hierarchy look correct
- Fix any folder naming or
_index.mdfrontmatter issues in your repo - Set
DRY_RUN = Falseand run again to import - 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 leftcenter— Centered blockright— Float right
data-size Values
small— 25% widthmedium— 50% widthlarge— 75% widthxl— 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 Type | HTML Equivalent | Attributes | Has 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 Type | HTML Equivalent | Attributes |
|---|---|---|
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"
}
}| Attribute | Type | Required | Description |
|---|---|---|---|
src | string | ✅ | CDN image URL (already optimized) |
alt | string | ✅ | Alt text for accessibility |
caption | string | null | No | If present, render as <figure> + <figcaption> |
size | string | null | No | Layout hint: small (25%), medium (50%), large (75%), xl (100%) |
alignment | string | null | No | Layout hint: left, center, right |
💡 Note: The contentHtml rendering maps size → data-size and alignment → data-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
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:
Interactions with the Mentra API are tracked automatically. You don't need to do anything extra.
- Quiz Submissions
- AI Content Generation
- Mnemonic Creation
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.
/telemetry/public/trackcurl -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
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.
/api/v1/adaptive/quiz/submitSubmit 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"]
}
]
}/api/v1/adaptive/remediateGenerate 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
Triggered when any content item (Step, Journey, etc.) is published.
Triggered when content is unpublished.
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:
| Header | Description |
|---|---|
| X-Mentra-Signature | HMAC-SHA256 signature of the request body (hex digest). |
| X-Mentra-Event | The event type (e.g., content.published). |
| X-Mentra-Delivery | Unique UUID for this specific delivery attempt. |
| User-Agent | Mentra-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.
| Code | Description | How to Handle |
|---|---|---|
200 | OK | Standard response for successful requests, including updates and upserts. |
201 | Created | Content created successfully. Returned by POST write endpoints for new items. |
304 | Not Modified | Content hasn't changed. Use cached version. |
400 | Bad Request | Check your parameters (e.g., missing locale). |
401 | Unauthorized | Missing or invalid API Key. Common causes:
|
403 | Forbidden | Valid key but insufficient permissions (e.g., missing write:content scope for write endpoints). |
404 | Not Found | Resource doesn't exist, hasn't been published, or parent ID is invalid. |
409 | Conflict | Content with the same externalId already exists. Returned by bulk import with onConflict: "fail". |
422 | Unprocessable Entity | Request is well-formed but semantically invalid (e.g., invalid externalId, missing required parent, or content conversion failure). |
429 | Too Many Requests | Slow down! Implement exponential backoff. |
500 | Internal Server Error | Something 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.
| Limit | Value | Applies 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-Afterheader 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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.)
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.)
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.
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.
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.
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.
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.
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.
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.
Show your sources — the standalone detail endpoint can now return the research citations (title + URL) behind a piece via include=sources.
SEO-friendly content discovery — look up any content item by its human-readable slug instead of internal IDs.
Safer imports and full data portability — preview import results before committing, export your entire content library, and batch-delete content in bulk.
Cleaner content output — HTML responses are now pure semantic markup, giving you full control over styling in your own frontend.
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.
Flexible hierarchy naming — access content using generic level-based paths that match your tenant's custom labels.
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.