# Contentrain AI — Full Documentation > The complete ai.contentrain.io documentation as a single markdown file for LLM ingestion. Curated index: https://ai.contentrain.io/llms.txt # Contentrain AI Source: https://ai.contentrain.io/ ## Start With the Mess You Already Have AI made you faster at producing code. It did not solve what happens to the content inside that code. **Already shipped?** Hardcoded UI text lives across components, pages, and docs. No translation path, no single source of truth: - **Monday:** Founder says "we need Turkish" - **Tuesday:** Marketer wants to change the hero headline — it's in 7 files - **Wednesday:** Mobile team asks for the same content via API - **Friday:** You're still doing grep-and-replace This is the first wedge for Contentrain: **take the content chaos already living in your codebase and turn it into a governed, reviewable content layer.** **Starting fresh?** That still matters. You can begin with a real content layer from day one. But the most urgent use case is rescue, not prevention. **This is not mainly a CMS problem. It's a governance problem.** AI can produce content quickly; Contentrain governs how that content gets structured, reviewed, translated, and delivered. ## Three Core Use Cases ::: code-group ```bash [Hardcoded Strings Rescue] # Existing app? This is the main entry point npx contentrain init # Then tell your agent: "Scan my project and extract hardcoded UI strings" ``` ```bash [Day-One Content Layer] # New project? Start with structured content instead of inline strings npx contentrain init # Then tell your agent: "Create a hero section model with title, subtitle, and CTA" ``` ```bash [Cross-Platform Delivery] # Same content for web, docs, mobile, and backend consumers npx contentrain generate # Use plain JSON/Markdown or the typed SDK in TypeScript apps ``` ::: ## Start Here If you only want to understand the product once before going deeper: 1. Read the [2-Minute Demo](/demo) 2. Use the full [Normalize Flow](/guides/normalize) on a real repo 3. Follow [Getting Started](/getting-started) 4. Use the [Ecosystem Map](/ecosystem) once review, team ops, or CDN delivery matter **Before** (hardcoded, untranslatable, unmanageable): ```vue

Build faster with AI-powered content

Ship your next project in days, not weeks

``` **After** (structured, translatable, platform-independent): ```vue

{{ t('hero.title') }}

{{ t('hero.subtitle') }}

``` Content lives in plain JSON — any platform can read it: ```json { "hero.title": "Build faster with AI-powered content", "hero.subtitle": "Ship your next project in days, not weeks", "cta.get_started": "Get started free" } ``` ## Start From a Template Pick a production-ready starter with Contentrain content models, generated SDK client, and framework best practices: | Template | Framework | Use Case | |---|---|---| | [astro-blog](https://github.com/Contentrain/contentrain-starter-astro-blog) | Astro | Blog / editorial | | [astro-landing](https://github.com/Contentrain/contentrain-starter-astro-landing) | Astro | Landing page | | [next-commerce](https://github.com/Contentrain/contentrain-starter-next-commerce) | Next.js | E-commerce | | [next-multi-surface-saas](https://github.com/Contentrain/contentrain-starter-next-multi-surface-saas) | Next.js | Marketing + app + docs | | [next-saas-dashboard](https://github.com/Contentrain/contentrain-starter-next-saas-dashboard) | Next.js | SaaS dashboard | | [next-white-label-portal](https://github.com/Contentrain/contentrain-starter-next-white-label-portal) | Next.js | White-label portal | | [nuxt-admin-console](https://github.com/Contentrain/contentrain-starter-nuxt-admin-console) | Nuxt | Admin console | | [nuxt-saas](https://github.com/Contentrain/contentrain-starter-nuxt-saas) | Nuxt | SaaS marketing | | [sveltekit-editorial](https://github.com/Contentrain/contentrain-starter-sveltekit-editorial) | SvelteKit | Editorial | | [vitepress-docs](https://github.com/Contentrain/contentrain-starter-vitepress-docs) | VitePress | Documentation | Each is a GitHub template — click **"Use this template"** to create your own repo. ## Who It Is For ### AI-assisted product teams You already use Claude Code, Cursor, or another agent and want content changes to go through a real reviewable system instead of landing as random string edits. ### Teams with hardcoded UI copy or docs in source You need to extract strings, make i18n possible, and stop treating copy updates like grep-and-replace work. ### Builders who want Git-native content, not dashboard-first content You want plain JSON and Markdown in Git, optional SDK ergonomics, and a content layer your stack can consume directly. ## Who It Is Not For - Teams looking for a database-first or dashboard-first CMS without Git in the workflow - Teams that do not want Git in the content workflow - Teams that do not use AI agents and do not need a governed extraction/review layer ## Platform Independent Contentrain outputs plain JSON and Markdown files. **Any platform that can read JSON can consume your content:** - Web frameworks (Vue, React, Nuxt, Next.js, Astro, SvelteKit) - Mobile (React Native, Flutter, Swift, Kotlin) - Backend (Node.js, Go, Python, Rust, .NET) - Desktop (Electron, Tauri) - Game engines, IoT, email templates, CLI tools The TypeScript SDK (`@contentrain/query`) provides type-safe queries as a convenience — but it's optional. The content files are the product, not the SDK. ## Bring Your Own Agent Contentrain does not ship its own model. Your agent (Claude Code, Cursor, Windsurf, or any MCP-compatible tool) **is** the intelligence layer. Contentrain provides deterministic MCP tools and CLI workflows that enforce consistency: ``` Agent decides what to extract → MCP validates and writes → Human reviews → Git commits ``` No AI markup in your code. No proprietary syntax. No vendor lock-in. If you stop using Contentrain, your content files are still plain JSON in your Git repo. ::: info Contentrain Studio [Contentrain Studio](/studio) is the open-core team web layer for Contentrain: chat-first operations, role-based collaboration, branch review, media management, and CDN delivery on top of the same Git-native content model. Teams can self-host the AGPL core or use the managed plans (Starter/Pro/Enterprise). ::: --- # Getting Started Source: https://ai.contentrain.io/getting-started # Getting Started Contentrain AI is an open-source, repo-native content governance stack. Your AI agent creates, extracts, and updates content through [Model Context Protocol (MCP)](https://modelcontextprotocol.io) tools, while Contentrain ensures that content is structured, validated, reviewed, and deliverable to any platform. ## Prerequisites - Node.js 22+ - Git - An MCP-compatible AI agent (OpenAI Codex, Claude Desktop, Claude Code, Cursor, Windsurf, or similar) ## Choose Your Path ::: code-group ```bash [Existing Project] # This is the main wedge: rescue hardcoded strings npx contentrain init # Then tell your agent: "Scan my project and extract all hardcoded strings" ``` ```bash [New Project] # Start with structured content from day one npx contentrain init ``` ::: ## Quick Start: Existing Project Already have hardcoded strings scattered across your codebase? Start here. ```bash npx contentrain init npx contentrain serve --stdio ``` Then tell your agent: ```text Scan my project and extract all hardcoded UI strings into structured content. ``` Typical outcome: ```text Agent scans 47 files → finds 523 strings → classifies → creates models → writes content → proposes source patches ``` Review the extracted content and branch diffs locally, then continue with the full [Normalize Flow](/guides/normalize). ## Quick Start: New Project ### 1. Initialize ```bash npx contentrain init ``` This creates `.contentrain/` in your project root with configuration, model definitions, and content directories. If the directory is not a git repo, it runs `git init` automatically. ### 2. Connect your AI agent **Claude Code — one command.** The plugin bundles the MCP server and the skills that teach Claude the normalize workflow, so there is nothing else to configure: ``` /plugin marketplace add Contentrain/ai /plugin install contentrain@contentrain ``` Then run `/reload-plugins`. See the [plugin README](https://github.com/Contentrain/ai/tree/main/plugins/contentrain) for what it includes. For every other client, the `setup` command auto-configures the MCP server: ```bash npx contentrain setup claude-code # → creates .mcp.json npx contentrain setup cursor # → creates .cursor/mcp.json npx contentrain setup vscode # → creates .vscode/mcp.json npx contentrain setup windsurf # → creates .windsurf/mcp.json npx contentrain setup copilot # → creates .vscode/mcp.json npx contentrain setup codex # → creates .codex/config.toml npx contentrain setup --all # → configures all detected IDEs ``` Codex configures MCP servers in TOML rather than JSON, so `setup codex` writes a `[mcp_servers.contentrain]` table. If the file already exists the table is appended, leaving your other Codex settings untouched. The equivalent by hand: ```bash codex mcp add contentrain -- npx -y contentrain serve --stdio ``` Claude Desktop is still configured manually: ```json { "mcpServers": { "contentrain": { "command": "npx", "args": ["-y", "contentrain", "serve", "--stdio"] } } } ``` ::: tip Auto-configured during init If your IDE is detected during `contentrain init`, the MCP config is created automatically — you may already be set up. :::
Manual configuration (all IDEs use the same config) ```json { "mcpServers": { "contentrain": { "command": "npx", "args": ["contentrain", "serve", "--stdio"] } } } ``` | IDE | Config file | |-----|-------------| | OpenAI Codex | `.codex/config.toml` or `~/.codex/config.toml` | | Claude Desktop | `claude_desktop_config.json` | | Claude Code | `.mcp.json` (project root) | | Cursor | `.cursor/mcp.json` | | VS Code | `.vscode/mcp.json` | | Windsurf | `.windsurf/mcp.json` |
::: tip HTTP Transport In addition to stdio, MCP also serves over HTTP at `POST /mcp`. Useful for Studio / CI runners / remote agents that drive Contentrain operations without a local IDE: ```bash npx contentrain serve --mcpHttp --authToken $(openssl rand -hex 32) ``` See the [HTTP Transport guide](/guides/http-transport) for auth, deployment patterns, and programmatic embedding. ::: ::: tip Studio MCP Cloud When the project is connected to Studio, Workspace Settings → MCP Cloud creates a hosted Streamable HTTP endpoint and project-scoped API key. Use that endpoint for Codex, Claude Desktop, Claude Code, Cursor, or any MCP client that should operate the Studio project without a local checkout. ::: ### 3. Create a content model Tell your agent: ``` Create a hero section model with title, subtitle, and CTA fields ``` The agent calls `contentrain_model_save` and creates: ``` .contentrain/ models/hero-section.json content/marketing/hero-section/en.json ``` ### 4. Add content ``` Add content to the hero section: title is "Build faster with AI", subtitle is "Content governance for any platform" ``` ### 5. Use your content The content is plain JSON — you can read it directly from any language or platform: ```json { "title": "Build faster with AI", "subtitle": "Content governance for any platform" } ``` For TypeScript projects, generate a typed SDK client for convenience: ```bash npx contentrain generate ``` ```ts import { singleton } from '#contentrain' const hero = singleton('hero-section').locale('en').get() console.log(hero.title) // "Build faster with AI" ``` ::: tip SDK is optional The generated TypeScript SDK provides type-safe queries, but the content files are plain JSON and Markdown. Any platform that reads JSON — Go, Python, Swift, Kotlin, Rust — can consume your content directly. ::: ### 6. Review with the local UI ```bash npx contentrain serve ``` Open `http://localhost:3333` to browse models, content, validation results, and pending branches. ## Team Workflows When the local CLI and MCP flow are not enough, [Contentrain Studio](/studio) adds the team web layer: - workspace and project management - role-based access and review responsibilities - chat-first content operations - branch and diff review - media management - CDN delivery for non-web platforms Connect your local project to Studio with two commands: ```bash contentrain studio login contentrain studio connect ``` The `connect` command detects your git remote, verifies GitHub App installation, scans for `.contentrain/` configuration, and creates the project — all in one interactive flow. See [CLI Studio Integration](/packages/cli#connecting-a-repository) for details. ## The Content Pipeline Every operation follows the same governance pipeline: ``` Agent generates → MCP validates → Human reviews → Git commits → Content delivered ``` - **Agent** decides what to create (your AI, your choice — BYOA) - **MCP** enforces structure, validation, and canonical serialization - **Human** reviews and approves through the local UI or [Studio](/studio) - **Git** stores everything — full history, rollback, audit trail - **Content** is delivered as plain JSON/Markdown to any platform ## Packages All packages are published on npm: | Package | Description | Install | |---|---|---| | [`contentrain`](https://www.npmjs.com/package/contentrain) | CLI (init, serve, generate, validate) | `npx contentrain init` | | [`@contentrain/mcp`](https://www.npmjs.com/package/@contentrain/mcp) | 27 MCP tools for AI agents | `pnpm add @contentrain/mcp` | | [`@contentrain/query`](https://www.npmjs.com/package/@contentrain/query) | TypeScript query SDK (optional) | `pnpm add @contentrain/query` | | [`@contentrain/types`](https://www.npmjs.com/package/@contentrain/types) | Shared TypeScript types | `pnpm add @contentrain/types` | | [`@contentrain/rules`](https://www.npmjs.com/package/@contentrain/rules) | AI agent quality rules | `pnpm add @contentrain/rules` | | [`@contentrain/skills`](https://www.npmjs.com/package/@contentrain/skills) | 16 AI agent workflow skills | `pnpm add @contentrain/skills` | | [`@contentrain/wp-import`](https://www.npmjs.com/package/@contentrain/wp-import) | WordPress WXR/REST → `.contentrain` | `npx contentrain import export.xml` | | [`@contentrain/emitter-astro`](https://www.npmjs.com/package/@contentrain/emitter-astro) | ProjectIR → an Astro project | `pnpm add @contentrain/emitter-astro` | | [`@contentrain/verify`](https://www.npmjs.com/package/@contentrain/verify) | Offline gates for a built site | `npx @contentrain/verify dist` | | [`@contentrain/decide`](https://www.npmjs.com/package/@contentrain/decide) | Typed decisions: rule first, then Jev, then Claude Haiku | `pnpm add @contentrain/decide` | ::: tip Updating Skills & Rules After upgrading packages, run `contentrain skills --update` to refresh IDE skills and rules. Use `contentrain skills --list` to check installation status. You can also install skills directly via the [Agent Skills CLI](https://agentskills.io): ```bash npx skills add Contentrain/ai/packages/skills ``` This works with Claude Code, Cursor, Windsurf, GitHub Copilot, OpenAI Codex, Gemini CLI, and 40+ other agents. ::: ## Starter Templates Want to skip setup? Start from a production-ready template with content models, SDK client, and framework patterns pre-configured: | Template | Framework | Use Case | |---|---|---| | [astro-blog](https://github.com/Contentrain/contentrain-starter-astro-blog) | Astro | Blog / editorial | | [astro-landing](https://github.com/Contentrain/contentrain-starter-astro-landing) | Astro | Landing page | | [next-commerce](https://github.com/Contentrain/contentrain-starter-next-commerce) | Next.js | E-commerce | | [next-saas-dashboard](https://github.com/Contentrain/contentrain-starter-next-saas-dashboard) | Next.js | SaaS dashboard | | [nuxt-saas](https://github.com/Contentrain/contentrain-starter-nuxt-saas) | Nuxt | SaaS marketing | | [sveltekit-editorial](https://github.com/Contentrain/contentrain-starter-sveltekit-editorial) | SvelteKit | Editorial | | [vitepress-docs](https://github.com/Contentrain/contentrain-starter-vitepress-docs) | VitePress | Documentation | [See all 10 templates on GitHub](https://github.com/orgs/Contentrain/repositories?q=contentrain-starter&type=template) ## What's Next? - [Core Concepts](/concepts) — Models, content kinds, domains, and the governance architecture - [Ecosystem Map](/ecosystem) — How AI packages and Studio fit together - [MCP Tools](/packages/mcp) — All 27 tools available to your agent - [Normalize Flow](/guides/normalize) — Extract hardcoded strings from existing code - [i18n Workflow](/guides/i18n) — Add languages to your content - [Framework Integration](/guides/frameworks) — Platform-specific setup patterns - [WordPress Migration](/guides/migration) — Import a WordPress site, and what the open packages do and do not do ::: info Contentrain Studio [Contentrain Studio](/studio) is the open-core team operations surface for Git-native structured content. Teams can self-host the AGPL core or use the managed plans (Starter/Pro/Enterprise) when they want web-based collaboration, review, media, and CDN delivery on top of the same content model. ::: --- # 2-Minute Demo Source: https://ai.contentrain.io/demo # 2-Minute Demo This is the **one demo story** that explains Contentrain fastest: > You have hardcoded UI text in a real app. > Your agent extracts it into a governed content layer. > Contentrain keeps the process reviewable, typed, and Git-native. ## Before Your component ships with inline text: ```tsx export default function Hero() { return (

Build faster with AI-powered content

