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.jsonINFO
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/.
Shared Configuration
config.json defines the project contract used by both Contentrain AI (local packages and CLI) and Contentrain 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
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/<project>
}
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
{
"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 below. |
workflow | WorkflowMode | Yes | auto-merge or review. See 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 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. |
git.verify | boolean | No | true runs the repository's commit hooks (pre-commit, commit-msg, …) on Contentrain's machine commits (local provider). A hook that rejects the content commit fails the write with the hook's output; nothing is retried without hooks. The CONTENTRAIN_VERIFY env overrides it in both directions (1/true on, 0/false off). Default: false — commit hooks are skipped, as before; merge and pre-push hooks run either way. |
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):
- the
CONTENTRAIN_BRANCHenvironment variable; repository.default_branchinconfig.json;- the remote's default branch (
refs/remotes/origin/HEAD, asgit cloneleaves it), when that branch exists locally; - a local
main, then a localmaster; - 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:
{ "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
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
{
"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
interface Vocabulary {
version: number
terms: Record<string, Record<string, string>>
// terms[term_key][locale] = translated_value
}Example
{
"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
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<string, FieldDef> // 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<string, unknown> // Runtime-owned public form configuration; preserved by structural edits
comments?: Record<string, unknown> // 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:
contentrain validate --fixThe 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:
- a required name-like field (
title,name,label,heading) - an optional name-like field
- a required prose field (
string,email,text,markdown,richtext) - 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.
See the Model Kinds page for detailed kind-specific documentation and the 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
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 |
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:
{
"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:
// 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:
{
"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
defaultare 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.