Skip to content

Contentrain Directory Format

The .contentrain/ directory is the contract between Studio and the Git repository. It contains configuration, model definitions, content data, metadata, and project context.

Directory Structure

.contentrain/
├── config.json                    # Project configuration
├── vocabulary.json                # Shared terminology across locales
├── context.json                   # Project stats and last operation
├── models/                        # Model definitions (one file per model)
│   ├── blog-post.json
│   ├── hero.json
│   └── ui-strings.json
├── content/                       # Content data organized by domain
│   └── {domain}/
│       └── {modelId}/
│           ├── en.json            # Localized content (i18n: true)
│           ├── tr.json
│           └── data.json          # Non-localized content (i18n: false)
├── meta/                          # Entry metadata (timestamps, authors)
│   └── {modelId}/
│       ├── en.json
│       └── data.json
└── client/                        # Generated SDK output (gitignored)

config.json

The root configuration file defines the project stack, locales, domains, and workflow.

json
{
  "version": 1,
  "stack": "nuxt",
  "locales": {
    "default": "en",
    "supported": ["en", "tr"]
  },
  "domains": ["marketing", "blog", "system"],
  "workflow": "auto-merge"
}
FieldTypeDescription
versionnumberConfig format version (currently 1)
stackstringFramework: nuxt, next, astro, sveltekit, remix, vue, react, other
locales.defaultstringDefault locale code
locales.supportedstring[]All supported locale codes
domainsstring[]Content domains (directory grouping)
workflowstringauto-merge or review

Model Definitions

Each model is a JSON file in .contentrain/models/{modelId}.json.

Collection Model

json
{
  "id": "blog-post",
  "name": "Blog Post",
  "kind": "collection",
  "domain": "blog",
  "i18n": true,
  "description": "Blog articles with authors and categories",
  "fields": {
    "title": {
      "type": "string",
      "required": true,
      "max": 200,
      "description": "Post title"
    },
    "excerpt": {
      "type": "text",
      "max": 500
    },
    "author": {
      "type": "relation",
      "model": "team-members",
      "required": true
    },
    "tags": {
      "type": "array",
      "items": "string"
    },
    "cover": {
      "type": "image"
    },
    "publishDate": {
      "type": "date"
    }
  }
}

Singleton Model

json
{
  "id": "hero",
  "name": "Hero Section",
  "kind": "singleton",
  "domain": "marketing",
  "i18n": true,
  "fields": {
    "headline": { "type": "string", "required": true },
    "tagline": { "type": "text" },
    "illustration": { "type": "image" }
  }
}

Dictionary Model

Dictionaries have no fields property. All content is free-form key-value string pairs.

json
{
  "id": "ui-strings",
  "name": "UI Strings",
  "kind": "dictionary",
  "domain": "system",
  "i18n": true,
  "description": "User-facing UI labels, buttons, headings, and messages"
}

Document Model

Documents store content as markdown files with YAML frontmatter.

json
{
  "id": "docs",
  "name": "Documentation",
  "kind": "document",
  "domain": "blog",
  "i18n": true,
  "fields": {
    "title": { "type": "string", "required": true },
    "category": { "type": "select", "options": ["guide", "reference", "tutorial"] }
  }
}

Content Kinds Summary

KindStorageEntry IDKey FormatFields
collectionJSON object map12-char hex{ entryId: { field: value } }Defined in model
singletonJSON flat objectN/A{ field: value }Defined in model
dictionaryJSON flat objectN/A{ "key": "string value" }None (free-form)
documentMarkdown filesSlugYAML frontmatter + bodyDefined in model

Content Files

Collection Content

File: .contentrain/content/{domain}/{modelId}/{locale}.json

json
{
  "a1b2c3d4e5f6": {
    "title": "Getting Started with Studio",
    "excerpt": "Learn how to manage content with AI",
    "author": "d4e5f6a1b2c3",
    "tags": ["tutorial", "getting-started"],
    "publishDate": "2026-01-15"
  },
  "b2c3d4e5f6a1": {
    "title": "Advanced Content Models",
    "excerpt": "Deep dive into field types and relations"
  }
}

Singleton Content

File: .contentrain/content/{domain}/{modelId}/{locale}.json

json
{
  "headline": "Welcome to Contentrain Studio",
  "tagline": "The conversation-first CMS for teams",
  "illustration": "media/original/hero.webp"
}

Dictionary Content

File: .contentrain/content/{domain}/{modelId}/{locale}.json

json
{
  "auth.sign_in_title": "Sign in to your account",
  "auth.sign_in_button": "Sign In",
  "auth.magic_link_label": "Sign in with magic link",
  "common.save": "Save",
  "common.cancel": "Cancel"
}

WARNING

All dictionary values must be strings. Non-string values cause validation errors.