Ship your next project in days, not weeks

) } ``` This works until: - marketing wants copy changes - you need a second language - mobile needs the same content - the agent starts editing strings directly in source files ## Step 1. Initialize ```bash npx contentrain init ``` Now you have a governed `.contentrain/` workspace in your repo. ## Step 2. Connect your agent Run the local MCP entrypoint: ```bash npx contentrain serve --stdio ``` Then ask your agent: > Scan this project and extract the hardcoded strings in the hero section. ## Step 3. Extract The agent uses `contentrain_scan` to find strings and `contentrain_apply(mode: "extract")` to move them into content files. Generated content: ```json { "title": "Build faster with AI-powered content", "subtitle": "Ship your next project in days, not weeks", "cta": "Get started free" } ``` Example location: ```text .contentrain/content/marketing/hero-section/en.json ``` At this stage, **source files are untouched**. You can already review, translate, and validate the extracted content. ## Step 4. Reuse Then ask your agent: > Replace the hardcoded hero strings with content references. The agent proposes a dry-run patch, then `contentrain_apply(mode: "reuse")` updates the source file on a review branch. ## After ```tsx import { singleton } from '#contentrain' export default function Hero() { const hero = singleton('hero-section').locale('en').get() return (

{hero.title}

{hero.subtitle}

) } ``` ## Why this demo matters This one flow shows Contentrain's full value: - **AI agent does the extraction work** - **Contentrain enforces schema and Git review** - **Your content becomes reusable and translatable** - **Your app consumes plain JSON or the typed SDK** ## What to do next - Follow the full [Getting Started](/getting-started) guide - Read the full [Normalize Flow](/guides/normalize) - Use [Framework Integration](/guides/frameworks) for your stack ## Ready for Team Collaboration? This demo shows extraction and reuse locally. When your team needs role-based review, web-based collaboration, or CDN delivery for mobile and non-web platforms, connect your project to [Contentrain Studio](/studio): ```bash contentrain studio login contentrain studio connect ``` Studio uses the same `.contentrain/` content model — no changes needed. --- # Ecosystem Map Source: https://ai.contentrain.io/ecosystem # Ecosystem Map Contentrain has two product surfaces operating on the same `.contentrain/` contract: - **Contentrain AI** — the open-source, local-first operating core: MCP, CLI, SDK, rules, skills, and the open ends of the migration pipeline - **Contentrain Studio** — the open-core team web surface: chat, review, media, forms, APIs, and CDN delivery The format does not change. The operating surface does. ## The Split Use **Contentrain AI** when you need: - local-first agent workflows - hardcoded-string rescue through normalize - direct Git-native content operations from an IDE or terminal - package-level adoption through `@contentrain/mcp`, `contentrain`, `@contentrain/query`, `@contentrain/rules`, and `@contentrain/skills` - the open ends of a WordPress migration — `@contentrain/wp-import`, `@contentrain/emitter-astro`, `@contentrain/verify` Use **Contentrain Studio** when you need: - authenticated team workflows in a web app - role-aware review and approval - project/workspace management - media, forms, conversation APIs, and CDN delivery - self-hosted or managed team collaboration on top of the same content model ## Package Bridges | AI surface | Primary job | Studio bridge | Go deeper | |---|---|---|---| | `@contentrain/mcp` | Deterministic local content operations and normalize | Studio applies the same `.contentrain/` contract through governed web workflows and review surfaces | [MCP Tools](/packages/mcp), [Studio AI Chat](https://docs.contentrain.io/guide/ai-chat) | | `contentrain` CLI | Bootstrap, validate, diff, generate, serve, and local review | Studio takes over when teams need authenticated review, roles, and project management | [CLI](/packages/cli), [Studio Quickstart](https://docs.contentrain.io/guide/quickstart) | | `@contentrain/query` | Local typed consumption and CDN client transport | Studio adds remote delivery, API keys, and CDN publishing on top of the same content | [Query SDK](/packages/sdk), [Studio CDN](https://docs.contentrain.io/guide/cdn) | | `@contentrain/rules` | Quality standard and schema/content guardrails | Studio should match the same quality standard in chat, review, and content validation | [Rules](/packages/rules), [Studio Architecture](https://docs.contentrain.io/developer/architecture) | | `@contentrain/skills` | Agent playbooks, workflow hints, and normalize/review procedures | Studio mirrors these workflows in chat-led operations, onboarding, and promotion moments | [Skills](/packages/skills), [Studio AI Chat](https://docs.contentrain.io/guide/ai-chat) | | `@contentrain/wp-import` | Reads a WordPress site into source-faithful `RawIR`, then a `.contentrain` store | Studio receives the finished migration as a `MigrationHandoff` and hosts the comments the import exported | [WordPress Import](/packages/wp-import) | | `@contentrain/emitter-astro` | Renders a `ProjectIR` into a complete Astro project | Studio supplies the runtime binding that turns emitted form and comment placeholders into working components | [Astro Emitter](/packages/emitter-astro), [Forms & Comments](/guides/forms-comments) | | `@contentrain/verify` | Offline SEO/indexing/link/asset gates over documents you already have | Studio (or any producer holding a safe fetcher) supplies the pages; this package never makes a network request | [Verify](/packages/verify) | | `@contentrain/decide` | Typed decisions (a choice or a score) for the band a rule cannot settle: rule, then Jev, then Claude Haiku (opt-in) when Jev does not answer | Migrate and Studio pass inputs; the package shapes them, caches, caps and audits, and never fails the call | [Decide](/packages/decide) | ## MCP Distribution Surfaces | Surface | What users see | Best fit | |---|---|---| | MCP Registry | `io.github.Contentrain/contentrain` | Discover and install the local `@contentrain/mcp` server from MCP-compatible clients | | OpenAI Codex | `contentrain setup codex` writes `.codex/config.toml`; `codex mcp add` by hand | Developers who want Contentrain tools inside Codex CLI, desktop, or IDE workflows | | Claude Desktop | `claude_desktop_config.json` | Local desktop users who want a checkout-scoped Contentrain server | | Claude Code plugin | `/plugin marketplace add Contentrain/ai` | One-command install that bundles the MCP server plus the normalize skills — the shortest path for Claude Code users | | Claude Code (manual) | `claude mcp add`, `.mcp.json` | Coding-agent workflows that want project-scoped MCP approval without the plugin | | Studio MCP Cloud | Hosted Streamable HTTP endpoint + API key | Teams that want external agents to operate a Studio project without sharing a local checkout | ## Product Philosophy - **Governance, not generation** — Contentrain standardizes, validates, and reviews content. Your model provider still does the generation. - **Git-native, not database-first** — content stays in Git; branches, commits, diffs, and merge rules stay visible. - **Packages drive adoption** — the AI repo is the easiest entry point for developers and agents. - **Studio deepens operations** — once content exists, teams need permissions, review, shareable workflows, and delivery surfaces. ## Marketing and Distribution Logic The growth loop is intentional: 1. **Normalize is the wedge** — most developers discover Contentrain by rescuing hardcoded strings in an existing codebase. 2. **Packages create trust** — MCP, CLI, rules, skills, and query are adoptable independently. 3. **Studio is the expansion surface** — teams move into Studio when review, collaboration, and delivery become operational needs. 4. **CDN and team workflows monetize naturally** — after the content layer already exists. That means AI docs should mention Studio at natural escalation points: - after `contentrain init` - after successful normalize extraction - when review branches accumulate - when multiple collaborators or locales appear - when content must ship beyond web runtime Studio docs should point back to AI packages whenever they explain: - where the `.contentrain/` contract comes from - how normalize starts the journey - how rules and skills shape agent behavior - how `@contentrain/query` relates to CDN delivery and consumer apps ## Docs Map | Need | Canonical surface | |---|---| | Package contracts and local workflows | `ai.contentrain.io` | | Team workflows and product docs | `docs.contentrain.io` | | Studio deployment and self-hosting | `docs.contentrain.io/developer/self-hosting` | ## Short Definition Contentrain AI is the package surface that gets structured content into Git. Contentrain Studio is the team surface that helps people review, operate, and deliver that same governed content. --- # Core Concepts Source: https://ai.contentrain.io/concepts # Core Concepts ## The Big Idea **Governance, not generation.** Contentrain is not an AI content generator — there are dozens of those. Contentrain is the infrastructure that governs what happens _after_ AI generates content: validation, structure, review, approval, and delivery. Most content tools ask you to learn their system — their dashboard, their API, their markup syntax. Contentrain inverts this: **your AI agent already understands your codebase**, so let it manage your content. Contentrain provides the deterministic infrastructure (MCP tools, validation, Git transactions) while the agent provides the intelligence (what to extract, how to structure, where to replace). > Agent generates. Human approves. System standardizes. The result is a content pipeline with full auditability: ``` Agent generates → MCP validates → Human reviews → Git commits → Content delivered ``` Every step is a git diff. Nothing reaches production without your approval. ## The Agent-Driven Content Model Contentrain AI inverts the traditional CMS workflow: | Traditional CMS | Contentrain AI | |---|---| | Human opens dashboard | Human talks to AI agent | | Human creates schema manually | Agent creates models via MCP | | Human types content in forms | Agent writes content to Git | | Content stored in database | Content stored as files in `.contentrain/` | | API calls to read content | Plain JSON files — read from any language, optional typed SDK | | Deploys need API availability | Static files — works offline, zero runtime deps, any platform | ## Three Layers ### 1. MCP (Infrastructure) 27 tools (22 core + 5 media) that AI agents call to manage content: - **Read:** `contentrain_status`, `contentrain_describe`, `contentrain_describe_format`, `contentrain_doctor`, `contentrain_content_list` - **Project setup:** `contentrain_init`, `contentrain_scaffold` - **Content and schema writes:** `contentrain_model_save`, `contentrain_model_delete`, `contentrain_content_save`, `contentrain_content_delete` - **Normalize:** `contentrain_scan`, `contentrain_apply` - **Workflow and operations:** `contentrain_validate`, `contentrain_submit`, `contentrain_merge`, `contentrain_reconcile`, `contentrain_branch_list`, `contentrain_branch_delete`, `contentrain_bulk` MCP is **deterministic infrastructure** — it doesn't make content decisions. The agent decides what to create; MCP executes it. MCP runs over two transports (stdio for IDE agents, HTTP for Studio / CI / remote drivers) and three provider backends: **Local** (simple-git + worktree), **GitHub** (Octokit over the Git Data API), and **GitLab** (gitbeaker over the REST API). The tool surface is identical across all three; some tools require `LocalProvider` — see [Providers and transports](/guides/providers) for the capability matrix. ### 2. Agent (Intelligence) The AI agent (Claude, GPT, etc.) is the intelligence layer: - Understands your codebase structure - Evaluates which strings are user-facing content - Chooses the right model kind for each content group - Determines framework-specific replacement patterns - Manages the review workflow with the developer ### 3. Serve UI (Review Surface) A local web UI for developers to: - **Monitor:** browse models, content, validation, history - **Approve:** review and merge agent-created branches - **Prompt:** copy pre-built prompts to paste into their agent The UI never triggers mutations directly — all actions go through the agent. ## Model Kinds Contentrain supports four content model kinds: ### Collection Multiple entries with the same field schema. Each entry has an auto-generated ID. ```json // .contentrain/content/marketing/faq/en.json { "abc123": { "question": "What is Contentrain?", "answer": "..." }, "def456": { "question": "How does it work?", "answer": "..." } } ``` **Use for:** blog posts, FAQ items, team members, testimonials, pricing plans. ### Singleton A single entry per locale with structured fields. ```json // .contentrain/content/marketing/hero/en.json { "title": "Build Faster", "subtitle": "With AI", "cta_text": "Get Started" } ``` **Use for:** hero sections, site settings, navigation config, footer content. ### Dictionary Flat key-value pairs — all values are strings. No field schema. ```json // .contentrain/content/system/ui-labels/en.json { "nav.home": "Home", "nav.about": "About", "error.required": "This field is required" } ``` **Use for:** UI labels, button text, error messages, validation messages, i18n strings. ::: tip Parameterized Templates Dictionary values support `{placeholder}` syntax: ```ts dictionary('ui-labels').locale('en').get('welcome', { name: 'Ahmet' }) // "Hello, {name}!" → "Hello, Ahmet!" ``` ::: ### Document Markdown files with frontmatter metadata. ```markdown --- title: Getting Started category: guides --- # Getting Started This is the markdown body content... ``` **Use for:** blog articles, documentation pages, changelogs, legal pages. ## Domains Models are organized by domain — a logical grouping: ``` .contentrain/content/ marketing/ ← hero, pricing, testimonials blog/ ← blog-post, author system/ ← navigation, ui-labels ``` Domains are configured in `.contentrain/config.json`. ## Locales & i18n Every model can be i18n-enabled (`i18n: true`). Content is stored per locale: ``` .contentrain/content/marketing/hero/ en.json ← English tr.json ← Turkish ``` Any platform can read the locale files directly. The TypeScript SDK adds convenience: ```ts // Option 1: Read JSON directly (any language) // Just read .contentrain/content/marketing/hero/tr.json // Option 2: TypeScript SDK (optional) singleton('hero').locale('tr').get() // typed, with query API ``` ## Git Workflow Every write operation creates a Git commit on a namespaced branch: ``` cr/model/hero-section ← model changes cr/content/blog-post ← content changes cr/normalize/extract/... ← normalize extraction cr/normalize/reuse/... ← source patching ``` Branches are auto-merged or held for review depending on your workflow config. An auto-merge advances the project's [base branch](/reference/config#base-branch) — the default branch, never merely the branch you have checked out. A local write is planned from your working tree but committed on the `contentrain` tip, after fetching the remote. When another writer (Studio, a teammate, CI) reached `contentrain` since your last pull, the write is carried over entry by entry: their entries and fields stay, yours are added. The meta write stamp (`updated_at`/`updated_by`/`source`) is not a conflict: the latest write's stamp wins. If both sides changed the same value, nothing is written and the tool returns `CONTENT_WORKING_TREE_STALE` — pull and retry. On a checked-out feature branch, which content writes do not update, `.contentrain/` is read from the `contentrain` branch instead of the working tree, so repeated edits there need no merge, and read tools (`content_list`, `describe`, `status`, `validate`) show what is on `contentrain` and say so with `content_source`. When a review branch is merged or rejected, its remote copy is deleted too, so merged branches don't linger as phantom pending reviews — run `contentrain prune` to drain any existing backlog, or set `remoteBranchCleanup: false` to opt out. ### Divergence The `contentrain` branch normally contains the base branch, so advancing the base is always a fast-forward. The invariant breaks when a change lands on the base branch directly — legitimately so for a **dual-domain migration** (a package bump and a `.contentrain/` schema change that must ship as one atomic PR). From then on the branches have **diverged**: writes still land safely on `contentrain`, but they report `base_advance: "blocked_diverged"` instead of advancing the base, and `contentrain status` shows the relation in both directions instead of a one-sided "in sync". Divergence is reconciled with a content-aware three-way merge — `contentrain_reconcile` (MCP) or `contentrain reconcile` (CLI). Everything one side changed merges mechanically at entry/key/term+locale granularity; only the same item changed differently on both sides becomes a conflict for the editor to decide. The result is a two-parent merge commit on `contentrain`, after which the fast-forward advance works again. See [Reconcile & Divergence](/reference/reconcile) for the merge policy, the conflict set, and how decisions are answered. ## How It Compares | | Contentrain AI | Headless CMS (Sanity, Strapi) | Git CMS (Tina, Decap) | |---|---|---|---| | **Content source** | Your codebase or agent-created from scratch | External dashboard | Markdown/JSON in repo | | **AI integration** | Native (MCP tools) | Manual API calls | None | | **Extract from code** | Yes (normalize flow) | No | No | | **Vendor lock-in** | None (plain files + Git) | API dependency | Editor dependency | | **Type safety** | Generated SDK (optional) | Manual types | Manual types | | **i18n** | Built-in (per-locale files) | Plugin/addon | Plugin/addon | | **Review workflow** | Git branches + local UI | Dashboard roles | PR-based | | **Runtime dependency** | Zero (static files) | API availability | Build-time | ::: tip No Lock-In Contentrain stores everything as plain JSON and Markdown files in your Git repo. If you stop using Contentrain tomorrow, your content is still there — readable, portable, yours. ::: ## Beyond Web: Content CDN Web projects deploy content with `git push` — no extra infrastructure needed. But non-web platforms (iOS, Android, React Native, Flutter, desktop apps, game engines, IoT) can't read from a git repo at runtime. For these use cases, [Contentrain Studio](/studio)'s managed CDN publishes merged content over HTTP (`GET /api/cdn/v1/{projectId}/{path}` on the Studio host), delivering the same structured JSON your SDK queries — just over HTTP instead of the filesystem. The CDN is a Studio delivery feature (Cloudflare R2-backed), not part of the MIT core; the SDK's CDN client points at it via `cdn.url` in `config.json`. ``` Git repo → merge → CDN publish → iOS/Android/Flutter fetch → typed response ``` This means one content source powers your website, mobile app, and any other platform — all governed by the same review workflow. ## Contentrain Studio [Contentrain Studio](/studio) is the open-core team operations panel for Git-native structured content. Teams can self-host the AGPL core or use the managed plans (Starter/Pro/Enterprise) on top of the same model. The local open-source stack gives you: - MCP tools - CLI workflows - local review UI - generated SDK - rules and skills Studio adds the multi-user operating layer on top of that: - **Workspace and project management** — organize repos, teams, and environments - **Chat-first operations** — run bounded content workflows through a web app - **Permissioned collaboration** — owners, admins, editors, reviewers, and viewers - **Branch and diff review** — inspect, merge, reject, and track content changes - **Media and submissions** — manage assets and inbound content workflows - **CDN delivery** — publish merged content to apps that cannot read from Git at runtime The local tools and Studio share the same Git-native content model. The difference is not the content format — it is the operating surface. --- # Contentrain Studio Source: https://ai.contentrain.io/studio # Contentrain Studio Contentrain Studio is the open-core team operations and delivery surface for Git-native structured content. In short: > Contentrain AI gives developers local-first packages and agent workflows. Studio gives teams an authenticated web surface for review, collaboration, media, APIs, and CDN delivery on top of the same `.contentrain/` contract. Teams can self-host the AGPL core or use the managed plans (Starter/Pro/Enterprise), depending on how much infrastructure and operations they want to own. It is not a separate content format and it is not a different storage engine. Studio sits on top of the same Contentrain model: - content lives in Git - content is schema-based - changes are written through branches, commits, and diffs - agents and humans operate on the same governed content layer - the same content can later be delivered through files, SDK queries, or CDN endpoints ## What Studio Is Studio is the team-facing web application in the Contentrain ecosystem. It combines three layers: - **Management layer** — workspaces, projects, members, permissions, and AI configuration - **Content execution layer** — chat-driven and UI-driven content operations backed by Git - **Delivery layer** — CDN publishing, API access, media, and external conversation endpoints If the open-source stack is the local operating core, Studio is the collaboration and delivery surface. ## What Studio Handles Based on the current product surface, Studio covers these categories: 1. authentication and session management 2. workspace and project management 3. chat-based content operations 4. structured content writing and review workflows 5. branch and diff management 6. media management 7. CDN-based content delivery 8. external conversation API 9. form submission management 10. client-side content cache and search ## Operating Model Studio is not database-first. It is Git-first. The core flow looks like this: 1. a user works inside a workspace and project 2. the project is connected to a Git repo 3. the user requests a change 4. the request reaches the server through chat or an API route 5. permissions are checked 6. the content engine validates, serializes, opens a branch, and creates a commit 7. the workflow either auto-merges or leaves a review branch open 8. the UI exposes the result through conversations, branches, diffs, and snapshots That means Studio is not just a dashboard. It is the governed web operating surface for reviewable content operations. ## Package Bridges Studio is where the AI package surfaces meet team operations: | AI surface | What starts in AI | What continues in Studio | |---|---|---| | `@contentrain/mcp` | deterministic local content operations, normalize, validation | governed web workflows, review, approval, project-level operations | | `contentrain` CLI | init, serve, diff, generate, validate | authenticated review, role-based collaboration, project/workspace management | | `@contentrain/rules` | quality and schema guardrails | shared content quality expectations across chat, review, and validation | | `@contentrain/skills` | workflow playbooks and promotion hints | mirrored chat-led workflows, onboarding moments, and review handoffs | | `@contentrain/query` | local typed consumption and CDN transport | remote delivery, API key management, CDN publishing, and non-web distribution | ## Core Concepts ### Workspace The workspace is the team, billing, and permission boundary. Studio can: - list available workspaces - create new workspaces - update workspace settings - manage members - enforce workspace-level roles ### Project A project is a repo-connected unit inside a workspace. Studio can: - list accessible projects - connect new projects to repositories - fetch project details - manage project-level access ### Structured Content Studio uses the same four model kinds as Contentrain: - collection - singleton - document - dictionary The point is not to create a separate Studio-only model. The point is to operate the same structured content layer through a team web app. ## Auth, Roles, and Permissions Studio is an authenticated application with role-aware access. Supported auth flows: - GitHub OAuth - Google OAuth - magic link Permissions are layered: - workspace roles: `owner`, `admin`, `member` - project roles: `editor`, `reviewer`, `viewer` - model-level restrictions where needed This matters because Studio is not just “can the user log in?” It is “who can change which project, which models, and which workflows?” ## Chat-First Content Operations One of Studio's defining features is chat. But this is not an open-ended chatbot. It is a bounded, tool-driven content operations interface: - user message enters with project context - permissions and phase rules are applied - the system builds the right agent/tool context - tool calls are executed through the governed content path - results stream back to the UI So the chat surface is an operations UI, not a generic assistant shell. ## Content Engine The content engine is the execution core behind Studio. It is a [`@contentrain/mcp`](/packages/mcp) server driven over HTTP (`POST /mcp`) with a `GitHubProvider` or `GitLabProvider` pointed at the team's content repository. Studio never talks to Git directly — every operation flows through MCP's `RepoProvider` contract and lands as an atomic commit on a `cr/*` feature branch. The engine is responsible for: - reading model definitions - loading current content - validating data - resolving content paths - serializing canonically - creating branches and commits - producing diffs Supported operations include: - content save - document save - content delete - model save - entry status update - locale copy - project init - branch merge / reject Studio uses the same tool contract the stdio-driven IDE agents use. A new git host support added to MCP (today: Local / GitHub / GitLab; Bitbucket coming) becomes a Studio connector automatically. See [Providers & Transports](/guides/providers) for the capability matrix and [HTTP Transport](/guides/http-transport) for the deployment pattern. ## Review, Branches, and Diffs Studio is built around reviewable content changes. It provides: - pending branch listings - branch diff inspection - merge and reject flows - conversation-linked review context This is one of the clearest places where Studio stops being “content admin UI” and becomes “team operations panel.” ## Delivery, CDN, and APIs Studio is not only about editing content. It also handles delivery. That includes: - CDN publishing - CDN keys and access control - build history and triggers - public delivery routes - external conversation API keys and scoped access This makes Studio useful for: - mobile apps - desktop apps - game engines - other platforms that cannot read from Git directly at runtime ## Media and Content-In Flows Studio also extends the ecosystem with adjacent operational capabilities: - media upload and asset management - metadata and variants - forms and submission review - client-side cached snapshots and search These are not side details. They matter because team content operations rarely end at “edit a JSON file.” ## Relationship to the Open-Source Stack The open-source stack remains the foundation: - `@contentrain/mcp` - `contentrain` - `@contentrain/query` - `@contentrain/rules` - `@contentrain/skills` Studio is the open-core web layer on top: - team access - web workflows - review UX - media - delivery So the split is: - **open-source Contentrain** = local, Git-native content governance core - **Contentrain Studio** = team operations and delivery layer for the same content contract ## Connecting from the CLI The fastest way to create a Studio project from an existing repository is the CLI `connect` command: ```bash contentrain studio login contentrain studio connect ``` This single interactive flow handles workspace selection, GitHub App installation, repository detection, `.contentrain/` scanning, and project creation. See the [CLI Studio Integration](/packages/cli#connecting-a-repository) section for the full step-by-step. ## Go Deeper - [Ecosystem Map](/ecosystem) - [Studio AI Chat](https://docs.contentrain.io/guide/ai-chat) - [Studio Branches & Review](https://docs.contentrain.io/guide/branches-and-review) - [Studio CDN](https://docs.contentrain.io/guide/cdn) - [Studio Self-Hosting](https://docs.contentrain.io/developer/self-hosting) ## Short Definition Contentrain Studio is a web application that lets teams manage structured content living in Git through chat, visual workflows, branch review, and delivery APIs. --- # MCP Tools Source: https://ai.contentrain.io/packages/mcp # MCP Tools [![npm version](https://img.shields.io/npm/v/@contentrain/mcp)](https://www.npmjs.com/package/@contentrain/mcp) [![npm downloads](https://img.shields.io/npm/dm/@contentrain/mcp)](https://www.npmjs.com/package/@contentrain/mcp) Contentrain's MCP (Model Context Protocol) package is the deterministic execution layer that sits between your AI agent and your filesystem. While the agent makes intelligent content decisions, `@contentrain/mcp` enforces consistent file operations, canonical serialization, and git-backed safety. ## Why MCP? Traditional content management relies on APIs, dashboards, and manual workflows. Contentrain inverts this: - **Agent produces** content decisions (what to write, where, in what structure) - **MCP applies** deterministic filesystem and git operations (how to write it safely) - **Humans review** and merge through git workflows - **The system guarantees** schema, locale, and serialization consistency This separation means your AI agent never directly touches files. Every write goes through MCP's validation, canonical serialization, and git transaction pipeline. ::: tip Why not just let the agent write files? Agents are non-deterministic. The same prompt can produce different file formats, inconsistent JSON ordering, or broken git state. MCP is the deterministic guardrail that makes AI-generated content safe for production. ::: ## Install ```bash pnpm add @contentrain/mcp ``` Requirements: - Node.js 22+ - Git available on the machine Optional parser support for higher-quality source scanning: - `@vue/compiler-sfc` — Vue SFC parsing - `@astrojs/compiler` — Astro component parsing - `svelte` — Svelte component parsing ## Discovery `@contentrain/mcp` is published for both package-based and registry-based MCP discovery: - **npm package:** [`@contentrain/mcp`](https://www.npmjs.com/package/@contentrain/mcp) - **MCP Registry name:** `io.github.Contentrain/contentrain` - **Local server binary:** `contentrain-mcp` - **CLI stdio entrypoint:** `contentrain serve --stdio` - **Hosted remote endpoint:** Contentrain Studio MCP Cloud at `https://studio.contentrain.io/api/mcp/v1/{projectId}/mcp` Use the local stdio server when the agent should work against a checkout on your machine. Use Studio MCP Cloud when an external agent should operate a connected Studio project through a project-scoped API key. ## Tool Catalog The MCP server exposes **27 tools** — 22 core + 5 media — organized by function. Each tool includes [MCP annotations](https://spec.modelcontextprotocol.io/specification/2025-03-26/server/tools/#annotations) (`readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint: false` everywhere except `contentrain_media_ingest`, which fetches a caller-supplied URL server-side) so clients can distinguish safe reads from writes and destructive operations. ::: info Capability-aware listing `tools/list` is filtered per session: tools whose requirements (local project root, provider capabilities) cannot be met are not registered at all. A local stdio server lists the 22 core tools; a remote-provider session lists the remote-safe subset plus — on media-capable providers like Studio MCP Cloud — the 5 `contentrain_media_*` tools. Core remote-safe subset — `status`, `describe`, `describe_format`, `model_save`, `model_delete`, `content_save`, `content_delete`, `content_list`, `vocabulary_save`, `vocabulary_delete`, `validate`. See `TOOL_REQUIREMENTS` in `@contentrain/mcp/tools/availability`. ::: | Tool | Title | Read-only | Destructive | |------|-------|-----------|-------------| | `contentrain_status` | Project Status | Yes | — | | `contentrain_describe` | Describe Model | Yes | — | | `contentrain_describe_format` | Describe Format | Yes | — | | `contentrain_doctor` | Project Health Report | Yes | — | | `contentrain_init` | Initialize Project | — | — | | `contentrain_scaffold` | Scaffold Template | — | — | | `contentrain_model_save` | Save Model | — | — | | `contentrain_model_delete` | Delete Model | — | **Yes** | | `contentrain_content_save` | Save Content | — | — | | `contentrain_content_delete` | Delete Content | — | **Yes** | | `contentrain_content_list` | List Content | Yes | — | | `contentrain_vocabulary_save` | Save Vocabulary Terms | No | — | | `contentrain_vocabulary_delete` | Delete Vocabulary Terms | No | Yes | | `contentrain_validate` | Validate Project | — | — | | `contentrain_submit` | Submit Branches | — | — | | `contentrain_merge` | Merge Branch | — | — | | `contentrain_reconcile` | Reconcile Diverged Branches | — | — | | `contentrain_branch_list` | List Branches | Yes | — | | `contentrain_branch_delete` | Delete Branch | — | **Yes** | | `contentrain_scan` | Scan Source Code | Yes | — | | `contentrain_apply` | Apply Normalize | — | — | | `contentrain_bulk` | Bulk Operations | — | — | | `contentrain_media_list` | List Media Assets | Yes | — | | `contentrain_media_get` | Get Media Asset | Yes | — | | `contentrain_media_ingest` | Ingest Media From URL | — | — | | `contentrain_media_update` | Update Media Metadata | — | — | | `contentrain_media_delete` | Delete Media Asset | — | **Yes** | ### Detailed Reference ### Read Tools (Safe, No Side Effects) | Tool | Purpose | Description | |------|---------|-------------| | `contentrain_status` | Project overview | Config, models, branch health, context, validation summary | | `contentrain_describe` | Model deep-dive | Full schema, sample data, field types for any model | | `contentrain_describe_format` | Format reference | File structure, JSON formats, markdown conventions, locale strategies | | `contentrain_doctor` | Health diagnostics | Setup validation, SDK freshness, orphan content, local branch limits, remote `cr/*` count, unused keys, missing translations | | `contentrain_content_list` | Read content | List entries with `filter`, optional relation resolution (`resolve`), and `limit`/`offset` pagination | | `contentrain_vocabulary_save` | Write vocabulary | Add or update canonical terms — `{ "term-slug": { "en": "…" } }`, merges with what exists | | `contentrain_vocabulary_delete` | Write vocabulary | Remove canonical terms by slug (`confirm: true`) | ### Write Tools (Git-Backed, Branch-Isolated) | Tool | Purpose | Description | |------|---------|-------------| | `contentrain_init` | Bootstrap project | Creates `.contentrain/` structure, config, and git setup | | `contentrain_scaffold` | Apply templates | Starter templates: `blog`, `landing`, `docs`, `ecommerce`, `saas`, `i18n`, `mobile` | | `contentrain_model_save` | Define schemas | Create or update model definitions with field types and constraints | | `contentrain_model_delete` | Remove models | Delete a model definition and its content | | `contentrain_content_save` | Write content | Save entries for any model kind (collection, singleton, dictionary, document) | | `contentrain_content_delete` | Remove content | Delete specific content entries | | `contentrain_validate` | Check & fix | Validate content against schemas, optionally auto-fix structural issues | | `contentrain_submit` | Push branches | Push `cr/*` review branches to remote, then lazily prune merged local + remote leftovers | | `contentrain_merge` | Merge branches | Merge a review-mode branch into contentrain locally (by exact branch or model; no external platform needed); deletes the branch's remote copy | | `contentrain_reconcile` | Reconcile branches | Content-aware three-way merge of a diverged contentrain ↔ base pair — dry_run previews, resolutions answer conflicts, execute lands a two-parent merge commit and restores the fast-forward advance | | `contentrain_branch_list` | Inspect branches | List pending `cr/*` branches with merge status and branch-health pressure (`remote: true` adds a remote view + remote-only leftovers) | | `contentrain_branch_delete` | Clean up branches | Delete a stale/failed `cr/*` branch locally and on the remote (the contentrain branch is protected; supports remote-only deletion) | ### Normalize Tools (Scan + Apply) | Tool | Purpose | Description | |------|---------|-------------| | `contentrain_scan` | Find hardcoded strings | Graph-based component scan with candidate detection | | `contentrain_apply` | Extract or reuse | Two-phase normalize: extract content or patch source files | | `contentrain_bulk` | Batch operations | Bulk locale copy, status updates, and deletes | ### Field constraints Fields draw from a **27-type catalog** — see [Field Types](/reference/field-types) for the full list and per-type constraint enforcement. `contentrain_content_save` **validates before it writes**. A `severity: error` issue on any entry in the call means nothing is committed — no branch, nothing to clean up. Fix the values and call again. Warnings pass and come back in the response. The split is deliberate: - **Errors** are definitional. A `slug` that does not match `SLUG_PATTERN` is not a slug; an unparseable `date` is not a date; `3.7` is not an `integer`. - **Warnings** are heuristics. `email`, `url`, `color` and `phone` patterns are approximations, and a legitimate value can sit outside one. Only the entries in the call are fatal. A pre-existing bad entry elsewhere in the model is reported but does not block your save. Array items are validated by the same rules as a scalar of that type, so `items: { type: 'string', max: 50 }` means what it looks like it means. ::: warning What MCP does not enforce `maxSize` is the one constraint MCP accepts but cannot check — it stores a path and never sees the file. Your media provider enforces it at ingest, and `model_save` returns a `schema_warnings` entry saying so. `accept` **is** checked, but against the file extension only, which is why it warns rather than errors. ::: `model_save` rejects a constraint declared where it cannot apply — `options` on a non-select, `items` on a non-array, `accept` on a non-media field, `unique` on a singleton, `min > max`, an uncompilable `pattern` — instead of storing it and doing nothing. Unknown keys are rejected too, so a typo'd `requird: true` is an error rather than a silent no-op. ### Publish status Entry status lives in `.contentrain/meta/`, not in content, and it decides CDN delivery: a collection entry is served only when its status is `published`. - **`contentrain_content_save` never changes status.** Editing a field is not a publish decision — an existing entry keeps its `status`, `approved_by`, and `version`. Only a brand-new entry starts at `draft`. - **`contentrain_bulk update_status` is the only way to publish.** Each meta layout has its own address: pass `entry_ids` for collections, `slugs` for documents (their meta is one file per slug, the same identity `contentrain_content_save` uses), and neither for singletons and dictionaries, which have one meta record per locale. Pass `locale` to scope the change, or every supported locale is rewritten. - **`contentrain_validate` flags publish-state drift** — drafts sitting beside published entries in one collection — as a notice. It never auto-fixes it: publishing is a content decision, and MCP does not make those. - **Scheduled publishing lives in meta too — and only there.** `contentrain_content_save` accepts optional `publish_at` / `expire_at` (ISO 8601) per entry, alongside `data`, never inside it; `expire_at` must be after `publish_at`. Three things to know: - They **never change `status`**. A `publish_at` in the past does not publish a draft. The dates gate *delivery* of a published entry: the CDN serves it once `publish_at` has passed and until `expire_at`. The save response says so whenever a date was set. - They are **never written to the content file** — not into document frontmatter, not into collection JSON. Earlier versions did both; `validate` warns about such leftover frontmatter keys when meta holds the same value, and `fix: true` strips them. - **`null` clears** a date; omitting the key leaves it unchanged — the same rule a document's `body` follows. ::: tip Non-i18n models A model with `i18n: false` keeps all content in one `data.json`, so it has exactly one meta record — at the **default locale**, never `data.json`. - **A locale is meaningless here, so it is rejected, not guessed.** `contentrain_content_delete` refuses a `locale`-scoped delete on an `i18n: false` model: the locale would otherwise map onto `data.json` and the default-locale meta, deleting the shared content and the wrong meta while a stray per-locale meta stayed behind. Omit `locale` to delete the entry. - **`contentrain_validate` warns about stray per-locale meta** left by older versions, and **`fix: true` cleans it up deterministically**: when the default-locale meta is authoritative it prunes the extras (no status is merged, so a `published` record is never downgraded); when only a stray exists it is migrated to the default path so the record is preserved. Several strays with no default is ambiguous — it is left for you to resolve by hand. ::: ### Media Tools (Provider Media Facet) A deterministic passthrough to the provider's optional media stack (`RepoProvider.media`) — registered **only when the provider exposes one** (e.g. Studio MCP Cloud). The flow: list assets → pick a `media/...` path → reference it via `contentrain_content_save` (normalized to absolute delivery URLs when `mediaBaseUrl` is set). | Tool | Purpose | Description | |------|---------|-------------| | `contentrain_media_list` | Discover assets | Search, tag filter, cursor pagination — returns storage paths + delivery URLs | | `contentrain_media_get` | Inspect one asset | Path, URL, mime, size, alt, tags, provider metadata | | `contentrain_media_ingest` | Add from URL | Provider fetches the URL server-side under its own SSRF/MIME/size policy (MCP never fetches; the only `openWorldHint: true` tool) | | `contentrain_media_update` | Edit metadata | Alt text, tags, filename — never touches the binary | | `contentrain_media_delete` | Remove asset | Destructive; content references are NOT rewritten | ## Key Principles ### 1. Deterministic Infrastructure MCP is infrastructure, not intelligence. It does not decide what content to write — the agent does. MCP guarantees: - **Canonical JSON** — sorted keys, 2-space indent, trailing newline - **Consistent file paths** — locale strategy determines where files live - **Atomic git transactions** — every write is committed to a branch - **Schema enforcement** — content is validated against model definitions ### 2. Preview Where It Counts `dry_run` belongs to `contentrain_apply` — the one tool that can modify your source files. The normalize pattern is always: 1. Run `contentrain_apply` with `dry_run: true` (the default) to preview the plan 2. Review the output 3. Run with `dry_run: false` to commit The other write tools do not take a `dry_run` parameter. They are made safe by different guarantees: every write lands on an isolated `cr/*` branch, `contentrain_content_save` validates before committing (an error means nothing is written), and destructive tools (`content_delete`, `model_delete`, `branch_delete`, `merge`, `bulk delete_entries`) require an explicit `confirm: true`. ::: warning Never Skip the Apply Preview Always call `contentrain_apply` with `dry_run: true` first and review the plan. It is the only tool that can touch source code — skipping the preview risks unreviewed source patches. ::: ### 3. Git-Native Workflow All write operations create or update `cr/*` branches: - Content changes go to isolated branches (`cr/{scope}/{target}[/{locale}]/{timestamp}-{suffix}`) - Humans review via `contentrain diff` or the serve UI - Approved changes merge into the `contentrain` branch, baseBranch is advanced via update-ref. baseBranch is the project's default branch — `CONTENTRAIN_BRANCH` env, `repository.default_branch`, the remote HEAD, then `main`/`master` — and only as a last resort the checked-out branch; a write made on a feature branch never merges, moves or pushes it (see [Base Branch](/reference/config#base-branch)) - A local write plans from the working tree but commits on the fetched `contentrain` tip; JSON changes carry over key by key, so another writer's entries survive a stale working tree. The same value changed on both sides is refused with `CONTENT_WORKING_TREE_STALE` and nothing is written - On a checked-out feature branch (a named branch other than the base, `contentrain` or `cr/*`), `.contentrain/**` is read from the local `contentrain` branch — one tree listing and one blob batch per tool call, reused while `contentrain` does not move — and read tools add `content_source: { source: 'ref', ref: 'contentrain', commit, checked_out }`. Source files, scan and normalize still read the working tree, and so does a detached HEAD (CI) - A diverged baseBranch (commits `contentrain` lacks — typically a dual-domain migration PR) is a **partial success**, not an error: the write lands on `contentrain`, the response reports `base_advance: "blocked_diverged"`, and `contentrain_reconcile` merges the branches content-aware so the fast-forward works again. `contentrain_status` reports the relation (`in_sync` / `content_ahead` / `base_ahead` / `diverged`) in both directions - Merging (or deleting) a branch also removes its copy on the remote, so merged branches don't pile up as phantom pending reviews — best-effort, opt out with `remoteBranchCleanup: false` in `config.json`. Drain an existing backlog with `contentrain prune` - Local mode — `"git": { "push": false }` in `config.json`, or `CONTENTRAIN_NO_PUSH=1` for one run (the env wins over the config in both directions; `=0` turns pushing back on) — keeps every write local: no branch push, no remote delete or prune. Fetching still happens. Results report `remote_push: "disabled"` with `remote_note: "not pushed (local mode)"`, and `contentrain_submit` returns an error. The remote name is `CONTENTRAIN_REMOTE` (default `origin`) (see [Config](/reference/config#field-reference)) - Merged-branch detection survives base-history rewrites (ancestry check with a patch-id fallback) - Branch health is tracked and surfaced via `contentrain_status` (warning at 50, blocked at 80 active branches); `contentrain_doctor` adds a remote `cr/*` count - Legacy `contentrain/*` branches are auto-migrated on first init ### 4. Local-First by Default, Remote Providers Opt-In The default shape — stdio transport + `LocalProvider` — operates entirely on the local filesystem. No GitHub API, no cloud service, no external dependency. Remote providers (`GitHubProvider` via `@octokit/rest`, `GitLabProvider` via `@gitbeaker/rest`) are **optional peer dependencies**. They are installed only when Studio, CI, or a remote agent needs to drive MCP over an HTTP transport against a hosted git repo. A session that uses `LocalProvider` never loads these SDKs. That means: - Default install works offline and needs no API keys - Optional remote backends ship on the same tool contract — see [Providers and transports](/guides/providers) for the full capability matrix - Normalize, scan, and apply always need a `LocalProvider` — they return a `capability_required` error on remote providers ### 5. Capability Gates Every tool declares the capabilities it needs. Tools that require `astScan`, `sourceRead`, `sourceWrite`, or `localWorktree` reject on providers that do not expose them with a uniform error: ```json { "error": "contentrain_scan requires local filesystem access.", "capability_required": "astScan", "hint": "This tool is unavailable when MCP is driven by a remote provider. Use a LocalProvider or the stdio transport." } ``` Agent drivers treat `capability_required` as a retry signal. See [Providers & Transports](/guides/providers) for the full capability matrix. ## Transports - **stdio** — `contentrain serve --stdio` or `npx contentrain-mcp`. IDE agents (Claude Code, Cursor, Windsurf) connect over stdin/stdout. - **HTTP** — `contentrain serve --mcpHttp --authToken $TOKEN` or the programmatic `startHttpMcpServer({...})` / `startHttpMcpServerWith({ provider })` exports. Streamable HTTP at `POST /mcp` with secure-by-default Bearer auth. See the [HTTP Transport guide](/guides/http-transport). Both transports serve the same tool surface and the same JSON response shapes. ## Providers `@contentrain/mcp` ships three `RepoProvider` implementations behind a single contract: - **`LocalProvider`** — simple-git + temporary worktree on your disk - **`GitHubProvider`** — Octokit over the Git Data + Repos APIs (no clone) - **`GitLabProvider`** — gitbeaker over the GitLab REST API (no clone; supports self-hosted) Bitbucket is on the roadmap. See [Providers & Transports](/guides/providers) for the capability matrix and [RepoProvider Reference](/reference/providers) for the interface definitions. ## Studio Bridge `@contentrain/mcp` is the local execution layer for the ecosystem. It is where agents do deterministic work such as init, schema changes, validation, and normalize. When a project moves from solo or IDE-led work into team review and delivery, [Contentrain Studio](/studio) becomes the authenticated web surface on top of the same `.contentrain/` contract: - MCP keeps local writes deterministic and git-safe - Studio adds team roles, review UI, media, forms, APIs, and CDN delivery - both surfaces should describe the same models, locales, and branch semantics Use the [Ecosystem Map](/ecosystem) when you need the full package-to-product relationship, or jump to the Studio docs for the team-facing workflow: - [Studio Overview](/studio) - [Studio AI Chat](https://docs.contentrain.io/guide/ai-chat) - [Studio Branches & Review](https://docs.contentrain.io/guide/branches-and-review) ## Usage Examples ### Check Project Status Ask your agent: *"What's the current state of my Contentrain project?"* — triggers `contentrain_status`. Returns config, models list, branch health, pending changes, validation state. ### Run Health Checks Ask your agent: *"Is my Contentrain setup healthy?"* — triggers `contentrain_doctor`. Returns structured checks (env, structure, models, orphans, branches, SDK freshness). With `usage: true`, also analyzes unused keys, duplicates, missing translations. ### Create a Model ```ts // Agent calls contentrain_model_save { "id": "blog-post", "name": "Blog Posts", "kind": "collection", "domain": "content", "i18n": true, "title_field": "title", "fields": { "title": { "type": "string", "required": true }, "excerpt": { "type": "text" }, "author": { "type": "relation", "model": "team-members" }, "published": { "type": "boolean" } } } ``` Ask your agent: *"Create a blog post model with title, excerpt, author relation, and published flag"* ### Save Content ```ts // Agent calls contentrain_content_save { "model": "blog-post", "entries": [{ "locale": "en", "data": { "title": "Getting Started with Contentrain", "excerpt": "Learn how to set up AI-powered content management", "published": true } }] } ``` Ask your agent: *"Add a new blog post about getting started"* ::: tip Media in cloud mode For `image`/`video`/`file` fields (and `media/...` references in markdown bodies), pass either the media-library storage path (`media/...`) or a URL. When the server runs in cloud mode with a delivery base (Studio's MCP Cloud supplies one per project), `contentrain_content_save` normalizes those references to absolute public delivery URLs on save, so committed content renders anywhere with no SDK. In local mode the relative path is kept verbatim, and external URLs always pass through untouched. ::: ### Normalize Flow (Scan + Extract + Reuse) ```ts // Phase 1: Scan for hardcoded strings // Agent calls contentrain_scan → gets candidates // Phase 2: Extract content // Agent calls contentrain_apply with mode: "extract" // Creates models, writes content entries, tracks sources (cr/normalize/extract/*) // Phase 3: Reuse in source // Agent calls contentrain_apply with mode: "reuse" // Patches source files with i18n function calls (cr/normalize/reuse/*) ``` Ask your agent: *"Scan my landing page for hardcoded strings and extract them"* ## Agent Configuration The fastest way to connect your IDE agent: ```bash npx contentrain setup claude-code # or: cursor, vscode, windsurf, copilot ``` This auto-creates the correct MCP config file and installs AI rules/skills.
Manual configuration (all IDEs use the same JSON) ```json { "mcpServers": { "contentrain": { "command": "npx", "args": ["contentrain", "serve", "--stdio"] } } } ``` | IDE | Config file | |-----|-------------| | Claude Code | `.mcp.json` | | Cursor | `.cursor/mcp.json` | | VS Code | `.vscode/mcp.json` | | Windsurf | `.windsurf/mcp.json` |
Once connected, the agent has access to the full MCP tool surface and can manage your content through natural language. ### OpenAI Codex Codex uses `~/.codex/config.toml` or a trusted project-scoped `.codex/config.toml`. ```bash npx contentrain setup codex ``` This writes the project-scoped `.codex/config.toml`, appending the `[mcp_servers.contentrain]` table if the file already holds other Codex settings. To do it by hand instead: ```bash codex mcp add contentrain -- npx -y contentrain serve --stdio ``` Equivalent `config.toml`: ```toml [mcp_servers.contentrain] command = "npx" args = ["-y", "contentrain", "serve", "--stdio"] ``` For Studio MCP Cloud, configure a Streamable HTTP server with the project endpoint and Bearer token: ```toml [mcp_servers.contentrain] url = "https://studio.contentrain.io/api/mcp/v1/{projectId}/mcp" http_headers = { Authorization = "Bearer " } ``` ### Claude Desktop Claude Desktop reads `claude_desktop_config.json`. For a local checkout: ```json { "mcpServers": { "contentrain": { "command": "npx", "args": ["-y", "contentrain", "serve", "--stdio"] } } } ``` For Studio MCP Cloud: ```json { "mcpServers": { "contentrain": { "type": "http", "url": "https://studio.contentrain.io/api/mcp/v1/{projectId}/mcp", "headers": { "Authorization": "Bearer " } } } } ``` ### Claude Code Claude Code can use the project `.mcp.json` generated by `contentrain setup claude-code`, or add the hosted endpoint directly: ```bash claude mcp add --transport http contentrain \ https://studio.contentrain.io/api/mcp/v1/{projectId}/mcp \ --header "Authorization: Bearer " ``` ## Trust Model | Trust Level | Tools | Risk | Notes | |-------------|-------|------|-------| | **HIGH** (read-only) | `status`, `describe`, `describe_format`, `doctor`, `content_list`, `branch_list`, `scan`, `media_list`, `media_get` | None | Safe to call anytime, no side effects — `scan` reads source but never writes | | **MEDIUM** (git-isolated writes) | `init`, `scaffold`, `model_save`, `model_delete`, `content_save`, `content_delete`, `validate`, `bulk` | Low | Writes go through git transactions on isolated `cr/*` branches; destructive tools require `confirm: true` | | **MEDIUM** (branch lifecycle) | `submit`, `merge`, `branch_delete` | Medium | `submit` pushes to the remote (requires network); `merge` and `branch_delete` are local git operations — network is used only for best-effort remote-copy cleanup | | **MEDIUM** (provider media) | `media_ingest`, `media_update`, `media_delete` | Medium | Provider-side media stack; `media_delete` is destructive and does not rewrite content references | | **LOW** (source modification) | `apply` | Medium | `mode: "reuse"` patches source files — always run `dry_run: true` first | ::: danger Source Modifications The `contentrain_apply` tool with `mode: "reuse"` modifies your source code files. Always run with `dry_run: true` first, review the patches carefully, and use the review workflow before merging. ::: ## Typical Agent Workflow ``` 1. contentrain_status → understand project state 2. contentrain_doctor → validate setup health 3. contentrain_init → bootstrap if needed 4. contentrain_describe_format → understand storage contract 5. contentrain_model_save → define content schemas 6. contentrain_content_save → write content entries 7. contentrain_validate → check everything is valid 8. contentrain_submit → push for review ``` ## Core Exports For advanced integrations, the package exports low-level modules: ```ts import { createServer } from '@contentrain/mcp/server' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' const server = createServer(process.cwd()) const transport = new StdioServerTransport() await server.connect(transport) ``` Available subpath exports: - `@contentrain/mcp` (root) — stdio server entrypoint (what the `contentrain-mcp` bin runs) - `@contentrain/mcp/server` — MCP server factory and stdio setup - `@contentrain/mcp/server/http` — HTTP transport server factory - `@contentrain/mcp/core/config` — Config manager - `@contentrain/mcp/core/context` — Context JSON manager - `@contentrain/mcp/core/model-manager` — Model CRUD - `@contentrain/mcp/core/content-manager` — Content CRUD - `@contentrain/mcp/core/validator` — Validation engine - `@contentrain/mcp/core/scanner` — Source code scanner - `@contentrain/mcp/core/graph-builder` — Component graph - `@contentrain/mcp/core/apply-manager` — Normalize apply - `@contentrain/mcp/core/doctor` — Health check engine - `@contentrain/mcp/core/contracts` — RepoProvider interface types - `@contentrain/mcp/core/ops` — Plan APIs (`planContentSave`, `planReconcile`, `bindRef`, …) and path helpers - `@contentrain/mcp/core/overlay-reader` — Overlay file reading - `@contentrain/mcp/core/scan-config` — Scan configuration - `@contentrain/mcp/git/transaction` — Git transaction flow - `@contentrain/mcp/git/branch-lifecycle` — Branch health tracking - `@contentrain/mcp/git/errors` — Structured operation-error normalizer - `@contentrain/mcp/git/reconcile` — Local reconcile executor (`reconcileBranches`) - `@contentrain/mcp/templates` — Scaffold templates - `@contentrain/mcp/tools/annotations` — Tool metadata (TOOL_NAMES, TOOL_ANNOTATIONS) - `@contentrain/mcp/tools/availability` — Per-tool capability requirements (TOOL_REQUIREMENTS) - `@contentrain/mcp/testing/conformance` — Provider conformance test harness - `@contentrain/mcp/util/detect` — Framework detection - `@contentrain/mcp/util/fs` — File system utilities - `@contentrain/mcp/providers/local` — LocalProvider implementation - `@contentrain/mcp/providers/github` — GitHubProvider implementation - `@contentrain/mcp/providers/gitlab` — GitLabProvider implementation ## Related Pages - [CLI](/packages/cli) — Human-facing companion for local operations - [Query SDK](/packages/sdk) — Generated runtime client for consuming content - [Rules](/packages/rules) — Agent behavior policies - [Skills](/packages/skills) — Workflow playbooks that drive these tools - [Reconcile & Divergence](/reference/reconcile) — what `contentrain_reconcile` actually merges - [Contentrain Studio](/studio) — Hosted workspace, review, chat-first operations, and content CDN --- # CLI Source: https://ai.contentrain.io/packages/cli # CLI [![npm version](https://img.shields.io/npm/v/contentrain)](https://www.npmjs.com/package/contentrain) [![npm downloads](https://img.shields.io/npm/dm/contentrain)](https://www.npmjs.com/package/contentrain) The `contentrain` CLI is the human-facing companion to `@contentrain/mcp`. While MCP handles deterministic tool execution for AI agents, the CLI gives you direct terminal access to initialize projects, inspect health, generate typed SDK clients, review pending changes, and run the local review UI. ## Why a CLI? You might wonder: if the AI agent handles everything through MCP, why do you need a CLI? - **Bootstrapping** — `contentrain init` sets up `.contentrain/`, runs `git init` with an initial commit, and installs IDE rules and MCP config before the agent is even connected - **Visibility** — `contentrain status` and `contentrain doctor` give you instant project health without asking an agent - **SDK generation** — `contentrain generate` creates the typed client your application code imports - **Review workflow** — `contentrain diff` and `contentrain serve` let you review agent-created content in a proper UI - **CI/CD integration** — `contentrain validate` runs in pipelines to catch schema violations before deploy ::: tip Agent + CLI = Complete Workflow The agent creates content through MCP tools. The CLI helps you verify, review, and consume that content. They are complementary, not redundant. ::: ## Install Run directly with `npx` (no install required): ```bash npx contentrain init ``` Or install globally: ```bash npm install -g contentrain contentrain status ``` Requirements: - Node.js 22+ - Git available in `PATH` ## Global Options These flags are available on every command: | Flag | Environment Variable | Description | |------|----------------------|-------------| | `--debug` | `CONTENTRAIN_DEBUG=1` | Enable debug logging to stderr (useful for troubleshooting) | Example: `contentrain --debug status` or `CONTENTRAIN_DEBUG=1 contentrain status`. ## Commands | Command | Purpose | |---------|--------| | `contentrain init` | Initialize `.contentrain/`, git workflow, templates, and IDE rules | | `contentrain status` | Show project overview, models, branch pressure, and validation summary | | `contentrain doctor` | Check setup health, SDK freshness, orphan content, local branch limits, and remote `cr/*` count | | `contentrain validate` | Validate content against schemas, optionally create review-branch fixes | | `contentrain generate` | Generate `.contentrain/client/` and `#contentrain` package imports | | `contentrain describe` | Display full model schema and sample data | | `contentrain describe-format` | Show file format specification and storage conventions | | `contentrain scaffold` | Apply starter templates (blog, landing, docs, SaaS, ...) | | `contentrain import ` | Import a WordPress site (WXR export file or REST URL) into a `.contentrain` store | | `contentrain delta ` | Plan a WordPress source delta against the store (`--dry-run` only) | | `contentrain diff` | Review and merge or reject pending `cr/*` branches interactively (deletes the remote copy on merge/reject) | | `contentrain merge ` | Merge one pending `cr/*` branch non-interactively (deletes the remote copy) | | `contentrain reconcile` | Content-aware three-way merge of a diverged contentrain ↔ base pair (dry-run plan, interactive conflict decisions) | | `contentrain prune` | Delete merged `cr/*` branches locally and on the remote (drain the backlog) | | `contentrain setup` | Configure MCP server and AI rules for your IDE | | `contentrain skills` | Install, update, or list AI skills and rules for your IDE | | `contentrain serve` | Start the local review UI, the MCP stdio server, or the MCP HTTP server | | `contentrain studio connect` | Connect a repository to a Studio project | | `contentrain studio login` | Authenticate with Contentrain Studio | | `contentrain studio logout` | Log out from Studio | | `contentrain studio whoami` | Show current authentication status | | `contentrain studio status` | Show project overview from Studio | | `contentrain studio activity` | Show recent activity feed | | `contentrain studio usage` | Show workspace usage metrics | | `contentrain studio branches` | Manage remote content branches | | `contentrain studio cdn-init` | Set up CDN for content delivery | | `contentrain studio cdn-build` | Trigger a CDN rebuild | | `contentrain studio webhooks` | Manage webhooks | | `contentrain studio submissions` | Manage form submissions | --- ### `contentrain init` Bootstraps a Contentrain project in your repository. ```bash contentrain init # Interactive contentrain init --yes # Skip prompts contentrain init --root /path # Different project root ``` | Flag | Description | |------|-------------| | `--yes` | Skip prompts, use defaults | | `--root ` | Project root path | Creates `.contentrain/config.json`, the `models/`, `content/`, and `meta/` directories, `vocabulary.json`, and `context.json`, appends `.contentrain/.cache/` and `.contentrain/client/` to `.gitignore`, installs IDE rules, Agent Skills, and the MCP config (`.mcp.json` or IDE equivalent). Runs `git init` plus an initial commit if not already a repo. Records the project's default branch as `repository.default_branch` — the [base branch](/reference/config#base-branch) local writes advance — resolved once from the remote's default, then `main`, then `master`; run from a feature branch it still records the default. An initialized project is never re-initialized, so an existing value is kept. --- ### `contentrain status` ```bash contentrain status contentrain status --json # CI-friendly JSON contentrain status --root /path ``` Outputs config, models, active `cr/*` branches and their health, validation summary, and last operation context. --- ### `contentrain doctor` Runs a health check on your project. ```bash contentrain doctor contentrain doctor --usage # Analyze content key usage in source contentrain doctor --json # CI-friendly JSON; exits non-zero on failures ``` | Flag | Description | |------|-------------| | `--usage` | Analyze unused keys, duplicate values, locale coverage gaps | | `--json` | Machine-readable output; non-zero exit on check failures | | `--root ` | Project root path | Checks: `.contentrain/config.json` shape, SDK client freshness (mtime comparison), orphan content, branch limits (warning at 50, blocked at 80), Node/git versions. --- ### `contentrain validate` ```bash contentrain validate contentrain validate --fix # Auto-fix and create review branch contentrain validate --interactive # Choose fixes interactively contentrain validate --watch # Re-run on .contentrain/ changes (read-only) contentrain validate --model blog-posts contentrain validate --json # CI-friendly JSON ``` | Flag | Description | |------|-------------| | `--fix` | Auto-fix and create a `cr/fix/*` review branch | | `--interactive` | Choose which issues to fix interactively | | `--watch` | Re-run when `.contentrain/` content, models, or `config.json` change (read-only) | | `--model ` | Validate one model instead of all | | `--json` | CI-friendly JSON | ::: tip Watch mode for development Run `contentrain validate --watch` in one terminal alongside your editing workflow. It re-runs on `.contentrain/` changes without creating branches (read-only). Rapid feedback while editing content. ::: --- ### `contentrain generate` Generates the typed SDK client from model definitions. ```bash contentrain generate contentrain generate --watch # Regen on model/content changes contentrain generate --json # CI-friendly JSON contentrain generate --mediaBaseUrl https://cdn.example.com/api/cdn/v1/ # bake media()/mediaBody() resolvers ``` Writes `.contentrain/client/` (ESM, CJS, types + per-model data modules) and adds `#contentrain` subpath to `package.json`. | Flag | Description | |------|-------------| | `--watch` | Regenerate on model/content changes. | | `--json` | Emit the result as JSON (silences pretty output). | | `--mediaBaseUrl ` | Public media delivery base. Bakes [`media()`/`mediaBody()`](/packages/sdk#media-mediabody-delivery-urls) resolvers into the client that turn relative `media/...` paths (and `](media/...)` markdown references) into absolute URLs. Overrides `cdn.url` in [`config.json`](/reference/config). `--cdnBaseUrl` still works as a deprecated alias. | | `--root ` | Project root path. | --- ### `contentrain describe ` ```bash contentrain describe blog-post contentrain describe blog-post --sample --locale en contentrain describe blog-post --json ``` Shows full schema, field types, stats, sample data, and import snippet. --- ### `contentrain describe-format` ```bash contentrain describe-format contentrain describe-format --json ``` Prints the Contentrain content-format specification (directory structure, JSON formats, markdown conventions, locale strategies). --- ### `contentrain scaffold` ```bash contentrain scaffold # Interactive picker contentrain scaffold --template blog contentrain scaffold --template saas --locales en,tr,de --no-sample contentrain scaffold --template blog --json ``` Templates: `blog`, `landing`, `docs`, `ecommerce`, `saas`, `i18n`, `mobile`. --- ### `contentrain import ` ```bash contentrain import export.xml # a WXR export file contentrain import export.xml --out ./my-site contentrain import https://site.example # public REST contentrain import https://site.example --auth user:pass # lifts access to rest_auth contentrain import export.xml --force --json ``` | Flag | Effect | |---|---| | `--out ` | Target directory (default: current directory) | | `--auth ` | REST Application Password — lifts the access rung from `rest_public` to `rest_auth` | | `--force` | Overwrite an existing `.contentrain/` | | `--json` | Machine-readable report for scripting | Wraps [`@contentrain/wp-import`](/packages/wp-import): the conversion itself is a pure function in the library, so this command only detects the source kind, guards the target, writes files, and narrates. What lands on disk is the canonical `.contentrain/` store, `entry-source-map.json`, `comments-export.json` when the source had comments, and an import report. It refuses to write over an existing `.contentrain/` without `--force`, because a re-import replaces models and content wholesale. ::: warning Warnings are not decoration A REST import that hit a page cap, or whose pages partly failed, produces a store that looks exactly like a smaller site. Truncated collections, failed pages, and comments referencing posts outside the import are named in the warnings and nowhere else. See [WordPress Migration](/guides/migration). ::: --- ### `contentrain delta ` ```bash contentrain delta bridge-delta.json --incoming ./next-export --dry-run contentrain delta bridge-delta.json --incoming ./next-export --store ./site --dry-run --json ``` | Flag | Effect | |------|--------| | `--dry-run` | Required. The command only plans. | | `--incoming ` | The new export: its `.contentrain/` and `entry-source-map.json` (at the root or in `bridge/`) | | `--store ` | The repository store (default: current directory) | | `--taxonomy ` | Custom taxonomies whose ids are term ids | | `--json` | Print the planned `SourceDeltaPlan` | Wraps `planSourceDelta` from [`@contentrain/wp-import`](/packages/wp-import#source-deltas). Every record in the bridge's delta is placed in the store, or reported as `unmapped` with the reason. The plan shows: - which fields differ from the new export; - which addresses moved, each a 301; - tombstones, with trashed kept apart from purged; - conflicts: records the repository also edited since the import. A conflict is reported, never overwritten. There is no apply mode, because applying a plan is a governed write that goes through review. ### `contentrain diff` ```bash contentrain diff contentrain diff --json # Summary of pending cr/* branches for CI ``` Interactive (default) review of pending review branches. `--json` emits `{ branches: [{name, base, filesChanged, insertions, deletions, stat}] }` without entering the interactive loop. --- ### `contentrain merge ` ```bash contentrain merge cr/content/faq/1234-abcd contentrain merge cr/content/faq/1234-abcd --yes # Skip confirm (CI) ``` Non-interactive single-branch sibling of `contentrain diff`. Delegates to MCP's `mergeBranch` so dirty-file protections + selective sync warnings behave identically. After a successful merge it also deletes the branch's copy on the remote — best-effort, so an offline or permission failure only prints a warning. Opt out with `remoteBranchCleanup: false` in `config.json`. In local mode (`git.push: false` or `CONTENTRAIN_NO_PUSH=1`) it pushes nothing, leaves the remote copy alone, and says so. When the base branch has diverged, the merge still lands on `contentrain` and the command says so (`base_advance: blocked_diverged`) — run `contentrain reconcile` to restore the advance. --- ### `contentrain reconcile` ```bash contentrain reconcile # dry-run plan + interactive decisions contentrain reconcile --yes # execute a CLEAN plan without prompting contentrain reconcile --json # emit the dry-run plan for scripts ``` Content-aware three-way merge of a diverged `contentrain` ↔ base pair (the state a dual-domain migration PR leaves behind). Shows the plan — what merges mechanically from each side — then asks one ours/theirs question per surviving conflict, and executes as a two-parent merge commit that fast-forwards the base branch. `--yes` never defaults a conflict: a plan with open questions always stops. Conflict decisions are compare-and-set — if content moved between the preview and the decisions, the stale decision is dropped and asked again. --- ### `contentrain prune` ```bash contentrain prune --dry-run # Preview merged cr/* branches (local + remote) contentrain prune # Confirm, then delete them contentrain prune --yes # Skip confirm (CI) contentrain prune --json # Machine-readable output (mutates only with --yes) ``` Drains already-merged `cr/*` branches: local ones past their retention period plus every merged copy left on the remote. This is the operator-facing cleanup for backlogs the per-merge deletion could not remove — e.g. branches merged before remote cleanup shipped, or after an offline/permission failure. Merged detection uses the same ancestry + patch-id classification as the rest of the toolchain, so branches orphaned by a base-history rewrite are still recognised. Governed by `remoteBranchCleanup` in `config.json`; local mode (`git.push: false` or `CONTENTRAIN_NO_PUSH=1`) skips the remote half and says so; exits non-zero if any remote deletion fails. --- ### `contentrain setup` ```bash contentrain setup claude-code # or: cursor, vscode, windsurf, copilot contentrain setup --all # Configure every detected IDE ``` Writes the correct `.mcp.json` (or equivalent) for the target IDE and installs AI rules/skills if absent. Merges into existing config without overwriting other MCP servers. --- ### `contentrain skills` ```bash contentrain skills # Install / refresh contentrain skills --update # Force update contentrain skills --list # Show installed status per IDE ``` Installs 16 Agent Skills + essential rules across detected IDEs (Claude Code, Cursor, Windsurf, GitHub Copilot). --- ### `contentrain serve` Three roles: web UI, MCP stdio, MCP HTTP. #### Web UI (default) ```bash contentrain serve contentrain serve --port 8080 --host 0.0.0.0 contentrain serve --demo # Temporary demo project, no setup required ``` Serves REST endpoints (`/api/status`, `/api/content`, `/api/branches`, `/api/doctor`, `/api/describe-format`, `/api/preview/merge`, `/api/normalize/*`, `/api/capabilities`, `/api/branches/:name/sync-status`, `/api/tree`), a WebSocket stream (`meta:changed`, `file-watch:error`, `sync:warning`, `branch:*` events), and the bundled Vue UI. #### MCP stdio ```bash contentrain serve --stdio ``` For IDE agents. Uses `LocalProvider`, so it serves the 22 core tools over stdio (the `contentrain_media_*` tools need a media-capable provider such as Studio MCP Cloud). #### MCP HTTP ```bash contentrain serve --mcpHttp --authToken $(openssl rand -hex 32) contentrain serve --mcpHttp --port 3334 --host 0.0.0.0 --authToken $TOKEN ``` | Flag | Env var | Description | |------|---------|-------------| | `--mcpHttp` | `CONTENTRAIN_MCP_HTTP=true` | Enable HTTP MCP at `POST /mcp` | | `--port ` | `CONTENTRAIN_PORT` | Port (default 3333) | | `--host ` | `CONTENTRAIN_HOST` | Bind address (default `localhost`) | | `--authToken ` | `CONTENTRAIN_AUTH_TOKEN` | Bearer token, enforced per-request when set | **Auth:** when a token is supplied, every request must send `Authorization: Bearer `. Binding MCP HTTP to `0.0.0.0` without a token logs a warning but the server still starts — the endpoint exposes full project write access, so always pass a token on non-localhost binds. **Web UI is stricter:** the default serve UI **hard-errors** on any non-localhost bind without `--authToken` / `CONTENTRAIN_AUTH_TOKEN` (OWASP Secure-by-Default) — it refuses to start. Note the UI has no per-request auth today; the token requirement is a start-up gate for exposing it beyond localhost. ## Studio Integration The `studio` command group connects the CLI to [Contentrain Studio](/studio). ### Authentication ```bash contentrain studio login contentrain studio login --provider github contentrain studio login --url https://studio.example.com contentrain studio whoami contentrain studio logout ``` Credentials stored in `~/.contentrain/credentials.json` (mode `0o600`). Use `CONTENTRAIN_STUDIO_TOKEN` for CI. ### Connecting a Repository {#connecting-a-repository} ```bash contentrain studio connect contentrain studio connect --workspace ws-123 contentrain studio connect --json ``` Interactive flow: workspace → GitHub App install → repo detection → `.contentrain/` scan → project creation. Defaults are cached for subsequent `studio` commands. ### Project Monitoring ```bash contentrain studio status # Branches + CDN + team contentrain studio activity --limit 10 # Recent activity feed contentrain studio usage # AI messages, storage, bandwidth ``` ### Branch Management ```bash contentrain studio branches ``` List pending branches, interactively merge or reject. ### CDN Setup & Delivery ```bash contentrain studio cdn-init # First-time CDN setup contentrain studio cdn-build --wait # Trigger + wait for build ``` ### Webhooks & Submissions ```bash contentrain studio webhooks contentrain studio submissions --form contact-form contentrain studio submissions --form contact-form --status pending ``` The monitoring and management commands (`status`, `activity`, `usage`, `branches`, `webhooks`, `submissions`, `cdn-build`, `cdn-init`) accept `--workspace `, `--project `, and `--json` for scripting. The auth commands differ: `login` takes `--url`/`--provider`, `whoami` only `--json`, `logout` none, and `connect` takes `--workspace`/`--json`. --- ## Typical Workflow ```bash # 1. Initialize (auto-configures MCP for detected IDEs) contentrain init # 2. Or set up a specific IDE later contentrain setup claude-code # 3. Inspect project health contentrain status contentrain doctor # 4. Let the agent create models and content via MCP... # 5. Generate the typed SDK client contentrain generate # 6. Validate everything (optionally --watch during dev) contentrain validate # 7. Review agent-created branches contentrain diff # 8. Open the local review UI contentrain serve ``` ::: tip From Local to Team The CLI covers single-developer workflows. When you need workspace/project management, role-based collaboration, visual diff review, media operations, and content CDN delivery, [Contentrain Studio](/studio) extends the same Git-native model with an open-core team web surface. ::: ## Related Pages - [MCP Tools](/packages/mcp) — The deterministic tool layer the CLI wraps - [Query SDK](/packages/sdk) — The generated client that `contentrain generate` produces - [Rules](/packages/rules) — Agent behavior policies installed by `contentrain init` - [Skills](/packages/skills) — Workflow playbooks installed alongside them - [Contentrain Studio](/studio) — Team workspace, review, media, and CDN operations --- # Query SDK Source: https://ai.contentrain.io/packages/sdk # Query SDK [![npm version](https://img.shields.io/npm/v/@contentrain/query)](https://www.npmjs.com/package/@contentrain/query) [![npm downloads](https://img.shields.io/npm/dm/@contentrain/query)](https://www.npmjs.com/package/@contentrain/query) `@contentrain/query` is an **optional** TypeScript convenience layer for consuming Contentrain content. It generates a fully typed client from your model definitions — think Prisma, but for content. ::: tip SDK is optional Contentrain stores content as plain JSON and Markdown. Any language that reads JSON (Go, Python, Swift, Kotlin, Rust) can consume your content directly. The SDK adds type safety, query API, and relation resolution for TypeScript projects. ::: ## Why a Generated Client? You could read `.contentrain/content/` files directly — and for non-TypeScript platforms, that's exactly what you should do. But for TypeScript projects, raw file reads give you: - No TypeScript types - No query API (filtering, sorting, pagination) - No relation resolution - No locale-aware data loading - Manual JSON parsing for every model The generated client solves all of this with zero runtime dependencies and exact types from your model schemas. ::: tip Prisma Pattern `contentrain generate` reads your models and writes a typed client to `.contentrain/client/`. You import it via `#contentrain` — Node.js native subpath imports, no plugin magic. ::: ## Install ```bash pnpm add @contentrain/query ``` Requirements: - Node.js 22+ - A Contentrain project with `.contentrain/config.json` ## Quick Start ```bash # Generate the client npx contentrain generate ``` This writes: ``` .contentrain/client/ index.mjs — ESM entry (query runtime + re-exports) index.cjs — CJS entry (NestJS, Express, legacy tooling) index.d.ts — Generated TypeScript types from model schemas data/ {model}.{locale}.mjs — Static data modules per model/locale {model}--{slug}.{locale}.mjs — Document entries (one module per slug) {model}.mjs — Non-i18n models (no locale suffix) ``` It also updates your `package.json` with subpath imports: ```json { "imports": { "#contentrain": { "types": "./.contentrain/client/index.d.ts", "import": "./.contentrain/client/index.mjs", "require": "./.contentrain/client/index.cjs", "default": "./.contentrain/client/index.mjs" }, "#contentrain/*": { "types": "./.contentrain/client/*.d.ts", "import": "./.contentrain/client/*.mjs", "require": "./.contentrain/client/*.cjs", "default": "./.contentrain/client/*.mjs" } } } ``` Then import and query: ```ts import { query, singleton, dictionary, document } from '#contentrain' const posts = query('blog-post').locale('en').where('status', 'published').all() const hero = singleton('hero').locale('en').get() const labels = dictionary('ui-labels').locale('en').get() const article = document('blog-article').locale('en').bySlug('welcome') ``` ## Studio Bridge `@contentrain/query` is the local typed read surface for TypeScript apps. Studio extends the same content contract into remote delivery workflows: - local apps can import `#contentrain` for generated, type-safe reads - Studio adds API keys, CDN publishing, media distribution, and team-facing delivery controls - both sides should point back to the same model definitions and locale structure Use [Ecosystem Map](/ecosystem) for the package relationship, and use Studio docs when the same content needs remote delivery: - [Contentrain Studio](/studio) - [Studio CDN](https://docs.contentrain.io/guide/cdn) - [Studio Ecosystem Map](https://docs.contentrain.io/guide/ecosystem) ## API Reference The generated client exposes four entry points, one for each model kind. ### QueryBuilder — Collections For collection models (multiple entries, object-map storage). ```ts import { query } from '#contentrain' // Full API chain const posts = query('blog-post') .locale('en') // Set locale .where('status', 'published') // Exact match filter .sort('date', 'desc') // Sort by field, optional order .limit(10) // Limit results .offset(5) // Skip results (pagination) .include('author', 'tags') // Resolve relation fields (1 level deep) .all() // Returns T[] // Get first match const latest = query('blog-post') .locale('en') .sort('date', 'desc') .first() // Returns T | undefined ``` | Method | Signature | Returns | Description | |--------|-----------|---------|-------------| | `locale` | `locale(lang: string)` | `this` | Set the content locale | | `where` | `where(field, value)` | `this` | Equality filter (shorthand for `eq`) | | `where` | `where(field, op, value)` | `this` | Operator filter: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `contains` | | `sort` | `sort(field, order?)` | `this` | Sort by field, order is `'asc'` or `'desc'` | | `limit` | `limit(n: number)` | `this` | Limit number of results | | `offset` | `offset(n: number)` | `this` | Skip first N results | | `include` | `include(...fields)` | `this` | Resolve relation fields | | `count` | `count()` | `number` | Return count of matching entries | | `all` | `all()` | `T[]` | Execute query, return all matches | | `first` | `first()` | `T \| undefined` | Execute query, return first match | Where operator examples: ```ts query('plans').where('slug', 'ne', 'free').all() query('plans').where('price', 'gte', 10).where('price', 'lte', 50).all() query('starters').where('framework', 'in', ['nuxt', 'next']).all() query('blog').where('title', 'contains', 'Guide').count() ``` ### SingletonAccessor — Singletons For singleton models (single entry, e.g. site settings, hero section). ```ts import { singleton } from '#contentrain' const hero = singleton('hero') .locale('en') .include('featured_post') // Resolve relations on singletons too .get() // Returns T ``` | Method | Signature | Returns | Description | |--------|-----------|---------|-------------| | `locale` | `locale(lang: string)` | `this` | Set the content locale | | `include` | `include(...fields)` | `this` | Resolve relation fields | | `get` | `get()` | `T` | Get the singleton entry | ### DictionaryAccessor — Dictionaries For dictionary models (flat key-value string maps, ideal for i18n). ```ts import { dictionary } from '#contentrain' // Get all key-value pairs const allLabels = dictionary('ui-labels') .locale('en') .get() // Returns Record // Get a single value by key const label = dictionary('ui-labels') .locale('en') .get('submit_button') // Returns string | undefined // Parameterized templates const message = dictionary('ui-labels') .locale('en') .get('add-entry', { model: 'blog' }) // Value: "Add a new entry to {model}" // Returns: "Add a new entry to blog" ``` | Method | Signature | Returns | Description | |--------|-----------|---------|-------------| | `locale` | `locale(lang: string)` | `this` | Set the content locale | | `get` | `get()` | `Record` | Get all key-value pairs | | `get` | `get(key)` | `string \| undefined` | Get a single value by key | | `get` | `get(key, params)` | `string` | Get value with `{placeholder}` replacement | ::: info Parameterized Templates Dictionary values can contain `{placeholder}` syntax. The `get(key, params)` overload replaces matched placeholders with provided values. Unmatched placeholders are left as-is. ::: ### DocumentQuery — Documents For document models (markdown files with frontmatter). ```ts import { document } from '#contentrain' // Find by slug const article = document('blog-article') .locale('en') .include('author') // Resolve relations in frontmatter .bySlug('getting-started') // Returns T | undefined // Query with filters const techDocs = document('doc-page') .locale('en') .where('category', 'tech') .all() // Returns T[] // Get first match const latest = document('blog-article') .locale('en') .first() // Returns T | undefined ``` | Method | Signature | Returns | Description | |--------|-----------|---------|-------------| | `locale` | `locale(lang: string)` | `this` | Set the content locale | | `where` | `where(field, value)` | `this` | Equality filter (shorthand) | | `where` | `where(field, op, value)` | `this` | Operator filter (same operators as QueryBuilder) | | `sort` | `sort(field, order?)` | `this` | Sort by frontmatter field (`asc` default) | | `include` | `include(...fields)` | `this` | Resolve relation fields | | `bySlug` | `bySlug(slug)` | `T \| undefined` | Find document by slug | | `count` | `count()` | `number` | Return count of matching documents | | `all` | `all()` | `T[]` | Execute query, return all matches | | `first` | `first()` | `T \| undefined` | Execute query, return first match | ### media / mediaBody — Delivery URLs Two more exports, `media(value, baseOverride?)` and `mediaBody(markdown, baseOverride?)`, are generated **only** when a media delivery base is configured at generate time — set `cdn.url` in [`config.json`](/reference/config) or run `contentrain generate --mediaBaseUrl ` (`--cdnBaseUrl` still works as a deprecated alias). Without a configured base, neither export exists on the client at all — not a pass-through, an absent export — so `import { media } from '#contentrain'` fails to resolve. `media()` resolves a stored `media/...` path; `mediaBody()` resolves every `](media/...)` link and image embed inside a markdown string: ```ts import { media, mediaBody } from '#contentrain' media('media/original/hero.webp') // → '{mediaBaseUrl}/media/original/hero.webp' media('https://images.unsplash.com/x.jpg') // → unchanged (external pass-through) mediaBody('See ![cover](media/hero.webp) for more.') // → 'See ![cover](https://.../media/hero.webp) for more.' ``` Both are idempotent — external URLs (`http(s)://`, `//`, `data:`) and already-absolute delivery URLs pass through untouched — and are the local-mode counterpart of CDN mode's `MediaAccessor.url()`. For content delivered through Studio CDN, media fields already carry absolute URLs (normalized on write), so these are only needed for the relative-path / local-file model. Each takes an optional second argument that overrides the base baked in at generate time, for a host that reads its own runtime config per request instead — a Nuxt app, for one: ```ts // composable or component const { public: { contentrainMediaBaseUrl } } = useRuntimeConfig() const cover = media(post.cover, contentrainMediaBaseUrl) ``` A host that never bakes a base in at all — a Nuxt app reading `useRuntimeConfig()` per request, for one — has no `media()`/`mediaBody()` to call, since neither was generated. Import the underlying resolvers directly from `@contentrain/query` instead: `resolveMediaUrl(value, base)` and `resolveMediaRefsInBody(markdown, base)`. Both are opt-in — no base (`undefined`, `null`, `''`) returns the value unchanged — so they are safe to call unconditionally, unlike `media()`/`mediaBody()` which must be generated first. The Astro loader (`contentrainLoader({ mediaBaseUrl })`) applies the same resolver automatically to `image`/`video`/`file` fields — including those nested inside `object`/`array` fields, such as an image gallery — `markdown`/`richtext` fields, and document bodies (titled embeds and inline HTML `src`/`href` included), since it reads `.contentrain` directly and nothing upstream has rewritten those references yet. ## Relations All model kinds support relation resolution via `.include()`: ```ts // Without include: raw relation ID const raw = query('blog-post').locale('en').all() // raw[0].author --> 'author-id-123' (string ID) // With include: resolved object const resolved = query('blog-post').locale('en').include('author', 'tags').all() // resolved[0].author --> { id: '...', name: 'John', ... } (full object) ``` Relations are resolved 1 level deep. Nested relations are not expanded. ## Public Builds By default the generated client and the Astro loader include every entry, drafts included, which suits an editorial preview. For a deployed site, filter by publication state: | Option | CLI | Effect | |---|---|---| | `publishedOnly: true` | `--published` | Only `status: published` entries inside their `publish_at` / `expire_at` window. Entries with no meta stay visible (legacy content) | | `at: ''` | `--at ` | Evaluate the window at a fixed time, for a reproducible build. Implies `publishedOnly` | | `requireStatus: true` | `--require-status` | Also hide an entry with no status: no meta record, or one without `status`. Use it when every entry should carry one, as after a WordPress import. Implies `publishedOnly`, not `at`: the clock stays the build time | ```ts // src/content.config.ts import { defineCollection } from 'astro:content' import { contentrainLoader } from '@contentrain/query/astro' export const collections = { posts: defineCollection({ loader: contentrainLoader({ model: 'posts', locale: 'en', requireStatus: true }) }), } ``` The window is evaluated when the site is built. A scheduled entry goes live at the first build after its `publish_at`, so a static site needs a build scheduled at each deadline. ## Framework Setup The `#contentrain` subpath import works natively in Node.js 22+. For browser bundlers, you need an alias. ::: code-group ```ts [Vite (Vue, React, Svelte, Astro)] // vite.config.ts import { resolve } from 'node:path' export default defineConfig({ resolve: { alias: { '#contentrain': resolve(__dirname, '.contentrain/client/index.mjs'), }, }, }) ``` ```js [Next.js (webpack)] // next.config.js const path = require('path') module.exports = { webpack: (config) => { config.resolve.alias['#contentrain'] = path.resolve(__dirname, '.contentrain/client/index.mjs') return config }, } ``` ```ts [Nuxt 3] // nuxt.config.ts export default defineNuxtConfig({ alias: { '#contentrain': './.contentrain/client/index.mjs', }, }) ``` ```ts [SvelteKit] // vite.config.ts (SvelteKit uses Vite internally) import { resolve } from 'node:path' export default defineConfig({ resolve: { alias: { '#contentrain': resolve(__dirname, '.contentrain/client/index.mjs'), }, }, }) ``` ```js [Expo / React Native (Metro)] // metro.config.js const path = require('path') module.exports = { resolver: { extraNodeModules: { '#contentrain': path.resolve(__dirname, '.contentrain/client/index.mjs'), }, }, } ``` ```bash [Node.js / SSR-only] # No alias needed! # Node.js 22+ resolves #contentrain from package.json imports natively. ``` ::: For all bundler setups, also add a `paths` entry to `tsconfig.json` so the TypeScript language server resolves the alias: ```json { "compilerOptions": { "paths": { "#contentrain": ["./.contentrain/client/index.d.ts"], "#contentrain/*": ["./.contentrain/client/*.d.ts"] } } } ``` ## Framework Usage Patterns | Framework | Pattern | Example | |-----------|---------|--------| | Nuxt 3 | `useAsyncData` | `useAsyncData(() => singleton('hero').locale(locale).get())` | | Next.js RSC | Direct call in server component | `const data = singleton('hero').locale('en').get()` | | Astro | Frontmatter | `const posts = query('blog-post').locale('en').all()` | | SvelteKit | `+page.server.ts` load | `export const load = () => ({ hero: singleton('hero').locale('en').get() })` | | Expo / RN | Direct call | `const hero = singleton('hero').locale('en').get()` | | Node.js | Direct import | `import { query } from '#contentrain'` | ## TypeScript Types The generator produces exact TypeScript interfaces from your model schemas. For a model with fields `title: string`, `order: integer`, `published: boolean`: ```ts // Generated in .contentrain/client/index.d.ts export interface BlogPost { id: string title: string order: number published: boolean } // query('blog-post') returns QueryBuilder // Fully typed: .where() only accepts BlogPost field names ``` ## CommonJS Usage The generated client is ESM (`.contentrain/client/index.mjs`). From a CommonJS file, reach it with a dynamic `import()` inside an async function: ```js // service.cjs — NestJS, Express, or any older tooling async function loadContent() { const { init } = await import('#contentrain') const client = await init() return client.singleton('hero').get() } ``` ::: warning Not `require()`, and never a top-level `await` `await` at the top level of a CommonJS file is a **syntax error** — the file does not load at all. `require('#contentrain')` happens to work on Node 22.12 and newer, where `require(esm)` is unflagged, but it is flagged on earlier 22.x releases and it throws outright if the client module ever uses top-level await. `await import()` is correct on every version. ::: ## DOES NOT EXIST Common mistakes to avoid: ::: danger These APIs do not exist - `.filter()` — use `.where(field, value)` or `.where(field, op, value)` instead - `.byId()` — use `.where('id', value).first()` instead - `dictionary().all()` — use `.get()` instead - `await query(...)` — local queries are **synchronous**, do not use `await` - `.get()` on QueryBuilder — use `.all()` or `.first()` ::: ## CDN Transport For apps that fetch content from Contentrain Studio CDN instead of local files: ```ts import { createContentrain } from '@contentrain/query/cdn' const client = createContentrain({ projectId: '350696e8-...', apiKey: 'crn_live_xxx', }) // All CDN queries return Promises const posts = await client.collection('faq').locale('en').all() const hero = await client.singleton('hero').locale('en').get() const t = await client.dictionary('ui').locale('en').get() const doc = await client.document('docs').locale('en').bySlug('intro') ``` ### CDN Documents — `all()` has no bodies The CDN stores documents in two places, so `all()` and `bySlug()` return different things: | Call | Reads | Returns | |---|---|---| | `all()` / `first()` | `documents/{model}/_index/{locale}.json` | `DocumentIndexEntry` — frontmatter only, **no `body`** | | `bySlug(slug)` | `documents/{model}/{slug}/{locale}.json` | `{ frontmatter: T, body, html }` | ```ts const q = client.document('guide-sections').locale('tr') // Listing: frontmatter only — one request. const index = await q.sort('order', 'asc').all() // Need the prose? One request per document. const full = await Promise.all(index.map(s => q.bySlug(s.slug))) full.map(d => renderMarkdown(d!.body)) ``` ::: warning The bundled client differs The local `#contentrain` client's `document(...).all()` **does** include bodies — it reads the content files directly. Reading `.body` off a CDN `all()` result is a compile error, so code written against bundled delivery fails to build rather than silently rendering empty pages after a CDN switch. ::: ### CDN Collection Operators ```ts await client.collection('products') .locale('en') .where('price', 'lt', 100) .where('category', 'in', ['electronics', 'accessories']) .sort('price', 'asc') .limit(20) .all() ``` Supported operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `contains`. ### CDN Entry Metadata Enrich collection entries with status, publish/expire dates: ```ts const posts = await client.collection('blog') .locale('en') .withMeta() .all() // posts[0]._meta → { status: 'published', publish_at: '...', expire_at: '...' } ``` ### CDN Media Access the media asset manifest and resolve variant URLs: ```ts const media = client.media() const assets = await media.list() // All assets with path + meta const asset = await media.asset('hero.jpg') // Single asset const url = media.url(asset, 'thumb') // Full CDN variant URL asset.meta.width // 1920 asset.meta.blurhash // 'LEHV6nWB...' asset.meta.alt // 'Hero image' ``` ### CDN Forms The browser side of Studio's public `/api/forms/v1`. No API key travels with these requests — the endpoints are unauthenticated by design and the public CORS allows only `Content-Type`. ```ts const form = client.form() const config = await form.config('contact') // config.fields → { name: { type: 'string', required: true }, email: { type: 'email', … } } (exposed fields, keyed by id) // config.captcha → 'turnstile' | null — mount the widget with config.captchaSiteKey // config.honeypotField → '_hp' | null — render a hidden input with that name, leave it empty const result = await form.submit('contact', { name: 'Alice', email: 'alice@example.com', }, { captchaToken: 'tok_xxx', honeypot: '' }) // { success: true, message } · { success: false, errors: [{ field, message }] } // 402 / 403 / 404 / 429 reject with ContentrainError { status, code?, message } ``` ### CDN Comments The browser side of Studio's public `/api/comments/v1`. Only approved comments are returned, nested under their roots; no email, IP, user agent or referrer ever leaves the server. `body` is plain text — render it escaped. ```ts const comments = client.comments() const thread = await comments.thread('posts', entryId, { locale: 'en', page: 1, limit: 20, sort: 'oldest' }) // thread.config → { closed, requireApproval, requireEmail, maxDepth, maxBodyLength, captcha, captchaSiteKey, honeypotField } // thread.comments → [{ id, parentId, depth, author: { name, url, isModerator }, body, type, createdAt, replies }] const result = await comments.submit('posts', entryId, { author: { name: 'Ada', email: 'ada@example.com' }, body: 'Great post', parentId: thread.comments[0]?.id, // reply — omit for a root comment }, { locale: 'en' }) // { success: true, status: 'pending' | 'approved', comment } · { success: false, errors: [{ field, message }] } ``` ### CDN Conversation API Send messages to the AI content agent for external content operations: ```ts const conv = client.conversation() // Send a message — returns complete response const response = await conv.send('Create a blog post about Vue 4') response.conversationId // 'conv-abc123' response.message // 'I created the blog post...' response.toolResults // [{ id, name, result }] response.usage // { inputTokens, outputTokens } // Continue conversation await conv.send('Translate to Turkish', { conversationId: response.conversationId, }) // Fetch history const history = await conv.history('conv-abc123', { limit: 50 }) ``` ::: info Conversation API Keys Conversation API uses dedicated keys (`crn_conv_*` prefix) with per-key role, tool allowlist, model restrictions, and rate limits. Keys are managed in Studio workspace settings. ::: ### CDN Metadata Endpoints ```ts const manifest = await client.manifest() // _manifest.json const models = await client.models() // models/_index.json const model = await client.model('faq') // models/faq.json ``` ### Bundle Preload Opt-in mode that collapses per-render fetch waterfalls (N content requests + include resolution) into a single conditional GET. The transport fetches `_bundle/{locale}.json` — one artifact containing every JSON model for that locale — and serves all content, document-index, and dictionary reads from memory: ```ts const client = createContentrain({ projectId: '350696e8-...', apiKey: 'crn_live_xxx', bundle: true, // or { revalidateMs: 30_000 } }) // Optional eager warmup (e.g. SSR boot) — resolves true when a bundle was found await client.preload('en') // Zero network: served from the primed bundle const posts = await client.collection('faq').locale('en').all() const t = await client.dictionary('ui').locale('en').get() ``` - **Coverage** — all JSON models (collections, singletons, dictionaries, document indexes) come from the bundle. Document bodies (`bySlug()`), entry metadata (`withMeta()`), and media manifests keep using per-path fetch. - **Revalidation** — the bundle is re-checked after `revalidateMs` (default `60_000`) with a conditional request; an unchanged bundle answers `304` at negligible cost. - **Fallback** — if the bundle is missing (`404`), invalid, or the request fails, the transport transparently falls back to per-path fetching and retries after `revalidateMs`. With `bundle` unset, behavior is identical to previous versions. - **Non-i18n paths** — `content/{model}/data.json` entries ship in every locale bundle and are resolved via `defaultLocale` (default `'en'`). ::: info Requires bundle publishing The `_bundle/{locale}.json` artifact is produced by Studio CDN publishing. Until the project's first rebuild publishes a bundle, the SDK silently uses per-path fetching — enabling `bundle: true` early is safe. ::: ### CDN vs Local Comparison | Aspect | Local (`#contentrain`) | CDN (`createContentrain()`) | |--------|----------------------|---------------------------| | Data source | Bundled `.mjs` files | HTTP fetch from Studio CDN | | Return type | Sync (`T[]`) | Async (`Promise`) | | Auth | None | API key required | | Caching | In-memory (embedded) | ETag-based HTTP cache + optional bundle preload | | Use case | SSG, build-time | SSR, client-side, serverless, mobile | ### Error Handling ```ts import { ContentrainError } from '@contentrain/query' try { await client.collection('faq').locale('en').all() } catch (err) { if (err instanceof ContentrainError) { console.log(err.status) // 401, 402, 403, 404, 429 console.log(err.code) // the API's machine code, when the body has one console.log(err.message) } } ``` When the workspace's subscription is inactive, forms and comments reject with `402` and `code: 'payment_required'`. The message is written for the workspace owner, not a visitor, and retrying does not help until the owner updates billing, so hide the form or thread: ```ts import { isPaymentRequired } from '@contentrain/query' try { await form.submit('contact', values) } catch (err) { if (isPaymentRequired(err)) hideTheForm() // no retry, no message to the visitor else showError(err) } ``` `ContentrainError.code` carries the API's machine code (`data.code`) whenever the error body has one. Branch on `status` or `code`, never on the message. ## For Framework SDK Authors The package root exports runtime primitives for building framework-specific SDKs: ```ts import { QueryBuilder, SingletonAccessor, DictionaryAccessor, DocumentQuery, createContentrainClient, } from '@contentrain/query' // Load the generated client dynamically const client = await createContentrainClient(process.cwd()) const posts = client.query('blog-post').locale('en').all() ``` The base SDK is framework-agnostic and MIT-licensed. Framework-specific integrations should build on top of these primitives without changing the underlying `.contentrain/` contract. ## Starter Templates Every starter template comes with a pre-configured SDK client and content models: | Template | Framework | Use Case | |---|---|---| | [astro-blog](https://github.com/Contentrain/contentrain-starter-astro-blog) | Astro | Blog / editorial | | [next-commerce](https://github.com/Contentrain/contentrain-starter-next-commerce) | Next.js | E-commerce | | [nuxt-saas](https://github.com/Contentrain/contentrain-starter-nuxt-saas) | Nuxt | SaaS marketing | | [sveltekit-editorial](https://github.com/Contentrain/contentrain-starter-sveltekit-editorial) | SvelteKit | Editorial | [See all 10 templates](https://github.com/orgs/Contentrain/repositories?q=contentrain-starter&type=template) ## Related Pages - [CLI](/packages/cli) — `contentrain generate` command that runs the SDK generator - [MCP Tools](/packages/mcp) — The tool layer that creates models and content the SDK consumes - [Rules](/packages/rules) — Agent guidance for content operations - [Skills](/packages/skills) — The `contentrain-sdk` skill, and the embedded one this package ships - [Contentrain Studio](/studio) — Hosted team workflows and CDN delivery for non-web platforms ## Embedded Agent Skill This package ships an embedded [Agent Skill](https://agentskills.io) at `skills/contentrain-query/SKILL.md`. AI coding agents can discover and load it for type-safe SDK usage guidance, including: - QueryBuilder, SingletonAccessor, DictionaryAccessor, DocumentQuery APIs - Local mode vs CDN mode differences - Framework-specific bundler configuration (Vite, Next.js, Nuxt, SvelteKit, Metro) The skill and its references are available via the `@contentrain/query/skills/*` subpath export. ## Package Exports | Export Path | Description | |-------------|-------------| | `@contentrain/query` | Runtime classes + `createContentrain()` CDN factory + `MediaAccessor` + `FormsClient` + `ConversationClient` | | `@contentrain/query/cdn` | CDN transport: `HttpTransport`, async queries, `MediaAccessor`, `FormsClient`, `ConversationClient` | | `@contentrain/query/generate` | Programmatic generation API | | `@contentrain/query/skills/*` | Embedded Agent Skill (SKILL.md + references/) | --- # Rules Source: https://ai.contentrain.io/packages/rules # Rules [![npm version](https://img.shields.io/npm/v/@contentrain/rules)](https://www.npmjs.com/package/@contentrain/rules) `@contentrain/rules` is the **policy** layer: what is allowed and what is not, loaded for every conversation regardless of the task. Its counterpart, [`@contentrain/skills`](/packages/skills), is the procedural layer — the step-by-step workflows an agent loads on demand. Together they mean any agent — Claude, GPT, or a custom model — holds the same quality standards and operational patterns when it touches Contentrain content. ## Why Separate Rules and Skills? Consider the difference between a traffic law and a driving manual: - **Rules** say "speed limit is 60 km/h" and "stop at red lights" — constraints that must always hold - **Skills** say "to parallel park: signal, position your car, turn the wheel..." — procedures for specific tasks An agent needs both. Rules prevent it from producing invalid content or breaking schemas. Skills guide it through multi-step workflows like normalize, content creation, or SDK generation. ::: tip The Agent-MCP-Rules Triangle - **MCP** = deterministic execution (how files are written) - **Rules** = behavioral constraints (what quality standards to meet) - **Skills** = workflow procedures (what steps to follow) ::: ## Studio Bridge `@contentrain/rules` and `@contentrain/skills` are not only for local IDE agents. They are also the package-level contract that Studio should mirror in its chat, validation, onboarding, and review flows. The alignment model is simple: - local agents use rules and skills directly through IDE integrations - Studio translates the same standards into its authenticated web workflows - marketing and distribution should point users from the normalize wedge into Studio without changing the underlying quality model See [Ecosystem Map](/ecosystem) for the full package map, then compare the Studio surface here: - [Contentrain Studio](/studio) - [Studio Ecosystem Map](https://docs.contentrain.io/guide/ecosystem) - [Studio Architecture](https://docs.contentrain.io/developer/architecture) ## What Rules Govern Rules define non-negotiable constraints across several domains: | Domain | File | What It Covers | |--------|------|---------------| | Content Quality | `content-quality.md` | Writing standards, tone, accuracy, completeness | | Schema Design | `schema-rules.md` | Field types, naming conventions, model structure | | i18n Quality | `i18n-quality.md` | Locale consistency, translation completeness, key naming | | SEO | `seo-rules.md` | Meta descriptions, titles, structured data | | Accessibility | `accessibility-rules.md` | Alt text, ARIA labels, semantic markup | | Security | `security-rules.md` | Input sanitization, sensitive data handling | | Media | `media-rules.md` | Image optimization, file naming, asset organization | | Content Conventions | `content-conventions.md` | Markdown formatting, frontmatter patterns | | MCP Usage | `mcp-usage.md` | Tool calling patterns, dry-run protocol, trust levels | | Workflow Rules | `workflow-rules.md` | Branch management, review process, merge criteria | | Normalize Rules | `normalize-rules.md` | Extraction patterns, reuse expressions, scope safety | ## Prompt Layers Rules include mode-specific prompt layers that agents load based on their current task: | Prompt | Purpose | |--------|---------| | `common.md` | Base context loaded for all operations | | `generate-mode.md` | Additional context for SDK generation tasks | | `normalize-mode.md` | Additional context for normalize (scan/extract/reuse) | | `review-mode.md` | Additional context for content review and approval | ## Context Bridge The `context-bridge.md` file defines how agents should read and interpret `.contentrain/context.json` — the metadata file that MCP updates after every write operation. This ensures agents understand project state without making redundant tool calls. ## Programmatic Access ```ts import { FIELD_TYPES, MODEL_KINDS, MCP_TOOLS, ESSENTIAL_RULES_FILE, STACKS, } from '@contentrain/rules' // The full registry — not the subset any one session lists console.log(MCP_TOOLS.length) // 27 console.log(MCP_TOOLS.includes('contentrain_validate')) // true console.log(MCP_TOOLS.includes('contentrain_reconcile')) // true console.log(MCP_TOOLS.includes('contentrain_doctor')) // true // Path to essential guardrails markdown console.log(ESSENTIAL_RULES_FILE) // 'essential/contentrain-essentials.md' // All 27 field types console.log(FIELD_TYPES.length) // 27 ``` ## Skills live in their own package The procedural half — 16 Agent Skills, 9 framework guides, and the progressive-disclosure model that keeps them cheap — moved to its own page: **[Skills](/packages/skills)**. The two packages ship and version separately, and an agent can load either without the other. What follows applies to both, because `contentrain init` distributes both. ## IDE Integration `contentrain init` installs a compact essential guardrails file (always-loaded) plus Agent Skills directories (on-demand) for detected IDEs: | IDE | Rules Dir | Skills Dir | Format | |-----|-----------|------------|--------| | Claude Code | `.claude/rules/` | `.claude/skills/` | Plain markdown | | Cursor | `.cursor/rules/` | `.cursor/skills/` | `.mdc` with `alwaysApply: true` | | Windsurf | `.windsurf/rules/` | `.windsurf/skills/` | `trigger: always_on` frontmatter | | GitHub Copilot | `.github/` | `.agents/skills/` | `copilot-instructions.md` | ::: info Non-Destructive Installation Existing rule files are not overwritten. Old granular rule files from previous versions are automatically cleaned up. ::: ## How Agents Use Rules and Skills Together Here is a real example of the agent workflow when a user says "Extract the hardcoded strings from my header component": ### 1. Essential Rules Load First The agent's essential guardrails are always loaded from `.claude/rules/contentrain-essentials.md` (or equivalent IDE path). These cover MCP tool catalog, mandatory protocols, and security basics. ### 2. Skill Guides the Workflow The agent activates `contentrain-normalize` skill (SKILL.md + references/): ``` Step 1: Call contentrain_status (check project is initialized) Step 2: Call contentrain_scan (find hardcoded strings in source) Step 3: Classify candidates (agent intelligence, not MCP) Step 4: Call contentrain_apply mode:"extract" dry_run:true (preview) Step 5: Review dry-run output with user Step 6: Call contentrain_apply mode:"extract" dry_run:false (commit) Step 7: Report results and suggest next steps ``` ### 3. Rules Enforce Quality Throughout At every step, rules constrain the agent: - Do not extract strings that look like code, URLs, or secrets - Dictionary keys must follow `kebab-case` naming - All extractions must go through the review workflow - Source patches in the reuse phase must be scope-safe ::: warning Critical Rule: Always dry_run First The most important rule across all Contentrain operations: **always call write tools with `dry_run: true` first**, review the output, then call with `dry_run: false`. This applies to content_save, model_save, apply, and all other write operations. ::: ## Install ```bash # Rules package pnpm add @contentrain/rules # Skills package pnpm add @contentrain/skills ``` In most cases, you do not install these manually. `contentrain init` handles IDE rule distribution, and MCP agents load skills through their configuration. To update skills and rules after upgrading packages: ```bash contentrain skills --update ``` To check installed status: ```bash contentrain skills --list ``` ## AGENTS.md The repo root includes an [`AGENTS.md`](https://github.com/Contentrain/ai/blob/main/AGENTS.md) file following the [AGENTS.md standard](https://agents.md). This file provides project-level guidance for any AI agent (Codex, Copilot, Gemini CLI, etc.) working with the repo — skill catalog, essential rules reference, key constraints, and framework guides. ## Related Pages - [Skills](/packages/skills) — the procedural layer these constraints hold around - [MCP Tools](/packages/mcp) — the deterministic execution layer that rules govern - [CLI](/packages/cli) — `contentrain init` installs IDE rules automatically - [Query SDK](/packages/sdk) — the generated client for consuming content (ships an embedded skill) - [Contentrain Studio](/studio) — chat-first team UI where agents use the same rules through a web interface --- # Skills Source: https://ai.contentrain.io/packages/skills # Skills [![npm version](https://img.shields.io/npm/v/@contentrain/skills)](https://www.npmjs.com/package/@contentrain/skills) `@contentrain/skills` is the **procedural** layer: step-by-step workflows an agent loads when it needs them. Its counterpart, [`@contentrain/rules`](/packages/rules), is the policy layer — the constraints that hold whatever the agent is doing. The split is the difference between a driving manual and a traffic law. An agent needs both. ## Progressive disclosure Skills follow the [Agent Skills standard](https://agentskills.io). The point of the standard is that an agent should not pay for instructions it is not using: | Tier | What loads | When | Cost | |---|---|---|---| | 1. Catalog | `name` + `description` (`AGENT_SKILLS`) | Session start | ~50 tokens per skill | | 2. Instructions | The full `SKILL.md` body | Skill activates | Under 5,000 tokens | | 3. References | `references/*.md` | The agent needs the detail | 50–500 tokens per file | Every `SKILL.md` in this package is under 300 lines. The detail lives in 19 reference files behind them — `contentrain` alone has 9 — and none of it is loaded until something asks. ``` skills/{name}/ ├── SKILL.md # Instructions, loaded on activation └── references/ # Deep dives, loaded on demand └── *.md ``` ## The 16 skills The `description` is what the agent matches against — it names the trigger, not just the topic. | Skill | Activates when | |---|---| | `contentrain` | Working with the `.contentrain/` directory, content models, or MCP tools. Core architecture, content formats, tool usage | | `contentrain-init` | Setting up `.contentrain/`, configuring stack and locales, scaffolding templates | | `contentrain-model` | Creating models, updating schemas, adding fields, changing model structure | | `contentrain-content` | Adding entries to collections, populating singletons, writing documents, managing dictionary keys | | `contentrain-normalize` | Normalizing, extracting hardcoded strings, replacing them with content references — both phases | | `contentrain-quality` | Reviewing content quality, checking SEO, validating media assets | | `contentrain-review` | Reviewing, checking quality, or approving changes before publishing | | `contentrain-validate-fix` | Checking content validity or fixing validation errors | | `contentrain-translate` | Adding translations, localizing content, managing multi-language entries | | `contentrain-bulk` | Copying locales, updating status in batches, deleting multiple entries | | `contentrain-diff` | Comparing changes, reviewing branch differences, checking what changed | | `contentrain-doctor` | Troubleshooting setup, checking configuration, verifying project integrity | | `contentrain-serve` | Launching the serve interface, reviewing changes visually, managing normalize plans | | `contentrain-generate` | Running `contentrain generate`, setting up the SDK, configuring bundler aliases, `#contentrain` imports | | `contentrain-sdk` | Importing from `#contentrain`, using `QueryBuilder`, `SingletonAccessor`, `DictionaryAccessor`, `DocumentQuery` | | `contentrain-migrate-wordpress` | Migrating from WordPress, handling a WXR export or REST URL, wiring imported content to Astro, Nuxt or Next | ## Framework guides Published under `frameworks/` — how Contentrain content is actually consumed on each stack: | Guide | Covers | |---|---| | `nuxt.md` | `useAsyncData`, server routes | | `next.md` | RSC, App Router, ISR patterns | | `astro.md` | Frontmatter queries, content collections, islands | | `sveltekit.md` | `+page.server.ts` loaders, `$lib` patterns | | `react.md` | Hooks, context providers, client-side queries | | `vue.md` | Composition API, ` ``` ```tsx [React] import { singleton } from '#contentrain' export function Hero() { const hero = singleton('hero-section').locale('en').get() return (

{hero.title}

{hero.subtitle}

) } ``` ```astro [Astro] --- import { singleton } from '#contentrain' const hero = singleton('hero-section').locale('en').get() ---

{hero.title}

{hero.subtitle}

``` ::: ## Real Example: Normalizing a Vue Landing Page Here is a complete walkthrough normalizing a Vue 3 landing page. ### Before: hardcoded strings everywhere ```vue ``` ### Agent prompt sequence 1. > "Scan my project and find all hardcoded strings in the landing page components" 2. > "Classify them into appropriate models — use a singleton for the hero, a dictionary for button labels" 3. > "Write the normalize plan and open the review UI" 4. After approval in UI: > "Apply the extraction" 5. After merge: > "Now replace the hardcoded strings with vue-i18n references" 6. After merge: > "Copy English content to Turkish and translate it" ### After: clean, manageable content ```vue ``` Content now lives in `.contentrain/content/marketing/hero-section/en.json`: ```json { "title": "Build faster with AI-powered content", "subtitle": "Ship your next project in days, not weeks. Contentrain manages your content so you can focus on code." } ``` And UI labels in `.contentrain/content/ui/cta-labels/en.json`: ```json { "cta.get_started": "Get started free", "cta.view_docs": "View documentation" } ``` ## Why Normalize is the Starting Point Most developers discover Contentrain through normalize. The pain is immediate and universal: - You've been vibe-coding with AI for weeks — 50+ generated files, strings everywhere - The founder asks for Turkish. Or the marketer wants to change the CTA on 12 pages. - You realize there's no content layer — just hardcoded text in components Normalize solves this in minutes. And once your content is extracted and structured, the rest of Contentrain's value (SDK queries, i18n, Studio review, CDN delivery) becomes available naturally. ``` normalize → content exists → SDK queries work → i18n is possible → Studio review makes sense ``` ::: tip Ready for Team Collaboration? After extracting content, connect your project to [Contentrain Studio](/studio) for team review, CDN delivery, and collaboration: ```bash contentrain studio login contentrain studio connect ``` See [CLI Studio Integration](/packages/cli#connecting-a-repository) for the full setup flow. ::: ## Important Rules ::: warning - **Always dry-run before apply.** Both extract and reuse require a preview step. - **Normalize branches always use review mode.** Never auto-merge. - **Phase 2 requires Phase 1 to be merged.** Content must exist before patching source. - **Process reuse one model at a time.** Keep diffs small and reviewable. - **MCP is framework-agnostic.** The agent provides all stack-specific replacement expressions. - **Do not change component structure.** Only replace string literals with content references. ::: --- # Framework Integration Source: https://ai.contentrain.io/guides/frameworks # Framework & Platform Integration Contentrain content is plain JSON and Markdown — **any platform that reads files can consume it**. The `@contentrain/query` SDK adds TypeScript type safety and a query API as an optional convenience layer. This guide covers setup patterns for popular frameworks and platforms. ## Starter Templates Each framework has production-ready starter templates with content models, generated SDK client, and best practices pre-configured: | Framework | Starters | |---|---| | **Astro** | [Blog](https://github.com/Contentrain/contentrain-starter-astro-blog), [Landing Page](https://github.com/Contentrain/contentrain-starter-astro-landing) | | **Next.js** | [Commerce](https://github.com/Contentrain/contentrain-starter-next-commerce), [Multi-Surface SaaS](https://github.com/Contentrain/contentrain-starter-next-multi-surface-saas), [SaaS Dashboard](https://github.com/Contentrain/contentrain-starter-next-saas-dashboard), [White-Label Portal](https://github.com/Contentrain/contentrain-starter-next-white-label-portal) | | **Nuxt** | [SaaS Marketing](https://github.com/Contentrain/contentrain-starter-nuxt-saas), [Admin Console](https://github.com/Contentrain/contentrain-starter-nuxt-admin-console) | | **SvelteKit** | [Editorial](https://github.com/Contentrain/contentrain-starter-sveltekit-editorial) | | **VitePress** | [Documentation](https://github.com/Contentrain/contentrain-starter-vitepress-docs) | Each is a GitHub template — click **"Use this template"** to create your own repo. ## Universal Setup Every framework follows the same initial steps: ### Step 1. Install the SDK ```bash pnpm add @contentrain/query ``` ### Step 2. Generate the typed client ```bash npx contentrain generate ``` This reads `.contentrain/models/` and produces a typed client in `.contentrain/client/`. The generator automatically adds the subpath import to your `package.json`: ```json { "imports": { "#contentrain": { "types": "./.contentrain/client/index.d.ts", "import": "./.contentrain/client/index.mjs", "require": "./.contentrain/client/index.cjs", "default": "./.contentrain/client/index.mjs" } } } ``` ### Step 3. Import and query ```ts import { query, singleton, dictionary, document } from '#contentrain' ``` ::: tip All SDK queries are synchronous. Content is bundled as static generated modules — no runtime API calls, no `await` needed. ::: ### Step 4. Run in watch mode during development ```bash npx contentrain generate --watch ``` Any model or content change under `.contentrain/` triggers client regeneration automatically. ## Nuxt 3 ### Setup ```bash pnpm add @contentrain/query npx contentrain generate ``` Restart the Nuxt dev server after initial generation. Run watch mode alongside dev: ```bash npx contentrain generate --watch & npx nuxt dev ``` ### Server-only imports Treat `#contentrain` as **server-only** in Nuxt. Use it in Nitro server routes, server utilities, and route middleware. Do not import it directly in client-rendered Vue components. ### Page data loading Load content through a server route, consume with `useAsyncData`: ```ts // server/api/blog-posts.get.ts import { query } from '#contentrain' import { getQuery } from 'h3' export default defineEventHandler((event) => { const locale = getQuery(event).locale?.toString() ?? 'en' return query('blog-post').locale(locale).sort('publishedAt', 'desc').all() }) ``` ```vue ``` ### Dynamic routes ```ts // server/api/blog-post/[slug].get.ts import { document } from '#contentrain' import { getRouterParam } from 'h3' export default defineEventHandler((event) => { const slug = getRouterParam(event, 'slug') return document('blog-article').locale('en').bySlug(slug ?? '') }) ``` ### i18n with @nuxtjs/i18n ```vue ``` ### Build and deploy ```bash npx nuxt generate ``` All SDK calls resolve to static JSON reads — no runtime API calls during generation. ## Next.js (App Router) ### Setup ```bash pnpm add @contentrain/query npx contentrain generate ``` Restart the Next.js dev server after initial generation. ```bash npx contentrain generate --watch & npx next dev ``` ### React Server Components SDK calls work directly in Server Components — no hooks, no client-side fetching: ```tsx // app/blog/page.tsx import { query } from '#contentrain' export default async function BlogPage() { const posts = query('blog-post').locale('en').sort('publishedAt', 'desc').all() return (
    {posts.map(post => (
  • {post.title}
  • ))}
) } ``` ### Dynamic routes with locale ```tsx // app/[locale]/blog/[slug]/page.tsx import { document } from '#contentrain' interface Props { params: Promise<{ locale: string; slug: string }> } export default async function PostPage({ params }: Props) { const { locale, slug } = await params const post = document('blog-article').locale(locale).bySlug(slug) return (