Non-Localized Content

When i18n: false, content is stored in data.json instead of {locale}.json:

File: .contentrain/content/{domain}/{modelId}/data.json

Document Content

Documents are stored as individual markdown files:

  • i18n + slug: .contentrain/content/{domain}/{modelId}/{slug}/{locale}.md
  • No i18n + slug: .contentrain/content/{domain}/{modelId}/{slug}.md
markdown
---
category: guide
title: Getting Started
---

Welcome to Contentrain Studio. This guide walks you through...

Meta Files

Meta files track the editorial state and authorship of each entry. They live in a separate .contentrain/meta/ tree -- never inlined into the content -- so content files stay clean and diff-friendly.

The shape of a meta file mirrors the model kind. Collections use an object-map keyed by entry ID; every other kind stores a single EntryMeta object.

KindPathStructure
collection.contentrain/meta/{modelId}/{locale}.jsonObject-map: { entryId: EntryMeta }
singleton.contentrain/meta/{modelId}/{locale}.jsonSingle EntryMeta object
dictionary.contentrain/meta/{modelId}/{locale}.jsonSingle EntryMeta object
document.contentrain/meta/{modelId}/{slug}/{locale}.jsonSingle EntryMeta object

EntryMeta fields

FieldTypeDescription
statusstringdraft, in_review, published, rejected, or archived
sourcestringWho authored the entry: agent, human, or import
updated_bystringIdentity of the last writer -- an email or an agent id such as contentrain-mcp
approved_bystring | nullIdentity of the approver, or null when unapproved (optional)
versionstringOptional content version tag
publish_atstringOptional ISO timestamp for scheduled publishing
expire_atstringOptional ISO timestamp for content expiry

Collection meta -- an object-map keyed by entry ID:

json
{
  "a1b2c3d4e5f6": {
    "status": "published",
    "source": "agent",
    "updated_by": "contentrain-mcp",
    "approved_by": "[email protected]"
  }
}

Singleton, dictionary, and document meta -- a single object:

json
{
  "status": "draft",
  "source": "human",
  "updated_by": "[email protected]"
}

INFO

Creation and update timestamps are derived from Git history, not stored in meta. The EntryMeta.source field (agent | human | import) records who authored the entry. This is distinct from the context.json operation source described below, which records which client performed the last write.

vocabulary.json

Shared terminology that should be consistent across locales:

json
{
  "version": 1,
  "terms": {
    "brand_name": {
      "en": "Contentrain",
      "tr": "Contentrain"
    },
    "product_name": {
      "en": "Studio",
      "tr": "Studio"
    }
  }
}

The vocabulary is included in the agent system prompt so the AI uses consistent terminology.

context.json

Project-level context tracking updated after every write operation. The MCP server writes it; Studio reads it to keep the IDE and the dashboard in sync.

json
{
  "version": "1",
  "lastOperation": {
    "tool": "contentrain_content_save",
    "model": "blog-post",
    "locale": "en",
    "entries": ["a1b2c3d4e5f6"],
    "timestamp": "2026-01-15T14:30:00.000Z",
    "source": "studio-ui"
  },
  "stats": {
    "models": 8,
    "entries": 59,
    "locales": ["en"],
    "lastSync": "2026-01-15T14:30:00.000Z"
  }
}
FieldTypeDescription
versionstringContext format version
lastOperation.toolstringTool that performed the write (e.g. contentrain_content_save)
lastOperation.modelstringModel the operation touched
lastOperation.localestringLocale the operation touched
lastOperation.entriesstring[]Entry IDs affected (optional)
lastOperation.timestampstringISO timestamp of the operation
lastOperation.sourcestringClient that performed the write: mcp-local, mcp-studio, or studio-ui
stats.modelsnumberTotal model count
stats.entriesnumberTotal entry count
stats.localesstring[]Locales in use
stats.lastSyncstringISO timestamp of the last sync

WARNING

context.json's lastOperation.source (mcp-local | mcp-studio | studio-ui) is the write client, not the content author. Do not confuse it with EntryMeta.source (agent | human | import), which records who authored the entry.

Canonical JSON Format

All JSON files follow the canonical format:

RuleValue
Indent2 spaces
EncodingUTF-8
Trailing newlineYes
Null valuesOmitted
Default valuesOmitted
Key orderingAlphabetically sorted (recursive)

This ensures byte-identical output across Studio, MCP, and manual edits.

Content Path Override

Models can override their content storage path with the content_path property:

json
{
  "id": "docs",
  "kind": "document",
  "content_path": "content/docs"
}

This stores files at {contentRoot}/content/docs/ instead of the default .contentrain/content/{domain}/{modelId}/ path. Path traversal (..) and sensitive directories (.github, node_modules, .env) are blocked.

Released under the AGPL-3.0 License.