{post.title}

{post.body}
) } ``` ### Static params generation ```tsx import { query } from '#contentrain' export async function generateStaticParams() { const posts = query('blog-post').locale('en').all() return posts.map(post => ({ slug: post.slug })) } ``` ### Client Components Client Components cannot import `#contentrain` directly. Fetch data in a Server Component and pass as props: ```tsx // Server Component import { singleton } from '#contentrain' import HeroClient from './HeroClient' export default async function HeroSection() { const hero = singleton('hero').locale('en').get() return } ``` ### Build and deploy ```bash npx next build ``` For full static export, set `output: 'export'` in `next.config.js`. ## Astro ### Setup ```bash pnpm add @contentrain/query npx contentrain generate ``` ```bash npx contentrain generate --watch & npx astro dev ``` ### Page components SDK calls go in the frontmatter: ```astro --- import { query } from '#contentrain' const posts = query('blog-post').locale('en').sort('publishedAt', 'desc').all() --- ``` ### Dynamic routes ```astro --- import { query } from '#contentrain' export function getStaticPaths() { const posts = query('blog-post').locale('en').all() return posts.map(post => ({ params: { slug: post.slug }, props: { post } })) } const { post } = Astro.props ---

{post.title}

``` ### i18n with Astro built-in routing ```astro --- import { dictionary } from '#contentrain' const locale = Astro.currentLocale ?? 'en' const t = dictionary('ui-labels').locale(locale).get() ---

{t.welcome_message}

``` ### Islands architecture Content fetched in static Astro component, passed to interactive islands: ```astro --- import { query } from '#contentrain' import SearchWidget from '../components/SearchWidget.tsx' const posts = query('blog-post').locale('en').all() ---

Blog

``` ### Build and deploy ```bash npx astro build ``` Output goes to `dist/`. Content is baked into the HTML — zero runtime dependencies. ## SvelteKit ### Setup ```bash pnpm add @contentrain/query npx contentrain generate ``` ```bash npx contentrain generate --watch & npx vite dev ``` ### Server load functions Content loading belongs in `+page.server.ts` or `+layout.server.ts`: ```ts // src/routes/blog/+page.server.ts import { query } from '#contentrain' import type { PageServerLoad } from './$types' export const load: PageServerLoad = () => { const posts = query('blog-post').locale('en').sort('publishedAt', 'desc').all() return { posts } } ``` ```svelte ``` ### Dynamic routes ```ts // src/routes/blog/[slug]/+page.server.ts import { document } from '#contentrain' import { error } from '@sveltejs/kit' import type { PageServerLoad } from './$types' export const load: PageServerLoad = ({ params }) => { const post = document('blog-article').locale('en').bySlug(params.slug) if (!post) throw error(404, 'Post not found') return { post } } ``` ### Prerendering ```ts // src/routes/blog/[slug]/+page.server.ts import { query } from '#contentrain' export const prerender = true export function entries() { const posts = query('blog-post').locale('en').all() return posts.map(post => ({ slug: post.slug })) } ``` ### Build and deploy ```bash npx vite build ``` Use the appropriate SvelteKit adapter for your platform. ## Vue 3 + Vite (SPA) ### Setup ```bash pnpm add @contentrain/query npx contentrain generate ``` Restart the Vite dev server after generation. ### SFC usage In a Vue SPA, direct imports work in any component: ```vue ``` ## React + Vite (SPA) ### Setup ```bash pnpm add @contentrain/query npx contentrain generate ``` Restart Vite after generation. ### Component usage ```tsx import { query, singleton, dictionary } from '#contentrain' export function HomePage() { const hero = singleton('hero').locale('en').get() const labels = dictionary('ui-labels').locale('en').get() const posts = query('blog-post').locale('en').all() return (

{hero.title}

    {posts.map(post => (
  • {post.title}
  • ))}
) } ``` ## Expo / React Native ### Setup ```bash pnpm add @contentrain/query npx contentrain generate ``` ### Bootstrap pattern Expo uses a CJS bootstrap with async initialization: ```ts // app/content.ts let clientPromise: Promise | null = null export function getContentrainClient() { if (!clientPromise) { clientPromise = require('#contentrain').init() } return clientPromise } ``` ```tsx import { useEffect, useState } from 'react' import { Text, View } from 'react-native' import { getContentrainClient } from './content' export function HomeScreen() { const [title, setTitle] = useState('') useEffect(() => { getContentrainClient().then((client) => { setTitle(client.singleton('hero').locale('en').get().title) }) }, []) return ( {title} ) } ``` ::: info Cache the client promise. Do not initialize on every render. Re-run `contentrain generate` after content or model changes. ::: ## tsconfig.json Paths The `#contentrain` import uses Node.js native subpath imports (defined in `package.json`). No tsconfig paths are needed for runtime resolution. For TypeScript IDE support, ensure your `tsconfig.json` includes: ```json { "compilerOptions": { "module": "Node16", "moduleResolution": "Node16" } } ``` Or with bundler resolution: ```json { "compilerOptions": { "module": "ESNext", "moduleResolution": "Bundler" } } ``` Both resolve `#contentrain` from the `imports` field in `package.json` automatically. ## SSR Considerations ::: warning **Server-side only.** The SDK reads from the file system. In SSR frameworks (Nuxt, Next.js, SvelteKit), use it only in server contexts: - **Nuxt:** Nitro server routes, server middleware - **Next.js:** React Server Components, route handlers, `generateStaticParams` - **SvelteKit:** `+page.server.ts`, `+layout.server.ts`, `+server.ts` - **Astro:** Component frontmatter (server by default) For client components, fetch data server-side and pass it as props. ::: ## Build Verification After setting up any framework, verify the build works: ::: code-group ```bash [Nuxt] npx contentrain generate && npx nuxt build ``` ```bash [Next.js] npx contentrain generate && npx next build ``` ```bash [Astro] npx contentrain generate && npx astro build ``` ```bash [SvelteKit] npx contentrain generate && npx vite build ``` ```bash [Vue/React Vite] npx contentrain generate && npx vite build ``` ::: If the build succeeds with no import errors, the integration is working correctly. ## Type Safety The generated client includes full TypeScript types for every model: ```ts import { query } from '#contentrain' // Fully typed — IDE autocomplete for field names and return types const posts = query('blog-post').locale('en').all() posts[0].title // string posts[0].author // Author type when using .include('author') ``` Keep the generator running in watch mode during development to stay in sync with model changes. ## Deployment Flow All frameworks share the same deployment pattern: 1. Content changes are committed to Git via MCP tools 2. Push triggers platform rebuild (Vercel, Netlify, Cloudflare Pages) 3. Build runs, SDK reads `.contentrain/` content 4. Static or server output is produced with embedded content No external API dependency. No database. Content lives in Git. ::: info Non-Web Platforms For mobile (React Native, Expo, Flutter) and desktop apps that can't read from Git at build time, [Contentrain Studio](/studio) publishes merged content to a CDN — same JSON, delivered over HTTP. ::: --- # i18n Workflow Source: https://ai.contentrain.io/guides/i18n # i18n Workflow Contentrain handles internationalization through per-locale JSON files and dictionary models. Each locale gets its own file — any platform that reads JSON can load the right locale. The TypeScript SDK adds a convenience API, but the files are the source of truth. ## How Contentrain Handles i18n Every piece of content in Contentrain can be localized. The system stores one file per locale per model, with the same keys across all locales. ### Locale configuration Locales are defined in `.contentrain/config.json`: ```json { "locales": [ { "code": "en", "label": "English", "default": true }, { "code": "tr", "label": "Turkish" }, { "code": "de", "label": "German" } ] } ``` The default locale is the source of truth. All other locales are translations of the default. ### Locale strategies Each model can use a different file organization strategy: | Strategy | File pattern | Best for | |---|---|---| | `file` (default) | `{model-id}/{locale}.json` | Most models | | `suffix` | `{model-id}.{locale}.json` | Single-directory layouts | | `directory` | `{locale}/{model-id}.json` | Locale-first organization | | `none` | `{model-id}/data.json` | Single-language projects | For document models, the patterns use markdown files: | Strategy | File pattern | |---|---| | `file` | `{slug}/{locale}.md` | | `suffix` | `{slug}.{locale}.md` | | `directory` | `{locale}/{slug}.md` | ## Dictionary Model for UI Strings Dictionaries are the primary model for UI strings — button labels, form messages, navigation items, error messages. ### Structure A dictionary is a flat key-value JSON file where all values are strings: ```json { "auth.login.button": "Sign in", "auth.login.error": "Invalid email or password", "auth.register.button": "Create account", "nav.home": "Home", "nav.blog": "Blog", "nav.pricing": "Pricing", "cta.get_started": "Get started free", "cta.learn_more": "Learn more" } ``` ::: tip Keys are semantic dot-separated addresses. Use a consistent naming convention: `{section}.{subsection}.{element}`. Keys are the identities — no `id` or `slug` field. ::: ### Creating a dictionary > "Create a dictionary model called ui-labels for all UI strings" The agent creates the model and populates it with extracted strings. Each locale gets its own file: ``` .contentrain/content/ui/ui-labels/ en.json # Source locale tr.json # Turkish translation de.json # German translation ``` ### Querying dictionaries ::: code-group ```vue [Vue / Nuxt] ``` ```tsx [React / Next.js] import { dictionary } from '#contentrain' // All labels: const labels = dictionary('ui-labels').locale('en').get() // Single key: const loginBtn = dictionary('ui-labels').locale('en').get('auth.login.button') ``` ```astro [Astro] --- import { dictionary } from '#contentrain' const locale = Astro.currentLocale ?? 'en' const t = dictionary('ui-labels').locale(locale).get() --- ``` ::: ## Collection and Singleton Locale Files Collections and singletons also support per-locale files. ### Collection example A `testimonials` collection with i18n enabled: ``` .contentrain/content/marketing/testimonials/ en.json tr.json ``` `en.json`: ```json { "a1b2c3d4e5f6": { "author": "Jane Smith", "role": "CTO at Acme", "quote": "Contentrain transformed our content workflow" }, "f6e5d4c3b2a1": { "author": "John Doe", "role": "Lead Developer", "quote": "Finally, content that developers can manage" } } ``` `tr.json`: ```json { "a1b2c3d4e5f6": { "author": "Jane Smith", "role": "Acme CTO", "quote": "Contentrain içerik iş akışımızı dönüştürdü" }, "f6e5d4c3b2a1": { "author": "John Doe", "role": "Baş Geliştirici", "quote": "Sonunda, geliştiricilerin yönetebileceği içerik" } } ``` ::: info Entry IDs are identical across locales. Only field values change. The `author` field may or may not be translated depending on context. ::: ### Singleton example A `hero-section` singleton: ``` .contentrain/content/marketing/hero-section/ en.json tr.json ``` `en.json`: ```json { "title": "Build faster with AI-powered content", "subtitle": "Ship your next project in days, not weeks", "cta_text": "Get started free" } ``` `tr.json`: ```json { "title": "Yapay zeka destekli içerikle daha hızlı geliştirin", "subtitle": "Projenizi haftalar değil günler içinde gönderin", "cta_text": "Ücretsiz başlayın" } ``` ## Translation Workflow The complete i18n pipeline follows four steps: extract, copy locale, translate, generate. ### Step 1. Extract content (Phase 1 of normalize) First, extract hardcoded strings into Contentrain content. See the [Normalize Flow](./normalize) guide for the full process. After extraction, you have content in the default locale (e.g., `en.json`). ### Step 2. Copy locale Create target locale files by copying the source: > "Copy all English content to Turkish" The agent calls: ``` contentrain_bulk({ operation: "copy_locale", model: "ui-labels", source_locale: "en", target_locale: "tr" }) ``` This creates `tr.json` with the same structure as `en.json`, values still in English. Repeat for each model that needs translation. ::: tip Copy locale preserves all keys and structure. The agent translates values in the next step without risk of missing keys. ::: ### Step 3. Translate Ask the agent to translate the copied content: > "Translate all Turkish content. Keep the tone professional and concise. Preserve all placeholders like {name} and {count}." The agent reads each `tr.json` file, translates every value, and saves using `contentrain_content_save`. For dictionaries: ```json { "auth.login.button": "Giriş yap", "auth.login.error": "Geçersiz e-posta veya şifre", "auth.register.button": "Hesap oluştur", "nav.home": "Ana Sayfa", "nav.blog": "Blog", "nav.pricing": "Fiyatlandırma" } ``` ::: warning The agent must preserve all placeholder tokens like `{name}`, `{count}`, `{price}` during translation. These are runtime-replaced values. ::: ### Step 4. Regenerate the SDK client ```bash npx contentrain generate ``` The application now serves localized content from the new locale files. ## Parameterized Templates Contentrain supports `{placeholder}` syntax for dynamic values in strings: ```json { "welcome.greeting": "Welcome back, {name}!", "cart.item_count": "You have {count} items in your cart", "pricing.monthly": "{price}/month" } ``` Placeholders are preserved across all locales: ```json { "welcome.greeting": "Tekrar hoş geldin, {name}!", "cart.item_count": "Sepetinizde {count} ürün var", "pricing.monthly": "{price}/ay" } ``` ### Rendering parameterized strings The SDK returns raw strings with placeholders. Your application replaces them at runtime: ::: code-group ```vue [Vue / Nuxt] ``` ```tsx [React / Next.js] import { dictionary } from '#contentrain' const labels = dictionary('ui-labels').locale('en').get() function t(key: string, params: Record = {}) { let value = labels[key] ?? key for (const [k, v] of Object.entries(params)) { value = value.replace(`{${k}}`, v) } return value } export function Welcome({ name }: { name: string }) { return

{t('welcome.greeting', { name })}

} ``` ```astro [Astro] --- import { dictionary } from '#contentrain' const labels = dictionary('ui-labels').locale(Astro.currentLocale ?? 'en').get() function t(key: string, params: Record = {}) { let value = labels[key] ?? key for (const [k, v] of Object.entries(params)) { value = value.replace(`{${k}}`, v) } return value } ---

{t('welcome.greeting', { name: user.name })}

``` ::: ::: tip If you are already using a framework i18n library (vue-i18n, next-intl, paraglide), use the library's interpolation instead. Contentrain dictionaries can serve as the message source for these libraries. ::: ## Agent Prompt Examples Here are practical prompts for each stage of the i18n workflow: ### Setting up locales > "Add Turkish and German as supported locales in my Contentrain config" ### Creating dictionaries > "Create a dictionary model for UI labels. Extract all button texts, form labels, and navigation items from my components." ### Translating content > "Translate all content in the ui-labels dictionary to Turkish. Use formal tone. Preserve all {placeholder} tokens." ### Adding a new locale later > "Add French as a new locale. Copy all existing content from English and translate it." ### Reviewing translations > "Check all Turkish translations for missing keys or untranslated values" The agent calls `contentrain_validate` which checks for i18n completeness — missing keys, mismatched placeholders, and untranslated values. ## SDK Locale Selection Every SDK query method accepts a `.locale()` call: ```ts import { query, singleton, dictionary, document } from '#contentrain' // Collections const posts = query('blog-post').locale('tr').all() // Singletons const hero = singleton('hero').locale('tr').get() // Dictionaries const labels = dictionary('ui-labels').locale('tr').get() // Documents const article = document('blog-article').locale('tr').bySlug('getting-started') ``` ### Dynamic locale from request ::: code-group ```ts [Nuxt server route] // server/api/posts.get.ts import { query } from '#contentrain' import { getQuery } from 'h3' export default defineEventHandler((event) => { const locale = getQuery(event).locale?.toString() ?? 'en' return query('blog-post').locale(locale).all() }) ``` ```tsx [Next.js Server Component] // app/[locale]/blog/page.tsx import { query } from '#contentrain' export default async function BlogPage({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params const posts = query('blog-post').locale(locale).all() return
    {posts.map(p =>
  • {p.title}
  • )}
} ``` ```astro [Astro] --- import { query } from '#contentrain' const locale = Astro.currentLocale ?? 'en' const posts = query('blog-post').locale(locale).all() --- ``` ```ts [SvelteKit] // src/routes/[locale]/blog/+page.server.ts import { query } from '#contentrain' import type { PageServerLoad } from './$types' export const load: PageServerLoad = ({ params }) => { const posts = query('blog-post').locale(params.locale).all() return { posts } } ``` ::: ## Content Update Workflow When dictionary content is added or updated: 1. Edit content via MCP (`contentrain_content_save`) 2. Re-run `npx contentrain generate` to update the static data modules 3. Rebuild the application For development, use watch mode: ```bash npx contentrain generate --watch ``` Changes to `.contentrain/` content trigger automatic client regeneration, which Vite/webpack hot-reloads into the running dev server. ::: tip Team Translation Workflow For teams with dedicated translators or multilingual reviewers, [Contentrain Studio](/studio) provides a visual interface for reviewing translations side-by-side, with approval workflows and version tracking. ::: --- # Serve UI Source: https://ai.contentrain.io/guides/serve-ui # Serve UI Contentrain Serve is a local web UI that acts as the bridge between your AI agent and your review process. It provides monitoring, approval, and prompt surfaces for all content operations. ## What Serve UI Is Serve UI is a **monitoring + approval surface**, not an editor. The philosophy: - **Monitoring:** Browse models, content, validation results, history, and branches — read-only - **Approval:** Approve or reject normalize plans, merge or delete branches — human decisions - **Agent prompts:** Every page shows copyable prompts that you paste into your AI agent to trigger actions ::: info All mutations (create, edit, delete, scan, normalize, fix) are **agent-driven via MCP tools**. The UI never triggers these directly. You review what the agent produced and approve or reject it. ::: ## Starting Serve From your project root (where `.contentrain/` lives): ```bash npx contentrain serve ``` The server starts on `http://localhost:3333` by default. ### Options | Flag | Default | Description | |---|---|---| | `--port` | `3333` | Port number | | `--host` | `localhost` | Host address | | `--open=false` | `true` | Prevent auto-opening browser (or set `CONTENTRAIN_NO_OPEN=true`) | | `--demo` | disabled | Temporary demo project (no setup required) | | `--stdio` | disabled | MCP stdio transport for IDE integration (no web UI) | | `--mcpHttp` | disabled | MCP HTTP transport at `POST /mcp` (Bearer auth enforced when a token is set) | | `--authToken` | — | Bearer token; the web UI refuses to start on non-localhost binds without it | ### Check if already running Before starting a new instance: ```bash lsof -ti:3333 ``` If a process is running, serve is already up — navigate directly to the URL. ## Pages Overview ### Dashboard (`/`) The landing page shows a project overview: - Total models and entries - Content status breakdown (draft, published, in review) - Recent agent activity from `context.json` - Pending branches waiting for review - Quick links to each section **Agent prompt hint shown in UI:** > "Show me the project status and any pending changes" ### Models (`/models`) Browse all model definitions: - Model list with kind badges (collection, singleton, dictionary, document) - Field schema details per model - Content path and locale strategy - Entry count per locale ### Content (`/content`) Browse and inspect content entries: - Filter by model, locale, and status - View entry details with all field values - See metadata (status, source, timestamps) - Locale switcher to compare translations side by side ::: tip The content page is read-only. To edit content, copy the agent prompt from the UI and paste it into your AI agent. ::: ### Validate (`/validate`) Inspect validation results: - Schema compliance errors - i18n completeness warnings (missing keys, untranslated values) - Duplicate entry detection - Vocabulary alignment checks ### Branches (`/branches`) Manage content branches: - List all `cr/*` branches - See diff summary for each branch - **Merge** approved branches - **Delete** rejected branches - View commit history per branch - Click a branch to see a detailed preview with merge conflict detection ### Branch Detail (`/branches/:name`) When you click a branch in the list, you see a detailed **merge preview** fetched from `/api/preview/merge`: - Fast-forward status (can the branch merge cleanly?) - Already-merged check (is this branch already in the content-tracking branch?) - Conflict detection (best-effort list of conflicting paths via `git merge-tree`) - Diff summary and file-by-file changes - Sync-warning panel surfacing any files the last selective sync skipped This preview is generated without running the actual merge — a safe way to answer "what happens if I approve this?" before committing to the action. ### Doctor (`/doctor`) Project health dashboard wrapping the `contentrain_doctor` MCP tool: - Environment checks (Node ≥22, git available) - `.contentrain/` structure validation - Model definition parsing - Orphan content detection - Branch pressure (warning at 50 `cr/*`, blocked at 80) - SDK client freshness (comparing mtimes) - Optional **usage analysis** toggle: unused keys, duplicate dictionary values, missing locale keys Each check renders with severity (error / warning / info). The "Run" button re-fetches `/api/doctor?usage=...`. **Agent prompt hint:** > "Run a full project health check and fix any errors" ### Format Reference (`/format`) A read-only reference of how Contentrain stores content — renders the output of the `contentrain_describe_format` tool via `/api/describe-format`: - Models schema (JSON structure of `.contentrain/models/`) - Content layout (directory and file structure) - Metadata organization (SEO, entry-level metadata) - Vocabulary format - Locale strategies Useful as a living spec for developers and integrators who need the physical file format without reading docs. ### Normalize (`/normalize`) The normalize page is active when a normalize plan exists (`.contentrain/normalize-plan.json`): - **Extraction review panel:** Candidates grouped by model with field mappings - **Source trace panel:** Click any extraction to see its original file and line number - **Patch preview panel:** See the exact source file changes that will happen in Phase 2 - **Approve & Apply** button: Executes the extraction - **Reject** button: Deletes the plan This page appears only during the [normalize flow](/guides/normalize). When no plan exists, it shows a prompt to start normalization. ## WebSocket Real-time Updates Serve UI uses WebSocket connections for real-time updates. You do not need to manually refresh the page. ### WebSocket Events The serve server broadcasts the following events over the WebSocket connection: | Event | Emitted when | Payload | |---|---|---| | `connected` | Client connects | (none) | | `config:changed` | `.contentrain/config.json` changes | (none) | | `context:changed` | `.contentrain/context.json` changes | `{ context }` | | `model:changed` | A model definition is created/updated | `{ modelId }` | | `content:changed` | Content entries are created/updated | `{ modelId, locale }` | | `meta:changed` | SEO or model metadata is written | `{ modelId, entryId?, locale }` | | `normalize:plan-updated` | Normalize plan is created/updated/deleted | (none) | | `validation:updated` | Validation results change | (none) | | `branch:created` | A new `cr/*` branch is created | `{ branch }` | | `branch:merged` | A branch is merged into `contentrain` | `{ branch }` | | `branch:rejected` | A branch is deleted | `{ branch }` | | `branch:merge-conflict` | Branch merge fails | `{ branch, message }` | | `sync:warning` | Merge succeeds but skips dirty working-tree files | `{ branch, skippedCount }` | | `file-watch:error` | chokidar file watcher encounters an error | `{ message, timestamp }` | ## HTTP API Surface The serve backend exposes REST endpoints at `http://localhost:3333/api/*`. Key routes: | Route | Method | Purpose | |---|---|---| | `/api/status` | GET | Wraps `contentrain_status` | | `/api/capabilities` | GET | Provider + transport + branch health in one call | | `/api/doctor?usage=...` | GET | Wraps `contentrain_doctor` (optional usage analysis) | | `/api/describe-format` | GET | Wraps `contentrain_describe_format` | | `/api/describe/:modelId` | GET | Wraps `contentrain_describe` | | `/api/content/:modelId` | GET | List content entries | | `/api/content/:modelId/:entryId` | GET | Single entry read | | `/api/content/:modelId/:entryId/fix` | POST | Quick fix (content save) | | `/api/validate?fix=true` | GET | Wraps `contentrain_validate` | | `/api/branches` | GET | List `cr/*` branches | | `/api/branches/diff?name=cr/...` | GET | Branch diff against `contentrain` | | `/api/branches/approve` | POST | Approve & merge a branch | | `/api/branches/reject` | POST | Delete a branch | | `/api/branches/:name/sync-status` | GET | Cached sync warnings from last merge | | `/api/preview/merge?branch=cr/...` | GET | Side-effect-free merge preview (FF / conflicts / already-merged) | | `/api/history` | GET | Recent contentrain-related commits | | `/api/context` | GET | `.contentrain/context.json` | | `/api/normalize/*` | various | Scan / results / plan / approve / reject / apply / approve-branch / sources / file-context | | `/api/tree` | GET | Project source-file tree (`?ext=` filter) | Every write route validates its body through Zod schemas (`parseOrThrow`) before reaching the MCP tool layer. ## Approval Flow The typical workflow between agent and developer: 1. **Agent creates content** via `contentrain_content_save` (or similar write tool), landing on a `cr/*` branch. 2. **Developer reviews in UI** at `/branches/:name` — sees the merge preview, per-file diff, sync warnings if any. 3. **Approve or Reject** — click **Merge** or **Delete**. 4. **Agent detects the decision** by re-reading the filesystem/context state. ::: warning For normalize operations, the workflow always uses review mode. Even if the project is configured for auto-merge, normalize branches require explicit approval. ::: ## Workflow Configuration The approval flow behavior is controlled by the workflow setting in `.contentrain/config.json`: ```json { "workflow": "review" } ``` | Mode | Behavior | |---|---| | `review` | All content changes create branches for review | | `auto-merge` | Content changes merge into `contentrain` branch, baseBranch is advanced via update-ref | ::: info Normalize operations always use review mode regardless of this setting. ::: ## IDE Integration (stdio mode) For IDE-embedded use (VS Code, Cursor, Claude Code), serve can run in stdio mode: ```bash npx contentrain serve --stdio ``` This starts the MCP server with stdio transport instead of the web UI. ## HTTP MCP Transport For Studio, CI, or remote agents, serve can expose MCP over Streamable HTTP: ```bash npx contentrain serve --mcpHttp --authToken $(openssl rand -hex 32) ``` When a token is supplied, every request must send `Authorization: Bearer `. Binding to `0.0.0.0` without a token logs a warning but the server still starts — the endpoint exposes full project write access, so always pass a token on non-localhost binds. **The web UI is stricter:** the default (non-MCP) serve mode **hard-errors** on any non-localhost bind without `--authToken` / `CONTENTRAIN_AUTH_TOKEN` and refuses to start (OWASP Secure-by-Default). The UI has no per-request auth today; the token requirement is a start-up gate for exposing it beyond localhost. See the [HTTP Transport guide](/guides/http-transport) for deployment patterns. ## Troubleshooting ### Port already in use ```bash lsof -ti:3333 | xargs kill # or npx contentrain serve --port 4444 ``` ### UI not updating 1. Check that the serve process is still running 2. Look for a `file-watch:error` banner at the top of the UI — this indicates the chokidar watcher stopped (e.g. OS inotify limit). Restart `contentrain serve` to reattach. 3. Verify that `.contentrain/` files are being written (check `context.json` timestamp) ### Content not appearing If content created by the agent does not appear: 1. Check if the content is on a feature branch (not yet merged into the `contentrain` branch) 2. Navigate to the Branches page to see pending branches 3. Click the branch for a detailed merge preview 4. Merge the branch to make content visible on the Content page ### Merge conflicts when approving When clicking **Merge** on a branch, the UI performs a real merge using the MCP `mergeBranch` helper. Conflicts can occur when: 1. The branch and `contentrain` branch have diverged 2. You've made manual edits to `.contentrain/` files outside the agent workflow The UI surfaces the conflict message. Resolve it manually or ask the agent to create a fresh branch from the current `contentrain` state. ## Beyond Local: Contentrain Studio `contentrain serve` is designed for single-developer, local workflows. When your team grows, [Contentrain Studio](/studio) extends this with: - **GitHub-connected review** — PRs created from agent branches, reviewable in a web UI - **Team roles** — invite editors and reviewers with scoped permissions - **Chat-first agent** — talk to your agent through a web interface with full MCP access - **Content CDN** — publish approved content for mobile and non-web platforms - **Audit trail** — track who created, reviewed, and approved every content change Connect your local project to Studio with [`contentrain studio connect`](/packages/cli#connecting-a-repository) — it detects your repo, installs the GitHub App, and creates the project in one interactive flow. --- # WordPress Migration Source: https://ai.contentrain.io/guides/migration # WordPress Migration Moving a WordPress site to a static build is four separate problems, and Contentrain ships three of them as MIT packages. ``` WordPress site │ │ @contentrain/wp-import ← MIT, in this repo ▼ RawIR ───────────────────────────────── what the source said │ │ migration engine ← CLOSED SOURCE (Contentrain Migrate) ▼ ProjectIR ────────────────────────────── what the new site should be │ │ @contentrain/emitter-astro ← MIT, in this repo ▼ Astro project │ │ @contentrain/verify ← MIT, in this repo ▼ pass / fail ``` ## Read this before anything else ::: danger The MIT packages do not convert a site for you The step that turns a `RawIR` into a `ProjectIR` — deciding what the route model is, which layout families exist, where a component belongs, what the design tokens are — is the analysis, and it is **not** in this repository. It runs in Contentrain Migrate, which is a closed-source product. What the open packages give you is the two ends and the contracts between them: a faithful importer, a boring renderer, and an offline gate. If you supply your own `ProjectIR`, the whole chain runs on MIT code. If you do not, you have an importer and an emitter with a gap between them. Anyone describing this as "Contentrain automatically converts your WordPress site" is describing the closed product, not these packages. ::: The boundary is deliberate and it is written into the contracts themselves. From `@contentrain/types`: > Producers: the WordPress Bridge plugin (GPL, separate repo), REST/WXR importers. Consumers: the migration engine, Studio, and the open Astro emitter. > > A GPL plugin writes RawIR; a proprietary service reads it; an MIT emitter consumes ProjectIR. The contract is the only thing they share, so it lives in the one package all of them may depend on. Every migration document is plain JSON — snake_case keys, no class instances, no functions — because these documents cross process, repository, and license boundaries. ## 1. Know what you can reach How much of a WordPress site you can read depends entirely on how you reach it, and the difference is not small: | Rung | Access | Field coverage | |---|---|---| | `rest_public` | Public REST API | ~34% (measured) | | `rest_auth` | REST + Application Password | ~57% (measured) | | `wxr` | A WXR export file | ~82% (measured) | | `bridge` | The Bridge plugin (GPL, separate repo) | All fields by design — it reads WordPress from inside; not a sampled figure | The measured figures date from August 2026, before migration contract version 1; a WordPress or plugin release can move them. Every `RawIR` records the rung that produced it. Two imports of the same site are only comparable if their rungs are — which is also why a `rest_public` import showing fewer fields is **not** evidence that the site is simpler. A `CapabilityManifest` answers the other half of the question: what the site *uses*. It is evidence-based detection across 23 capability keys — `seo`, `forms`, `comments`, `search`, `analytics`, `consent`, `acf`, `ecommerce`, `redirects`, `i18n`, `scheduling` and the rest — each carrying the plugin it identified and how it was detected (`rest`, `dom`, `headers`, `meta`). `present: true` with evidence beats a silent guess; nothing is inferred from a theme name. This is the input for the "what happens to X?" conversation, which is a conversation, not a build step. ## 2. Import The CLI wraps `@contentrain/wp-import` and writes the store to disk: ```bash # From a WXR export — the highest rung you can reach offline npx contentrain import export.xml --out ./my-site # Or over REST, with an Application Password to reach rest_auth npx contentrain import https://site.example --auth user:app-password ``` | Flag | Effect | |---|---| | `--out ` | Target directory (default: current) | | `--auth ` | REST Application Password — lifts access to `rest_auth`, which also imports drafts, scheduled, pending and private posts and held comments | | `--force` | Overwrite an existing `.contentrain/` | | `--json` | Machine-readable report, for scripting | It refuses to overwrite an existing `.contentrain/` without `--force`, because a re-import replaces models and content wholesale. What lands on disk: - the canonical `.contentrain/` content store - `entry-source-map.json` — the WP-id → entry-address mapping - `comments-export.json` — when the source had comments - an import report, plus warnings ::: warning Read the warnings before you call it a migration A REST import that hit a page cap, or whose pages partly failed, produces a store that looks exactly like a smaller site. Truncated collections and failed pages are named in the warnings and nowhere else. The same applies to comments referencing posts outside the import. ::: See [WordPress Import](/packages/wp-import) for multilingual handling, scheduled posts, identity formulas, and the REST limits in detail. ## 3. Plan — the closed step The migration engine reads the `RawIR` and produces a `ProjectIR`: route model, layout families, component variants, query bindings, design tokens. This is where the judgement lives — which URL patterns the site actually serves, which pages share a layout, what is chrome and what is content, which repeated block is a component. It is not in this repository. If you are building your own pipeline, this is the piece you write (or the product you buy). The `ProjectIR` shape is MIT and fully specified in [`@contentrain/types`](/packages/types), so nothing stops you. ## 4. Emit ```ts import { emitAstroProject, writeEmit } from '@contentrain/emitter-astro' const result = emitAstroProject({ ir, content, css, runtime }) console.warn(result.warnings) await writeEmit(result, './out/site') ``` The emitter is deliberately boring: it renders the IR it is handed and never infers anything about WordPress. Read `result.warnings` — that is where it reports a component with no mount point, a lifted region with unbalanced tags, or a runtime component left as a placeholder. Two of its behaviours matter most at this stage: - **It removes the template page's SEO tags from the head chrome.** A cloned site that inherits one canonical de-indexes itself. See [SEO](/packages/emitter-astro#seo). - **A route collision fails the build.** WordPress serves posts and pages from one root; a static generator cannot. Rather than silently dropping a section of the site, the emitter writes a guard that stops `astro build` and names both routes. ## 5. Gate ```bash npx @contentrain/verify dist --site https://example.com --baseline ./old-pages ``` [`@contentrain/verify`](/packages/verify) checks identity, indexing, status, hreflang, structured data, navigation and assets over the documents you give it. ::: info It does not crawl `@contentrain/verify` never makes a network request. It takes documents — a `dist/` directory, or pages a capture step already collected — and checks those. A hardened crawler is a security surface of its own, and it belongs where that hardening is maintained rather than inside the MIT library every consumer embeds. So it will not "scan your live site". Whoever holds a safe fetcher supplies the pages. ::: The rule for severity is one sentence: **error** means the built site is wrong; **warning** means it is worse than the site it replaced. A run with no baseline, no sitemap and no redirect rules names those absences in `report.skipped` — a green report over a run that compared nothing is the most dangerous output the package could produce. ## 6. Bind the runtime Forms and comments do not survive a static build on their own. When the emitter is given a `RuntimeBinding`, `` and `` become real implementations talking to a provider's public API — see [Forms & Comments](/guides/forms-comments). If the project id does not exist yet, emit without it and write `src/data/runtime.json` later. No re-emit is needed. ## The handoff `MigrationHandoff` is the document that ends a migration: site URL, repository coordinates, preview URL, a content summary, the detected capabilities, and `offers` — what each capability could be replaced with, including a cost comparison and the honest warning where one applies (*"keeping comments on WordPress means the WordPress server stays live"*). It is MIT and lives in `@contentrain/types`, so the closed engine, Studio, and any third-party tool agree on what a finished migration looks like. ## Doing this with an agent The `contentrain-migrate-wordpress` skill drives the open parts of this chain — import, wire the content to a framework, validate. Install it with the rest: ```bash npx skills add Contentrain/ai/packages/skills --skill contentrain-migrate-wordpress ``` The skill activates on "migrate from WordPress", a WXR export, a REST URL, or connecting imported content to Astro, Nuxt or Next. It does not substitute for the planning step. ## Related Pages - [WordPress Import](/packages/wp-import) — the importer in detail - [Astro Emitter](/packages/emitter-astro) — what the renderer emits, and what it refuses to guess - [Verify](/packages/verify) — every check, with the failure each one catches - [Forms & Comments](/guides/forms-comments) — runtime components on a static site - [Types](/packages/types) — `RawIR`, `ProjectIR`, `CapabilityManifest`, `MigrationHandoff` --- # Forms & Comments Source: https://ai.contentrain.io/guides/forms-comments # Forms & Comments A static build has no server, so the two things every content site needs — a contact form and a comment thread — are the first casualties of a migration. Contentrain solves them with one public, unauthenticated API and **two clients that speak it**: | Door | Who uses it | Ships in | |---|---|---| | `` / `` custom elements | A generated site, with no dependencies | [`@contentrain/emitter-astro`](/packages/emitter-astro) | | `FormsClient` / `CommentsClient` | An app you are writing yourself | [`@contentrain/query/cdn`](/packages/sdk#cdn-forms) | Same endpoints, same payloads, same rules. The emitted version inlines its client into `src/lib/embed.ts` precisely so a migrated site depends on nothing but Astro. ## No credential ever travels These endpoints are called from a visitor's browser, on a page anyone can view. ::: warning A page cannot keep a secret There is no session and no API key in any of this — by design, not by omission. Studio's CORS for these routes allows only `Content-Type`, so an `Authorization` header would fail the preflight before the request was ever sent. The emitter never writes a credential into a generated site. If you find yourself wanting to pass one here, the design has gone wrong somewhere upstream. ::: What the binding carries instead is public information: an origin and a project id. ```json // src/data/runtime.json — the only place the binding lives { "base_url": "https://studio.contentrain.io", "project_id": "proj_…" } ``` Components read it at build time. When the project id does not exist at emit time — a Studio project created after the migration — write this one file and rebuild. No re-emit, and the component files do not change. ## Forms ``` GET {base}/{projectId}/{modelId}/config → FormConfig POST {base}/{projectId}/{modelId}/submit → FormSubmitResult ``` A form names a Contentrain model; the model's exposed fields *are* the form. ```ts const form = client.form() const config = await form.config('contact') // { modelId, locale, fields, captcha, captchaSiteKey, successMessage, honeypotField } const result = await form.submit('contact', { name, email, message }, { captchaToken, // when config.captcha is set honeypot: '', // the hidden input — a human leaves it empty }) // { success: true, message } · { success: false, errors: [{ field, message }] } ``` `fields` is a **map keyed by field id**, carrying the model's own `FieldDef` shape — so the renderer gets the field type, and validation on the server is the same validation the model already declares. `locale` is the project default: what a submission is validated against and written to. Submitted values are wrapped in `data` so the control fields (`captchaToken`, `_hp`) can never collide with a model field named `captchaToken`. ### The two spam defences | Defence | How it behaves | |---|---| | Honeypot | `honeypotField` names a hidden input to render and leave empty. A filled one is dropped **silently, server-side** — a bot learns nothing | | Turnstile | `captcha: 'turnstile'` with a `captchaSiteKey`, when the operator configured one. A failed token comes back as an ordinary field error on `captcha` | Both follow the project's configuration. Neither is invented by the emitter. ## Comments ``` GET {base}/{projectId}/{modelId}/{entryId}?locale&page&limit&sort → CommentThread POST {base}/{projectId}/{modelId}/{entryId}?locale → CommentSubmitResult ``` A thread is keyed on the **entry address** — model, entry id, locale — which is exactly what `EmitPost.entry` carries onto each generated page. A translated post has its own thread, because it is its own entry address. ```ts const comments = client.comments() const thread = await comments.thread('posts', entryId, { locale: 'en', page: 1, limit: 20, sort: 'oldest', }) const result = await comments.submit('posts', entryId, { author: { name, email, url }, body, parentId: thread.comments[0]?.id, // omit for a root comment }) // { success: true, status: 'pending' | 'approved', comment } ``` Each comment comes back as `{ id, parentId, depth, author, body, type, createdAt, replies }`, already nested under its root. ::: danger Two rules the renderer must not break **Only approved comments are returned.** A pending comment lives on the provider and is never in the public response — so a submission answering `status: 'pending'` must tell the visitor their comment is awaiting moderation, not optimistically render it as published. **`body` is plain text. Render it escaped.** It is visitor-authored content arriving over an unauthenticated endpoint; treating it as markup is a stored-XSS hole with a queue in front of it. ::: Email, IP, user agent and referrer never leave the server. `author.url` is public and nullable; `author.isModerator` is set when a workspace member wrote the comment from Studio. The thread's `config` tells the renderer what to draw: | Field | Effect | |---|---| | `closed` | Render the comments, hide the form | | `requireApproval` | Whether a new comment appears immediately | | `requireEmail` | Whether the email field is mandatory | | `maxDepth` | Reply nesting cap for new submissions — `0` is flat | | `maxBodyLength` | Client-side length limit | | `captcha` / `captchaSiteKey` | Same Turnstile contract as forms | ## On a migrated site WordPress comments come across in the import. `contentrain import` writes a `comments-export.json` — a `contentrain-comments@1` payload built from the `RawIR` and the `EntrySourceMap`, because the WP-id → entry-address mapping only exists at conversion time. That file is an **intake payload for a live comments service**, not a content store. Loading it is what makes the old threads appear under the new pages. ::: warning Comments that point nowhere The import warns when comments reference posts outside the import — a partial REST import orphans them. Check that warning before loading the export, or those threads land under nothing. ::: Then, at emit time: - a `comments` component with a runtime binding becomes `` on the entry pages - a `form` component becomes `` and must name its model (`ComponentDef.model`) - **without a binding — or a form without a model — the component stays a placeholder**, and each one is named in `result.warnings` A placeholder looks fine in a screenshot. Read the warnings. ### What the thread and the form say The labels, buttons and messages a visitor sees — "Post comment", "Your comment is awaiting moderation", "Send" — are the site's content, not its code. They live in a dictionary in the site's own store, `.contentrain/content/site/ui-strings/{locale}.json`, and the components read the page language's file at build time: ```json { "comments.title": "Yorumlar", "comments.post": "Yorum gönder", "form.send": "Gönder" } ``` - Edit it in Studio or through MCP, rebuild, and the site says the new text — no re-emit. - A key the dictionary lacks shows its English default. A page in another language with no dictionary shows English throughout, and **the build log says so** — once per language, naming the file or the missing keys. - The key list, the English defaults and the model definition are exported from `@contentrain/emitter-astro` as `UI_STRING_DEFAULTS` and `UI_STRINGS_MODEL`, so a migration creates exactly the dictionary the site reads. Declare the languages it wrote in `options.uiStrings.locales` and the emit report names any page language left without one. ### When the workspace's subscription is inactive Studio answers `402 payment_required` on the public forms and comments endpoints when the workspace's billing is locked (trial or grace period over, subscription canceled). The body's `data.code` is `payment_required`, and its `message` is written for the workspace owner, not for a visitor. `` and `` take themselves off the page when they get it, whether loading, submitting or loading more comments: - The element is emptied and hidden (`hidden` plus an inline `display: none`, so a site stylesheet cannot bring back an empty box). The rest of the page is untouched, and the visitor sees no error. - The request is not retried: nothing changes until the owner updates billing. - One `console.debug` line tells the owner what happened. Every other failure (a 403 `*.upgrade`, a rate limit, validation) is shown as before. The emitted `isPaymentRequired()` checks the status or the code; it does not read `billingState`. ## The acceptance test A migration is not finished because the components render. The gate is the round trip: > generated site → submit → provider stores it → moderation → public `GET` shows it — **and a pending item is not visible to the public before approval.** Both halves matter. The second one is the one that is easy to get wrong and expensive to discover later. ## Related Pages - [Astro Emitter](/packages/emitter-astro) — how components find their mount points - [Query SDK](/packages/sdk#cdn-forms) — the same clients for an app you are writing - [WordPress Migration](/guides/migration) — where the comments export comes from - [Contentrain Studio](/studio) — the provider side: moderation, storage, and the public API --- # Model Kinds Source: https://ai.contentrain.io/reference/model-kinds # Model Kinds Contentrain organizes content into four distinct **model kinds**, each optimized for a specific content pattern. Every model definition includes a `kind` field that determines how content is stored, accessed, and localized. ```ts type ModelKind = 'singleton' | 'collection' | 'document' | 'dictionary' ``` ## Decision Table Use this table to choose the right kind for your content: | Question | Collection | Singleton | Dictionary | Document | |---|---|---|---|---| | Multiple entries? | Yes | No | Yes (keys) | Yes | | Typed fields? | Yes | Yes | No (all strings) | Yes (frontmatter) | | Has ID/slug? | Auto hex ID | N/A | No (key = identity) | Slug (required) | | Rich body content? | No | No | No | Yes (Markdown) | | Typical use case | Blog posts, products, team members | Site settings, hero section | i18n translations, labels | Documentation, articles | | Storage format | JSON object-map | JSON object | JSON flat key-value | Markdown + frontmatter | ## Collection Collections store **multiple typed entries** as a JSON object-map keyed by auto-generated hex IDs. ### Storage Format ```json { "a1b2c3": { "title": "Getting Started", "slug": "getting-started", "category": "tutorial", "published": true }, "d4e5f6": { "title": "Advanced Usage", "slug": "advanced-usage", "category": "guide", "published": false } } ``` ### Key Characteristics - **Object-map storage** — entries keyed by ID, not an array - **Auto-generated hex IDs** — 12 lowercase hex characters when generated; hand-written IDs may be any 1-40 alphanumeric characters with hyphens/underscores - **Typed fields** — each field has a defined `FieldType` with validation - **Sorted keys** — canonical serialization sorts entry IDs alphabetically - **Per-locale files** — each locale gets its own JSON file ### Model Definition Example ```json { "id": "blog-posts", "name": "Blog Posts", "kind": "collection", "domain": "blog", "i18n": true, "title_field": "name", "fields": { "title": { "type": "string", "required": true }, "slug": { "type": "slug", "required": true, "unique": true }, "excerpt": { "type": "text", "max": 300 }, "cover": { "type": "image" }, "published": { "type": "boolean", "default": false } } } ``` ### Output Format (MCP / SDK) When you read a collection via `content_list`, entries are returned as an **array** — not the object-map used on disk: ```json [ { "id": "a1b2c3", "title": "Getting Started", "slug": "getting-started", "category": "tutorial", "published": true }, { "id": "d4e5f6", "title": "Advanced Usage", "slug": "advanced-usage", "category": "guide", "published": false } ] ``` ::: info Storage vs Output On disk, collections use **object-map** format (keys = IDs) for git-friendly merges. MCP and SDK convert this to an **array** with `id` injected into each entry. This is intentional — storage is optimized for git, output is optimized for consumers. ::: ### Path Patterns | Scenario | Path | |---|---| | i18n: true | `.contentrain/content/{domain}/{model-id}/{locale}.json` | | i18n: false | `.contentrain/content/{domain}/{model-id}/data.json` | | Meta (i18n: true) | `.contentrain/meta/{model-id}/{locale}.json` | ### MCP content_save Call ```json { "model": "blog-posts", "entries": [ { "locale": "en", "data": { "title": "My First Post", "slug": "my-first-post", "excerpt": "An introduction to Contentrain.", "published": true } } ] } ``` ::: tip The `id` field is auto-generated when omitted. You can provide your own ID if needed, but it must be unique within the collection. ::: ## Singleton Singletons store a **single entry** per locale — ideal for one-off content like site settings or hero sections. ### Storage Format ```json { "site_name": "My Website", "tagline": "Build faster with AI", "logo": "/images/logo.svg", "social_links": ["https://twitter.com/example"] } ``` ### Key Characteristics - **Single object** — no ID, no slug, just field key-value pairs - **Typed fields** — same field system as collections - **One file per locale** — `en.json`, `tr.json`, etc. - **No entry multiplicity** — exactly one entry per model per locale ### Model Definition Example ```json { "id": "site-settings", "name": "Site Settings", "kind": "singleton", "domain": "system", "i18n": true, "title_field": "title", "fields": { "site_name": { "type": "string", "required": true }, "tagline": { "type": "string" }, "logo": { "type": "image" }, "social_links": { "type": "array", "items": "string" } } } ``` ### Output Format (MCP / SDK) Singletons are returned as a plain object — the same shape as on disk: ```json { "site_name": "My Website", "tagline": "Build faster with AI", "logo": "/images/logo.svg", "social_links": ["https://twitter.com/example"] } ``` ### MCP content_save Call ```json { "model": "site-settings", "entries": [ { "locale": "en", "data": { "site_name": "My Website", "tagline": "Build faster with AI" } } ] } ``` ## Dictionary Dictionaries store **flat key-value string maps** — the primary kind for i18n translations and UI labels. ### Storage Format ```json { "auth.login.title": "Sign In", "auth.login.submit": "Log In", "auth.login.forgot": "Forgot your password?", "common.save": "Save", "common.cancel": "Cancel", "greeting": "Hello, {name}!" } ``` ### Key Characteristics - **Flat key-value** — all values are strings, no nested objects - **No fields definition** — dictionaries do not use the `fields` property - **No ID, no slug** — the key itself is the identity (semantic address) - **Semantic keys** — use dot notation for namespacing (e.g., `auth.login.title`) - **Parameterized templates** — values can include `{param}` placeholders - **All values must be strings** — numbers, booleans, etc. must be string-encoded ::: warning Dictionary models must NOT have `fields` defined. The keys in the data object are the content identifiers. Do not pass `id` or `slug` — they are ignored. ::: ### Model Definition Example ```json { "id": "ui-labels", "name": "UI Labels", "kind": "dictionary", "domain": "system", "i18n": true, "title_field": "key", "content_path": "locales" } ``` ### Output Format (MCP / SDK) Dictionaries are returned as a flat key-value object — same shape as on disk: ```json { "auth.login.title": "Sign In", "auth.login.submit": "Log In", "auth.login.forgot": "Forgot your password?", "common.save": "Save", "common.cancel": "Cancel" } ``` ### MCP content_save Call ```json { "model": "ui-labels", "entries": [ { "locale": "en", "data": { "auth.login.title": "Sign In", "auth.login.submit": "Log In", "common.save": "Save" } }, { "locale": "tr", "data": { "auth.login.title": "Giriş Yap", "auth.login.submit": "Oturum Aç", "common.save": "Kaydet" } } ] } ``` ## Document Documents store **Markdown files with frontmatter** — ideal for long-form content like documentation, articles, and blog posts with rich body text. ### Storage Format ```markdown --- title: Getting Started description: Learn how to set up Contentrain in your project order: 1 --- # Getting Started Welcome to Contentrain. This guide walks you through... ``` ### Key Characteristics - **Markdown + frontmatter** — structured metadata above, rich content below - **Slug-based** — each document has a required `slug` that determines the file path - **`body` key** — the markdown content below the frontmatter delimiter - **Frontmatter delimiters** — `---` on its own line - **Auto-parsed values** — `true`/`false` become booleans, integers become numbers - **Typed fields** — frontmatter fields are defined in the model schema ### Model Definition Example ```json { "id": "docs-guide", "name": "Documentation Guides", "kind": "document", "domain": "docs", "i18n": false, "title_field": "title", "content_path": "docs/guides", "fields": { "title": { "type": "string", "required": true }, "description": { "type": "text" }, "order": { "type": "integer" } } } ``` ### Output Format (MCP / SDK) When you read documents via `content_list`, each entry is returned as a `DocumentEntry`: ```ts interface DocumentEntry { slug: string frontmatter: Record body: string } ``` ```json [ { "slug": "getting-started", "frontmatter": { "title": "Getting Started", "description": "Learn how to set up Contentrain in your project", "order": 1 }, "body": "# Getting Started\n\nWelcome to Contentrain. This guide walks you through..." } ] ``` ::: info The `frontmatter` object contains all fields defined in the model schema. The `body` field contains everything below the `---` delimiter. When listing documents without requesting full content, `body` may be empty. ::: ### Path Patterns | Scenario | Path | |---|---| | i18n: true | `.contentrain/content/{domain}/{slug}/{locale}.md` | | i18n: false | `.contentrain/content/{domain}/{slug}.md` | | Meta (i18n: true) | `.contentrain/meta/{model-id}/{slug}/{locale}.json` | | Meta (i18n: false) | `.contentrain/meta/{model-id}/{slug}/{default-locale}.json` | ::: warning Document meta files have a **different path structure** than other kinds. They include the `{slug}` segment: `.contentrain/meta/{model-id}/{slug}/{locale}.json`. Other kinds use `.contentrain/meta/{model-id}/{locale}.json` directly. On `i18n: false` models (any kind) the single meta record is pinned to the **default locale** — the filename is still `{default-locale}.json`, never `data.json` or a flat `{slug}.json`. ::: ### MCP content_save Call ```json { "model": "docs-guide", "entries": [ { "slug": "getting-started", "data": { "title": "Getting Started", "description": "Learn how to set up Contentrain", "order": 1, "body": "# Getting Started\n\nWelcome to Contentrain..." } } ] } ``` ::: tip The `slug` field is required for documents. It determines the file path and URL. The `body` key inside `data` is reserved for the markdown content. ::: ## Locale Strategies Each model can specify how localized content files are organized using the `locale_strategy` field: ```ts type LocaleStrategy = 'file' | 'suffix' | 'directory' | 'none' ``` | Strategy | JSON Pattern | Markdown Pattern | Best For | |---|---|---|---| | `file` (default) | `{dir}/{locale}.json` | `{dir}/{slug}/{locale}.md` | Most projects | | `suffix` | `{dir}/{model}.{locale}.json` | `{dir}/{slug}.{locale}.md` | Nuxt Content style | | `directory` | `{dir}/{locale}/{model}.json` | `{dir}/{locale}/{slug}.md` | Directory-first projects | | `none` | `{dir}/{model}.json` | `{dir}/{slug}.md` | Single-language projects | ### When i18n is Disabled When a model has `i18n: false`, locale is ignored entirely: - **JSON models:** stored as `{dir}/data.json` - **Document models:** stored as `{dir}/{slug}.md` ## Locale Coverage An `i18n: true` model is expected to carry content in **every** locale in `config.locales.supported`. That is what `contentrain validate` checks parity against, and on a fully-translated site it is exactly right. Some sites are not fully translated. A blog in three languages next to a set of pages that only ever existed in one is not a defect — but without a way to say so, the pages model fails on every locale it has no file for, and the only ways to clear it are to invent empty translations or to drop the locale from the whole project. The optional `locales` field says which project locales a model actually covers: ```json { "id": "pages", "name": "Pages", "kind": "collection", "domain": "site", "i18n": true, "locales": ["en", "da"], "title_field": "title" } ``` | | Meaning | |---|---| | **Absent** (the default) | The model covers every locale in `config.locales.supported` — the behaviour every model had before this field existed | | **Present** | Parity is checked against these locales only. The rest are neither required nor reported | Rules: - `locales` **must be a subset of `config.locales.supported`**. A locale the project does not support is a validation error — add it to `config.json` first, or drop it here. - It **narrows validation; it never grants a locale.** Writing content is still governed by `config.locales.supported`. - It has **no effect on an `i18n: false` model**, which stores one locale-agnostic copy. Declaring it there is a warning. - Inside the subset, the ordinary rules still apply: a locale a model claims but has no file for is still an error, and an entry one of its locales lacks is still reported. Every locale-coverage message names the list it was evaluated against, so the two cases read differently: ``` Locale file missing: tr.json (checked against the model's own locales [en, tr]) Entry parity: entry "a1b2c3" exists in en but missing in tr (checked against the project's supported locales [en, tr, da]) ``` ::: warning `contentrain validate --fix` never invents a `locales` value for a model that does not declare one. Choosing a narrower set would be deciding which translations are *supposed* to exist — a content decision — and the one value the tool could compute ("the locales that happen to have files today") would write the current gaps into the schema and silence the very errors that reveal them. Set it deliberately, with `contentrain_model_save`. ::: Severity separates a translation that was never written from a locale that is not there at all: | Finding | Severity | |---|---| | An entry of a `collection`, or a `document`, that one locale has and another lacks (`Entry parity`, `Missing translation`) | **warning** | | A locale with no content file (`Locale file missing`) | **error** | | A `dictionary` key that one locale has and another lacks (`Key parity`) | **error** | A partly translated site — the usual state of a WordPress site imported from WPML or Polylang — therefore validates, with one warning per untranslated entry. `@contentrain/wp-import` writes this field for you: importing a partially-translated WordPress site sets `locales` to the languages each post type actually has content in (the default locale always included), and omits it entirely when the type is fully translated. ## content_path Override By default, content files are stored inside `.contentrain/content/{domain}/{model-id}/`. The `content_path` field overrides this, writing content directly into framework-specific directories at the project root. ### How It Works ```json { "id": "blog-posts", "kind": "collection", "domain": "blog", "i18n": true, "title_field": "title", "content_path": "content/blog" } ``` | | Without content_path | With `content_path: "content/blog"` | |---|---|---| | **Content** | `.contentrain/content/blog/blog-posts/en.json` | `content/blog/en.json` | | **Meta** | `.contentrain/meta/blog-posts/en.json` | `.contentrain/meta/blog-posts/en.json` | ::: warning **Meta files always stay in `.contentrain/meta/`** regardless of `content_path`. Only content files are redirected. This ensures governance data (status, source, approvals) remains in the Contentrain directory. ::: ### content_path + locale_strategy The `locale_strategy` applies to the overridden path too: | locale_strategy | content_path: `locales` | Result | |---|---|---| | `file` (default) | `locales/{locale}.json` | `locales/en.json`, `locales/tr.json` | | `suffix` | `locales/ui-labels.{locale}.json` | `locales/ui-labels.en.json` | | `directory` | `locales/{locale}/ui-labels.json` | `locales/en/ui-labels.json` | | `none` | `locales/ui-labels.json` | Single file (no locale) | ### Common content_path Patterns | Framework | content_path | Kind | Purpose | |---|---|---|---| | Nuxt Content | `content/blog` | document | Nuxt reads from `content/` | | Astro | `src/content/blog` | collection | Astro content collections | | Next.js | `content/posts` | collection | Custom content directory | | i18n (vue-i18n, next-intl) | `locales` | dictionary | Translation JSON files | | VitePress | `docs/guide` | document | Documentation pages | ### When to Use content_path - **Use it** when your framework expects content in a specific directory (e.g., Nuxt Content reads from `content/`, i18n libraries read from `locales/`) - **Don't use it** for content that only Contentrain SDK consumes — the default `.contentrain/content/` path works fine and keeps everything centralized ## Meta Files Every model has corresponding meta files in `.contentrain/meta/{model-id}/` that track lifecycle state: ```json { "status": "published", "source": "agent", "updated_by": "contentrain-mcp", "version": "1.0.0", "publish_at": "2026-01-15T00:00:00Z", "expire_at": "2026-12-31T23:59:59Z" } ``` | Field | Type | Description | |---|---|---| | `status` | `ContentStatus` | `draft` \| `in_review` \| `published` \| `rejected` \| `archived` | | `source` | `ContentSource` | `agent` \| `human` \| `import` | | `updated_by` | `string` | Author identifier | | `approved_by` | `string?` | Approver identifier (optional) | | `version` | `string?` | Version string (optional) | | `publish_at` | `string?` | ISO 8601 scheduled publish date (optional) | | `expire_at` | `string?` | ISO 8601 scheduled expiry date (optional) | **Meta file structure by kind:** | Kind | Meta Structure | |---|---| | Collection | Object-map: `{ "entry-id": { status, source, ... } }` | | Singleton | Single object: `{ status, source, ... }` | | Document | Single object: `{ status, source, ... }` | | Dictionary | Single object: `{ status, source, ... }` | ## Collection ID vs Dictionary Key This is a common source of confusion. Here is a detailed comparison: | Aspect | Collection ID | Dictionary Key | |---|---|---| | **Purpose** | Unique entry identifier | Semantic content address | | **Format** | Auto-generated hex (`a1b2c3`) | Dot-notation path (`auth.login.title`) | | **Generation** | Automatic by MCP | Manual by agent/human | | **Mutability** | Immutable after creation | Key is the identity, can be renamed | | **Typed fields** | Yes — each entry has typed fields | No — all values are strings | | **Nested data** | Supports complex field types | Flat key-value only | | **Use case** | Structured entities (posts, products) | Translation strings, labels | | **ID in data** | Added on output (`{ id, ...fields }`) | Key IS the data address | ## Relation System Fields with type `relation` or `relations` create references between models. ### Relation Types | Type | Cardinality | Storage Value | Example | |---|---|---|---| | `relation` | One-to-one | `"author": "a1b2c3"` | Blog post → Author | | `relations` | One-to-many | `"tags": ["t1", "t2"]` | Blog post → Tags | ### Reference Keys by Target Kind | Target Kind | Reference Key | Example | |---|---|---| | Collection | Entry `id` | `"author": "a1b2c3d4e5f6"` | | Document | Document `slug` | `"related_doc": "getting-started"` | | Singleton | Not referenceable | Single instance, no need for reference | | Dictionary | Not referenceable | No schema, no stable identity | ### Polymorphic Relations When `model` is an array, the relation can target multiple model types: ```json { "related_content": { "type": "relation", "model": ["blog-post", "docs-guide", "case-study"] } } ``` Polymorphic values are stored as `{ model, ref }` objects: ```json { "related_content": { "model": "blog-post", "ref": "getting-started" } } ``` ### Self-Referencing A model can reference itself for tree structures: ```json { "id": "categories", "kind": "collection", "title_field": "name", "fields": { "name": { "type": "string", "required": true }, "parent": { "type": "relation", "model": "categories" } } } ``` ### Relation Validation | Rule | Description | |---|---| | **Referential integrity** | Referenced ID/slug must exist in the target model | | **Locale-agnostic** | IDs and slugs are the same across all locales | | **No delete-time guard** | Deleting a referenced *entry* is not blocked and emits nothing at delete time; the broken reference surfaces as an **error** on the next `contentrain_validate`. (Deleting a whole *model* that others reference is blocked.) | | **Array ordering** | `relations` array order is preserved | | **Min/max** | `relations` supports `min` and `max` element count | ::: tip Unified Across Surfaces Model kinds, storage formats, and content paths are the same whether you define them locally with `@contentrain/mcp` or in [Contentrain Studio](/studio). Content created in one surface works seamlessly in the other. ::: --- # Field Types Source: https://ai.contentrain.io/reference/field-types # Field Types Contentrain provides **27 field types** for defining model schemas. Each type includes built-in validation, constraints, and serialization rules. ```ts type FieldType = | 'string' | 'text' | 'email' | 'url' | 'slug' | 'color' | 'phone' | 'code' | 'icon' | 'markdown' | 'richtext' | 'number' | 'integer' | 'decimal' | 'percent' | 'rating' | 'boolean' | 'date' | 'datetime' | 'image' | 'video' | 'file' | 'relation' | 'relations' | 'select' | 'array' | 'object' ``` ## Field Definition Schema Every field in a model is defined using the `FieldDef` interface: ```ts interface FieldDef { type: FieldType // Required: the field type required?: boolean // Validation: must have a value unique?: boolean // Validation: unique within the collection default?: unknown // Default value when not provided min?: number // Minimum (length for strings, value for numbers) max?: number // Maximum (length for strings, value for numbers) pattern?: string // Regex validation pattern options?: string[] // Allowed values (for select type) model?: string | string[] // Target model(s) for relation types items?: string | FieldDef // Item type for array fields fields?: Record // Sub-fields for object type accept?: string // MIME types for media fields maxSize?: number // Max file size in bytes for media fields description?: string // Human-readable field description } ``` ## Complete Field Type Reference ### Text Group Field types for textual content of varying lengths and formats. | Type | Description | Constraints | Storage | |---|---|---|---| | `string` | Short text, single line | `min`, `max` (length), `pattern` | `string` | | `text` | Multi-line plain text | `min`, `max` (length) | `string` | | `email` | Email address | Auto-validated format | `string` | | `url` | Web URL | Auto-validated format | `string` | | `slug` | URL-friendly identifier | Lowercase, hyphens, no spaces | `string` | | `code` | Source code or preformatted text | `min`, `max` (length) | `string` | | `icon` | Icon identifier (e.g., icon library name) | `pattern` | `string` | #### Examples ```json { "title": { "type": "string", "required": true, "min": 1, "max": 200 }, "bio": { "type": "text", "max": 1000, "description": "Author biography" }, "contact_email": { "type": "email", "required": true }, "website": { "type": "url" }, "post_slug": { "type": "slug", "required": true, "unique": true }, "snippet": { "type": "code" }, "category_icon": { "type": "icon", "pattern": "^[a-z0-9-]+$" } } ``` ### Rich Content Group Field types for formatted content with markup. | Type | Description | Constraints | Storage | |---|---|---|---| | `markdown` | Markdown-formatted text | `min`, `max` (length) | `string` | | `richtext` | HTML/rich text content | `min`, `max` (length) | `string` | #### Examples ```json { "content": { "type": "markdown", "required": true, "description": "Article body in Markdown format" }, "formatted_bio": { "type": "richtext", "max": 5000 } } ``` ::: tip For document-kind models, use the `body` key in content data for the main Markdown content rather than a `markdown` field. The `markdown` field type is for additional rich text fields within any model kind. ::: ### Numeric Group Field types for numbers with different precision and display semantics. | Type | Description | Constraints | Storage | |---|---|---|---| | `number` | General number (integer or float) | `min`, `max` (value) | `number` | | `integer` | Whole number only | `min`, `max` (value) | `number` | | `decimal` | Floating-point number | `min`, `max` (value) | `number` | | `percent` | Percentage value (0-100) | `min`, `max` (value, defaults 0-100) | `number` | | `rating` | Star/score rating | `min`, `max` (value, e.g., 1-5) | `number` | #### Examples ```json { "price": { "type": "decimal", "required": true, "min": 0 }, "quantity": { "type": "integer", "min": 0, "max": 10000, "default": 0 }, "discount": { "type": "percent", "min": 0, "max": 100 }, "user_rating": { "type": "rating", "min": 1, "max": 5 }, "sort_order": { "type": "number", "default": 0 } } ``` ### Boolean Simple true/false toggle. | Type | Description | Constraints | Storage | |---|---|---|---| | `boolean` | True or false | `default` | `boolean` | #### Example ```json { "is_featured": { "type": "boolean", "default": false }, "published": { "type": "boolean", "required": true } } ``` ### Date/Time Group Field types for temporal data. | Type | Description | Constraints | Storage | |---|---|---|---| | `date` | Date only (no time) | `min`, `max` (ISO date strings) | `string` (ISO 8601) | | `datetime` | Full date and time | `min`, `max` (ISO datetime strings) | `string` (ISO 8601) | #### Examples ```json { "publish_date": { "type": "date", "required": true }, "event_time": { "type": "datetime" } } ``` ::: info Dates are stored as ISO 8601 strings: `"2026-03-15"` for date, `"2026-03-15T14:30:00Z"` for datetime. ::: ### Media Group Field types for file uploads and media references. | Type | Description | Constraints | Storage | |---|---|---|---| | `image` | Image file reference | `accept`, `maxSize` | `string` (path) | | `video` | Video file reference | `accept`, `maxSize` | `string` (path) | | `file` | Generic file reference | `accept`, `maxSize` | `string` (path) | #### Examples ```json { "cover_image": { "type": "image", "required": true, "accept": "image/png,image/jpeg,image/webp", "maxSize": 5242880, "description": "Cover image (max 5MB)" }, "intro_video": { "type": "video", "accept": "video/mp4,video/webm", "maxSize": 52428800 }, "resume": { "type": "file", "accept": "application/pdf", "maxSize": 10485760 } } ``` ::: tip Media paths reference files in the project's `assets_path` (configured in `config.json`). The `accept` field uses standard MIME types. ::: ### Color Color value field. | Type | Description | Constraints | Storage | |---|---|---|---| | `color` | Color value | `pattern` (e.g., hex format) | `string` | #### Example ```json { "brand_color": { "type": "color", "required": true, "pattern": "^#[0-9a-fA-F]{6}$", "default": "#000000" } } ``` ### Phone Phone number field. | Type | Description | Constraints | Storage | |---|---|---|---| | `phone` | Phone number | `pattern` | `string` | #### Example ```json { "contact_phone": { "type": "phone", "pattern": "^\\+[1-9]\\d{1,14}$", "description": "E.164 format phone number" } } ``` ### Relation Group Field types for connecting entries across models. | Type | Description | Constraints | Storage | |---|---|---|---| | `relation` | Single relation to another entry | `model` (target model ID or array) | `string` (entry ID); `{ model, ref }` when `model` names more than one target | | `relations` | Multiple relations to other entries | `model` (target model ID or array) | `string[]` (entry IDs); `{ model, ref }[]` when `model` names more than one target | #### Examples ```json { "author": { "type": "relation", "required": true, "model": "team-members", "description": "Post author" }, "tags": { "type": "relations", "model": "tags", "description": "Associated tags" }, "related_content": { "type": "relations", "model": ["blog-posts", "case-studies"], "description": "Related items from multiple collections" } } ``` A relation whose `model` lists several targets is **polymorphic**: an entry ID alone does not say which model it belongs to, so the reference stores both — `{ "model": "case-studies", "ref": "a1b2c3d4e5f6" }`. A one-element `model` array is a single target and stores the plain ID. `validateFieldValue` and `contentrain_validate` both apply this rule. ::: warning Relation fields store entry IDs as references. Use the `resolve` parameter in `contentrain_content_list` to expand relations to full entry data at query time. ::: ### Complex Group Field types for structured and compound data. | Type | Description | Constraints | Storage | |---|---|---|---| | `select` | Single choice from predefined options | `options` (required) | `string` | | `array` | Ordered list of items | `items` (type or FieldDef), `min`, `max` | `array` | | `object` | Nested object with sub-fields | `fields` (Record of FieldDef) | `object` | #### Examples ```json { "status": { "type": "select", "required": true, "options": ["active", "inactive", "coming-soon"], "default": "active" }, "features": { "type": "array", "items": "string", "min": 1, "max": 10, "description": "List of feature names" }, "gallery": { "type": "array", "items": { "type": "object", "fields": { "src": { "type": "image", "required": true }, "caption": { "type": "string" }, "alt": { "type": "string", "required": true } } } }, "seo": { "type": "object", "fields": { "meta_title": { "type": "string", "max": 60 }, "meta_description": { "type": "text", "max": 160 }, "og_image": { "type": "image" }, "no_index": { "type": "boolean", "default": false } } } } ``` ::: info The `array` type can hold simple values (`items: "string"`) or complex objects (`items: { type: "object", fields: {...} }`). Declare `fields` on an `object` to type its sub-fields — `fields` on any other type is rejected by `model_save`. Nesting is capped at 10 levels. ::: ## Complete Type Summary Table | # | Type | Group | JS Storage | Supports `min`/`max` | Supports `pattern` | Supports `options` | Supports `model` | Supports `items`/`fields` | |---|---|---|---|---|---|---|---|---| | 1 | `string` | Text | `string` | Length | Yes | No | No | No | | 2 | `text` | Text | `string` | Length | Yes | No | No | No | | 3 | `email` | Text | `string` | Length | Auto | No | No | No | | 4 | `url` | Text | `string` | Length | Auto | No | No | No | | 5 | `slug` | Text | `string` | Length | Auto | No | No | No | | 6 | `code` | Text | `string` | Length | Yes | No | No | No | | 7 | `icon` | Text | `string` | Length | Yes | No | No | No | | 8 | `markdown` | Rich Content | `string` | Length | Yes | No | No | No | | 9 | `richtext` | Rich Content | `string` | Length | Yes | No | No | No | | 10 | `number` | Numeric | `number` | Value | No | No | No | No | | 11 | `integer` | Numeric | `number` | Value | No | No | No | No | | 12 | `decimal` | Numeric | `number` | Value | No | No | No | No | | 13 | `percent` | Numeric | `number` | Value | No | No | No | No | | 14 | `rating` | Numeric | `number` | Value | No | No | No | No | | 15 | `boolean` | Boolean | `boolean` | No | No | No | No | No | | 16 | `date` | Date/Time | `string` | Length | Yes | No | No | No | | 17 | `datetime` | Date/Time | `string` | Length | Yes | No | No | No | | 18 | `image` | Media | `string` | No | No | No | No | No | | 19 | `video` | Media | `string` | No | No | No | No | No | | 20 | `file` | Media | `string` | No | No | No | No | No | | 21 | `color` | Color | `string` | Length | Yes | No | No | No | | 22 | `phone` | Phone | `string` | Length | Yes | No | No | No | | 23 | `relation` | Relation | `string` | No | No | No | Yes | No | | 24 | `relations` | Relation | `string[]` | Count | No | No | Yes | No | | 25 | `select` | Complex | `string` | No | No | Yes (required) | No | No | | 26 | `array` | Complex | `array` | Count | No | No | No | `items` | | 27 | `object` | Complex | `object` | No | No | No | No | `fields` | ::: info How to read `min`/`max` and `pattern` Both are enforced generically at validation time: `min`/`max` compare **string length**, **numeric value**, or **array count** depending on the stored value, and `pattern` runs against any string value. The table marks the types where they are meaningful — a `pattern` on an `image` path is technically enforced too, it is just rarely what you want. There is no date-range semantics: on `date`/`datetime` fields `min`/`max` compare string length, not chronology. ::: ## Constraint Properties Reference | Property | Applicable Types | Description | |---|---|---| | `required` | All | Field must have a non-null value | | `unique` | All (collection and document) | Value must be unique across all entries — rejected on singleton models (`model_save` errors), N/A for dictionary (no fields) | | `default` | All | Default value declared on the schema (must be coherent with the type/options) | | `min` | All string types, numeric types, `array`, `relations` | Minimum length (strings), value (numbers), or count (arrays/relations) | | `max` | All string types, numeric types, `array`, `relations` | Maximum length (strings), value (numbers), or count (arrays/relations) | | `pattern` | All string types (primary use: string, icon, color, phone) | Regular expression, enforced on every string value; must compile or `model_save` rejects it | | `options` | select only | Array of allowed string values — declaring it on any other type is a `model_save` error | | `model` | relation, relations | Target model ID(s) for the relation — required for these types | | `items` | array | Item type: a type name string or a nested `FieldDef` — items are validated by the same rules as a scalar of that type | | `accept` | image, video, file | Allowed formats — checked against the **file extension only**, as a warning that never blocks; unknown extensions stay silent | | `maxSize` | image, video, file | Maximum file size in bytes — **accepted but never enforced by MCP** (it stores a path, not the file); your media provider enforces it at ingest, and `model_save` returns a `schema_warnings` entry saying so | | `fields` | object | Sub-field definitions as `Record` | | `description` | All | Human-readable description of the field | | `label` | All | Editor label. A string is one label for every locale; an object keyed by locale (`{ "en": "Body", "tr": "Gövde" }`) carries a translation each. Absent means the field name is the label. | | `order` | All | Display position, ascending. Fields without one sort last, alphabetically — so a model that declares none behaves as it always has. Fractional values allow inserting between two fields without renumbering. | ## How Constraints Are Enforced Two distinct gates apply, at different moments: **At `model_save` (authoring time).** The schema itself is validated and a misplaced constraint is a blocking error, not a silent no-op: `options` on a non-select, `items` on a non-array, `fields` on a non-object, `accept`/`maxSize` on a non-media field, `unique` on a singleton, `min > max`, an uncompilable `pattern`, a `select` without non-empty `options`, a `relation`/`relations` without `model`, a `default` incoherent with the type. Model IDs must be kebab-case, field names snake_case (`^[a-z][a-z0-9_]*$`), and unknown keys are rejected — a typo'd `requird: true` is an error. The snake_case rule applies to fields that are *new*: a field the model already has under a legacy name (`creativeWork`, from a project created before the rule) is kept and reported as a `schema_warning`, so a legacy model never becomes read-only. `contentrain validate` lists such fields as notices. **At `contentrain_content_save` (write time).** Values are validated *before* anything is committed — a `severity: error` on any entry in the call means nothing is written. Severities split by intent: - **Errors** (block the write): wrong type, `required` missing, `min`/`max` violations, `pattern` mismatch, `options` membership, and definitional semantic checks — an invalid `slug`, an unparseable `date`/`datetime`, a non-integer `integer`, an out-of-range `percent`. - **Warnings** (pass through, reported in the response): heuristic semantic checks — `email`, `url`, `color`, `phone` formats — and the extension-based `accept` check. `rating` has no range enforcement by design. ::: info Shared Across Contentrain AI and Studio These 27 field types define content schemas used identically in both local workflows (`@contentrain/mcp`, CLI) and [Contentrain Studio](/studio). The pure validators live in `@contentrain/types` (`validateFieldValue`, `validateSemanticType`), so Studio forms and MCP enforce the same contract. Content modeled locally works seamlessly in team workflows without changes. ::: --- # Configuration Source: https://ai.contentrain.io/reference/config # Configuration Contentrain uses a set of JSON configuration files stored in the `.contentrain/` directory at your project root. This page covers every configuration file, its schema, and how they interact. ## Directory Layout The `.contentrain/` directory is the central hub for all Contentrain data: ``` .contentrain/ ├── config.json # Project configuration ├── context.json # Last operation metadata (auto-written by MCP) ├── vocabulary.json # Shared terms for consistency (optional) ├── models/ │ ├── blog-posts.json # Model schema definitions │ ├── site-settings.json │ └── ui-labels.json ├── content/ │ ├── blog/ # Domain directory │ │ └── blog-posts/ # Model content directory │ │ ├── en.json # English entries │ │ └── tr.json # Turkish entries │ └── system/ │ ├── site-settings/ │ │ ├── en.json │ │ └── tr.json │ └── ui-labels/ │ ├── en.json │ └── tr.json └── meta/ ├── blog-posts/ │ ├── en.json # Entry metadata per locale │ └── tr.json ├── site-settings/ │ ├── en.json │ └── tr.json └── ui-labels/ ├── en.json └── tr.json ``` ::: info Models with a `content_path` override store their content files outside `.contentrain/content/` — for example, directly in `content/blog/` or `locales/`. The meta files always remain in `.contentrain/meta/`. ::: ::: info Shared Configuration `config.json` defines the project contract used by both Contentrain AI (local packages and CLI) and [Contentrain Studio](/studio). The same configuration governs local and team workflows — changes propagate across both surfaces. ::: ## config.json The primary project configuration file. Created by `contentrain init` and updated by MCP tools. ### Full Schema ```ts interface ContentrainConfig { version: number // Config version (currently 1) platform?: Platform // Target platform stack: StackType // Framework/stack identifier workflow: WorkflowMode // Content workflow mode repository?: { // Git repository info (optional) provider?: 'github' | 'gitlab' // Git host (Bitbucket coming) owner?: string name?: string default_branch: string // The base branch; `init` writes it alone when no host is known } locales: { default: string // Default locale code (e.g., "en") supported: string[] // All supported locale codes } domains: string[] // Content domain names assets_path?: string // Path for media assets cdn?: { // Public media delivery base (optional) url?: string // e.g. https://cdn.example.com/api/cdn/v1/ } branchRetention?: number // Days a merged cr/* branch is kept before lazy cleanup (default: 30) branchWarnLimit?: number // Unmerged cr/* count that triggers a warning (default: 50) branchBlockLimit?: number // Unmerged cr/* count that blocks new writes (default: 80) remoteBranchCleanup?: boolean // Delete remote cr/* copies on merge/delete + allow pruning (default: true) git?: { push?: boolean // false = local mode: no write ever pushes or deletes on the remote (default: true) } } ``` ### Example ```json { "version": 1, "platform": "web", "stack": "nuxt", "workflow": "review", "repository": { "provider": "github", "owner": "my-org", "name": "my-website", "default_branch": "main" }, "locales": { "default": "en", "supported": ["en", "tr", "de"] }, "domains": ["marketing", "blog", "system"], "assets_path": "public/uploads", "cdn": { "url": "https://cdn.example.com/api/cdn/v1/my-project" }, "branchRetention": 10, "remoteBranchCleanup": true } ``` ### Field Reference | Field | Type | Required | Description | |---|---|---|---| | `version` | `number` | Yes | Config schema version. Currently `1`. | | `platform` | `Platform` | No | Target platform: `web`, `mobile`, `api`, `desktop`, `static`, `other`. Informational — MCP's config reader strips it; only the raw file (read by `generate` and tooling) retains it. | | `stack` | `StackType` | Yes | Framework identifier. See [Supported Stacks](#supported-stacks) below. | | `workflow` | `WorkflowMode` | Yes | `auto-merge` or `review`. See [Workflow Modes](#workflow-modes). | | `repository` | `object` | No | Repository connection details. `provider`/`owner`/`name` are informational and optional. `repository.default_branch` is **not** informational: it is the [base branch](#base-branch) local writes advance, and `init` writes it. | | `locales.default` | `string` | Yes | Default locale code (e.g., `en`). | | `locales.supported` | `string[]` | Yes | All supported locale codes. Must include `default`. | | `domains` | `string[]` | Yes | Content domain names for organizing models. | | `assets_path` | `string` | No | Directory for media assets relative to project root. | | `cdn.url` | `string` | No | Public media delivery base. When set, `contentrain generate` (or its `--mediaBaseUrl` flag) bakes `media()`/`mediaBody()` resolvers into the client that turn relative `media/...` paths — and `](media/...)` references inside markdown — into absolute `{cdn.url}/{path}` URLs. Like `platform`, it is not surfaced through MCP's config reader — only `generate` and tooling read it from the raw file. | | `branchRetention` | `number` | No | Days a merged `cr/*` branch is kept locally before lazy cleanup. Default: `30`. | | `branchWarnLimit` | `number` | No | Unmerged `cr/*` branch count that triggers a warning. Default: `50`. | | `branchBlockLimit` | `number` | No | Unmerged `cr/*` branch count that blocks new writes. Default: `80`. | | `remoteBranchCleanup` | `boolean` | No | Delete the remote copy of a `cr/*` branch when it is merged or deleted locally, and allow `contentrain prune` / lazy sweeps to remove merged remote leftovers. Default: `true`. | | `git.push` | `boolean` | No | `false` turns on **local mode** (local provider): no write pushes a branch, and no merge, delete or prune touches the remote. Fetching still happens. Results report `remote_push: "disabled"` with `remote_note: "not pushed (local mode)"`, and `contentrain_submit` returns an error. The `CONTENTRAIN_NO_PUSH` env overrides it in both directions (`1`/`true` off, `0`/`false` on). Default: `true`. | ### Supported Stacks Contentrain supports a wide range of frameworks and platforms: | Category | Stack Types | |---|---| | **Meta-frameworks** | `nuxt`, `next`, `astro`, `sveltekit`, `remix`, `analog` | | **Frontend frameworks** | `vue`, `react`, `svelte`, `solid`, `angular` | | **Mobile** | `react-native`, `expo`, `flutter` | | **Backend** | `node`, `express`, `fastify`, `nestjs`, `django`, `rails`, `laravel`, `go`, `rust`, `dotnet` | | **Static site generators** | `hugo`, `jekyll`, `eleventy` | | **Desktop** | `electron`, `tauri` | | **Catch-all** | `other` | ### Workflow Modes The `workflow` field controls how content changes are integrated: | Mode | Behavior | Best For | |---|---|---| | `auto-merge` | Content branches are automatically merged to the default branch after commit | Development, single-author projects, rapid iteration | | `review` | Content branches remain open for human review before merging | Production, multi-author teams, quality gates | ::: warning Normalize operations (extraction and reuse) always use the `review` workflow regardless of this setting. This ensures human oversight for code-modifying changes. ::: ### Base Branch A local auto-merge merges the base branch into `contentrain` before each write, then fast-forwards the base to the `contentrain` tip and pushes both. The base is resolved the same way everywhere (writes, merge, reconcile, status, branch cleanup): 1. the `CONTENTRAIN_BRANCH` environment variable; 2. `repository.default_branch` in `config.json`; 3. the remote's default branch (`refs/remotes/origin/HEAD`, as `git clone` leaves it), when that branch exists locally; 4. a local `main`, then a local `master`; 5. the checked-out branch — only when none of the above exists. `contentrain init` (CLI and the `contentrain_init` tool) resolves the default branch once, with steps 3–5 above, and writes it as `repository.default_branch` — alone, when the git host is not known: ```json { "repository": { "default_branch": "main" } } ``` It does not record the `CONTENTRAIN_BRANCH` env (a per-process override is not the project's default), and it records the checked-out branch only when it is the repository's only branch. Run from a feature branch in a repository whose default is neither `main`/`master` nor named by `origin/HEAD`, it records nothing and says so; set the field by hand. Re-running `init` on an initialized project changes nothing, so an existing value is never overwritten. The checked-out branch is deliberately last. A content write made while a feature branch is checked out lands on `contentrain` and the base branch only: the feature branch is not merged into `contentrain`, not moved, and not pushed, and the developer's working tree and index are left as they are (the response carries a `warning` saying so). Because that tree then falls behind every write, MCP reads `.contentrain/` from the `contentrain` branch while such a branch is checked out: editing the same value twice needs no `git merge`, and `content_list`, `describe`, `status` and `validate` report `content_source` with the ref and commit they read. Source files, scan and normalize keep reading the working tree; the base branch, `contentrain`, `cr/*` branches and a detached HEAD read the working tree as before. Set `repository.default_branch` when your default branch is not `main`/`master` and the remote HEAD is not available. ## context.json Automatically maintained by MCP after every write operation. This file provides last-operation tracking for IDE integrations and the Serve UI. ### Full Schema ```ts interface ContextJson { version: string lastOperation: { tool: string // MCP tool name that performed the operation model: string // Model ID that was affected locale: string // Locale of the operation entries?: string[] // Entry IDs affected (collection only) timestamp: string // ISO 8601 timestamp source: ContextSource // Who triggered: 'mcp-local' | 'mcp-studio' | 'studio-ui' } stats: { models: number // Total number of models entries: number // Total number of content entries locales: string[] // Active locale codes lastSync: string // ISO 8601 timestamp of last sync } } ``` ### Example ```json { "version": "1.0.0", "lastOperation": { "tool": "contentrain_content_save", "model": "blog-posts", "locale": "en", "entries": ["a1b2c3", "d4e5f6"], "timestamp": "2026-03-15T10:30:00Z", "source": "mcp-local" }, "stats": { "models": 5, "entries": 42, "locales": ["en", "tr"], "lastSync": "2026-03-15T10:30:00Z" } } ``` ::: tip Do not manually edit `context.json`. It is auto-written by MCP after every write operation. IDE extensions and the Serve UI read this file to stay synchronized. ::: ### Context Sources | Source | Description | |---|---| | `mcp-local` | Operation triggered via local MCP (CLI/IDE) | | `mcp-studio` | Operation triggered via Contentrain Studio MCP | | `studio-ui` | Operation triggered via the Studio web UI | ## vocabulary.json An optional file for maintaining shared terms across all content. Ensures consistency when multiple agents or authors work on the same project. ### Full Schema ```ts interface Vocabulary { version: number terms: Record> // terms[term_key][locale] = translated_value } ``` ### Example ```json { "version": 1, "terms": { "product_name": { "en": "Contentrain", "tr": "Contentrain" }, "company_name": { "en": "Contentrain Inc.", "tr": "Contentrain A.Ş." }, "cta_primary": { "en": "Get Started", "tr": "Başla" }, "support_email": { "en": "support@contentrain.io", "tr": "support@contentrain.io" } } } ``` ### Usage - **Agents** reference vocabulary terms to ensure brand names, CTAs, and product terminology are consistent across all content. - **Validation** can flag content that deviates from established vocabulary. - **Terms are locale-aware** — each term has translations for all supported locales. ::: info Vocabulary is optional but recommended for projects with multiple content authors or AI agents. It prevents inconsistencies like "Get Started" vs "Start Now" vs "Begin Here" across different pages. ::: ## Model Definition Files Stored in `.contentrain/models/{model-id}.json`. Each file defines one model's schema. ### Full Schema ```ts interface ModelDefinition { id: string // Unique model identifier name: string // Human-readable name kind: ModelKind // 'collection' | 'singleton' | 'document' | 'dictionary' domain: string // Organizational domain i18n: boolean // Whether content is localized locales?: string[] // Project locales this model covers (subset of locales.supported); absent = all title_field: string // Field shown as an entry's title (dictionary: "key") description?: string // Optional description fields?: Record // Field definitions (not used for dictionary) content_path?: string // Framework-relative path override locale_strategy?: LocaleStrategy // How locale is encoded in file paths form?: Record // Runtime-owned public form configuration; preserved by structural edits comments?: Record // Runtime-owned comments configuration; preserved by structural edits } ``` ### `title_field` Required. Names the field whose value is shown as an entry's title — in a listing, a picker, a relation reference. It must name a field declared on the model, and that field's type must be one that can render as text: `string`, `text`, `slug`, `email`, `url`, `code`, `markdown`, `richtext`. Pointing it at an `image`, `relation`, `number` or `icon` field is rejected. Dictionary models have no fields — their entries are keyed strings — so their only legal value is the reserved `"key"`, meaning the entry key is the title. Declare it when creating a model, and keep it correct when renaming or removing fields. It exists because consumers that inferred the title from field order or length picked the icon over the headline. Projects created before this became required can be migrated in place: ```bash contentrain validate --fix ``` The backfill walks the same chain Studio's own title resolver does, with requiredness as the tiebreak on every rung — a required field always renders, an optional one may be empty: 1. a required name-like field (`title`, `name`, `label`, `heading`) 2. an optional name-like field 3. a required prose field (`string`, `email`, `text`, `markdown`, `richtext`) 4. any prose field A `slug`, `url` or `code` field is never chosen by inference. Each is a legal `title_field` when you name it, but guessed it is the failure this property exists to prevent — a settings singleton titled by its WhatsApp URL. When only such fields exist, `--fix` reports them and asks for an explicit choice. Each backfilled value is reported as a notice naming the rule that chose it, so a wrong pick is visible and correctable with `contentrain_model_save`. A `title_field` that is present but names the wrong field is reported and never rewritten — that is an authoring decision, not a gap to fill. ### `locales` Optional. The project locales this model covers — a subset of `locales.supported`. Absent, the model covers every supported locale, which is what parity is validated against and what every model did before the field existed. Present, validation checks only the locales named, so a partially-translated site can say so instead of failing on translations it never had. A locale outside `locales.supported` is a validation error, and the field has no effect on an `i18n: false` model. `contentrain validate --fix` never invents a value here. See [Locale Coverage](/reference/model-kinds#locale-coverage). See the [Model Kinds](/reference/model-kinds) page for detailed kind-specific documentation and the [Field Types](/reference/field-types) page for field definition details. ## Meta Files Stored in `.contentrain/meta/{model-id}/{locale}.json`. Track the lifecycle state of content entries. ### Entry Metadata Schema ```ts interface EntryMeta { status: ContentStatus // 'draft' | 'in_review' | 'published' | 'rejected' | 'archived' source: ContentSource // 'agent' | 'human' | 'import' updated_by: string // Author identifier updated_at?: string // ISO 8601 UTC, stamped on every write (optional) approved_by?: string // Approver identifier (optional) version?: string // Version string (optional) publish_at?: string // ISO 8601 scheduled publish (optional) expire_at?: string // ISO 8601 scheduled expiry (optional) } ``` ### Content Status Lifecycle | Status | Description | Typically Moves To | |---|---|---| | `draft` | Initial state, work in progress | `in_review`, `published` | | `in_review` | Submitted for review | `published`, `rejected` | | `published` | Live and active | `archived`, `draft` | | `rejected` | Review rejected | `draft` | | `archived` | No longer active | `draft` | ::: info Conventions, not a state machine The transitions above are the intended editorial flow — MCP does not enforce them. `contentrain_bulk update_status` can set any status directly; the lifecycle discipline lives in your workflow (and Studio's review UI), not in a validator gate. ::: ### Content Sources | Source | Description | |---|---| | `agent` | Created or modified by an AI agent via MCP | | `human` | Created or modified by a human via Studio UI or direct edit | | `import` | Imported from an external system | ### Scheduled Publishing Use `publish_at` and `expire_at` for time-based content lifecycle: ```json { "status": "draft", "source": "agent", "updated_by": "contentrain-mcp", "updated_at": "2026-01-15T09:30:00.000Z", "publish_at": "2026-04-01T00:00:00Z", "expire_at": "2026-06-30T23:59:59Z" } ``` The dates are a **delivery gate on top of `status`**, not a replacement for it. The CDN serves an entry when its status is `published` *and* `publish_at` has passed *and* `expire_at` has not. The record above is therefore still a draft: a `publish_at` in the past never flips `status`. Publishing stays a deliberate `contentrain_bulk update_status` call. Set the dates with `contentrain_content_save` — as `publish_at` / `expire_at` on the entry, next to `data`, never inside it. They live in meta only and are never written to the content file. Pass `null` to clear a date; omit the key to leave it unchanged. ::: warning `expire_at` must be after `publish_at`. The MCP validation tool will flag invalid date ranges. ::: ## Domain Organization Domains group related models and are declared in `config.json`'s `domains` array. ### Common Patterns | Domain | Typical Models | Description | |---|---|---| | `marketing` | Landing pages, CTAs, testimonials, pricing | Public-facing marketing content | | `blog` | Blog posts, authors, categories, tags | Blog and editorial content | | `system` | Site settings, navigation, footer, labels | System-level configuration | | `docs` | Guides, reference, tutorials | Documentation content | | `product` | Features, changelog, roadmap | Product-related content | | `legal` | Privacy policy, terms of service | Legal documents | ### Adding a Domain Domains are declared in `config.json` and used as the `domain` field in model definitions: ```json // config.json { "domains": ["marketing", "blog", "system", "docs"] } // .contentrain/models/blog-posts.json { "id": "blog-posts", "domain": "blog", "kind": "collection" } ``` Content files for this model would be stored at `.contentrain/content/blog/blog-posts/` (unless `content_path` is set). ## Locale Configuration ### Default Locale The `locales.default` value determines: - Which locale is used when no locale is specified in MCP calls - The fallback locale for missing translations - The primary locale for the content editing experience ### Supported Locales The `locales.supported` array lists all active locale codes: ```json { "locales": { "default": "en", "supported": ["en", "tr", "de", "fr", "es"] } } ``` ::: tip The `supported` array must include the `default` locale. Use standard BCP 47 locale codes (e.g., `en`, `tr`, `de`, `fr`, `pt-BR`). ::: ### Per-Model i18n Each model independently controls whether it uses localization: | `i18n` Value | File Pattern | Use Case | |---|---|---| | `true` | Separate file per locale (`en.json`, `tr.json`) | Translatable content | | `false` | Single `data.json` file | Language-independent content (e.g., color codes, API keys) | ## Path Patterns by Kind All content files follow predictable path conventions. These are exported as `PATH_PATTERNS` from `@contentrain/types`. ### Content Paths | Kind | i18n: true | i18n: false | |---|---|---| | **Collection** | `.contentrain/content/{domain}/{modelId}/{locale}.json` | `.contentrain/content/{domain}/{modelId}/data.json` | | **Singleton** | `.contentrain/content/{domain}/{modelId}/{locale}.json` | `.contentrain/content/{domain}/{modelId}/data.json` | | **Dictionary** | `.contentrain/content/{domain}/{modelId}/{locale}.json` | `.contentrain/content/{domain}/{modelId}/data.json` | | **Document** | `.contentrain/content/{domain}/{slug}/{locale}.md` | `.contentrain/content/{domain}/{slug}.md` | ### Meta Paths | Kind | Path | |---|---| | **Collection** | `.contentrain/meta/{modelId}/{locale}.json` | | **Singleton** | `.contentrain/meta/{modelId}/{locale}.json` | | **Dictionary** | `.contentrain/meta/{modelId}/{locale}.json` | | **Document** | `.contentrain/meta/{modelId}/{slug}/{locale}.json` | ::: warning Document meta files include the `{slug}` segment in the path, unlike other kinds. This is because each document is a separate entity with its own lifecycle. On `i18n: false` models the `{locale}` in the meta path is pinned to the **default locale** — one `data.json` (or `{slug}.md`) means exactly one meta record, at `.contentrain/meta/{modelId}/{default-locale}.json`. ::: ### Other Paths | File | Path | |---|---| | Config | `.contentrain/config.json` | | Context | `.contentrain/context.json` | | Vocabulary | `.contentrain/vocabulary.json` | | Model definition | `.contentrain/models/{modelId}.json` | ## Validation Patterns Contentrain enforces consistent formats for identifiers. These are exported as regex patterns from `@contentrain/types`: | Pattern | Regex | Valid Examples | Invalid Examples | |---|---|---|---| | **Slug** | `/^[a-z0-9]+(?:-[a-z0-9]+)*$/` | `hello-world`, `post1` | `Hello`, `post_1`, `--bad` | | **Entry ID** | `/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,39}$/` | `a1b2c3d4e5f6`, `my-entry` | `""`, `-start`, `@invalid` | | **Locale** | `/^[a-z]{2}(?:-[A-Z]{2})?$/` | `en`, `tr`, `en-US`, `pt-BR` | `english`, `EN`, `e` | ## Canonical Serialization All JSON files written by Contentrain follow strict serialization rules for deterministic, clean git diffs. These rules are exported as `CANONICAL_JSON` from `@contentrain/types`. | Rule | Value | Rationale | |---|---|---| | **Key sorting** | Lexicographic (alphabetical) | Parallel edits land in different positions — reduces merge conflicts | | **Indent** | 2 spaces | Standard, readable | | **Encoding** | UTF-8 | Universal character support | | **Trailing newline** | Yes (single `\n`) | POSIX compliance, clean git diffs | | **Null values** | Omitted entirely | Token efficiency — don't write what doesn't exist | | **No trailing commas** | Standard JSON | Compatibility | ### Why Canonical Serialization Matters - **Collection entries** are sorted by entry ID → adding entries in parallel produces conflict-free merges - **Entry fields** are sorted alphabetically; only the model definition file keeps a curated key order (`id`, `name`, `kind`, `domain`, `i18n`, `description`, `content_path`, `locale_strategy`, `fields`) → consistent, predictable diffs - **Omitting null/undefined** saves tokens when AI agents read content → lower cost, faster processing (values equal to a field's declared `default` are still written — the serializer does not compare against schema defaults) - **Deterministic output** means running the same operation twice produces identical files — no noise in git history All MCP tools, the CLI, and the SDK follow these rules. The `contentrain_validate` tool can detect and auto-fix serialization violations with `fix: true`. --- # RepoProvider Reference Source: https://ai.contentrain.io/reference/providers # RepoProvider Reference Contentrain's provider-agnostic engine is defined by a small set of interfaces in `@contentrain/types`. Third-party tools can implement a custom `RepoProvider` (for a private git host, an internal service, a test harness) without taking a dependency on `@contentrain/mcp`. The canonical source lives in `packages/types/src/provider.ts`. `@contentrain/mcp/core/contracts` re-exports every symbol for backward compatibility. ## RepoReader Read-only surface — three methods. Paths are content-root relative; `ref` is a branch name, tag, or commit SHA. ```ts interface RepoReader { readFile(path: string, ref?: string): Promise listDirectory(path: string, ref?: string): Promise fileExists(path: string, ref?: string): Promise } ``` Error semantics: - `readFile` **throws** when the file is missing. Callers opt into tolerance with an explicit try/catch. - `listDirectory` returns `[]` for a missing directory. The empty case is the common, uninteresting one. ## RepoWriter Write surface — one method, one atomic commit per call. ```ts interface RepoWriter { applyPlan(input: ApplyPlanInput): Promise } interface ApplyPlanInput { branch: string changes: FileChange[] message: string author: CommitAuthor base?: string // Defaults to CONTENTRAIN_BRANCH ('contentrain') } ``` `changes` entries are `{ path, content }`; `content: null` means delete. Providers are responsible for resolving paths against their backing store and translating the change set into whatever commit primitive the backend supports. ### A commit SHA as base (GitHub) The GitHub provider takes a full 40-hex commit SHA as `base` in `applyPlan` and as `fromRef` in `createBranch`. Anything shorter is read as a branch name. This is a compare-and-set write. A host reads content at a pinned commit and passes that commit as `base`, so the write builds on exactly what it read: - **`branch` does not exist:** it forks from the SHA. No ref is looked up. - **`branch` exists and points at the SHA:** the commit goes on top. The ref update only fast-forwards, so a branch that moves after the check rejects the commit. - **`branch` exists and has moved:** the write is refused with a 409, which maps to `PROVIDER_CONFLICT`. Nothing is written. Re-read and rebuild. - **The SHA is unknown:** GitHub's error comes back as it is. There is no fallback to a branch. A branch-name `base` behaves as before: it is used only when `branch` does not exist yet. The GitLab and Local providers accept a SHA in `createBranch` because their backends do, but their `applyPlan` takes a branch name only. ## Branch operations Providers extend `RepoReader` and `RepoWriter` with branch / merge / diff operations to form the full `RepoProvider`: ```ts interface RepoProvider extends RepoReader, RepoWriter { readonly capabilities: ProviderCapabilities // Optional per-project public media delivery base (project segment included). // When set by a hosted provider, the content-write path normalizes relative // `media/...` references to absolute delivery URLs. Undefined for local/CLI // providers, where media stays a relative path. readonly mediaBaseUrl?: string // Optional media facet. Present only on providers whose backend exposes a // media stack (e.g. Studio MCP Cloud); drives the `contentrain_media_*` // tools, which are not registered when this is absent. readonly media?: MediaProvider listBranches(prefix?: string): Promise createBranch(name: string, fromRef?: string): Promise deleteBranch(name: string): Promise getBranchDiff(branch: string, base?: string): Promise // opts.removeSourceBranch deletes the source after a successful merge // (opt-in, best-effort — reported via MergeResult.remote). Long-lived // branches (into, contentrain, the default branch) are never deleted. mergeBranch(branch: string, into: string, opts?: { removeSourceBranch?: boolean }): Promise isMerged(branch: string, into?: string): Promise getDefaultBranch(): Promise // Optional reconcile pair. getMergeBase names the common ancestor of two // refs (git merge-base / compare APIs); createMergeCommit writes resolved // changes as a TWO-PARENT merge commit so the reconciled ref contains both // histories and fast-forward advances work again. A provider that omits // them cannot drive a reconcile — callers fall back to a PR/MR flow. getMergeBase?(refA: string, refB: string): Promise createMergeCommit?(input: { branch: string; ours: string; theirs: string changes: FileChange[]; message: string; author: CommitAuthor }): Promise } ``` `MergeResult` is `{ merged, sha, pullRequestUrl, sync?, remote? }`. GitHubProvider fills `sha` on direct merges; GitLabProvider fills both `sha` and `pullRequestUrl` (merges via MR). LocalProvider populates `sync: SyncResult` to describe file syncing to the working tree. When branch protection blocks a direct merge, any provider may return `merged: false` with a `pullRequestUrl` fallback. `remote` reports the outcome of the optional post-merge source-branch cleanup (`deleted`, plus `skipped` for an expected no-op or `warning` for a non-fatal failure). ## Capabilities Every provider advertises what it can do. Tools gate on capabilities and reject with `capability_required` when the active provider can't satisfy them. ```ts interface ProviderCapabilities { localWorktree: boolean sourceRead: boolean sourceWrite: boolean pushRemote: boolean branchProtection: boolean pullRequestFallback: boolean astScan: boolean // Optional (absent = false): provider can write a two-parent merge commit mergeCommit?: boolean } ``` Capability meanings: | Capability | Purpose | |---|---| | `localWorktree` | Provider backs onto a local filesystem worktree and can selectively sync changes to developer's working tree | | `sourceRead` | Provider can read arbitrary source files outside `.contentrain/` (required for normalize extract) | | `sourceWrite` | Provider can write arbitrary source files outside `.contentrain/` (required for normalize reuse) | | `pushRemote` | Provider can push commits to a remote repository (required for submit) | | `branchProtection` | Provider detects branch protection rules on the remote | | `pullRequestFallback` | Provider can open a pull request as a fallback when direct merge is blocked | | `astScan` | Provider can execute AST scanners against source files (implies local disk access) | | `mergeCommit` | Provider can write a two-parent merge commit via `createMergeCommit` (absent = false; GitLab omits it — its Commits API cannot express two parents) | Built-in capability sets: | Capability | LocalProvider | GitHubProvider | GitLabProvider | |---|---|---|---| | `localWorktree` | ✓ | — | — | | `sourceRead` | ✓ | — | — | | `sourceWrite` | ✓ | — | — | | `pushRemote` | ✓ | ✓ | ✓ | | `branchProtection` | — | ✓ | ✓ | | `pullRequestFallback` | — | ✓ | ✓ | | `astScan` | ✓ | — | — | | `mergeCommit` | ✓ | ✓ | — | `LOCAL_CAPABILITIES` is exported from `@contentrain/types` for ergonomic use in custom providers that back onto the local filesystem: ```ts export const LOCAL_CAPABILITIES: ProviderCapabilities = { localWorktree: true, sourceRead: true, sourceWrite: true, pushRemote: true, branchProtection: false, pullRequestFallback: false, astScan: true, mergeCommit: true, } ``` ## Media facet (optional) `RepoProvider.media` is an optional facet — not a capability flag. When a provider implements it (hosted providers such as Studio MCP Cloud), the `contentrain_media_*` tools are registered; when it is absent (Local / GitHub / GitLab), those tools never appear in `tools/list`. It is a deterministic passthrough to the backend's media stack — MCP makes no media decisions. ```ts interface MediaProvider { list(opts?: MediaListOptions): Promise get(id: string): Promise ingest(input: MediaIngestInput): Promise // URL-based; provider owns SSRF/MIME/size policy update(id: string, patch: MediaUpdateInput): Promise delete(id: string): Promise } interface MediaAsset { id: string path: string // repo-relative storage path content fields reference (media/...) url?: string // absolute delivery URL when resolvable (see mediaBaseUrl) mime?: string size?: number alt?: string tags?: string[] createdAt?: string meta?: Record // provider-defined extras (dimensions, blurhash, variants) } interface MediaListOptions { search?: string; tag?: string; limit?: number; cursor?: string } interface MediaListResult { assets: MediaAsset[]; nextCursor?: string; total?: number } interface MediaIngestInput { url: string; filename?: string; alt?: string; tags?: string[] } interface MediaUpdateInput { alt?: string; tags?: string[]; filename?: string } ``` MCP has no binary channel, so ingest is URL-based: the agent supplies a source URL and the provider fetches it server-side under its own SSRF/MIME/size policy. See [MCP Tools → Media Tools](/packages/mcp) for the tool-level reference. ## Supporting types ```ts interface FileChange { path: string; content: string | null } interface CommitAuthor { name: string; email: string } interface Commit { sha: string message: string author: CommitAuthor timestamp: string // ISO 8601 } interface Branch { name: string; sha: string; protected?: boolean } interface FileDiff { path: string status: 'added' | 'modified' | 'removed' before: string | null after: string | null } interface MergeResult { merged: boolean sha: string | null pullRequestUrl: string | null sync?: SyncResult // Only populated by LocalProvider remote?: { // Post-merge source-branch cleanup outcome deleted: boolean skipped?: string // Expected no-op (cleanup disabled, no remote, ref gone) warning?: string // Non-fatal failure (offline, auth) — merge still succeeded } } interface SyncResult { synced: string[] // Files successfully synced to working tree skipped: string[] // Files skipped due to uncommitted local changes warning?: string // Human-readable warning if files were skipped } ``` ## Implementing a custom provider Minimum viable provider: ```ts import type { RepoProvider, ProviderCapabilities } from '@contentrain/types' import { LOCAL_CAPABILITIES } from '@contentrain/types' class MyProvider implements RepoProvider { readonly capabilities: ProviderCapabilities = { ...LOCAL_CAPABILITIES, // override what your backend actually supports astScan: false, } async readFile(path: string, ref?: string): Promise { /* ... */ } async listDirectory(path: string, ref?: string): Promise { /* ... */ } async fileExists(path: string, ref?: string): Promise { /* ... */ } async applyPlan(input): Promise { /* one atomic commit */ } async listBranches(prefix?: string) { /* ... */ } async createBranch(name, fromRef?) { /* ... */ } async deleteBranch(name) { /* ... */ } async getBranchDiff(branch, base?) { /* ... */ } async mergeBranch(branch, into) { /* ... */ } async isMerged(branch, into?) { /* ... */ } async getDefaultBranch() { /* ... */ } } // Plug it in: import { createServer } from '@contentrain/mcp/server' const server = createServer({ provider: new MyProvider() }) ``` Any custom provider slots straight into the MCP server and the HTTP transport with no further wiring. ## Reference implementations - `packages/mcp/src/providers/local/` — simple-git + worktree - `packages/mcp/src/providers/github/` — Octokit over the Git Data + Repos APIs - `packages/mcp/src/providers/gitlab/` — gitbeaker over the GitLab REST API Each is ~400–500 lines; they're small enough to read end-to-end and mirror each other's structure. They're the recommended starting point for a new backend. --- # Reconcile & Divergence Source: https://ai.contentrain.io/reference/reconcile # Reconcile & Divergence Contentrain keeps content on a dedicated `contentrain` branch that normally *contains* the base branch, so advancing the base is always a fast-forward. This page is about what happens when that stops being true. ## How divergence happens The invariant breaks when a change lands on the base branch directly. That is not a mistake — it is what a **dual-domain migration** requires: a package bump and a `.contentrain/` schema change that must ship as one atomic PR. From then on the two branches have diverged: - writes still land safely on `contentrain` - they report `base_advance: "blocked_diverged"` instead of advancing the base - `contentrain status` shows the relation in both directions instead of a one-sided "in sync" ::: info A diverged write is a partial success, not a failure The content was written. What did not happen is the fast-forward. Treating this as an error would push people toward force-pushing a branch that has legitimate commits on it. ::: `contentrain_status` reports the relation as one of `in_sync` · `content_ahead` · `base_ahead` · `diverged`. ## Two doors | Door | Shape | |---|---| | `contentrain_reconcile` (MCP) | `dry_run` previews, `resolutions` answer conflicts, execute lands the merge | | `contentrain reconcile` (CLI) | Dry-run plan, one interactive question per conflict, then execute | ```bash contentrain reconcile # plan + interactive decisions contentrain reconcile --yes # execute a CLEAN plan without prompting contentrain reconcile --json # emit the dry-run plan for scripts ``` `--yes` never defaults a conflict. A plan with open questions always stops. ## The rule everything reduces to Reconcile is a three-way merge: **base** is the merge-base tree, **ours** is `contentrain`, **theirs** is the base branch. Every policy row below reduces to one function: | base | ours | theirs | Result | |---|---|---|---| | same | same | same | unchanged | | — | changed | changed *identically* | converges | | same | changed | — | ours wins | | same | — | changed | theirs wins | | present | deleted | edited | **`delete_edit` conflict** | | — | changed | changed *differently* | **`both_changed` conflict** | A side that did not change keeps no vote. Delete-versus-edit is split out because it needs a different question — "keep or drop?" rather than "which value?". Equality is **canonical**, not textual: both values are normalized through `sortKeys` — which drops `null` and `undefined` and sorts keys recursively, the same normal form written to disk — then compared. So `{ a: null }` equals `{}`, key order never matters, and absent equals null. Exactly the distinctions the storage layer cannot represent anyway. ## Granularity, by kind The merge is content-aware: it descends to the smallest unit where a disagreement is real. | Kind | Merged at | Notes | |---|---|---| | Collection / singleton | **Field** inside an entry | Two sides editing different fields of one entry both win | | Dictionary | **Key** | | | Vocabulary | **Term + locale** | One side adding a term's Turkish while another fixes its English are different leaves. Only the same term in the same locale is a question | | Model | **Top-level key**, recursing one level into `fields` | Two sides adding different fields both win | | Document (`.md`) | Frontmatter **key by key**; body as **one leaf** | | | Meta | Field, with special rules — see below | | | Anything unrecognized | **Whole file** | The planner never invents content for a file it does not understand | ::: danger Document bodies are never text-merged Interleaving two prose edits line by line produces syntactically plausible, editorially wrong hybrids. Choosing between two versions of prose is a content decision, and MCP does not make content decisions. ::: If a document's frontmatter does not survive a parse→serialize round trip — the parser round-trips scalars and scalar arrays, but nested maps serialize without parsing back — that document falls back to whole-file resolution rather than having its nested data silently destroyed. ### Meta has its own rules | Field | Rule | |---|---| | `updated_at` | The later timestamp wins; present beats absent | | `updated_by` | Follows whichever side supplied the winning `updated_at`. With no timestamps to arbitrate, ours is kept and an advisory says so | | `status` | One side's change wins mechanically — but **two different publish decisions are a question for a human, never a pick** | An entry-level delete-versus-edit in meta resolves mechanically to the edited side with an advisory: meta is bookkeeping that trails content, and the *content* delete-versus-edit conflict — reported separately — is the real question. ## The four phases 1. **Models** — schema first, because the schema decides where content lives 2. **Per-model content and meta** — each side enumerated with *its own* model definition, so a `content_path` or strategy change moves files correctly 3. **Fixed paths** — project settings merge by top-level key, so a branch renaming the content root and main flipping the workflow do not collide 4. **Unclaimed scan** — everything under the recognized roots no phase claimed ::: warning Structural keys block a model's content phase `kind`, `i18n`, `locale_strategy` and `content_path` decide where a model's files live and how they are keyed. When one of *those* is itself in conflict, the model's content cannot be located safely, so the content phase is blocked and only the model-level conflict is reported. Answer that one, then reconcile again. ::: ## The conflict set is closed Consumers key localized editor questions on these values, so the set is a contract: adding one is a minor version bump with a changelog entry; renaming or removing one is breaking. | `ConflictCode` | Means | |---|---| | `field_value_conflict` | Collection/singleton: the same field changed differently on both sides | | `dictionary_value_conflict` | The same dictionary key, two different values | | `vocabulary_value_conflict` | The same term + locale, two different translations | | `model_key_conflict` | The same model schema key changed differently | | `meta_status_conflict` | Both sides moved an entry's publish status differently | | `document_body_conflict` | Both sides edited a document body | | `frontmatter_value_conflict` | The same frontmatter key, two different values | | `delete_edit_conflict` | One side deleted what the other edited | | `file_conflict` | An unrecognized file changed on both sides — resolved by choosing a side | Each conflict arrives as a `ConflictItem`: ```ts { id: string // derived from the position AND the three values path: string // content-root-relative kind: 'collection' | 'singleton' | 'document' | 'dictionary' | 'vocabulary' | 'model' | 'meta' | 'file' model?: string key?: string // entry id, dictionary key, term, or schema key field?: string locale?: string base?: unknown // the three values, for display ours?: unknown theirs?: unknown code: ConflictCode message: string // ready-to-display English sentence suggested?: 'ours' | 'theirs' } ``` `suggested` appears only where the approved policy names a preferred side — model schema keys suggest `theirs`, carrying "the schema belongs to the developer". **It is never auto-applied.** ## Answering conflicts A plan with conflicts is not applied. Everything mechanical is already merged into the plan's changes; you collect decisions and run a second round: ```ts type ConflictResolution = | { id: string, choose: 'ours' | 'theirs' } // absence on that side means deletion | { id: string, value: unknown } // a hand-authored replacement ``` ::: tip Stale decisions cannot land `ConflictItem.id` is derived from the position **and** the three values. If any side moved between the preview and the decision, the id no longer matches: the resolution is dropped and the conflict re-reported with a fresh id. That is compare-and-set, for free, with no extra round trip — and it is why an agent may safely plan, think, and then answer. ::: ## What executing produces A **two-parent merge commit** on `contentrain`, after which the fast-forward advance works again. The result reports what happened mechanically: | Field | Meaning | |---|---| | `files_merged` | Content-layer files whose merged output differs from ours | | `entries_taken_ours` / `entries_taken_theirs` | Items resolved by taking the side that changed them | | `entries_field_merged` | Items where both sides changed different fields and the union won | | `regenerated` | Derived files rebuilt — `['.contentrain/context.json']`, or empty on a no-op | ## Provider requirements Reconcile needs two optional `RepoProvider` members: `getMergeBase` (the common ancestor) and `createMergeCommit` (a genuinely two-parent commit, so the reconciled ref contains both histories). | Provider | Reconcile | |---|---| | Local | Yes | | GitHub | Yes | | GitLab | Falls back to an MR flow | A provider implementing neither cannot drive a reconcile; callers fall back to a PR/MR flow. ## Related Pages - [Core Concepts](/concepts#divergence) — where divergence sits in the branch model - [CLI](/packages/cli#contentrain-reconcile) — the interactive command - [MCP Tools](/packages/mcp) — `contentrain_reconcile` and `contentrain_status` - [RepoProvider Reference](/reference/providers) — `getMergeBase` and `createMergeCommit` - [Types](/packages/types) — `ConflictItem`, `ConflictCode`, `ConflictResolution`, `BaseAdvance`