# Contentrain Studio Documentation — Full Text > Conversation-first CMS for teams who manage structured content over Git. This file is the complete documentation corpus concatenated for LLM ingestion — the "full" companion to https://docs.contentrain.io/llms.txt. Pages appear in navigation order; each is preceded by its source URL. Generated at build time from https://docs.contentrain.io. --- # What is Contentrain Studio? Source: https://docs.contentrain.io/guide/ > An introduction to Contentrain Studio — a conversation-first, Git-backed CMS and team surface for the same `.contentrain/` contract used by Contentrain AI. # What is Contentrain Studio? Contentrain Studio is an open-core, Git-backed content operations platform. Teams can self-host the AGPL core or use a managed Pro/Enterprise offering. Instead of traditional CRUD interfaces, Studio puts an AI-powered conversation at the center of your content workflow. **Connect a GitHub repository, define content models, and manage your content through natural conversation or a structured UI — every change is tracked as a Git commit.** Developers usually enter the ecosystem through Contentrain AI packages such as `contentrain`, `@contentrain/mcp`, `@contentrain/rules`, `@contentrain/skills`, and `@contentrain/query`. Studio adds the authenticated team web surface on top of that same contract. See [Ecosystem Map](/guide/ecosystem). ## How It Works ``` GitHub Repository ← → Contentrain Studio ← → Your Team ↕ AI Content Agent ``` 1. **Connect** your GitHub repository through the GitHub App 2. **Define** content models — collections, documents, singletons, or dictionaries 3. **Create and edit** content through the AI chat or the structured content panel 4. **Review** changes on branches with visual diffs 5. **Merge** approved changes back to your main branch 6. **Deliver** content through CDN, APIs, or directly from Git ## Key Concepts ### Workspaces A workspace is your team boundary. It holds projects, members, and billing. Every user gets a personal workspace on signup, and can create team workspaces to collaborate. ### Projects Each project connects to a GitHub repository. The project workspace gives you a three-panel layout: sidebar navigation, AI chat, and content management. ### Content Models Studio supports four model kinds: | Kind | Purpose | Example | |------|---------|---------| | **Collection** | Multiple entries with shared schema | Blog posts, team members | | **Singleton** | Single entry per locale | Site settings, homepage hero | | **Document** | Markdown with frontmatter | Documentation pages, articles | | **Dictionary** | Key-value pairs | UI translations, config maps | Each model can use any of the 27 built-in field types — from simple strings and numbers to relations, images, rich text, and structured objects. ### AI Chat The AI agent can perform content operations on your behalf: - Create and modify content models - Add, update, or delete content entries - Manage locales and vocabulary - Explain your content structure - Suggest changes based on context Every action the agent takes creates a real Git commit on a review branch. ### Branch Workflow Content changes don't go directly to your main branch. Studio creates short-lived `cr/*` feature branches for every operation, then advances the `contentrain` branch as the content source of truth. You review the diff, approve or reject, and merge — exactly like a code review, but for content. ## Architecture Studio follows a **provider/adapter pattern** — all external services (auth, database, storage, Git) are accessed through interfaces. Auth and database ship as two selectable **provider pairs**: Supabase (the default), or a Supabase-free pair that runs on plain PostgreSQL with Studio's built-in managed auth. The pair is chosen by environment variables, and application code never changes. ``` ┌─────────────────────────────────┐ │ Contentrain Studio │ │ (Nuxt 4 Full-Stack App) │ ├─────────────────────────────────┤ │ AuthProvider │ DatabaseProvider│ │ GitProvider │ StorageProvider │ │ EmailProvider │ PaymentProvider │ ├─────────────────────────────────┤ │ Supabase (default) — or — │ │ managed auth + plain PostgreSQL │ └─────────────────────────────────┘ ``` ## Plans & Pricing Studio uses an **open core** model: | Plan | For | Key Features | |------|-----|-------------| | **Free** | New signups | Structural workspace shell, 0 AI messages — start a trial to unlock the platform | | **Starter** | Solo & small teams | Full platform, 3 seats, 150 AI messages | | **Pro** | Content and platform teams | Conversation API, advanced permissions, larger limits | | **Enterprise** | IT & security | SSO, custom domain, white-label, managed or self-hosted deployment options | The self-hostable AGPL core (Community Edition) is a fully working product, not a demo. On the managed service, the **Free** tier is only a structural signup shell with no AI messages — start a Starter or Pro trial to unlock the platform. ## Next Steps - [Quickstart](/guide/quickstart) — Get up and running in 5 minutes - [Ecosystem Map](/guide/ecosystem) — See how Studio connects to the AI package surface - [Core Concepts](/guide/concepts) — Deeper dive into the data model - [Self-Hosting](/developer/self-hosting) — Deploy Studio on your infrastructure --- # Ecosystem Map Source: https://docs.contentrain.io/guide/ecosystem > How Contentrain Studio fits with Contentrain AI packages across normalize, review, collaboration, and delivery. # Ecosystem Map Contentrain Studio is not a separate content format. It is the team web surface for the same `.contentrain/` contract used by Contentrain AI. ## Two Surfaces, One Contract - **Contentrain AI** is the open-source, local-first operating core: MCP, CLI, query SDK, rules, and skills. - **Contentrain Studio** is the open-core team web surface: chat, review, media, forms, APIs, and CDN delivery. Teams can self-host the AGPL core or use a managed Pro/Enterprise offering. The content model stays the same: - content lives in Git - schemas live in `.contentrain/models/` - content lives in `.contentrain/content/` - changes move through branches, commits, and review workflows - delivery can happen through direct file reads, typed queries, or CDN endpoints ## AI Package Bridges | AI surface | What it does | How Studio relates | Canonical docs | |---|---|---|---| | `@contentrain/mcp` | Deterministic local content operations and normalize | Studio picks up after local creation when teams need authenticated review, roles, and delivery | [MCP Tools](https://ai.contentrain.io/packages/mcp) | | `contentrain` CLI | `init`, `serve`, `generate`, `diff`, `validate` | Studio becomes the team-facing surface after bootstrap and local review | [CLI](https://ai.contentrain.io/packages/cli) | | `@contentrain/rules` | Shared schema, quality, and workflow standards | Studio chat, review, and validation should stay aligned with the same quality model | [Rules & Skills](https://ai.contentrain.io/packages/rules) | | `@contentrain/skills` | Agent playbooks and workflow hints | Studio mirrors these workflows in onboarding, chat-led operations, and handoff moments | [Rules & Skills](https://ai.contentrain.io/packages/rules) | | `@contentrain/query` | Local typed consumption and CDN client transport | Studio extends the same content into CDN publishing and remote delivery | [Query SDK](https://ai.contentrain.io/packages/sdk) | ## Typical Growth Path Most teams do not discover Contentrain through Studio first. They discover it through the AI package surface: 1. Run `contentrain init` 2. Use normalize to rescue hardcoded strings from an existing codebase 3. Review and approve content changes 4. Move into Studio when multiple people, review responsibilities, or delivery workflows appear 5. Enable CDN or API-based delivery when content must ship beyond web runtime In other words, most teams enter through normalize and move into Studio as their content work becomes collaborative. ## When To Start In AI Start with Contentrain AI when you need: - local-first setup - IDE agent workflows - deterministic MCP operations - hardcoded-string rescue - package-level adoption without bringing in a full web app ## When To Move To Studio Move into Studio when you need: - authenticated team collaboration - project and workspace management - branch review in a browser - media, forms, and conversation APIs - CDN delivery and operational visibility ## Product Philosophy - **Governance, not generation** — Contentrain standardizes, validates, and reviews content; your model provider still generates it. - **Git-native, not database-first** — content stays versioned in Git. - **One contract, multiple surfaces** — the package layer gets content into Git; Studio helps teams operate that content at scale. ## Next Steps - [What is Contentrain Studio?](/guide/) - [AI Chat](/guide/ai-chat) - [Branches & Review](/guide/branches-and-review) - [CDN](/guide/cdn) - [Contentrain AI Ecosystem Map](https://ai.contentrain.io/ecosystem) --- # Quickstart Source: https://docs.contentrain.io/guide/quickstart > Get started with Contentrain Studio in 5 minutes — sign up, connect a repo, and create your first content. # Quickstart Get Contentrain Studio running and create your first content entry in under 5 minutes. If you already entered the ecosystem through `contentrain`, `@contentrain/mcp`, `@contentrain/rules`, `@contentrain/skills`, or `@contentrain/query`, Studio is the next surface: the authenticated team web layer on top of the same `.contentrain/` contract. See [Ecosystem Map](/guide/ecosystem). ## Prerequisites - A GitHub account with at least one repository - A modern web browser ## Step 1: Sign In Navigate to your Studio instance and sign in with GitHub. This grants Studio access to your repositories through the GitHub App. ::: tip Workspace owners must sign in with GitHub (for repository access). Invited team members can use Google OAuth or a magic link instead. ::: ## Step 2: Install the GitHub App After your first login, Studio will prompt you to install the Contentrain GitHub App on your account or organization. This is required to connect repositories. Choose which repositories to grant access to — you can always change this later in your GitHub settings. ## Step 3: Create a Project From your workspace dashboard, you have two options: ### Option A: Connect an Existing Repository Click **Connect Repository** and select a repo from the list. Studio will scan it for any existing `.contentrain/` configuration. If found, it imports your models and content automatically. This is the typical path after: - `contentrain init` - local normalize extraction and reuse - local validation and review through AI packages ### Option B: Start from a Template Click **Start from Template** and choose a starter kit: - **Astro** — Blog or portfolio starter - **Nuxt** — Full-stack Vue starter - **Next.js** — React framework starter - **SvelteKit** — Svelte starter - **VitePress** — Documentation starter Studio creates a new repository from the template with pre-configured content models. ## Step 4: Explore the Workspace Your project opens in a three-panel layout: | Panel | Purpose | |-------|---------| | **Sidebar** | Navigate content models, resources, and pending changes | | **Chat** | Talk to the AI agent to manage content | | **Content** | Browse and edit content entries directly | Inside a project, the sidebar is organized into these sections: - **Content models** — your models grouped by domain, each showing an entry count and a globe marker when the model is localized (i18n) - **Vocabulary** — the project's UI string dictionary, with a term count - **CDN Delivery** — publish content to the delivery API (plan-gated; appears locked with an upgrade prompt when your plan lacks it) - **Assets** — the media library for images and files (plan-gated, same as CDN) - **Pending Changes** — review branches awaiting merge The sidebar footer is always available, with **Search** (the `Cmd/Ctrl+K` command palette), workspace **Settings**, the dark/light theme toggle, and your profile with sign out. ## Step 5: Create Content via Chat Type a message in the chat panel: > "Create a blog post titled 'Hello World' with a summary and published date" The AI agent will: 1. Find the appropriate content model (or create one if needed) 2. Create the entry with the fields you specified 3. Show you what was created 4. Commit the change to a review branch ## Step 6: Review and Merge In the sidebar, you'll see a new branch under **Pending Changes**. Click it to: 1. View the diff — see exactly what changed 2. **Merge** to accept the changes into your main branch 3. Or **Reject** to discard them That's it! Your content is now committed to your GitHub repository. ## What's Next? - [Core Concepts](/guide/concepts) — Understand workspaces, projects, and models in depth - [Content Models](/guide/content-models) — Learn about the four model kinds and 27 field types - [AI Chat](/guide/ai-chat) — Master the conversation-first workflow - [Ecosystem Map](/guide/ecosystem) — See how Studio connects to the AI package surface - [Self-Hosting](/developer/self-hosting) — Deploy your own Studio instance --- # Core Concepts Source: https://docs.contentrain.io/guide/concepts > Understand the key concepts behind Contentrain Studio — workspaces, projects, content models, the AI agent, and the Git-backed review workflow. # Core Concepts This page explains the fundamental building blocks of Contentrain Studio and how they fit together. ## Workspace Hierarchy ``` User └── Workspace (billing & team boundary) └── Project (connected GitHub repo) ├── Models (content schemas) ├── Content (entries, locales) ├── Branches (review workflow) ├── Media (asset library) ├── Forms (submissions) └── Webhooks (event notifications) ``` ### Users Every user has their own identity, linked to an OAuth provider (GitHub or Google) or a magic link email. Users can belong to multiple workspaces with different roles. ### Workspaces A workspace is the top-level organizational unit. It represents a team, company, or individual context. Workspaces own: - **Projects** — connected repositories - **Members** — team with assigned roles - **Billing** — subscription plan and limits - **GitHub Installation** — the GitHub App connection - **Connected apps** — AI apps signed in over OAuth (remote MCP) Each member can also add their own optional BYOA (Bring Your Own API Key) for AI models, in Workspace Settings → AI. Every user gets a **personal workspace** automatically on signup. Team workspaces can be created for collaboration. ### Projects A project connects a GitHub repository to Studio. Each project has: - A **three-panel workspace** (sidebar, chat, content) - Its own **content models** and **entries** - An independent **branch workflow** - Project-specific **members and roles** - Optional **media library**, **forms**, **CDN**, and **webhooks** ## Content Model System ### Four Model Kinds Studio supports four distinct content model types, each optimized for a different data pattern: ![Content models grouped by domain in the sidebar, each with its kind icon, entry count, and i18n globe](/screenshots/sidebar-models.png) #### Collection A collection holds **multiple entries** sharing the same schema. Each entry has a unique ID and can be queried, filtered, and sorted. **Use for:** Blog posts, team members, products, FAQs, events. ```json { "id": "blog-posts", "kind": "collection", "fields": { "title": { "type": "string", "required": true }, "slug": { "type": "slug", "required": true, "unique": true }, "body": { "type": "markdown" }, "author": { "type": "relation", "model": "authors" }, "published_at": { "type": "date" } } } ``` #### Singleton A singleton holds **exactly one entry per locale**. There is no list view — just a single document to edit. **Use for:** Site settings, homepage hero, footer configuration, SEO defaults. ```json { "id": "site-settings", "kind": "singleton", "fields": { "site_name": { "type": "string", "required": true }, "logo": { "type": "image" }, "tagline": { "type": "text" } } } ``` #### Document A document stores **Markdown content with typed frontmatter**. The body is Markdown, and the fields define the frontmatter schema. **Use for:** Documentation pages, blog articles, long-form content, knowledge base entries. #### Dictionary A dictionary stores **flat key-value pairs**. No field definitions — just string keys mapped to string values. **Use for:** UI translations, configuration maps, feature flags, i18n strings. ```json // Content: { "nav.home": "Home", "nav.about": "About Us" } ``` ### Field Types Studio supports **27 field types** covering every common content pattern: | Category | Types | |----------|-------| | **Text** | `string`, `text`, `email`, `url`, `slug`, `color`, `phone`, `code`, `icon` | | **Rich** | `markdown`, `richtext` | | **Number** | `number`, `integer`, `decimal`, `percent`, `rating` | | **Boolean** | `boolean` | | **Date** | `date`, `datetime` | | **Media** | `image`, `video`, `file` | | **Relational** | `relation`, `relations` | | **Structural** | `select`, `array`, `object` | See [Field Types Reference](/developer/field-types) for detailed documentation on each type. ### Domains Models are organized into **domains** — logical groups like `marketing`, `blog`, `system`, or `docs`. Domains are purely organizational and have no functional impact. ### Locales Content can be managed in multiple languages. Each model can opt into **i18n support**, which creates separate content files per locale. A default locale is always required. ## AI Agent The AI agent is a Claude-powered assistant integrated into every project workspace. It operates within a **bounded task executor** — a controlled environment where every action: 1. Is **scoped** to the current project 2. Creates a **real Git commit** on a review branch 3. Is **auditable** through the branch diff 4. Respects the user's **role and permissions** ### What the Agent Can Do - Create, modify, and delete content models - Add, update, and remove content entries - Manage locales and vocabulary terms - Scan and import existing repository structures - Explain content schemas and relationships ### What the Agent Cannot Do - Access repositories outside the current project - Bypass role-based permissions - Push directly to the main branch (always creates a review branch) - Access external services or the internet ### Context System You can **pin context items** (models, entries, fields) from the content panel to the chat. These context chips give the agent focused awareness of what you're working on, improving response quality. ## Branch Workflow Every content mutation in Studio follows a Git-native review workflow: ``` User/Agent Action ↓ Create cr/* feature branch ↓ Commit changes to branch ↓ Auto-merge to contentrain branch (SSOT) ↓ Review diff in Studio UI ↓ Merge to main ──or── Reject (delete branch) ``` ### Key Concepts - **`contentrain` branch** — the single source of truth (SSOT) for all Contentrain-managed content - **`cr/*` branches** — short-lived feature branches for individual operations - **Two-step merge** — changes go `cr/* → contentrain → main` - **Visual diff** — see exactly what files and content entries changed - **No direct writes** — the main branch is never modified directly by Studio ## Roles & Permissions Studio uses a **two-tier role system**: ### Workspace Roles | Role | Capabilities | |------|-------------| | **Owner** | Full control, billing, transfer ownership | | **Admin** | Manage members, settings, all projects | | **Member** | Access assigned projects only | ### Project Roles | Role | Capabilities | |------|-------------| | **Editor** | Create and edit content, use AI chat | | **Reviewer** | Review branches, merge/reject (Starter+) | | **Viewer** | Read-only access to content (Starter+) | Workspace Owners and Admins have **implicit access** to all projects. Members need explicit project-level assignments. ## Next Steps - [Authentication](/guide/authentication) — Sign-in methods and security - [Workspaces](/guide/workspaces) — Creating and managing workspaces - [Content Models](/guide/content-models) — Deep dive into the model system --- # Authentication Source: https://docs.contentrain.io/guide/authentication > Learn about Contentrain Studio's authentication methods — GitHub OAuth, Google OAuth, and Magic Link — and when to use each. # Authentication Contentrain Studio supports three sign-in methods, each suited to a different role in your team. All authentication flows are handled through the provider/adapter pattern, so the underlying implementation can be swapped without changing any application code. ![The login page — sign in with GitHub, Google, or a magic link](/screenshots/auth-login.png) ## Sign-In Methods ### GitHub OAuth GitHub OAuth is the primary authentication method for workspace owners. When you sign in with GitHub, Studio gains access to your repositories through the GitHub App, which is required to connect projects. **Best for:** Workspace owners and developers who need repository access. **Flow:** 1. Click **Sign in with GitHub** on the login page 2. GitHub redirects you to authorize the Contentrain app 3. Studio creates your account and a personal workspace 4. You are redirected back to Studio ::: tip If this is your first login, Studio automatically creates a personal workspace for you. You can create additional team workspaces later. ::: ### Google OAuth Google OAuth provides a convenient sign-in option for team members who do not need direct repository access. **Best for:** Content editors, reviewers, and anyone who prefers a Google account over GitHub. **Flow:** 1. Click **Sign in with Google** on the login page 2. Google redirects you to authorize the Contentrain app 3. Studio creates your account and a personal workspace (or signs you in if you already have one) 4. You are redirected to your workspace ::: tip Signing in with Google for the first time self-registers, exactly like GitHub — Studio creates your account and a personal workspace automatically. No invitation is required. (If your Google email matches an account you were already invited under, Studio links to it instead of creating a duplicate.) ::: ### Magic Link Magic Link is a passwordless email-based authentication method. Studio sends a one-time login link to your email address. **Best for:** Team members who prefer not to use OAuth providers, or organizations that restrict third-party OAuth. **Flow:** 1. Click **Sign in with email** on the login page 2. Enter your email address 3. Click **Send magic link** 4. Check your inbox and click the link 5. Studio verifies the token and signs you in ![Magic link sent: a confirmation showing the email address, with a "Try a different email" option](/screenshots/magic-link-sent.png) After the magic link is sent, you will see a confirmation screen with the email address. You can click **Try a different email** to start over. ::: tip Magic links expire after a short period. If your link has expired, return to the login page and request a new one. ::: ## When to Use Which Method | Method | Who | Why | |--------|-----|-----| | **GitHub OAuth** | Workspace owners, developers | Required for repository access and GitHub App installation | | **Google OAuth** | Editors, reviewers, teammates | Quick sign-in with an existing Google account | | **Magic Link** | Invited team members | Passwordless, no third-party OAuth dependency | ## Auth Callback After any OAuth flow (GitHub or Google), Studio redirects to `/auth/callback`. This page: 1. Validates the OAuth state parameter (CSRF protection) 2. Exchanges the authorization code for a session 3. Creates or updates the user profile 4. Redirects to the workspace dashboard You do not need to interact with the callback page directly — it handles the handoff automatically. ## Session Management Studio uses encrypted, HTTP-only cookies for session management: - **Encryption:** AES-256 encrypted session data - **Storage:** Server-side `httpOnly` cookie (not accessible to JavaScript) - **Auto-refresh:** Sessions are automatically refreshed on activity - **Expiration:** Sessions expire after a period of inactivity ### Signing Out You can sign out at any time by clicking your avatar in the sidebar and selecting **Sign out**. This clears your session cookie and redirects you to the login page. ### Account Deletion Studio supports full account deletion from the user settings page at `/settings`: 1. Navigate to **Settings** from the sidebar 2. Open the **Account** tab 3. In the **Danger Zone**, click **Delete account** 4. Confirm by typing your email address ::: warning Account deletion is permanent. All your data, including workspace ownership, is removed. If you own workspaces with other members, transfer ownership before deleting your account. ::: ## Security Considerations - **CSRF protection:** OAuth flows use a `state` parameter stored server-side in an encrypted, `httpOnly` cookie and validated on callback - **No password storage:** Studio never stores passwords — all authentication is delegated to OAuth providers or magic link tokens - **Provider isolation:** Authentication logic is contained within the auth provider adapter. No Supabase-specific code exists outside the provider layer - **Role enforcement:** Authentication identity is separate from authorization. Roles and permissions are enforced at the API level regardless of sign-in method ## Next Steps - [Workspaces](/guide/workspaces) — Create and manage your team workspaces - [Team Management](/guide/team-management) — Invite members and assign roles - [Quickstart](/guide/quickstart) — Get started with your first project --- # Workspaces Source: https://docs.contentrain.io/guide/workspaces > Understand workspaces in Contentrain Studio — the top-level organizational unit for teams, projects, billing, and GitHub integrations. # Workspaces A workspace is the top-level organizational unit in Contentrain Studio. It groups your projects, team members, billing, and GitHub App installation into a single boundary. ## Workspace Types Studio has two workspace types: ### Personal Workspace Every user gets a **personal workspace** automatically when they sign up. This workspace: - Is created during your first GitHub OAuth sign-in - Has `type: primary` in the system - Cannot be deleted (it is tied to your account) - Serves as your default workspace if no other is selected ### Team Workspace You can create additional **team workspaces** for collaboration: - Has `type: secondary` in the system - Can be renamed, configured, and deleted - Supports multiple members with different roles - Has its own billing plan and GitHub App installation ## Workspace Dashboard When you navigate to `/w/:slug`, you see the workspace dashboard. This page displays: - A header with the workspace name and project count - Two action buttons: **Use Template** and **Connect Repository** - A grid of project cards showing your connected repositories - Empty states when no GitHub App is installed or no projects exist ![Workspace dashboard showing the project grid with cards](/screenshots/workspace-dashboard.png) ### Empty States The dashboard shows contextual empty states: 1. **No GitHub App installed** — prompts you to install the Contentrain GitHub App with an illustration and action button 2. **App installed but no projects** — prompts you to connect a repository or start from a template ## Creating a Workspace To create a new team workspace: 1. Open the command palette with `Cmd+K` (or `Ctrl+K`) 2. Type "Create workspace" or use the workspace switcher 3. Enter a name and URL slug for your workspace 4. The workspace is created and you are switched to it ::: tip Workspace slugs must be unique and URL-safe. They form part of your workspace URL: `/w/your-slug`. ::: ## Switching Workspaces You can switch between workspaces in several ways: - **Sidebar:** Click the workspace name in the sidebar to open the switcher - **Command palette:** Press `Cmd+K` and search for a workspace name - **Direct URL:** Navigate to `/w/:slug` directly ![The workspace switcher: your workspaces with plan badges and a "Create workspace" option](/screenshots/workspace-switcher.png) Studio remembers your last active workspace and restores it on your next visit. ## Workspace Settings Access workspace settings from the sidebar or by navigating to `/w/:slug/settings`. Settings are organized into tabs: ### Overview General workspace information: - Workspace name (editable) - Workspace slug (read-only after creation) - Workspace type (primary or secondary) - Danger zone: delete workspace (secondary workspaces only) ![Workspace settings — the Overview tab with name, slug, and plan, plus tabs for Members, Billing, GitHub, Connected apps, and AI, and the danger zone](/screenshots/workspace-settings-overview.png) ### Members Team management: - View all workspace members and their roles - Invite new members by email - Change member roles (Admin or Member) - Remove members - Resend pending invitations See [Team Management](/guide/team-management) for details. ### Billing Subscription and plan management: - Current plan and billing state - Trial status and days remaining - Upgrade/downgrade options - Stripe Customer Portal access See [Billing & Plans](/guide/billing) for details. ### GitHub GitHub App integration: - Current installation status - Link to install or reconfigure the GitHub App - Connected repositories overview ![The GitHub tab: the connected installation (account and repo-access count) with Manage Permissions, plus the list of accessible repositories](/screenshots/github-settings.png) ### Connected apps Third-party applications (Claude, ChatGPT, Codex) that signed in to the workspace with OAuth over remote MCP: - Copy the workspace's remote MCP endpoint and the connect snippets - Review connected apps and the scopes they were granted - Disconnect an app at any time API keys for CI and headless agents are not here: they live in each project's settings (**Project settings → API keys**). See [Connected Apps](/guide/connected-apps) and the [MCP Connector](/developer/mcp-connector). ### AI Your own Anthropic API key (BYOA, Bring Your Own API Key). It is personal: it is encrypted, visible only to you, and each member adds their own. - Add your own Anthropic API key - View and delete your key Plans without BYOA see an upgrade prompt in this tab; Community Edition hides it. See [AI Chat](/guide/ai-chat#byoa-bring-your-own-api-key) for details. ## Workspace Hierarchy Understanding how workspaces relate to other entities: ``` User └── Workspace (billing & team boundary) ├── GitHub App Installation ├── Members (Owner, Admin, Member) ├── AI key per member (BYOA) ├── Billing (Stripe subscription) └── Projects (connected repositories) ├── Models ├── Content ├── Branches ├── Media ├── Forms ├── API keys (MCP keys, Conversation API keys) └── Webhooks ``` ### Key Rules - A workspace **owns** its projects — deleting a workspace removes all its projects - The workspace's **GitHub App installation** determines which repositories are available to connect - **Billing** is per-workspace — each workspace has its own plan - **Members** are invited at the workspace level and then assigned to specific projects ## Deleting a Workspace ::: warning Deleting a workspace permanently removes all its projects, content configurations, and member assignments. This action cannot be undone. ::: To delete a secondary workspace: 1. Navigate to **Settings > Overview** 2. Scroll to the **Danger Zone** 3. Click **Delete workspace** 4. Confirm the action Personal (primary) workspaces cannot be deleted — they are removed only when you delete your account. ## Transferring Ownership Workspace owners can transfer ownership to another Admin member: 1. Navigate to **Settings > Members** 2. Find the Admin member you want to transfer to 3. Use the transfer ownership action 4. The previous owner becomes an Admin ::: tip Only Admin members can receive ownership transfers. Promote a Member to Admin first if needed. ::: ## Next Steps - [Projects](/guide/projects) — Connect repositories and create projects - [Team Management](/guide/team-management) — Manage members and roles - [Billing & Plans](/guide/billing) — Understand plans and subscriptions --- # Projects Source: https://docs.contentrain.io/guide/projects > Learn how to connect GitHub repositories, use starter templates, and navigate the three-panel project workspace in Contentrain Studio. # Projects A project in Contentrain Studio connects a GitHub repository to the content management system. Each project has its own content models, entries, branches, media library, and team members. ## Creating a Project There are two ways to create a project from your workspace dashboard. ### Connecting an Existing Repository Use this option when you already have a repository (with or without Contentrain configuration): 1. From the workspace dashboard, click **Connect Repository** 2. If the GitHub App is not installed, you will be prompted to install it first 3. Select a repository from the list of available repos 4. Studio scans the repository for existing `.contentrain/` configuration 5. Review the scan results (detected stack, existing content, i18n settings) 6. Click **Connect** to create the project ![Connect repository dialog showing GitHub repository selection](/screenshots/connect-repo-dialog.png) The dialog follows a state machine flow: | Step | What Happens | |------|-------------| | **Install** | Shown if no GitHub App installation exists — links to GitHub App setup | | **Select** | Browse and search your repositories, filter by name | | **Confirm** | Review scan results (stack detection, content directory) and confirm | ::: tip You can search your repositories by name in the selection step. Studio shows the repository name, visibility (public/private), default branch, language, and last update date. ::: ### Starting from a Template Use this option to create a new repository from a pre-configured starter kit: ![The "Start from Template" dialog: starter kits filtered by framework (Astro, Next.js, Nuxt, SvelteKit, VitePress), each with a description and tags](/screenshots/starter-kit-dialog.png) 1. From the workspace dashboard, click **Use Template** 2. Browse available starter kits, optionally filter by framework 3. Select a template 4. Enter a repository name and choose visibility (public/private) 5. Studio creates the repository from the template and connects it as a project Available framework filters include Astro, Nuxt, Next.js, SvelteKit, VitePress, and more. ## GitHub App Requirements Studio requires the Contentrain GitHub App to be installed on your GitHub account or organization: - **Repository access:** The app needs read/write access to repository contents and metadata - **Selective access:** You can choose which repositories the app can access - **Organization support:** Install on your personal account or any organization you administer - **Reconfiguration:** You can change repository access at any time from GitHub settings ::: warning Without the GitHub App installation, you cannot connect repositories or create projects. The workspace dashboard will show an empty state prompting you to install the app. ::: ## The Three-Panel Layout When you open a project, Studio presents a three-panel workspace: ``` ┌──────────────┬──────────────────┬──────────────────────┐ │ Sidebar │ Chat Panel │ Content Panel │ │ (240px) │ │ (400px) │ │ │ │ │ │ Models │ AI conversation │ Browse & edit │ │ Branches │ with tool calls │ content entries │ │ Vocabulary │ │ │ │ Navigation │ │ │ └──────────────┴──────────────────┴──────────────────────┘ ``` ### Sidebar The sidebar provides navigation for the project: - **Models:** List of all content models, grouped by domain - **Branches:** Pending `cr/*` content branches awaiting review - **Vocabulary:** Project-wide vocabulary terms - **CDN:** Content delivery settings - **Media:** Asset library - **Settings:** Project configuration Clicking a model in the sidebar opens it in the Content Panel. Clicking a branch opens the branch diff viewer. ### Chat Panel The center panel is the AI conversation interface. You can: - Send messages to the AI agent - View tool call results inline - Switch between AI models (Haiku, Sonnet, Opus) - Pin context items from the Content Panel - Browse conversation history See [AI Chat](/guide/ai-chat) for the full guide. ### Content Panel The right panel displays content based on your current context: - **Overview:** Project summary with model list and entry counts - **Model view:** Content entries for the selected model - **Branch view:** Visual diff of changes on a branch - **Vocabulary view:** Key-value term editor See [Content Editing](/guide/content-editing) for details. ## Project Initialization When you connect a repository that does not have a `.contentrain/` directory, the project enters an **uninitialized** state. You can initialize it in two ways: ### Via Chat Ask the AI agent to initialize the project: > "Initialize this project with a blog collection model and English locale" The agent will create the `.contentrain/` configuration structure, including `config.json`, model definitions, and empty content files. ### Via Starter Template If you created the project from a starter template, it comes pre-initialized with models and sample content. ## Project Settings Access project settings from the sidebar gear icon or through the command palette. The modal is organized into tabs: - **General:** workflow mode (auto-merge or review), supported locales and the default locale, and content domains - **Media:** media settings (where the CDN is on) - **API keys:** two sections. **MCP keys** let an MCP client (Cursor, Claude Code, CI) read and write this project; the tab shows how many of the workspace's keys are used. **Conversation API keys** (Pro and Enterprise) let your own app talk to the project's AI agent. Workspace owners and admins create and revoke keys; members see a note that admins manage them. Plans without a feature show an upgrade prompt in its section. - **Webhooks:** outbound webhook endpoints, with pause and resume per endpoint (Starter and above, Enterprise Edition) - **Danger zone:** delete the project (removes it from Studio; the repository itself is not deleted) > Project-level member assignment (Editor / Reviewer / Viewer) lives in **Workspace Settings → Members → Project Access**, not here. ![Project Settings — the General tab showing the workflow mode (auto-merge vs review), supported locales, and content domains](/screenshots/project-settings.png) ## Project Health Studio tracks project health metrics including: - Content validation status - Model schema consistency - Branch count and cleanup recommendations - Locale parity across models Access the health dashboard from the sidebar or command palette (**Project health**). ![The Project Health view: an overall score with critical, error, and warning counts, and a validation summary](/screenshots/project-health.png) ## Deleting a Project To delete a project: 1. Open project settings 2. Scroll to the Danger Zone 3. Click **Delete project** 4. Confirm the action ::: warning Deleting a project removes it from Studio and cleans up associated data (branches, media references, form submissions). The GitHub repository itself is **not** deleted. ::: ## Next Steps - [Content Models](/guide/content-models) — Define schemas for your content - [AI Chat](/guide/ai-chat) — Use the conversation interface to manage content - [Content Editing](/guide/content-editing) — Browse and edit entries in the content panel --- # Content Models Source: https://docs.contentrain.io/guide/content-models > Deep dive into Contentrain Studio's four content model kinds, 27 field types, domains, i18n support, and model configuration. # Content Models Content models define the schema for your content. Every piece of content in Studio follows a model definition that specifies what fields are available, their types, and their constraints. ## Four Model Kinds Studio supports four distinct model kinds, each optimized for a different data pattern. ![The content sidebar: models grouped by domain, each showing its kind icon, entry count, and an i18n globe for localized models](/screenshots/sidebar-models.png) ### Collection A collection holds **multiple entries** sharing the same schema. Each entry has a unique ID and can be individually created, updated, and deleted. **Use for:** Blog posts, team members, products, FAQs, events, testimonials. ```json { "id": "blog-posts", "name": "Blog Posts", "kind": "collection", "domain": "blog", "i18n": true, "fields": { "title": { "type": "string", "required": true }, "slug": { "type": "slug", "required": true, "unique": true }, "excerpt": { "type": "text" }, "cover": { "type": "image" }, "author": { "type": "relation", "model": "team-members" }, "published_at": { "type": "date" } } } ``` **Content format:** Object map of entries keyed by entry ID: ```json { "a1b2c3d4e5f6": { "title": "Getting Started", "slug": "getting-started", "excerpt": "Learn the basics...", "published_at": "2026-03-15" } } ``` ### Singleton A singleton holds **exactly one entry per locale**. There is no list view — just a single document to edit. **Use for:** Site settings, homepage hero, footer configuration, SEO defaults. ```json { "id": "site-settings", "name": "Site Settings", "kind": "singleton", "domain": "system", "i18n": false, "fields": { "site_name": { "type": "string", "required": true }, "logo": { "type": "image" }, "tagline": { "type": "text" }, "social_links": { "type": "array", "items": { "type": "object", "fields": { "platform": { "type": "string" }, "url": { "type": "url" } } } } } } ``` **Content format:** Flat object of field values (no entry IDs): ```json { "site_name": "My Website", "tagline": "Build something great", "logo": "media/original/logo.webp" } ``` ### Document A document stores **Markdown content with typed frontmatter**. The fields define the frontmatter schema, while the body is a full Markdown document. **Use for:** Documentation pages, blog articles, long-form content, knowledge base entries. Documents are identified by a **slug** rather than a generated ID. The `body` field holds the Markdown content, and all other fields become frontmatter. **Content format:** Frontmatter fields plus a separate `body` property for the Markdown. ### Dictionary A dictionary stores **flat key-value pairs**. No field definitions are needed — all content is free-form string keys mapped to string values. **Use for:** UI translations, configuration maps, feature flags, i18n string files. ```json { "id": "ui-strings", "name": "UI Strings", "kind": "dictionary", "domain": "system", "i18n": true } ``` **Content format:** ```json { "nav.home": "Home", "nav.about": "About Us", "footer.copyright": "2026 My Company" } ``` ::: tip Dictionaries do not require field definitions. The `fields` property is omitted entirely. All values must be strings. ::: ## Field Types Studio supports **27 field types** across six categories: ### Text Fields | Type | Description | Example | |------|------------|---------| | `string` | Single-line text | Title, name | | `text` | Multi-line text (textarea) | Description, excerpt | | `email` | Email address with validation | Contact email | | `url` | URL with validation | Website link | | `slug` | URL-safe identifier, auto-generated | URL path segment | | `color` | Hex color value | Brand color | | `phone` | Phone number | Contact number | | `code` | Code snippet (monospaced) | Embed code | | `icon` | Icon identifier | Menu icon | ### Rich Content Fields | Type | Description | Example | |------|------------|---------| | `markdown` | Markdown content | Article body | | `richtext` | Rich text (WYSIWYG) | Formatted description | ### Number Fields | Type | Description | Example | |------|------------|---------| | `number` | Generic number | Quantity, sort order | | `integer` | Whole number | Count, position | | `decimal` | Decimal number | Price, weight | | `percent` | Percentage (0-100) | Discount rate | | `rating` | Rating value (1-5) | Review score | ### Boolean Field | Type | Description | Example | |------|------------|---------| | `boolean` | True/false toggle | Featured, published | ### Date Fields | Type | Description | Example | |------|------------|---------| | `date` | Date only (YYYY-MM-DD) | Published date | | `datetime` | Date and time (ISO 8601) | Event start time | ### Media Fields | Type | Description | Example | |------|------------|---------| | `image` | Image file reference | Cover photo, avatar | | `video` | Video file reference | Hero video | | `file` | Generic file reference | PDF download | ### Relational Fields | Type | Description | Example | |------|------------|---------| | `relation` | Single reference to another entry | Post author | | `relations` | Array of references | Post categories | **Relation configuration:** ```json { "author": { "type": "relation", "model": "team-members" }, "categories": { "type": "relations", "model": "categories" } } ``` **Polymorphic relations** (multiple target models): ```json { "related_items": { "type": "relations", "model": ["blog-posts", "pages"] } } ``` ### Structural Fields | Type | Description | Example | |------|------------|---------| | `select` | Dropdown with predefined options | Status, category | | `array` | List of items | Tags, features | | `object` | Nested key-value structure | Address, SEO config | **Select field:** ```json { "status": { "type": "select", "options": ["draft", "published", "archived"] } } ``` **Array field (simple):** ```json { "tags": { "type": "array", "items": "string" } } ``` **Array field (objects):** ```json { "features": { "type": "array", "items": { "type": "object", "fields": { "title": { "type": "string" }, "icon": { "type": "icon" } } } } } ``` **Object field (max 2 levels deep):** ```json { "address": { "type": "object", "fields": { "street": { "type": "string" }, "city": { "type": "string" }, "zip": { "type": "string" } } } } ``` ## Field Options Each field can have these configuration options: | Option | Type | Description | |--------|------|-------------| | `required` | boolean | Whether the field must have a value | | `unique` | boolean | Whether values must be unique across entries | | `min` | number | Minimum value (numbers) or length (strings) | | `max` | number | Maximum value (numbers) or length (strings) | | `pattern` | string | Regex pattern for validation | | `default` | any | Default value when creating new entries | | `description` | string | Help text shown in the editor | | `options` | string[] | Valid options for select fields | | `model` | string or string[] | Target model(s) for relation fields | | `items` | string or object | Item schema for array fields | | `fields` | object | Nested field definitions for object fields | ## Domains Models are organized into **domains** — logical groups that represent content areas. Common domains include: - `blog` — Blog posts, authors, categories - `marketing` — Landing pages, testimonials, pricing - `system` — Site settings, navigation, UI strings - `docs` — Documentation pages, API reference Domains are purely organizational. They determine the directory structure in your repository (`.contentrain/content/{domain}/{modelId}/`) but have no functional impact on how content behaves. ## Creating Models ### Via AI Chat The easiest way to create a model is through the AI chat: > "Create a collection model called 'Team Members' in the marketing domain with name (required string), role (string), bio (text), avatar (image), and email (email)" The agent will create the model definition with the correct field schemas and commit it to a review branch. ### Via Content Panel You can also create models from the content panel UI by navigating to the project overview and using the model creation form. ## Multi-Locale Support (i18n) Models can opt into multi-locale support by setting `i18n: true`. When enabled: - Separate content files are created per locale (e.g., `en.json`, `tr.json`) - The content panel shows a locale switcher - The agent can copy content between locales using the `copy_locale` tool Locales are configured at the project level during initialization. A default locale is always required. ::: tip Dictionary models with i18n enabled are ideal for managing UI translations — each locale gets its own set of key-value pairs. ::: ## Model Configuration Model definitions live in `.contentrain/models/` as JSON files. Each model file contains: ```json { "id": "blog-posts", "name": "Blog Posts", "kind": "collection", "domain": "blog", "i18n": true, "description": "Articles published on the company blog", "fields": { "title": { "type": "string", "required": true }, "slug": { "type": "slug", "required": true, "unique": true } } } ``` | Property | Required | Description | |----------|----------|-------------| | `id` | Yes | Kebab-case unique identifier | | `name` | Yes | Human-readable display name | | `kind` | Yes | One of: `collection`, `singleton`, `document`, `dictionary` | | `domain` | Yes | Content domain (directory name) | | `i18n` | No | Enable multi-locale support (default: false) | | `description` | No | Description shown in the UI | | `fields` | Varies | Field definitions (required for all kinds except dictionary) | ## Modifying Models You can update existing models through the AI chat or by editing the model JSON files directly. When using the agent: > "Add a 'featured' boolean field to the Blog Posts model" The agent will update the model definition and commit the change to a review branch. ::: warning Removing fields from a model does not automatically remove the corresponding data from existing content entries. The orphaned data will be ignored but remains in the content files until cleaned up. ::: ## Next Steps - [Content Editing](/guide/content-editing) — Create and manage entries for your models - [AI Chat](/guide/ai-chat) — Use the agent to build and modify models - [Branches & Review](/guide/branches-and-review) — Review model changes before merging --- # Content Editing Source: https://docs.contentrain.io/guide/content-editing > Learn how to browse, create, edit, and manage content entries in Contentrain Studio using the content panel and editor modal. # Content Editing The Content Panel is the right-side panel in the project workspace. It provides structured views for browsing and editing content entries, with specialized interfaces for each model kind. ## Content Panel Navigation The content panel has several view modes: | View | Trigger | Purpose | |------|---------|---------| | **Overview** | Click project name in sidebar | Shows all models with entry counts | | **Collection** | Click a collection model | Lists entries in a searchable table | | **Singleton** | Click a singleton model | Shows the single entry editor | | **Document** | Click a document model | Shows documents with Markdown preview | | **Dictionary** | Click a dictionary model | Shows key-value pair editor | | **Vocabulary** | Click Vocabulary in sidebar | Shows project-wide vocabulary terms | | **Branch** | Click a branch in sidebar | Shows the branch diff viewer | ## Browsing Entries ### Collection View When you select a collection model, the content panel shows a list of all entries. Each entry displays field values in a compact row format. ![Collection view: a model's entries listed with status badges in the content panel](/screenshots/collection-view.png) Features: - **Search:** Filter entries by text content - **Locale switcher:** Switch between locales for i18n-enabled models - **Entry count:** Total number of entries displayed in the header - **Click to edit:** Click any entry to open the editor modal ### Singleton View Singleton models display their single entry directly in the panel. Fields are shown as a form that you can edit inline. ### Document View Document models show entries with their Markdown content. Each document is identified by its slug and displays a preview of the body content. ### Dictionary View Dictionary models show a flat list of key-value pairs. You can add, edit, and remove entries directly in the panel. ## The Editor Modal The editor modal provides a full-featured editing experience for individual entries. It opens when you click an entry in any content view. ![The editor modal for a blog post — a relation field (author), a relations field (categories), an image field that takes a media path or URL (cover_image), and a text field (excerpt), with a Save changes button](/screenshots/content-edit-modal.png) ### Editing Modes The editor supports two modes: | Mode | How it Works | |------|-------------| | **Inline** | Click a single field to edit it in place. Saves immediately on blur. | | **Batch** | Open the full editor modal. Edit all fields, then save all changes in one commit. | ### Creating New Entries For collection models, use the **Add entry** button in the content panel header. This opens the editor modal with empty fields. For singletons, the entry always exists — you edit the existing one. ### Field Editing Each field type has a specialized editor: - **String/Text:** Input field or textarea - **Markdown/Richtext:** Editor with formatting toolbar - **Image:** Asset picker connected to the media library - **Date/Datetime:** Date picker - **Boolean:** Toggle switch - **Select:** Dropdown with predefined options - **Relation:** Entry picker showing entries from the target model - **Array:** Repeatable item list with add/remove controls - **Object:** Nested field group - **Color:** Color picker with hex input ### Saving Changes When you save content (either inline or batch), Studio: 1. Validates the data against the model schema 2. Creates a `cr/*` feature branch 3. Commits the change to the branch 4. Auto-merges to the `contentrain` SSOT branch (if auto-merge is enabled) 5. Shows a success toast with the branch name ::: tip Every content save creates a Git commit. You can always see exactly what changed by viewing the branch diff. ::: ## Content Status Workflow Content entries in collections support a status workflow: ![Content entries with status badges (draft, published) and the publish/unpublish toggle on row hover](/screenshots/content-status.png) | Status | Description | |--------|-------------| | `draft` | Entry is being worked on, not ready for publication | | `published` | Entry is approved and visible to consumers | | `in_review` | Entry is pending review by a Reviewer | | `rejected` | Entry was reviewed and sent back for revision | | `archived` | Entry is retired and hidden from default views | ### Changing Status You can change an entry's status through: - The status dropdown in the editor modal - The AI chat ("Set the blog post 'Hello World' to published") - The content panel's status action buttons ::: warning Status changes also create Git commits on review branches. The CDN only serves entries with `published` status. ::: ## Multi-Locale Editing For models with `i18n: true`, the content panel includes a locale switcher: 1. Select a model in the sidebar 2. Use the locale dropdown to switch between available locales 3. Edit content in the selected locale 4. Each locale has its own content file in the repository ### Copying Content Between Locales To bootstrap a translation, you can ask the AI agent: > "Copy the English blog posts content to Turkish" The agent uses the `copy_locale` tool to copy all entries from one locale to another without overwriting existing translations. ## Vocabulary Editor The vocabulary is a project-wide set of terms shared across all locales. Access it from the sidebar under **Vocabulary**. ![The Vocabulary editor: shared term keys mapped to translations, with an add-term row](/screenshots/vocabulary-editor.png) Features: - **Add terms:** Enter a key and values for each locale - **Edit terms:** Click any term to modify its translations - **Delete terms:** Remove terms that are no longer needed - **Search:** Filter terms by key or value Vocabulary terms are stored in `.contentrain/vocabulary.json` and are separate from dictionary models. ## Content Panel Context Items in the content panel can be **pinned** to the chat context. This helps the AI agent understand what you are working on: 1. Hover over a model, entry, or field in the content panel 2. Click the pin icon to add it to the context bar 3. The pinned item appears as a chip above the chat input 4. Send a message — the agent sees the pinned context You can also **drag and drop** items from the content panel to the chat context bar. See [AI Chat](/guide/ai-chat#context-chips) for more details on the context system. ## Editing via AI Chat All content operations can be performed through the AI chat instead of the UI: ``` "Create a new blog post titled 'Welcome' with author 'a1b2c3d4e5f6'" "Update the site settings tagline to 'Build faster with AI'" "Delete the FAQ entry with ID 'x9y8z7w6v5u4'" "Add a new key 'nav.blog' with value 'Blog' to the UI Strings dictionary" ``` The agent handles schema validation, branch creation, and Git commits automatically. ## Next Steps - [Content Models](/guide/content-models) — Understand model kinds and field types - [AI Chat](/guide/ai-chat) — Master the conversation interface - [Branches & Review](/guide/branches-and-review) — Review and merge content changes - [Media Library](/guide/media-library) — Manage images and files --- # AI Chat Source: https://docs.contentrain.io/guide/ai-chat > Master the conversation-first workflow in Contentrain Studio — send messages, use tool calls, pin context, select AI models, and manage content through natural language. # AI Chat The AI chat is the centerpiece of Contentrain Studio. It is a Claude-powered conversation interface that lets you manage content, models, and project configuration through natural language. Every action the agent takes creates a real Git commit — auditable, reviewable, and reversible. Studio chat should be understood as the web continuation of the same package contract described in Contentrain AI. `@contentrain/rules` and `@contentrain/skills` define the quality and workflow expectations; Studio turns those expectations into an authenticated team interface with roles, review, and delivery. ## Chat Panel Overview The chat panel occupies the center of the three-panel project workspace. It consists of: - **Conversation area:** Scrollable message history with user and assistant messages - **Tool call indicators:** Expandable cards showing what the agent did - **Context bar:** Pinned items from the content panel (above the input) - **Input area:** Message input with model selector and send button - **Conversation history:** Sidebar list of past conversations ![The chat panel: an active conversation with the agent, the model selector, and the message input, alongside the content panel](/screenshots/chat-panel.png) ## Sending Messages Type your message in the input area and press **Enter** (or click the send button). The agent processes your message and responds with: 1. **Text response** — explanation, confirmation, or follow-up question 2. **Tool calls** — actions performed on your content (shown as expandable cards) 3. **Affected resources** — what changed as a result of the conversation Messages stream in real-time via Server-Sent Events (SSE). You can see the response building character by character. ### Example Prompts Here are common tasks you can accomplish through chat: **Content Management:** ``` "Create a blog post titled 'Getting Started with Studio' with a summary and set the published date to today" "Update the homepage hero tagline to 'Build content, not infrastructure'" "Delete all FAQ entries that have 'deprecated' in the title" "Show me all team members and their roles" ``` **Model Management:** ``` "Create a collection model called 'Testimonials' in the marketing domain with author (string), quote (text), rating (rating), and avatar (image)" "Add a 'featured' boolean field to the Blog Posts model" "List all models and their field counts" ``` **Branch Management:** ``` "List all pending content branches" "Merge the branch cr/content/blog-posts/en/..." "Show me what changed in the latest branch" ``` **Localization:** ``` "Copy all English blog posts to Turkish" "Add the key 'nav.products' with value 'Products' to the UI Strings dictionary in English" ``` **Media:** ``` "Upload this image from https://example.com/hero.jpg as the hero banner with alt text 'Product dashboard screenshot'" "Search the media library for images tagged 'team'" ``` ::: info Media uploads need CDN delivery Uploading an image into the media library (a media-intent upload) requires the project's **CDN delivery** to be enabled. If it is off, the agent returns an actionable error rather than storing a broken asset — attach the file as **context** (see [Context Chips](#context-chips)) so the agent can still read it inline. See [CDN](/guide/cdn) to turn delivery on. ::: ## Model Selection Studio offers four Claude AI models with different performance profiles: ![The model selector dropdown in the chat header: Haiku 4.5, Sonnet 4.6, Sonnet 5, and Opus 4.8](/screenshots/model-selector.png) | Model | Label | Best For | |-------|-------|----------| | `claude-haiku-4-5-20251001` | **Haiku 4.5** | Fast responses, simple queries, quick lookups | | `claude-sonnet-4-6` | **Sonnet 4.6** | Balanced — default on full-access plans | | `claude-sonnet-5` | **Sonnet 5** | Balanced — newest generation | | `claude-opus-4-8` | **Opus 4.8** | Complex tasks, multi-step operations, nuanced content | Which models you can pick depends on whether your plan includes the studio-hosted AI key (`ai.studio_key`), which unlocks the full model lineup: | Plan | Models Available | |------|------------------| | Starter | Full lineup (Haiku 4.5, Sonnet 4.6, Sonnet 5, Opus 4.8) | | Pro | Full lineup (Haiku 4.5, Sonnet 4.6, Sonnet 5, Opus 4.8) | | Enterprise | Full lineup, plus custom options | The full lineup is gated by `ai.studio_key`, which is on for **Starter, Pro, and Enterprise** — Starter is *not* limited to Haiku. Plans without the studio-hosted key fall back to Haiku only. On plans with full studio AI access, **Sonnet 4.6** is selected by default — switch to Haiku for speed, or Sonnet 5 / Opus for quality when needed. The selector sits next to the input area. ::: tip Haiku is ideal for quick queries like "list all models" or "show me the site settings." Save Opus for complex, multi-step tasks like "create a blog structure with three related models and sample content." ::: ## Tool Calls Explained When the agent performs an action, it uses **tools** — specialized functions that interact with your project. Tool calls appear as expandable cards in the conversation. ![A conversation where the agent lists authors and adds one, with collapsed tool-call cards (brain_query, save_content) beneath the reply](/screenshots/tool-call-chat.png) Expand a tool card to inspect its result — for example, `save_content` returns the branch, commit SHA, files changed, validation, and merge status: ![Expanded tool-call cards showing the brain_query and save_content results as JSON](/screenshots/tool-call-detail.png) ### Available Tools | Tool | Purpose | Creates Branch? | |------|---------|----------------| | `list_models` | List all models with field schemas, kind, domain, i18n | No | | `get_content` | Read content entries for a model and locale | No | | `save_content` | Create or update content (merges changed fields) | Yes | | `delete_content` | Remove entries or dictionary keys | Yes | | `save_model` | Create or update a model definition | Yes | | `validate` | Validate content against schemas | No | | `list_branches` | List pending content branches | No | | `merge_branch` | Merge a branch into the `contentrain` branch | Yes (merge commit) | | `reject_branch` | Reject and delete a branch | Yes (branch deletion) | | `init_project` | Initialize `.contentrain/` structure | Yes | | `copy_locale` | Copy content between locales | Yes | | `brain_query` | Read content from cache (fast) | No | | `brain_search` | Full-text search across content | No | | `search_media` | Search the media library | No | | `upload_media` | Upload an image from URL | No | | `get_media` | Get asset metadata | No | | `update_status` | Publish, unpublish, or archive entries | Yes | | `update_media` | Update asset alt text, tags, or focal point | No | | `delete_media` | Delete an asset and all its variants | No | | `branch_health` | Check pending-branch health vs warn/block limits | No | | `relation_expand` | Resolve forward/reverse relations for an entry | No | | `vocabulary` | Read or update the project glossary | Yes | | `add_locale` | Register a new supported locale in the config | Yes | | `delete_model` | Permanently delete a model and all its content | Yes | | `brain_analyze` | Analyze content (SEO, locale parity, stale, quality) | No | | `validate_schema` | Comprehensive schema validation with health score | No | | `list_submissions` | List form submissions for a model | No | | `approve_submission` | Approve a submission (creates a content entry) | Yes | | `reject_submission` | Reject a form submission | No | ### Tool Call Lifecycle Each tool call goes through these states: 1. **Pending** — Tool is about to execute 2. **Streaming** — Tool is running (for long operations) 3. **Complete** — Tool finished successfully, result is shown 4. **Error** — Tool execution failed, error message is shown Click on a tool call card to expand it and see the input parameters and result. ### Workflow Behavior Tools that modify content follow one of three workflows: | Behavior | Description | |----------|-------------| | **Auto-merge** | Changes merge automatically to the `contentrain` branch (used for init) | | **Workflow-dependent** | Follows the project's merge workflow setting | | **Manual** | Requires explicit user action (merge/reject branch tools) | | **None** | Read-only tools, no Git changes | ## Context Chips Context chips let you give the agent focused awareness of what you are working on. Instead of describing your context in text, you can pin items from the content panel. ![Context chips above the chat input — an image and a PDF pinned as CONTEXT for the agent](/screenshots/context-chips.png) ### Pinning Context There are two ways to pin context: 1. **Click the pin icon** — Hover over a model, entry, or field in the content panel and click the pin icon 2. **Drag and drop** — Drag an item from the content panel to the context bar above the chat input ### Context Types | Type | What It Provides | |------|-----------------| | **Model** | Full model schema with fields, kind, and domain | | **Entry** | Specific entry data with all field values | | **Field** | Individual field value and metadata | | **Asset** | Media asset information from the library | ### How Context Helps When you pin a model and ask "add a new entry," the agent knows exactly which model you mean and what fields are available. Without context, you would need to specify the model name in your message. **Without context:** > "Create a new entry in the Blog Posts model with title 'Hello World'" **With Blog Posts model pinned:** > "Create a new entry with title 'Hello World'" Context chips appear as removable tags above the input. Click the X on any chip to remove it. ## Conversation History Studio preserves your conversation history per project. You can: - **Browse past conversations** — Listed in the sidebar with auto-generated titles - **Load a conversation** — Click to restore the full message history - **Delete a conversation** — Remove conversations you no longer need - **Start a new conversation** — Click the "New conversation" button or use the command palette ::: tip Conversation titles are auto-generated from the first message. They help you find past conversations quickly. ::: ## BYOA (Bring Your Own API Key) By default, Studio uses its own API key with monthly message limits based on your plan. With BYOA, you can add your own Anthropic API key for unlimited usage. ![The AI tab: add your own Anthropic API key — encrypted and never stored in plaintext — under Workspace Settings](/screenshots/ai-keys-panel.png) ### Adding Your Key 1. Navigate to **Workspace Settings > AI** (the key is personal: only you see it) 2. Paste your Anthropic API key into the key field 3. Click **Add API Key** Your key is encrypted at rest and visible only to you. It is used only for your own chat requests in this workspace; each member adds their own. ::: warning BYOA is a paid feature, available on **Pro and above**. The Free plan uses only the studio-provided key and has **0 AI messages per month**. ::: ### Plan Limits | Plan | AI Messages/Month | BYOA | |------|-------------------|------| | Free | 0 | Not available | | Community (self-host) | Unlimited | Not available | | Starter | 150 | Not available | | Pro | 1,500 | Available | | Enterprise | Unlimited | Available | When using BYOA, studio message limits do not apply — you are billed directly by Anthropic for API usage. ## Bounded Task Executor The AI agent operates within a **bounded task executor** — a controlled environment with strict guardrails: ### What the Agent Can Do - Create, modify, and delete content models - Add, update, and remove content entries (all four model kinds) - Change publish status (publish, unpublish, archive) - Manage vocabulary terms and register new locales - Copy content between locales - Search, upload, update, and delete media assets - Initialize project structure - List and manage branches, and check branch health - Validate content and run schema/content health analysis - Review form submissions (approve or reject) ### What the Agent Cannot Do - Access repositories outside the current project - Bypass role-based permissions (the agent respects your role) - Push directly to the main branch (always creates a review branch) - Access external services or browse the internet - Execute arbitrary code or shell commands - Modify Git history or rebase branches ### Safety Guarantees - **Every mutation creates a branch** — Changes go to `cr/*` branches, never directly to `main` - **Auditable** — Every tool call is logged in the conversation and visible in branch diffs - **Reversible** — Reject a branch to discard all changes - **Permission-scoped** — The agent checks your role before executing tools - **Iteration-limited** — Maximum 5 tool iterations per message to prevent runaway loops - **Context-limited** — Tool results are truncated to manage the AI context window ## Error Handling If a tool call fails, the agent will: 1. Show the error in the tool call card 2. Explain what went wrong in its text response 3. Suggest corrective action if possible Common error scenarios: - **Validation failure** — Content does not match the model schema - **Branch conflict** — Another operation modified the same file - **Permission denied** — Your role does not allow the requested action - **Rate limit exceeded** — You have reached your plan's message limit ## Streaming Architecture The chat endpoint uses **Server-Sent Events (SSE)** for real-time streaming: 1. Your message is sent as a POST request 2. The server opens an SSE stream 3. Events are emitted as they occur: - `conversation` — Conversation ID assigned - `text` — Response text chunk - `tool_use` — Tool call started - `tool_result` — Tool call completed - `done` — Conversation turn complete, affected resources reported - `error` — Error occurred during processing This architecture provides a responsive experience — you see the agent's response building in real-time and can observe tool execution as it happens. ## Next Steps - [Content Models](/guide/content-models) — Understand the models the agent works with - [Content Editing](/guide/content-editing) — Use the content panel alongside the chat - [Branches & Review](/guide/branches-and-review) — Review changes the agent creates - [Keyboard Shortcuts](/guide/keyboard-shortcuts) — Navigate quickly with the command palette --- # Branches & Review Source: https://docs.contentrain.io/guide/branches-and-review > Understand Contentrain Studio's Git-native review workflow — the contentrain branch, cr/* feature branches, two-step merge, visual diffs, and branch health. # Branches & Review Every content mutation in Contentrain Studio follows a Git-native review workflow. Changes never go directly to your main branch — they are committed to short-lived feature branches, reviewed, and merged through a controlled pipeline. ## Branch Architecture Studio uses a three-tier branch model: ``` main (your production branch) ↑ merge contentrain (SSOT branch — single source of truth) ↑ merge cr/* (feature branches — individual operations) ``` ### The `contentrain` Branch The `contentrain` branch is the **single source of truth** (SSOT) for all Contentrain-managed content. It acts as a staging area between feature branches and your main branch. - All `cr/*` branches merge into `contentrain` first - The `contentrain` branch is then merged into `main` - This two-step process keeps your main branch clean ### Feature Branches (`cr/*`) Every content operation — whether triggered by the AI chat, the content panel, or the API — creates a short-lived `cr/*` feature branch. Branch names follow a structured pattern: ``` cr/content/{modelId}/{locale}/{timestamp}-{hash} cr/model/{modelId}/{timestamp}-{hash} cr/init/{timestamp}-{hash} ``` These branches contain exactly one logical change: - A content entry was created or updated - A model definition was modified - Content was copied between locales - The project was initialized ## The Review Workflow Pending review branches appear under **Pending Changes** in the project sidebar: ![The Pending Changes section in the sidebar, listing a cr/* review branch](/screenshots/pending-changes.png) ### Two-Step Merge Flow ``` User/Agent Action ↓ Create cr/* feature branch ↓ Commit changes to branch ↓ Auto-merge to contentrain (SSOT) ↓ Review diff in Studio UI ↓ Merge to main ──or── Reject (delete branch) ``` ### Auto-Merge When a content change is committed, the `cr/*` branch is automatically merged into the `contentrain` branch. This happens transparently — you do not need to take any action for the first merge step. The auto-merge behavior depends on the tool: | Tool | Merge Behavior | |------|---------------| | `init_project` | Always auto-merges | | `save_content` | Follows project workflow setting | | `save_model` | Follows project workflow setting | | `delete_content` | Follows project workflow setting | | `copy_locale` | Follows project workflow setting | | `merge_branch` | Manual (user-initiated) | | `reject_branch` | Manual (user-initiated) | ### Reviewing Changes To review pending changes: 1. Look at the **Branches** section in the sidebar 2. Click on any branch to open the branch diff viewer 3. Review the visual diff showing exactly what changed ## Branch Diff Viewer The branch diff viewer shows a detailed comparison of changes on a branch. ![The branch diff viewer: a field-level diff (old values struck through in red, new values in green) with Approve & Merge and Reject actions](/screenshots/branch-diff.png) ### What the Diff Shows Each branch diff includes: - **Branch name** — the full `cr/*` branch name - **File list** — every file that was added, modified, or removed - **Content comparison** — before/after view of each file's content ### File Status Indicators | Status | Meaning | |--------|---------| | **Added** | A new file was created | | **Modified** | An existing file was changed | | **Removed** | A file was deleted | ### Content Diff For each file, the diff viewer shows: - **Before:** The content before the change (null for new files) - **After:** The content after the change (null for deleted files) Content is displayed as structured data, making it easy to see exactly which fields changed. ## Merging Changes To merge a branch into your main branch: ### From the UI 1. Open the branch diff viewer by clicking the branch in the sidebar 2. Review the changes 3. Click **Merge** to accept the changes 4. The branch is merged: `contentrain` branch advances to `main` 5. The feature branch is deleted 6. A success toast confirms the merge ### From the Chat Ask the agent to merge a branch: ``` "Merge the branch cr/content/blog-posts/en/1774800862-27c1" ``` Or more generally: ``` "Merge all pending branches" "List branches and merge the latest one" ``` ### Merge Conflicts If a merge conflict occurs (another process modified the same files), Studio will report the conflict. You can resolve it by: 1. Rejecting the conflicting branch 2. Making the changes again (which creates a new branch with the latest base) ::: warning Studio cannot automatically resolve merge conflicts. If a conflict is detected, the merge will fail and you will see a message indicating that manual resolution on GitHub is needed. ::: ## Rejecting Changes To discard changes on a branch: ### From the UI 1. Open the branch diff viewer 2. Click **Reject** 3. The branch is deleted 4. No changes are applied to your repository ### From the Chat ``` "Reject the branch cr/content/blog-posts/en/1774800862-27c1" ``` ::: tip Rejecting a branch is non-destructive to your existing content. It simply discards the proposed changes by deleting the feature branch. ::: ## Branch Health Studio monitors branch health to keep your repository clean: - **Pending branch count** — How many `cr/*` branches are waiting for review - **Stale branches** — Branches that have been open for an extended period - **Cleanup** — Bulk cleanup of merged or stale branches ### Branch Cleanup To clean up old branches: 1. Use the command palette or sidebar to access branch management 2. Studio identifies branches that can be safely removed 3. Confirm the cleanup action The cleanup process removes `cr/*` branches that have already been merged or are no longer needed. ::: tip Regularly reviewing and merging or rejecting branches keeps your project healthy. A large number of pending branches can indicate bottlenecks in your content workflow. ::: ## Branch Naming Convention Understanding branch names helps you identify what changed: ``` cr/content/blog-posts/en/1774800862-27c1 │ │ │ │ │ │ │ │ │ └─ Timestamp + hash (unique ID) │ │ │ └─── Locale │ │ └────────────── Model ID │ └────────────────────── Operation type (content, model, init) └───────────────────────── Contentrain prefix ``` ## Permissions Branch operations respect your role: | Role | View Branches | Merge | Reject | |------|:---:|:---:|:---:| | **Owner** | Yes | Yes | Yes | | **Admin** | Yes | Yes | Yes | | **Editor** | Yes | No | No | | **Reviewer** | Yes | Yes | Yes | | **Viewer** | Yes | No | No | ## Next Steps - [AI Chat](/guide/ai-chat) — Use the agent to manage branches - [Content Editing](/guide/content-editing) — Make changes that create branches - [Team Management](/guide/team-management) — Configure who can merge and reject --- # Media Library Source: https://docs.contentrain.io/guide/media-library > Manage images, videos, and files in Contentrain Studio's media library — upload, organize, preview, and use assets in your content. # Media Library The media library provides a centralized place to manage all visual and file assets for your project. It supports direct uploads, URL imports, automatic image optimization, variant generation, and metadata management. ::: warning Managed service / Enterprise Edition The media library is an Enterprise Edition feature, gated by the `media.library` and `media.upload` plan features and backed by Cloudflare R2 storage. It is available on Contentrain's managed service (Starter plans and above) and on self-hosted Enterprise Edition deployments. It is not available in the self-hosted Community Edition, which has no media storage backend. ::: ## Accessing the Library Open the media library from: - **Sidebar:** Click **Assets** in the project sidebar - **Command palette:** Press `Cmd+K` and type "Media library" ![The Assets panel: a drag-and-drop upload zone above a grid of media assets](/screenshots/media-library-panel.png) ## Uploading Assets ### Direct Upload 1. Open the media library 2. Click **Upload** or drag and drop files onto the library area 3. Optionally set alt text and tags before upload 4. The file is uploaded, optimized, and variants are generated ### URL Import You can import images from external URLs: 1. Click **Import from URL** 2. Paste the image URL 3. Optionally set alt text and tags 4. Studio fetches, optimizes, and stores the image ### Upload via AI Chat The agent can upload images from URLs: ``` "Upload the image at https://example.com/hero.jpg as the hero banner with alt text 'Product dashboard screenshot'" ``` ### Bulk Upload Select multiple files to upload them all at once. Each file is processed individually with optimization and variant generation. ::: tip Supported formats include JPEG, PNG, WebP, GIF, SVG, MP4, and common document formats (PDF, etc.). ::: ## Image Optimization When you upload an image, Studio automatically: 1. **Converts to WebP** — optimal format for web delivery 2. **Generates variants** — multiple sizes for responsive design 3. **Computes blurhash** — low-resolution placeholder for lazy loading 4. **Extracts metadata** — dimensions, format, file size ### Variants Each uploaded image gets a set of size variants based on the configured presets. Variants include: | Property | Description | |----------|-------------| | `path` | Storage path for this variant | | `width` | Width in pixels | | `height` | Height in pixels | | `format` | Image format (typically webp) | | `size` | File size in bytes | Variant presets can be customized per field when defining image fields in your content models (Pro+ plan). ## Asset Metadata Every asset in the library has the following metadata: ![An asset's detail panel: preview, dimensions, file size, storage path, generated variants (og, card, hero, thumb), alt text, and tags](/screenshots/asset-detail.png) | Field | Description | |-------|-------------| | `filename` | Original file name | | `contentType` | MIME type (e.g., `image/webp`) | | `size` | File size in bytes | | `width` | Image width in pixels | | `height` | Image height in pixels | | `format` | File format | | `blurhash` | Low-resolution placeholder hash | | `alt` | Alt text for accessibility | | `tags` | Array of organizational tags | | `source` | How the asset was uploaded | | `variants` | Generated size variants | ### Editing Metadata To update an asset's metadata: 1. Click on an asset in the library to select it 2. The detail panel shows the asset preview and metadata 3. Edit the **alt text**, **tags**, or **focal point** 4. Changes save automatically ::: warning Always add descriptive alt text to images. It improves accessibility and helps the AI agent understand your media assets. ::: ## Browsing and Filtering The media library provides several ways to find assets: ![The expanded media library: search and sort controls above a grid of assets showing format and size](/screenshots/media-library-grid.png) ### Search Type in the search bar to filter by filename, alt text, or tags. ### Filter by Type Filter assets by type: - **All** — Show all assets - **Images** — JPEG, PNG, WebP, GIF, SVG - **Videos** — MP4, WebM - **Files** — PDF, documents ### Filter by Tags Click on tags to filter assets that have specific tags applied. ### Sort Order Sort assets by: | Sort | Description | |------|-------------| | **Newest** | Most recently uploaded first (default) | | **Oldest** | Oldest uploads first | | **Name** | Alphabetical by filename | | **Size** | Largest files first | ### Pagination The library loads 50 assets per page. Use pagination controls to browse through larger libraries. ## Asset Preview Click any asset to open a detailed preview: - **Full-size preview** — View the asset at its original dimensions - **Metadata panel** — See all properties including dimensions, format, and variants - **Edit controls** — Update alt text, tags, and focal point - **Usage info** — See where the asset is referenced in your content ## Using Assets in Content `image`, `video`, and `file` fields store a **string reference** — there is no separate visual asset picker in v1. In the content editor (see the [editor modal](/guide/content-editing#the-editor-modal)), enter a **media path or external URL** for the field: - A media library path, e.g. `media/original/abc123.webp` - An external URL, e.g. a CDN or Unsplash link ### Via AI Chat You can also reference media assets through the chat: ``` "Set the blog post cover image to the hero banner from the media library" "Search for images tagged 'team' and use the first one as the about page photo" ``` The agent uses `search_media` and `get_media` tools to find assets and `save_content` to set image field values. ## Deleting Assets To delete an asset: 1. Select the asset in the library 2. Click **Delete** in the detail panel 3. Confirm the deletion To delete multiple assets: 1. Select multiple assets using checkboxes 2. Click **Delete selected** 3. Confirm the bulk deletion ::: warning Deleting an asset does not automatically remove references to it in your content. Content fields that reference a deleted asset will show a broken reference. ::: ## Plan Limits Media library features vary by plan: | Feature | Free | Starter | Pro | Enterprise | |---------|------|---------|-----|------------| | Browse library | — | Yes | Yes | Yes | | Upload | — | Yes | Yes | Yes | | Storage | — | 1 GB | 15 GB | 100 GB | | Max file size | — | 5 MB | 50 MB | 100 MB | | Variants per field | — | 4 | 10 | Unlimited | | Custom variants | No | No | Yes | Yes | ## Next Steps - [Content Editing](/guide/content-editing) — Use media assets in your content - [AI Chat](/guide/ai-chat) — Upload and manage assets through conversation - [CDN](/guide/cdn) — Deliver optimized assets through the CDN --- # Forms Source: https://docs.contentrain.io/guide/forms > Enable public form submissions on your content models — accept user input, manage submissions, and convert approved entries to content. # Forms Contentrain Studio lets you accept public form submissions for your collection models. Visitors submit data through a public API endpoint, and your team reviews, approves, or rejects submissions before they become content entries. ::: tip Availability Core forms — public submit/config endpoints, the submission review queue, CAPTCHA, and auto-approve — work in every deployment, including the self-hosted Community Edition (the `forms.enabled` feature is not Enterprise-gated). A few extras are gated: - **Webhook notifications** on submission — Enterprise Edition (`forms.webhook_notification`). - **File upload in forms** — Enterprise Edition and on the roadmap (not yet available). - **Spam filter** — Enterprise Edition and on the roadmap (not yet available). - **Email notifications** — on the roadmap (not yet available). ::: ## How Forms Work ``` Public Form → Submit API → Submission Queue → Review → Content Entry ``` 1. You enable forms on a collection model and configure validation rules 2. A public API endpoint is generated for that model 3. External forms (your website, app, etc.) POST data to the endpoint 4. Submissions land in a review queue with `pending` status 5. Your team approves, rejects, or marks submissions as spam 6. Approved submissions can be converted to content entries ## Enabling Forms on a Model To enable forms for a collection model: 1. Open the model in the content panel 2. Navigate to the model's form configuration section 3. Toggle **Enable form submissions** 4. Configure validation rules, field mapping, and submission limits ![The Form tab: enable form submissions, mark the form public, and choose which fields are exposed](/screenshots/form-settings.png) ::: tip Only collection models support form submissions. Singletons, documents, and dictionaries cannot accept external submissions. ::: ## Public Form API Once enabled, each model gets a public submission endpoint: ``` POST /api/forms/v1/{projectId}/{modelId}/submit ``` ### Request Format The endpoint reads only `data` (the submitted field values), an optional `captchaToken`, and the honeypot field `_hp`. Forms are single-locale (`en`) — any `locale` field in the body is ignored. ```json { "data": { "name": "Jane Smith", "email": "jane@example.com", "message": "I'd like to learn more about your product." } } ``` ### Response On success, the endpoint returns the configured success message: ```json { "success": true, "message": "Thank you for your submission!" } ``` On a validation failure, it returns the per-field errors: ```json { "success": false, "errors": [ { "field": "email", "message": "email must be a valid email" } ] } ``` ### Configuration Endpoint You can fetch the form configuration (field definitions, validation rules) from the public API: ``` GET /api/forms/v1/{projectId}/{modelId}/config ``` This is useful for dynamically building forms on your frontend based on the model schema. ## Submission Management ### Viewing Submissions Navigate to the form submissions view by selecting a form-enabled model and opening its submissions tab. The list shows: - Submission data - Status badge (pending, approved, rejected, spam) - Source IP address - User agent - Referrer URL - Locale - Submission date ![The Submissions tab: filter by status (All / Pending / Approved / Rejected / Spam), each submission showing a status badge](/screenshots/submission-list.png) ### Submission Statuses | Status | Description | |--------|-------------| | **Pending** | New submission awaiting review | | **Approved** | Reviewed and accepted | | **Rejected** | Reviewed and declined | | **Spam** | Marked as spam | ### Reviewing a Submission Click any submission to open the detail modal: 1. Review all submitted field data 2. Choose an action: - **Approve** — Mark as approved (optionally convert to content entry) - **Reject** — Decline the submission - **Spam** — Flag as spam - **Delete** — Permanently remove the submission ### Bulk Actions Select multiple submissions using checkboxes and perform bulk actions: - **Approve all** — Approve selected submissions - **Reject all** — Reject selected submissions - **Mark as spam** — Flag selected as spam - **Delete all** — Permanently remove selected submissions ### Filtering and Sorting Filter submissions by: - **Status** — pending, approved, rejected, spam - **Sort order** — newest first or oldest first Pagination is available for large submission volumes. ## Converting Submissions to Content When you approve a submission, you can optionally convert it into a content entry: 1. Approve the submission 2. The submitted data is mapped to the model's field schema 3. A new content entry is created on a review branch 4. The entry goes through the standard branch review workflow The submission's `entry_id` field is populated with the newly created content entry ID, linking the submission to its resulting content. ## Form Configuration The form configuration section lets you control: | Setting | Description | |---------|-------------| | **Enabled** | Toggle form submissions on/off | | **Field mapping** | Map form fields to model fields | | **Required fields** | Which fields are required in submissions | | **Captcha** | Enable CAPTCHA protection (Cloudflare Turnstile, Starter+) | | **Auto-approve** | Automatically approve submissions (Starter+) | | **Notifications** | Send email notifications on new submissions (roadmap — not yet available) | | **Webhook notification** | Trigger a webhook on new submissions (Enterprise Edition) | | **Spam filter** | AI-powered spam detection (Enterprise Edition, roadmap — not yet available) | ## Security Form submissions include metadata for security review: - **Source IP** — The IP address of the submitter - **User Agent** — The browser or client identifier - **Referrer** — The referring page URL ::: warning The public form API is rate-limited to prevent abuse. Excessive submissions from a single IP will be throttled. ::: ## Plan Limits | Feature | Free | Starter | Pro | Enterprise | |---------|------|---------|-----|------------| | Form-enabled models | 0 | 1 | 15 | Unlimited | | Submissions/month | 0 | 100 | 3,000 | Unlimited | | File upload in forms | No | No | No | No | | CAPTCHA | No | Yes | Yes | Yes | | Auto-approve | No | Yes | Yes | Yes | | Spam filter | No | No | No | No | | Webhook notification | No | Yes | Yes | Yes | The **CAPTCHA** and **Auto-approve** columns reflect features that ship today. **Webhook notification** requires the Enterprise Edition. **File upload** and the **Spam filter** are Enterprise-Edition roadmap features and are not yet available. ## Next Steps - [Content Models](/guide/content-models) — Set up models for form submissions - [Webhooks](/guide/webhooks) — Get notified when forms are submitted - [Content Editing](/guide/content-editing) — Manage converted form entries --- # CDN Source: https://docs.contentrain.io/guide/cdn > Deliver your content through Contentrain Studio's built-in CDN — enable delivery, manage API keys, trigger builds, and access the public content endpoint. # CDN Contentrain Studio includes a built-in Content Delivery Network for serving your published content through a fast, public API. The CDN builds optimized content from your Git repository and serves it through edge infrastructure. ::: warning Managed service / Enterprise Edition The CDN is an Enterprise Edition feature, gated by the `cdn.delivery` plan feature and backed by Cloudflare R2 storage. It is available on Contentrain's managed service (Starter plans and above) and on self-hosted Enterprise Edition deployments. It is not available in the self-hosted Community Edition, which has no CDN storage backend. ::: ## How the CDN Works ``` Git Repository → Build Pipeline → CDN Storage → Public API ``` 1. Content is committed to your repository through Studio 2. A build is triggered (manually or automatically after a merge) 3. The build pipeline reads content, filters by status (`published` only), and serializes to optimized JSON 4. Content is uploaded to CDN storage 5. Consumers fetch content through the public delivery endpoint ## Enabling CDN CDN is available on Starter plans and above. To enable it for a project: 1. Open the project sidebar 2. Click **CDN** to open the CDN panel 3. Toggle **Enable CDN** in the settings 4. Create an API key for authentication ![The CDN Delivery panel: enable toggle, API keys with scopes, key permissions, and build history](/screenshots/cdn-panel.png) ## API Key Management CDN access requires an API key. Keys are used in the `Authorization` header of requests to the delivery endpoint. ### Creating a Key 1. In the CDN panel, navigate to the **Keys** section 2. Click **Create key** 3. Enter a descriptive name for the key 4. The key is generated and shown once — copy it immediately ::: warning API keys are shown only once at creation time. Studio stores only the SHA-256 hash of the key. If you lose the key, you must create a new one. ::: ### Key Format CDN API keys follow the format: ``` crn_live_{random-base62-string} ``` Example: `crn_live_7K2mX9pQ4rT6vW3nY8sB1hJ5` ### Deleting a Key To revoke access, delete the key from the CDN panel. This takes effect immediately — any requests using the deleted key will receive a `401 Unauthorized` response. ## Public Delivery Endpoint Content is served through a REST-style endpoint: ``` GET /api/cdn/v1/{projectId}/{path} Authorization: Bearer crn_live_... ``` ### Path Structure Objects are keyed by model ID (the domain is not part of the CDN path — it is written flat by the build pipeline): | Path | Returns | |------|---------| | `content/{modelId}/{locale}.json` | Published entries for an i18n collection/singleton/dictionary, by locale | | `content/{modelId}/data.json` | Published entries for a non-i18n model | | `meta/{modelId}/{locale}.json` | Per-entry metadata for an i18n model, by locale (`data.json` for non-i18n) | | `documents/{modelId}/{slug}/{locale}.json` | A single document entry (`{ frontmatter, body, html }`) | | `documents/{modelId}/_index/{locale}.json` | Frontmatter index for a document model, by locale | | `models/{modelId}.json` | A model definition | | `models/_index.json` | Summary index of all models | | `_manifest.json` | Build manifest (commit SHA, locales, domains, model list) | | `_media_manifest.json` | Media manifest — asset metadata (dimensions, blurhash, alt text) | | `_bundle/{locale}.json` | Per-locale bundle — all JSON content for one locale in a single fetch (see [Locale bundles](#locale-bundles)) | | `media/{...}` | Media binaries (images, files) — served keyless when public media is enabled | For example, the English entries of a `blog-post` collection are served at `content/blog-post/en.json`. ### Response Format The endpoint returns the raw published-content JSON file straight from CDN storage, with `Content-Type: application/json` and an `ETag` header (conditional `If-None-Match` requests return `304`). Collection and singleton files are an object map keyed by entry ID, containing only entries with `published` status: ```json { "a1b2c3d4e5f6": { "title": "Hello World", "slug": "hello-world", "body": "..." }, "g7h8i9j0k1l2": { "title": "Second Post", "slug": "second-post", "body": "..." } } ``` ### Delivery Model The delivery endpoint is built for the hot path. Key, project, and plan lookups are served from in-process TTL caches, so a warm request spends its round-trips only on the rate-limit check and the storage read. Storage reads are conditional: the endpoint forwards your `If-None-Match` to R2, so an unchanged object returns `304` with no body transfer. `Cache-Control` is split by auth mode: - **Keyed content** (JSON, manifests) is returned as `private, max-age=60` — a shared or edge cache must never replay keyed content to a keyless client. - **Keyless public media** is returned as `public, max-age=60, s-maxage=3600, stale-while-revalidate=86400`, so shared caches and CDNs can serve it. ### Locale bundles Every build also emits one bundle artifact per locale at `_bundle/{locale}.json`. A bundle packs all of a locale's JSON content into a single object so a client can prime its per-path cache with **one** conditional fetch instead of one request per model (plus one per `include()` relation). Content only changes on a build, so shipping the bodies together turns a 5–10+ round-trip page render into a single request. ```json { "version": "1", "commitSha": "abc123...", "builtAt": "2026-07-10T12:00:00.000Z", "locale": "en", "paths": { "content/authors/en.json": { "a1b2c3d4e5f6": { "name": "Jane" } }, "content/site-settings/data.json": { "title": "My Site" }, "documents/articles/_index/en.json": [] } } ``` - `paths` is keyed by the **exact** delivery paths the SDK already fetches, and each body is identical to the standalone artifact — one primed fetch serves every listed path from cache. - All JSON-kind models ship their full body (collections published-filtered, singletons, dictionaries). Non-i18n models appear under their real `content/{modelId}/data.json` path in **every** locale bundle. - Document models contribute only their `_index`; per-slug document bodies and `meta/...` are not bundled and fall through to normal per-path fetches. - The bundle is emitted on **every** build so it always mirrors the standalone artifacts. Consumers must ignore a bundle whose `version` is not `"1"` (or whose `paths` is missing) and fall back to per-path fetches. Like every delivery object, it is ETag'd and keyed-only (never publicly served). ### Authentication Content JSON and manifests **always** require a valid API key in the `Authorization` header: ```bash curl -H "Authorization: Bearer crn_live_..." \ https://your-studio.com/api/cdn/v1/{projectId}/content/blog-post/en.json ``` Media binaries (paths starting with `media/`) are the one exception: when a project has **public media** enabled, they are served keyless (rate-limited per project + client IP) so a browser `` can load them — an API key can't live in an `` tag. A project that has not enabled public media requires a key for media too. ## Build Pipeline ### Triggering a Build Builds can be triggered in two ways: 1. **Manual trigger** — Click **Trigger Build** in the CDN panel 2. **Automatic** — After a branch merge completes (when configured) ### Build Process Each build: 1. Reads the latest content from your Git repository 2. Loads all model definitions 3. Filters content entries to include only `published` status 4. Serializes Markdown content to HTML (for document models) 5. Uploads optimized JSON to CDN storage 6. Cleans up stale files from previous builds ### Build Results After a build completes, you can see: | Field | Description | |-------|-------------| | `buildId` | Unique build identifier | | `commitSha` | The Git commit the build was created from | | `filesUploaded` | Number of content files processed | | `filesDeleted` | Number of stale files removed | | `totalSizeBytes` | Total size of the uploaded content | | `changedModels` | Which models had content changes | | `durationMs` | Build duration in milliseconds | ### Build History The CDN panel shows a history of recent builds with their status, timing, and output details. ::: tip If content looks stale on the CDN, trigger a manual build to ensure the latest published content is served. ::: ## CDN Security - **API key authentication** — Content JSON and manifests require a valid key on every request. Media binaries are the only content that can be served keyless, and only when public media is enabled for the project. - **Rate limiting** — Keyed requests are rate-limited per key (configurable per plan); keyless public-media requests are rate-limited per project + client IP - **Allowed origins** — Configure CORS origins per API key for browser-based access - **Published only** — The CDN only serves entries with `published` status; draft, archived, and rejected content is never exposed ## Plan Limits | Feature | Free | Starter | Pro | Enterprise | |---------|------|---------|-----|------------| | CDN delivery | No | Yes | Yes | Yes | | API keys | 0 | 3 | 10 | Unlimited | | Bandwidth | 0 | 2GB/mo | 20GB/mo | Unlimited | | Preview branches | No | No | Yes | Yes | | Custom domain | No | No | No | Yes | ## Next Steps - [Content Editing](/guide/content-editing) — Publish content for CDN delivery - [Webhooks](/guide/webhooks) — Get notified when CDN builds complete - [Billing & Plans](/guide/billing) — Upgrade for more CDN capacity --- # Webhooks Source: https://docs.contentrain.io/guide/webhooks > Configure outbound webhooks in Contentrain Studio to receive real-time notifications when content, models, branches, media, or forms change. # Webhooks Webhooks let you receive real-time HTTP notifications when events occur in your project. Configure a webhook URL and select which events to listen for — Studio will POST a signed payload to your endpoint whenever a matching event fires. ::: warning Managed service / Enterprise Edition Outbound webhooks are an Enterprise Edition feature, gated by the `api.webhooks_outbound` plan feature. They are available on Contentrain's managed service (Starter plans and above) and on self-hosted Enterprise Edition deployments. They are not available in the self-hosted Community Edition. ::: ## Creating a Webhook 1. Open the project settings and go to the **Webhooks** tab 2. Click **Create webhook** 3. Enter the webhook URL (must be HTTPS for production) 4. Select the events you want to subscribe to 5. A webhook secret is generated automatically for signature verification ![Creating a webhook: name, endpoint URL, and the event checkboxes for all thirteen event types](/screenshots/webhook-settings.png) ::: warning Webhook URLs must point to public endpoints. Studio blocks requests to private networks (RFC 1918), localhost, link-local addresses, and cloud metadata endpoints for SSRF protection. ::: ## Event Types Studio supports thirteen webhook event types: | Event | Fires When | |-------|-----------| | `content.saved` | A content entry is created or updated | | `content.deleted` | A content entry is deleted | | `model.saved` | A content model is created or updated | | `branch.merged` | A review branch is merged to main | | `branch.rejected` | A review branch is rejected (deleted) | | `branch.changes_requested` | A reviewer requests changes on a review branch | | `cdn.build_complete` | A CDN build finishes successfully | | `media.uploaded` | A media asset is uploaded | | `form.submitted` | A public form submission is received | | `comment.submitted` | A visitor submits a comment | | `comment.approved` | A comment is approved | | `deploy.triggered` | A deploy hook is triggered | | `schedule.fired` | A scheduled run fires | ### Event Payload Format All webhook payloads follow the same structure: ```json { "event": "content.saved", "projectId": "project-uuid", "timestamp": "2026-04-02T10:30:00.000Z", "data": { "models": ["blog-posts"], "locale": "en", "source": "api" } } ``` The envelope is always `{ event, projectId, timestamp, data }`. The `data` field varies by event type and contains event-specific information. Most events include a `source` field (`api`, `conversation`, etc.) indicating how the change was made. ### Event-Specific Data **`content.saved`:** ```json { "models": ["blog-posts"], "locale": "en", "source": "api" } ``` **`content.deleted`:** ```json { "models": ["blog-posts"], "locale": "en", "entryIds": ["a1b2c3d4e5f6", "g7h8i9j0k1l2"], "source": "conversation" } ``` **`model.saved`:** ```json { "modelId": "blog-posts", "source": "api" } ``` **`branch.merged`:** ```json { "branch": "cr/content/blog-posts/en/1774800862-27c1", "source": "api" } ``` **`cdn.build_complete`:** ```json { "buildId": "build-uuid", "status": "success", "filesUploaded": 15, "durationMs": 3200, "error": null } ``` **`media.uploaded`:** ```json { "assetId": "asset-uuid", "filename": "hero-banner.webp", "contentType": "image/webp" } ``` **`form.submitted`:** ```json { "submissionId": "submission-uuid", "modelId": "contact-form", "status": "pending" } ``` ## Webhook Security ### HMAC-SHA256 Signatures Every webhook delivery includes a signature in the `X-Contentrain-Signature` header. The signature is an HMAC-SHA256 hash of the request body using your webhook secret. To verify a webhook delivery: ```javascript import { createHmac, timingSafeEqual } from 'crypto' function verifyWebhook(body, signature, secret) { const expected = createHmac('sha256', secret) .update(body) .digest('hex') return timingSafeEqual( Buffer.from(expected), Buffer.from(signature) ) } ``` ::: warning Always verify webhook signatures before processing the payload. Use timing-safe comparison to prevent timing attacks. ::: ### SSRF Protection Studio validates webhook URLs to prevent Server-Side Request Forgery: - **Blocked:** `localhost`, `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`, `::1`, cloud metadata endpoints - **Required:** `http://` or `https://` protocol - **Recommended:** Use HTTPS in production ## Test Delivery You can send a test delivery to verify your webhook configuration: 1. Open the webhook in settings 2. Click **Test** 3. Studio sends a test payload to your endpoint 4. View the delivery result (status code, response time) ## Delivery Logs Every webhook delivery is logged. View the delivery history to see: | Field | Description | |-------|-------------| | Event type | Which event triggered the delivery | | Status code | HTTP response code from your endpoint | | Response time | How long the delivery took | | Timestamp | When the delivery was attempted | | Payload | The data that was sent | ## Retry Behavior If a webhook delivery fails (non-2xx response or timeout), Studio retries with exponential backoff: | Attempt | Delay | |---------|-------| | 1 | Immediate | | 2 | 1 minute | | 3 | 5 minutes | | 4 | 30 minutes | | 5 | 2 hours | | 6 | 12 hours | After 5 failed retries, the delivery is marked as permanently failed. You can view failed deliveries in the delivery logs. ::: tip Ensure your webhook endpoint responds quickly (under 10 seconds) with a 2xx status code. Return `200 OK` as soon as you receive the payload, then process it asynchronously. ::: ## Managing Webhooks ### Editing a Webhook You can update a webhook's URL, events, or active status: 1. Open webhook settings 2. Click on the webhook to edit 3. Modify the URL or event subscriptions 4. Save changes ### Deleting a Webhook To remove a webhook: 1. Open webhook settings 2. Click the delete button on the webhook 3. Confirm the deletion Deleting a webhook stops all future deliveries immediately. ## Plan Limits | Feature | Free | Starter | Pro | Enterprise | |---------|------|---------|-----|------------| | Outbound webhooks | 0 | 3 | 10 | Unlimited | ## Use Cases | Scenario | Events | Action | |----------|--------|--------| | Rebuild static site | `branch.merged` | Trigger CI/CD pipeline | | Notify Slack on new content | `content.saved` | Post to Slack channel | | Invalidate CDN cache | `cdn.build_complete` | Purge cache keys | | Process form submissions | `form.submitted` | Send to CRM or email | | Sync search index | `content.saved`, `content.deleted` | Update Algolia/Typesense | | Audit trail | All events | Log to monitoring service | ## Next Steps - [CDN](/guide/cdn) — Deliver content through the built-in CDN - [Forms](/guide/forms) — Accept and manage form submissions - [Conversation API](/guide/conversation-api) — Programmatic content management --- # Conversation API Source: https://docs.contentrain.io/guide/conversation-api > Programmatically manage content through Contentrain Studio's Conversation API — send messages, receive AI responses, and automate content operations. # Conversation API The Conversation API lets you interact with Contentrain Studio's AI agent programmatically. Instead of using the web interface, you can send messages and receive structured responses through a REST API — enabling chatbots, CI/CD pipelines, and custom integrations. ## What the Conversation API Is The Conversation API exposes the same AI agent that powers the Studio chat panel, but through a programmatic interface: - Same tool set (create/update/delete content, manage models, etc.) - Same permission model (scoped by API key role) - Same branch workflow (changes go to review branches) - Same conversation context (maintains history within a session) ## API Key Management Conversation API access requires an API key, managed in the project settings. ### Creating an API Key 1. Open the project settings (gear icon in the sidebar) 2. Go to the **API keys** tab and find **Conversation API keys** 3. Click **Create key** 4. Configure the key: | Setting | Description | |---------|-------------| | **Name** | Descriptive label for the key | | **Role** | `viewer`, `editor`, or `admin` — determines what the agent can do | | **Specific models** | Restrict access to certain models (Enterprise) | | **Allowed models** | List of model IDs the key can access | | **Allowed tools** | Which agent tools the key can invoke | | **Allowed locales** | Which locales the key can read/write | | **Custom instructions** | Additional system prompt text for this key | | **AI model** | Which Claude model to use — Haiku or Sonnet (defaults to Sonnet) | 5. The key is generated and shown once — copy it immediately ### Key Format Conversation API keys follow the format: ``` crn_conv_{random-base62-string} ``` ::: warning API keys are shown only once at creation time. Studio stores only the SHA-256 hash. If you lose the key, create a new one and delete the old one. ::: ### Editing and Deleting Keys You can update a key's name, role, and restrictions at any time. Deleting a key immediately revokes access. ## Sending Messages ### Endpoint ``` POST /api/conversation/v1/{projectId}/message Authorization: Bearer crn_conv_... Content-Type: application/json ``` ### Request Body ```json { "message": "Create a blog post titled 'API-Created Post' with status published", "conversationId": null, "context": { "activeLocale": "en" } } ``` | Field | Required | Description | |-------|----------|-------------| | `message` | Yes | The user message to send to the agent | | `conversationId` | No | Continue an existing conversation (null for new) | | `context` | No | UI context hints — `activeLocale`, `activeModelId`, `activeEntryId`, `activeBranch` — that ground the agent's operations | There is no per-request model override. The Claude model is fixed per key: it is the **AI model** chosen when the key was created (see [Creating an API Key](#creating-an-api-key)). ### Response The endpoint returns a **single JSON object** once the agent's turn completes — it does not stream: ```json { "conversationId": "conv-uuid", "message": "Done! The blog post has been created on branch cr/content/blog-posts/en/...", "toolResults": [ { "id": "tool-1", "name": "save_content", "result": { "branch": "cr/content/blog-posts/en/..." } } ], "usage": { "inputTokens": 1234, "outputTokens": 567, "cacheCreationInputTokens": 0, "cacheReadInputTokens": 0 } } ``` | Field | Description | |-------|-------------| | `conversationId` | The conversation ID — pass it back as `conversationId` on the next request to continue the thread | | `message` | The agent's final response text | | `toolResults` | Results of any tools the agent invoked this turn (omitted when no tools ran) | | `usage` | Token usage for the turn — input, output, and prompt-cache tokens | ## Conversation History ### Fetching a Conversation ``` GET /api/conversation/v1/{projectId}/history?conversationId=conv-uuid Authorization: Bearer crn_conv_... ``` The `conversationId` query parameter is **required** — the endpoint returns the messages for that one conversation, not a list of conversations. An optional `limit` (default 50, max 100) caps how many messages come back. ```json { "conversationId": "conv-uuid", "messages": [ { "id": "msg-uuid-1", "role": "user", "content": "Create a blog post titled 'API-Created Post'", "createdAt": "2026-04-02T10:00:00Z" }, { "id": "msg-uuid-2", "role": "assistant", "content": "Done! The blog post has been created...", "model": "claude-sonnet-4-6", "usage": { "inputTokens": 1234, "outputTokens": 567 }, "createdAt": "2026-04-02T10:00:05Z" } ] } ``` ### Continuing a Conversation Pass the `conversationId` in subsequent requests to maintain context: ```json { "message": "Now update the title to 'Updated Post'", "conversationId": "conv-uuid" } ``` The agent will remember the previous context and can reference earlier operations. ## Custom Instructions Each API key can have **custom instructions** — additional text injected into the agent's system prompt. Use this to: - Set content guidelines ("Always use formal English") - Define default behaviors ("Set status to draft unless I say published") - Restrict scope ("Only work with the blog-posts model") - Add domain knowledge ("Our brand voice is friendly and professional") Custom instructions are configured when creating or editing the API key. ## Use Cases ### Chatbot on Your Website Embed a chat widget on your website that uses the Conversation API to let visitors ask questions about your content or submit feedback: ```javascript const response = await fetch('/api/conversation/v1/{projectId}/message', { method: 'POST', headers: { 'Authorization': 'Bearer crn_conv_...', 'Content-Type': 'application/json', }, body: JSON.stringify({ message: userInput, conversationId: sessionId, }), }) const { conversationId, message } = await response.json() // Show `message` to the visitor; reuse `conversationId` on the next turn. ``` ### CI/CD Content Updates Automate content updates from your deployment pipeline: ```bash curl -X POST \ -H "Authorization: Bearer crn_conv_..." \ -H "Content-Type: application/json" \ -d '{"message": "Set the site-settings version field to 2.1.0"}' \ https://your-studio.com/api/conversation/v1/{projectId}/message ``` ### Bulk Content Operations Use the API to perform bulk operations that would be tedious in the UI: ```javascript const operations = [ 'Set all blog posts with status draft to published', 'Add the tag "featured" to entries with rating above 4', 'Copy all English FAQ entries to Spanish', ] for (const op of operations) { await sendMessage(op, conversationId) } ``` ### Content Migration Migrate content from external sources by instructing the agent: ``` "Create a team member entry with name 'Jane Smith', role 'CTO', bio 'Leading technology strategy...', and email 'jane@example.com'" ``` ## Role-Based Access Each Conversation API key has exactly one of three roles, and that role determines what the agent can do: | Role | Can Read | Can Write | Can Delete | Can Manage Models | |------|:--------:|:---------:|:----------:|:-----------------:| | **Viewer** | Yes | No | No | No | | **Editor** | Yes | Yes | Yes | No | | **Admin** | Yes | Yes | Yes | Yes | ::: info These three roles (`viewer` / `editor` / `admin`) are specific to Conversation API keys. They are not the same as the workspace and project roles used by the in-app [AI Chat](/guide/ai-chat) agent (workspace `owner` / `admin` / `member` combined with project `editor` / `reviewer` / `viewer`). ::: ### Model Restrictions With `specificModels` enabled, the API key can only access the models listed in `allowedModels`. This is useful for creating scoped keys that can only manage specific content areas. ### Tool Restrictions The `allowedTools` setting lets you restrict which agent tools a key can invoke. For example, a viewer key might only allow `brain_query` and `brain_search`, while an editor key allows `save_content` and `delete_content`. ### Locale Restrictions The `allowedLocales` setting restricts which locales the key can read from and write to. ## Plan Limits | Feature | Free | Starter | Pro | Enterprise | |---------|------|---------|-----|------------| | Conversation keys | 0 | 0 | 15 | Unlimited | | API messages/month | 0 | 100 | 3,000 | Unlimited | | Custom instructions *(roadmap)* | No | No | Yes | Yes | ::: info The Conversation API is an Enterprise-Edition feature available on **Pro and above** — Starter workspaces cannot create conversation keys. ::: ## Next Steps - [AI Chat](/guide/ai-chat) — Understand the agent's capabilities - [Content Models](/guide/content-models) — Define models for API-driven content - [Webhooks](/guide/webhooks) — Get notified when content changes via API --- # Team Management Source: https://docs.contentrain.io/guide/team-management > Invite members, assign workspace and project roles, manage permissions, and transfer ownership in Contentrain Studio. # Team Management Contentrain Studio uses a two-tier role system for access control. Workspace roles govern high-level access, while project roles provide fine-grained permissions within individual projects. ## Inviting Members ### Workspace Invitations To invite a new member to your workspace: 1. Navigate to **Workspace Settings > Members** 2. Click **Invite member** 3. Enter the invitee's email address 4. Select a workspace role (**Admin** or **Member**) 5. Click **Send invitation** The invitee receives an email with a link to join your workspace. They can sign in with Google OAuth or a magic link — GitHub OAuth is not required for invited members. ![The Members tab: workspace members with role badges, the invite form, and per-project role assignment](/screenshots/workspace-members.png) ::: tip You can resend an invitation if the original email was missed. Click the resend button next to any pending invitation in the members list. ::: ### Invitation States | State | Description | |-------|-------------| | **Pending** | Invitation sent, not yet accepted (`accepted_at` is null) | | **Accepted** | Member has signed in and joined the workspace | ## Workspace Roles Every workspace member has exactly one workspace role: | Role | Capabilities | |------|-------------| | **Owner** | Full control — billing, settings, member management, transfer ownership, delete workspace. Every workspace has exactly one owner. | | **Admin** | Manage members, manage settings, access all projects implicitly. Cannot transfer ownership or delete the workspace. | | **Member** | Access only explicitly assigned projects. Cannot manage workspace settings or members. | ### Implicit Project Access Workspace **Owners** and **Admins** have implicit access to all projects in the workspace. They do not need explicit project member assignments. Workspace **Members** must be explicitly assigned to each project they need to access through project-level member management. ### Changing Roles To change a member's workspace role: 1. Navigate to **Workspace Settings > Members** 2. Find the member in the list 3. Use the role dropdown to change between **Admin** and **Member** ::: warning You cannot change the Owner role through the dropdown. Use the ownership transfer feature instead. You also cannot change your own role. ::: ### Removing Members To remove a member from a workspace: 1. Navigate to **Workspace Settings > Members** 2. Click the remove button next to the member 3. Confirm the removal Removing a workspace member also removes their project-level assignments within that workspace. ## Project Roles Project roles control what a member can do within a specific project: | Role | Content | Models | Branches | AI Chat | |------|:-------:|:------:|:--------:|:-------:| | **Editor** | Create, edit, delete | View | View, create | Full access | | **Reviewer** | View | View | Merge, reject | Full access | | **Viewer** | View only | View | View only | Read only | ::: info Reviewer & Viewer require Enterprise Edition The **Reviewer** and **Viewer** roles are available on Starter and above on the managed service, but they are **Enterprise Edition** capabilities (`requires_ee`). They only function when the enterprise bridge is present. In a self-hosted **Community Edition** deployment (no `ee/`), a member assigned Reviewer or Viewer degrades to **Editor** — read/write access with no review separation. ::: ### Editor Editors are the primary content creators. They can: - Create, update, and delete content entries - Use the AI chat to perform content operations - Create content branches through edits - View branches and their diffs ### Reviewer Reviewers focus on quality control. They can: - View all content (read-only) - Review branch diffs - Merge or reject content branches - Use the AI chat for read operations ::: tip The Reviewer role is ideal for editors-in-chief or content managers who approve content but do not create it directly. ::: ### Viewer Viewers have read-only access. They can: - Browse content entries - View model schemas - View branch diffs - Use the AI chat for read-only queries ## Assigning Project Members To assign a workspace member to a project: 1. Open the project settings 2. Navigate to the **Members** section 3. Enter the member's email (must already be a workspace member) 4. Select a project role (**Editor**, **Reviewer**, or **Viewer**) 5. Click **Assign** ### Model-Specific Access (Pro) On **Pro** and **Enterprise** plans, you can restrict a project member's access to specific models: 1. When assigning a project member, enable **Specific models** 2. Select which models the member can access from the **Allowed models** list 3. The member will only see and interact with the selected models This is useful for large projects where different team members manage different content areas (e.g., marketing team manages marketing models, docs team manages documentation). ::: info Enterprise Edition capability Model-specific access is gated by the `roles.specific_models` feature (Pro and above) and also requires the Enterprise Edition bridge (`requires_ee`). On Free and Starter plans, and in a self-hosted Community Edition deployment, **Specific models** is always treated as off (full model access). ::: ## Permission Matrix Complete permission breakdown: | Action | Owner | Admin | Editor | Reviewer | Viewer | |--------|:-----:|:-----:|:------:|:--------:|:------:| | View content | Yes | Yes | Yes | Yes | Yes | | Create/edit content | Yes | Yes | Yes | No | No | | Delete content | Yes | Yes | Yes | No | No | | Merge branches | Yes | Yes | No | Yes | No | | Reject branches | Yes | Yes | No | Yes | No | | Use AI chat (write) | Yes | Yes | Yes | No | No | | Use AI chat (read) | Yes | Yes | Yes | Yes | Yes | | Manage models | Yes | Yes | No | No | No | | Manage project settings | Yes | Yes | No | No | No | | Manage workspace settings | Yes | Yes | No | No | No | | Manage members | Yes | Yes | No | No | No | | Manage billing | Yes | No | No | No | No | | Transfer ownership | Yes | No | No | No | No | | Delete workspace | Yes | No | No | No | No | ## Transferring Ownership To transfer workspace ownership to another member: 1. The target member must have the **Admin** role 2. Navigate to **Workspace Settings > Members** 3. Use the transfer ownership action on the Admin member 4. Confirm the transfer 5. The previous owner becomes an Admin ::: warning Ownership transfer is immediate and cannot be undone from the UI. The new owner has full control including billing, member management, and workspace deletion. ::: ## Removing Project Members To remove a member from a project: 1. Open the project settings 2. Navigate to the **Members** section 3. Click the remove button next to the member 4. Confirm the removal This only removes the project assignment — the member remains in the workspace and can be reassigned to other projects. ## Authentication Methods by Role | Role | GitHub OAuth | Google OAuth | Magic Link | |------|:-----------:|:------------:|:----------:| | **Owner** | Required | No | No | | **Admin** | Optional | Yes | Yes | | **Member** | Optional | Yes | Yes | Workspace owners must use GitHub OAuth because they need repository access for the GitHub App installation. Invited members (Admins and Members) can use any available method. ## Plan Limits | Feature | Free | Starter | Pro | Enterprise | |---------|------|---------|-----|------------| | Team members | 1 | 3 | 25 | Unlimited | | Reviewer role *(EE)* | No | Yes | Yes | Yes | | Viewer role *(EE)* | No | Yes | Yes | Yes | | Model-specific access *(EE)* | No | No | Yes | Yes | | Conversation API keys *(EE)* | 0 | 0 | 15 | Unlimited | | API messages/month | 0 | 100 | 3,000 | Unlimited | | MCP Cloud keys | 0 | 1 | 15 | Unlimited | | MCP Cloud calls/month | 0 | 5,000 | 150,000 | Unlimited | | Outbound webhooks *(EE)* | 0 | 3 | 25 | Unlimited | Rows marked *(EE)* are Enterprise Edition capabilities — available on the managed service but hidden in a self-hosted Community Edition deployment. See [Billing & Plans](/guide/billing) for the full feature matrix. ## Next Steps - [Workspaces](/guide/workspaces) — Manage workspace settings - [Authentication](/guide/authentication) — Understand sign-in methods - [Billing & Plans](/guide/billing) — Upgrade for more team members --- # Connected Apps Source: https://docs.contentrain.io/guide/connected-apps > Review and disconnect the AI apps — Claude, ChatGPT, Codex — that have connected to your workspace over the remote MCP connector. # Connected Apps **Connected Apps** lists every AI app that has connected to your workspace through the [remote MCP connector](/developer/mcp-connector). Each entry is an active **OAuth grant** — an app (such as Claude or ChatGPT) that a member authorized in the browser, scoped to a single project. This tab is where you review those connections and disconnect any you no longer trust. ![The Connected apps tab: the remote MCP endpoint at the top, followed by a list of connected apps showing each app's logo, host, project, granted scopes, last-used time, and monthly call count, with a Disconnect button per row](/screenshots/connected-apps.png) ::: info Managed deployments only Connected Apps appears only on **managed-pair** deployments, where the remote MCP OAuth surface runs. On a self-hosted deployment that uses API-key access, the panel explains that the remote MCP endpoint is unavailable and points you to **Project settings → API keys** instead. Remote MCP connections must also be included in your plan. ::: ## Where to find it Open **Workspace Settings → Connected apps**. The tab sits alongside Members, Billing, GitHub, and AI. At the top of the panel you'll find your workspace's **remote MCP endpoint** and a ready-to-copy `claude mcp add` command (server name `contentrain-remote`, the same name in the Codex config) for connecting a new app; below that is the list of apps currently connected. ## What each connection shows Every row corresponds to one OAuth grant: | Field | Description | |-------|-------------| | **App** | The client app's host (e.g. `claude.ai`), name, and logo, as declared during authorization. | | **Project** | The repository the connection is scoped to — an app grant is bound to a single project. | | **Scopes** | The permissions granted, shown as tags (for example `content:read`, `content:write`). | | **Last used** | When the app last called the endpoint, or *Never used* if it hasn't yet. | | **Calls this month** | The number of requests this grant has made in the current calendar month. | | **You** badge | Marks connections that **you** authorized, so you can tell your own apps apart from teammates'. | ## Who can see and manage what Visibility follows your workspace role: | Role | Sees | Can disconnect | |------|------|----------------| | **Owner / Admin** | Every grant in the workspace | Any grant | | **Member** | Only their own grants | Only their own grants | A member's connection is theirs to manage, not to browse — members never see the apps other people have connected. ## Disconnecting an app To revoke a connection, click **Disconnect** on its row and confirm. Disconnecting revokes the grant's entire refresh-token family and deletes its live access tokens immediately. The connected app's next request fails cleanly with an `invalid_grant` response, and the app is prompted to re-authorize from scratch — there is no lingering access. ::: warning Disconnecting takes effect at once and cannot be undone. If the app is still in use, whoever relies on it will need to reconnect and re-authorize through the browser. Reconnecting mints a brand-new grant. ::: ## See also - [MCP Connector](/developer/mcp-connector) — how the remote MCP OAuth flow works and how to connect an app. - [Workspaces API reference](/developer/api-reference/workspaces) — the endpoints that back listing and revoking connected apps. --- # Account Settings Source: https://docs.contentrain.io/guide/account-settings > Manage your personal profile, avatar, theme, and account — including account deletion — from the user settings page. # Account Settings Your **account settings** are personal to you and span every workspace you belong to — they are separate from [workspace settings](/guide/workspaces), which configure a single team. Open them at **`/settings`**, or click your name at the bottom of the sidebar. The page has two tabs: **Profile** and **Account**. You can deep-link to a tab with `?tab=profile` or `?tab=account`. ## Profile The Profile tab manages how you appear across Studio. | Field | Editable | Notes | |-------|:--------:|-------| | **Display name** | Yes | 1–100 characters. Shown in the sidebar, on members lists, and as the `updated_by` author on your content changes. | | **Avatar** | Yes | Upload a JPEG, PNG, WebP, or GIF up to 2 MB. It is processed to a 256×256 WebP and stored as a base64 data URI in the `profiles.avatar_url` database column. You can remove it to fall back to your OAuth provider's avatar. | | **Email** | No | Read-only — it comes from your authentication provider. | | **Connected account** | No | A badge shows which provider you signed in with (GitHub, Google, or magic link). | | **Theme** | Yes | Light, Dark, or System. Your preference is saved to your profile and follows you across devices. | ::: tip Changing your display name updates the author attribution on **future** content changes only — it does not rewrite past Git history. ::: ## Account The Account tab holds the **danger zone**: permanent deletion of your account. ### Deleting your account Account deletion is guarded by a typed-confirmation step — you re-type your own email address in the UI to confirm. **No confirmation email is sent.** When you confirm, Studio: - Deletes your profile, avatar, and authentication identity - **Cascades** to every workspace you own and your memberships in other workspaces - Clears your session and signs you out ::: warning Ownership matters Deleting your account also deletes the workspaces you **own** — along with their projects' Studio-side metadata (members, keys, settings). Your content itself is unaffected: it lives in your connected Git repositories, which Studio never deletes. If a team should outlive you, [transfer workspace ownership](/guide/team-management#transferring-ownership) before deleting your account. ::: This is a GDPR-style hard delete. It cannot be undone. ## API These settings are backed by internal profile endpoints — `PATCH /api/profile`, `POST` / `DELETE /api/profile/avatar`, `GET /api/profile/owned-workspaces`, and `DELETE /api/profile`. See the [Authentication API reference](/developer/api-reference/authentication#update-profile) for request and response shapes. ## Next Steps - [Workspaces](/guide/workspaces) — workspace-level (team) settings - [Team Management](/guide/team-management) — roles, invitations, and ownership transfer - [Authentication](/guide/authentication) — how sign-in and connected accounts work --- # Billing & Plans Source: https://docs.contentrain.io/guide/billing > Understand Contentrain Studio's plan tiers, feature comparison, usage limits, overage, trial management, and billing lifecycle. # Billing & Plans Contentrain Studio uses an open core model. The AGPL-licensed core is a fully functional product, and paid plans unlock additional features and higher usage limits. Billing is per-workspace and runs through the configured payment provider — **Polar by default**, with **Stripe** also supported. ::: tip Editions vs. plans Plan tiers (`free` / `starter` / `pro` / `enterprise`) apply to the **managed** service. A **self-hosted Community Edition** deployment runs the AGPL core only: it resolves to the fixed `community` tier, has no billing UI, leaves numeric limits unenforced, and hides Enterprise-only features. See [Editions](/developer/enterprise-edition) for the full split. ::: ## Plan Tiers | | Free | Starter | Pro | Enterprise | |--|------|---------|-----|------------| | **Price** | $0 | $9/mo | $49/mo | Custom | | **Seats included** | 1 | 3 | 25 | Unlimited | | **AI messages/mo** | 0 | 150 | 1,500 | Unlimited | | **Bring your own key (BYOA)** | — | — | Yes | Yes | | **Media storage** | — | 1 GB | 15 GB | 100 GB | ### Free Free is a **structural workspace shell created at signup** — not a usable tier on its own: - No AI messages, no media, no project connection - Browse the dashboard and explore the interface - Clicking **Connect Repository** opens plan selection - Start a 14-day trial on Starter or Pro to unlock the platform ### Starter Starter unlocks the full platform for solo developers and small projects (14-day free trial): - AI agent with the **full model lineup** (Haiku 4.5, Sonnet 4.6, Sonnet 5, Opus 4.8) via Contentrain's Studio-hosted key — 150 messages/month - Content CRUD — all 4 model kinds, 27 field types, multi-locale, branch review workflow - Media upload — 1 GB storage, 5 MB max file size, 4 variants/field - CDN delivery — 3 API keys, 2 GB bandwidth/month - Forms — 1 form-enabled model, 100 submissions/month, CAPTCHA, auto-approve - Outbound webhooks — up to 3 - MCP Cloud — 1 key, 5,000 calls/month - Reviewer and Viewer roles - Up to 3 team members ### Pro Pro scales for growing content teams. Everything in Starter, plus: - AI agent — 1,500 messages/month on the Studio-hosted key (same full model lineup as Starter) - **BYOA** — bring your own Anthropic API key - Media — 15 GB storage, 50 MB max file size, custom variants (10/field) - CDN — 25 API keys, 60 GB bandwidth/month, preview branches *(roadmap)* - Forms — 15 form-enabled models, 3,000 submissions/month, spam filter *(roadmap)* - Conversation API — 15 keys, 3,000 API messages/month, custom instructions *(roadmap)* - MCP Cloud — 15 keys, 150,000 calls/month - Outbound webhooks — up to 25 - Model-specific access control - Up to 25 team members ### Enterprise Enterprise is for organizations with advanced requirements: - Custom limits, effectively unlimited usage - 100 GB media storage (or custom) - SSO — SAML / OIDC *(roadmap)* - White-label branding *(roadmap)* - Custom CDN domain *(roadmap)* - Dedicated support and custom pricing ## Feature Comparison Values below come from Studio's plan-feature matrix. Items marked *(roadmap)* are planned and not yet generally available. Most Media, CDN, Conversation API, outbound-webhook, and advanced-role capabilities are **Enterprise Edition** features — available on the managed service but absent from a self-hosted Community deployment. ### AI & Content | Feature | Free | Starter | Pro | Enterprise | |---------|:----:|:-------:|:---:|:----------:| | AI agent | — | 150 msg/mo | 1,500 msg/mo | Unlimited (custom) | | Bring your own API key (BYOA) | — | — | Yes | Yes | | Studio-hosted AI key | — | Yes | Yes | Yes | | Content CRUD | — | Yes | Yes | Yes | | All 4 model kinds | — | Yes | Yes | Yes | | 27 field types | — | Yes | Yes | Yes | | Multi-locale | — | Yes | Yes | Yes | | Branch review workflow | — | Yes | Yes | Yes | ### Media *(Enterprise Edition)* | Feature | Free | Starter | Pro | Enterprise | |---------|:----:|:-------:|:---:|:----------:| | Media library | — | Yes | Yes | Yes | | Upload | — | Yes | Yes | Yes | | Storage | — | 1 GB | 15 GB | 100 GB | | Max file size | — | 5 MB | 50 MB | 100 MB | | Variants per field | — | 4 | 10 | Unlimited | | Custom variants | — | — | Yes | Yes | ### CDN *(Enterprise Edition)* | Feature | Free | Starter | Pro | Enterprise | |---------|:----:|:-------:|:---:|:----------:| | CDN delivery | — | Yes | Yes | Yes | | API keys | 0 | 3 | 25 | Unlimited | | Bandwidth | 0 | 2 GB/mo | 60 GB/mo | Unlimited | | Preview branches *(roadmap)* | — | — | Yes | Yes | | Custom domain *(roadmap)* | — | — | — | Yes | ### Forms | Feature | Free | Starter | Pro | Enterprise | |---------|:----:|:-------:|:---:|:----------:| | Form-enabled models | 0 | 1 | 15 | Unlimited | | Submissions/month | 0 | 100 | 3,000 | Unlimited | | CAPTCHA (Turnstile) | — | Yes | Yes | Yes | | Auto-approve | — | Yes | Yes | Yes | | Webhook notification *(EE)* | — | Yes | Yes | Yes | | File upload *(EE, roadmap)* | — | Yes | Yes | Yes | | Spam filter *(EE, roadmap)* | — | — | Yes | Yes | ### API & Integrations | Feature | Free | Starter | Pro | Enterprise | |---------|:----:|:-------:|:---:|:----------:| | MCP Cloud | — | Yes | Yes | Yes | | MCP Cloud keys | 0 | 1 | 15 | Unlimited | | MCP Cloud calls/month | 0 | 5,000 | 150,000 | Unlimited | | Remote MCP (OAuth) | — | Yes | Yes | Yes | | Conversation API *(EE)* | — | — | Yes | Yes | | Conversation API keys | 0 | 0 | 15 | Unlimited | | API messages/month | 0 | 100 | 3,000 | Unlimited | | Outbound webhooks *(EE)* | 0 | 3 | 25 | Unlimited | | Custom instructions *(EE, roadmap)* | — | — | Yes | Yes | ### Team & Security | Feature | Free | Starter | Pro | Enterprise | |---------|:----:|:-------:|:---:|:----------:| | Team members | 1 | 3 | 25 | Unlimited | | Reviewer role *(EE)* | — | Yes | Yes | Yes | | Viewer role *(EE)* | — | Yes | Yes | Yes | | Model-specific access *(EE)* | — | — | Yes | Yes | | SSO — SAML / OIDC *(EE, roadmap)* | — | — | — | Yes | | White-label *(EE, roadmap)* | — | — | — | Yes | ## Usage & Overage Metered limits reset monthly. On Pro and above you can opt in to **overage** in **Workspace Settings → Billing** so that work continues past the included quota at metered rates instead of being blocked: ![The Billing tab: current plan plus per-resource usage meters (AI messages, form submissions, CDN bandwidth, media storage, API messages), each with an "Allow overage" toggle](/screenshots/billing-panel.png) | Metered resource | Overage rate | |------------------|--------------| | AI messages | $0.03 / message | | API messages (Conversation API) | $0.05 / message | | MCP Cloud calls | $0.005 / call | | CDN bandwidth | $0.10 / GB | | Form submissions | $0.01 / submission | | Media storage | $0.25 / GB / month | With overage disabled, requests beyond the included quota are rejected until the next monthly reset or a plan upgrade. ## Payment & Subscriptions Plan upgrades, subscription management, and invoicing run through the configured payment provider (Polar by default; Stripe supported). ### Starting a Subscription 1. Navigate to **Workspace Settings → Billing** 2. Click **Upgrade** next to the plan you want 3. You are redirected to the provider's hosted checkout 4. Complete the payment form 5. After successful payment, you are redirected back to Studio 6. Your workspace plan is updated automatically via a billing webhook ### Trial Period Starter and Pro include a **14-day free trial**. During the trial: - You have full access to all features of the selected plan - No payment is charged until the trial ends - You can cancel before the trial ends to avoid charges - A trial countdown shows remaining days in the billing panel ### Managing Your Subscription Click **Manage subscription** in the billing panel to open the provider's customer portal, where you can: - Update payment methods - View invoices and payment history - Change your plan (upgrade or downgrade) - Cancel your subscription ## Billing States Your workspace can be in one of these billing states: | State | Meaning | Access | |-------|---------|--------| | `free` | No subscription, Free shell | Free-tier only (no platform access) | | `trial_active` | Trial period in progress | Full plan features | | `trial_expired` | Trial ended without subscribing | Locked — upgrade required | | `subscribed` | Active paid subscription | Full plan features | | `past_due` | Payment failed, grace period active | Full features during grace | | `grace_expired` | Grace period ended | Locked — update payment | | `canceled` | Subscription canceled, period remaining | Full features until period end | | `canceled_expired` | Paid period ended after cancellation | Locked (402) — resubscribe required | ### Locked States When a workspace enters a locked state (`trial_expired`, `grace_expired`, `canceled_expired`), paid features are disabled: - Existing content stays readable, and you can always export your data - To restore full access, upgrade or update your payment method ::: tip Studio never deletes your content when a subscription lapses. Your content lives in your Git repository, and you always retain access to it. ::: ## Self-Hosted & Community Edition A self-hosted Community Edition deployment has no managed billing: - All workspaces resolve to the fixed `community` tier - Numeric limits are unenforced (you run your own infrastructure) - Enterprise-only features (Media, CDN, outbound webhooks, Conversation API, advanced roles) are hidden - AI uses your own Anthropic API key - No billing UI is shown; plan enforcement still routes through the same `hasFeature()` / `getPlanLimit()` system See [Self-Hosting](/developer/self-hosting) and [Enterprise Edition](/developer/enterprise-edition) for details. ## Upgrading and Downgrading ![The plan selection modal — Starter and Pro cards with usage limits, included features, and "coming soon" items; Enterprise via Contact Sales](/screenshots/plan-modal.png) ### Upgrading 1. Navigate to **Workspace Settings → Billing** 2. Click **Upgrade** on the desired plan 3. Complete hosted checkout 4. Features are available immediately after payment ### Downgrading 1. Open the customer portal via **Manage subscription** 2. Select a lower plan 3. Changes take effect at the end of the current billing period 4. Features above the new plan's limits become unavailable ::: warning When downgrading, ensure you are within the lower plan's limits (team members, API keys, form models, etc.). Excess items are not automatically removed but may become inaccessible. ::: ## Next Steps - [Workspaces](/guide/workspaces) — Understand workspace-level billing - [Team Management](/guide/team-management) — Manage seats within your plan - [AI Chat](/guide/ai-chat) — Understand AI message limits and model tiers --- # Keyboard Shortcuts Source: https://docs.contentrain.io/guide/keyboard-shortcuts > Navigate Contentrain Studio efficiently with the command palette, search modes, and keyboard shortcuts. # Keyboard Shortcuts Contentrain Studio includes a command palette and keyboard shortcuts for fast navigation. The command palette is a universal search and action hub that works from anywhere in the application. ## Command Palette Press **`Cmd+K`** (macOS) or **`Ctrl+K`** (Windows/Linux) to open the command palette. ![The command palette (Cmd+K): a search input with grouped results — here, the project's content models](/screenshots/command-palette.png) The palette provides: - **Universal search** across models, entries, vocabulary, branches, conversations, projects, and workspaces - **Command execution** for common actions - **Recent items** for quick access to previously used items ## Search Modes The command palette supports prefix-based search modes: | Prefix | Mode | Description | |--------|------|-------------| | *(none)* | **Global** | Search everything — models, entries, vocabulary, branches, conversations, projects, workspaces | | `>` | **Commands** | Filter to actionable commands only | | `#` | **Vocabulary** | Search vocabulary terms by key or value | | `!` | **Branches** | Search content branches by name | | `@model` | **Model** | Search within a specific model's entries (e.g., `@blog-posts`) | | `?` | **Help** | Show search mode help and keyboard shortcuts | ### Global Search Type anything without a prefix to search across all categories. Results are grouped by type: - **Recent** — Previously accessed items (shown when no query) - **Commands** — Matching actions (shown when query is 2+ characters) - **Models** — Content models matching the query - **Vocabulary** — Vocabulary terms matching key or value - **Branches** — Branches matching the name - **Conversations** — Past conversations matching the title - **Projects** — Projects matching the repository name - **Workspaces** — Workspaces matching name or slug ### Command Mode (`>`) Type `>` followed by your search to filter to commands only: ``` > dark mode → Toggle dark/light mode > settings → Open workspace settings > connect → Connect a repository > new conv → Start a new conversation ``` ### Vocabulary Mode (`#`) Type `#` to search vocabulary terms: ``` # navigation → Find vocab terms with "navigation" # home → Find terms with "home" in key or value ``` ### Branch Mode (`!`) Type `!` to search branches: ``` ! blog → Find branches related to blog content ! merged → Find branches with "merged" in the name ``` ### Model Mode (`@`) Type `@` followed by a model ID to search within that model: ``` @blog-posts → Show blog-posts model with entry count @team-members → Show team-members model ``` ### Help Mode (`?`) Type `?` to see all available search modes and their prefixes. ## Available Commands ### Appearance | Command | Keywords | Action | |---------|----------|--------| | **Toggle dark mode** | theme, dark, light, mode, appearance | Switch between light and dark themes | ### Navigation | Command | Keywords | Action | |---------|----------|--------| | **Sign out** | sign out, logout, exit, quit | Sign out of Studio | | **Switch workspace** | workspace, switch, change, home | Return to workspace list | | **Create workspace** | workspace, new, create | Create a new workspace | ### Workspace | Command | Keywords | Action | |---------|----------|--------| | **Settings** | settings, workspace, preferences | Open workspace settings | | **Members** | members, team, invite, people, roles | Open members management | | **GitHub** | github, installation, repos, git | Open GitHub settings | | **AI** | ai, keys, api, byoa, anthropic, claude | Open your AI key (Workspace Settings → AI) | | **Connect repository** | connect, repo, repository, new project, add project | Open connect repo dialog | ### Project (available when inside a project) | Command | Keywords | Action | |---------|----------|--------| | **Project overview** | overview, home, dashboard, back | Return to project overview | | **New conversation** | chat, new, conversation, clear | Start a fresh conversation | | **Vocabulary** | vocabulary, terms, glossary, dictionary | Open vocabulary editor | | **CDN Delivery** | cdn, delivery, api, build | Open CDN panel | | **Media library** | media, assets, images, files, library, upload | Open media library | | **Project health** | health, score, validation, warnings, quality | Open health dashboard | | **Project settings** | settings, project, config, configuration | Open project settings | | **Webhooks** | webhooks, hooks, events, notifications, integrations | Open webhook settings | | **API keys** | conversation, api, keys, external, bot, integration | Open the project's API keys tab (MCP keys and Conversation API keys) | | **MCP keys** | mcp, cursor, agent, keys, ci, claude code | Open the project's API keys tab (MCP keys section) | ### AI Model Selection (available when inside a project) | Command | Keywords | Action | |---------|----------|--------| | **Switch to Haiku 4.5** | haiku, fast, economic, model, ai, switch | Set AI model to Haiku 4.5 | | **Switch to Sonnet 4.6** | sonnet, balanced, model, ai, switch | Set AI model to Sonnet 4.6 | | **Switch to Sonnet 5** | sonnet, balanced, newest, model, ai, switch | Set AI model to Sonnet 5 | | **Switch to Opus 4.8** | opus, capable, best, model, ai, switch | Set AI model to Opus 4.8 | ### Agent Actions (available when inside a project) These commands send a pre-written prompt to the AI chat to kick off a content operation: | Command | Keywords | Prompt Sent | |---------|----------|-------------| | **Add model** | create, model, new, schema | "Create a new content model. Ask me what kind of content I want to manage." | | **Add entry** | create, entry, new, content, add | "Create a new entry. Ask me which model and what content." | | **Translate content** | translate, locale, language, i18n | "Translate content to another locale. Ask me which model and target language." | | **Validate schema** | validate, schema, check, integrity | "Run schema validation and health check on this project. Report any issues found." | | **SEO audit** | seo, audit, meta, optimization | "Run an SEO audit on all content models. Check for missing meta descriptions, titles, and slugs." | | **Analyze content quality** | analyze, quality, score, report | "Analyze content quality across all models. Give me a quality score and improvement suggestions." | | **Check translations** | locale, parity, translations, missing, i18n | "Check locale parity — find missing translations across all models and locales." | | **Find stale content** | stale, old, outdated, unused, cleanup | "Find stale content that hasn't been updated recently. Highlight entries that may need review." | | **Upload media** | upload, media, image, url, file | "Upload media from a URL. Ask me for the URL and details." | | **Copy locale** | copy, locale, bootstrap, language, duplicate | "Bootstrap a new locale by copying content from the source locale. Ask me which source and target languages." | ## Recent Items When you open the command palette without typing anything, it shows up to 8 recently accessed items. Recent items are stored in your browser's local storage and persist across sessions. Clicking any result in the command palette adds it to your recent items for quick future access. ## Keyboard Navigation Within the command palette: | Key | Action | |-----|--------| | `Arrow Up` / `Arrow Down` | Navigate between results | | `Enter` | Execute the selected result | | `Escape` | Close the palette | | Type anything | Filter results | ## Quick Reference | Shortcut | Action | |----------|--------| | `Cmd+K` / `Ctrl+K` | Open command palette | | `Escape` | Close palette or cancel current action | ::: tip The command palette is context-aware. When you are inside a project, project-specific commands (vocabulary, CDN, media, health) become available. When you are on the workspace dashboard, workspace commands are shown. ::: ## Next Steps - [AI Chat](/guide/ai-chat) — Use the chat interface for content operations - [Projects](/guide/projects) — Navigate the project workspace - [Workspaces](/guide/workspaces) — Switch between workspaces --- # Troubleshooting Source: https://docs.contentrain.io/guide/troubleshooting > Solutions to common issues — sign-in, connecting repositories, AI chat, content review, CDN, forms, MCP Cloud, and billing. # Troubleshooting Common problems and how to resolve them. If your issue isn't here, see the [GitHub repository](https://github.com/Contentrain/studio) to open an issue. ## Sign-in & access **Can't sign in.** Workspace owners sign in with **GitHub OAuth** (it needs repository access). Invited members use **Google OAuth** or a **magic link**. Magic-link requests are rate-limited to **5 per minute** — wait a moment and retry. If a magic link has expired, request a fresh one. **Logged out unexpectedly.** Sessions auto-refresh shortly before they expire. If you're signed out anyway, just sign in again — your work is in Git, not the session. ## Connecting a repository **"Connect Repository" opens a plan picker.** The **Free** tier is a structural signup shell — it can't create projects. Start a 14-day **Starter** or **Pro** trial to unlock the platform. **GitHub App not installed.** Connecting a repo requires the Contentrain **GitHub App** installed on the workspace's GitHub account or organization. Install it from the connect flow or **Workspace Settings → GitHub**. **Repository shows as inaccessible.** The GitHub App was uninstalled, suspended, or had its repository access/permissions changed. Re-grant access (or reinstall the App) and reconnect the project. ## AI chat **Agent won't respond / "no API key".** On **Starter**, the agent runs on Contentrain's Studio-hosted key with the full model lineup (Haiku 4.5, Sonnet 4.6, Sonnet 5, Opus 4.8). On **Pro**, you can also bring your own Anthropic key (**BYOA**). A self-hosted **Community** deployment must provide its own `NUXT_ANTHROPIC_API_KEY`. **"Monthly AI messages exhausted".** You've reached your plan's AI-message quota. Enable [overage](/guide/billing#usage-overage) (Pro+) or upgrade your plan. **A model isn't selectable.** All managed paid plans (Starter and above) expose the full model lineup (Haiku 4.5, Sonnet 4.6, Sonnet 5, Opus 4.8) via the Studio-hosted key. A self-hosted **Community** deployment has no Studio-hosted key and exposes only **Haiku 4.5**. ## Content & review **My changes don't show up on the content branch.** Under the **review** workflow, agent and MCP writes land as `cr/*` pending-review branches. Approve them under **Pending Changes**. Under **auto-merge**, they land automatically. See [Branches & Review](/guide/branches-and-review). **"Too many open branches".** Branch health **warns at 50** and **blocks at 80** open `cr/*` branches. Merge or reject pending branches; merged branches are cleaned up automatically. **Validation errors when saving.** Content must satisfy the model's field definitions (required fields, types, constraints). Fix the flagged fields and save again. ## CDN **Content isn't served / 401.** CDN delivery is an Enterprise-Edition feature (Starter+). You need a CDN API key (`crn_live_...`) and at least one successful build. Trigger a build and confirm it completed. See [CDN](/guide/cdn). ## Forms **Submission rejected.** Confirm the target model is form-enabled, the CAPTCHA (Turnstile) passed, and you're within your plan's monthly submission quota. See [Forms](/guide/forms). ## MCP Cloud **`429 Too Many Requests`.** You hit the key's per-minute rate limit or the monthly call quota. Back off for the `Retry-After` window, or raise the key's limits / upgrade the plan. **A tool returns a capability error.** Lifecycle tools (`contentrain_merge`, `contentrain_branch_*`, `contentrain_submit`, `contentrain_init`, `contentrain_scaffold`, `contentrain_bulk`, `contentrain_doctor`, `contentrain_scan`, `contentrain_apply`, and `contentrain_validate` with `fix`) are not exposed over MCP Cloud — that merge/review lifecycle is Studio-owned. See [MCP Cloud](/developer/mcp-cloud#remote-tool-surface). ## Billing **Workspace is locked.** The trial expired, or a payment failed and the grace period ended. Upgrade or update your payment method in **Workspace Settings → Billing**. Studio never deletes your content when a subscription lapses. ## Self-hosting For deployment-level issues (database, GitHub App, session secret, providers), see [Self-Hosting](/developer/self-hosting) and [Environment Variables](/developer/environment-variables). ## Next Steps - [Core Concepts](/guide/concepts) — how the pieces fit together - [Billing & Plans](/guide/billing) — limits, overage, and locked states - [Branches & Review](/guide/branches-and-review) — the content review workflow --- # Developer Guide Source: https://docs.contentrain.io/developer/ > Technical documentation for Contentrain Studio — architecture, self-hosting, API reference, and contributing. # Developer Guide Welcome to the Contentrain Studio developer documentation. This section covers the technical details you need to deploy, extend, and contribute to Studio. ## Quick Links | Topic | Description | |-------|-------------| | [Architecture](/developer/architecture) | System design, provider pattern, stack decisions | | [Self-Hosting](/developer/self-hosting) | Deploy Studio on your own infrastructure | | [Environment Variables](/developer/environment-variables) | Complete configuration reference | | [API Reference](/developer/api-reference/) | All REST API endpoints | | [Field Types](/developer/field-types) | 27 supported content field types | | [Provider Interfaces](/developer/provider-interfaces) | AuthProvider, DatabaseProvider, etc. | | [Roles & Permissions](/developer/roles-permissions) | Two-tier role system and tool access matrix | | [Contributing](/developer/contributing) | Development setup and PR process | ## Tech Stack | Layer | Technology | |-------|-----------| | **Framework** | Nuxt 4 (full-stack, SSR) | | **UI** | Radix Vue + Tailwind CSS 4 | | **Auth** | Provider interface (default: Supabase Auth) | | **Database** | Provider interface (default: Supabase PostgreSQL + RLS) | | **AI** | Claude API (Haiku, Sonnet, Opus) | | **Content** | Contentrain format (`.contentrain/` directory) | | **Icons** | Annon custom set via @iconify/tailwind4 | | **Package Manager** | pnpm | ## Open Core Model Studio is licensed under **AGPL-3.0** with an enterprise extension: ``` studio/ ├── app/ # AGPL — core application ├── server/ # AGPL — server routes & providers ├── ee/ # Proprietary — enterprise features └── ... ``` The AGPL core is a fully functional product. Enterprise features (`ee/`) add advanced capabilities like SSO, approval chains, audit logs, and white-label branding. See [Enterprise Edition](/developer/enterprise-edition) for the full boundary definition. ## Getting Started 1. Clone the repository and install dependencies: ```bash git clone https://github.com/Contentrain/studio.git cd studio pnpm install ``` 2. Copy the environment template and configure: ```bash cp .env.example .env ``` 3. Start the development server: ```bash pnpm dev ``` See [Self-Hosting](/developer/self-hosting) for production deployment with Docker. --- # Architecture Source: https://docs.contentrain.io/developer/architecture > System design, Nuxt 4 full-stack architecture, provider/adapter pattern, workspace hierarchy, and key design decisions in Contentrain Studio. # Architecture Overview Contentrain Studio is a conversation-first CMS built as a single Nuxt 4 full-stack application. It is deployment-flexible: the AGPL core is self-hostable, and managed Pro/Enterprise deployments can run on the same architecture. All external services are abstracted through provider interfaces, allowing operators to swap implementations without changing application code. Studio shares the `.contentrain/` contract with the Contentrain AI package surface, but it is not an `@contentrain/mcp` wrapper. The package side handles local-first execution and IDE workflows; Studio adds the authenticated web layer for collaboration, review, and delivery. ## System Overview ``` +--------------------------+ | Reverse Proxy | | (TLS termination) | +------------+-------------+ | +------------v-------------+ | Contentrain Studio | | Nuxt 4 / Nitro | | | | +-------+ +---------+ | | | App | | Server | | | | (SPA) | | (API) | | | +-------+ +---------+ | +------|---------|----------+ | | +------------------+---------+------------------+ | | | | | +-----v---+ +---v----+ +--v-----+ +--v----+ +--------v-------+ | Auth | | DB | | Git | | AI | | Object Storage | | Provider| |Provider| |Provider| |Provider| | (CDN/Media) | +---------+ +--------+ +--------+ +-------+ +----------------+ Supabase Supabase GitHub Anthropic Cloudflare R2 Auth PostgreSQL App Claude (ee/) or or managed plain JWT PostgreSQL Auth + Database ship as a matched pair, selected at boot by NUXT_AUTH_PROVIDER / NUXT_DATABASE_PROVIDER: supabase + supabase (default) | managed + postgres ``` ## Nuxt 4 Full-Stack Architecture Studio is a **single project** -- not a monorepo. Nuxt 4 runs both the client SPA and the Nitro server in one deployment unit. ``` studio/ ├── app/ # Client-side (SPA, SSR disabled) │ ├── components/ # Atomic design: atoms, molecules, organisms │ ├── composables/ # Client-side business logic │ ├── layouts/ # auth, default, workspace │ ├── pages/ # File-based routing │ └── assets/ # CSS, fonts, images ├── server/ # Server-side (Nitro) │ ├── api/ # REST API endpoints │ ├── middleware/ # Auth, billing, invite acceptance, audit │ ├── providers/ # Provider interfaces + implementations │ └── utils/ # Agent tools, content engine, helpers ├── shared/ # Shared code (client + server) │ └── utils/ # License/feature flags ├── ee/ # Enterprise Edition (proprietary) │ ├── cdn/ # Cloudflare R2 CDN provider │ ├── media/ # Sharp image processing │ └── enterprise/ # Bridge + route handlers └── .contentrain/ # Content directory (UI strings, agent prompts) ``` ### Key Configuration SSR is disabled (`ssr: false`) because Studio is an authenticated app with no public marketing pages. The client runs as a SPA behind the auth gate. ```ts // nuxt.config.ts export default defineNuxtConfig({ ssr: false, modules: ['@nuxt/eslint', '@nuxt/image'], css: ['~/assets/css/main.css'], // ... }) ``` ## Provider / Adapter Pattern This is the most critical architectural decision in Studio. **No implementation detail ever leaks into components, pages, composables, or server routes.** ### How It Works 1. **Interfaces** define contracts in `server/providers/`: ```ts // server/providers/auth.ts export interface AuthProvider { validateToken: (accessToken: string) => Promise refreshSession: (refreshToken: string) => Promise getOAuthRedirectUrl: (provider: 'github' | 'google', redirectTo: string) => Promise exchangeCode: (code: string, state?: string) => Promise sendMagicLink: (email: string, redirectTo: string) => Promise deleteUser: (userId: string) => Promise // ... } ``` 2. **Implementations** live alongside interfaces (or in `ee/`). Each contract ships with more than one implementation — for example, two `AuthProvider` implementations: ```ts // server/providers/supabase-auth.ts export function createSupabaseAuthProvider(): AuthProvider { // Uses @supabase/supabase-js internally // This is the ONLY file that imports Supabase client code } // server/providers/managed-auth.ts export function createManagedAuthProvider(): AuthProvider { // Supabase-free built-in auth: signs its own HS256 JWTs, // runs the OAuth dance, and issues magic links via Resend } ``` 3. **Factories** resolve singletons via `server/utils/providers.ts`, choosing the implementation from runtime config: ```ts // server/utils/providers.ts export function useAuthProvider(): AuthProvider { if (!_authProvider) { const kind = useRuntimeConfig().authProvider || 'supabase' if (kind === 'managed') { ensurePostgresConfigured() _authProvider = createManagedAuthProvider() } else { _authProvider = createSupabaseAuthProvider() } } return _authProvider } ``` The `DatabaseProvider` factory mirrors this, switching on `NUXT_DATABASE_PROVIDER` (`supabase` vs `postgres`). Auth and database are chosen as a matched pair — see the [provider pair](/developer/self-hosting#choosing-a-provider-pair) rules. 4. **Application code** only imports from factories: ```ts // In any server route const auth = useAuthProvider() const user = await auth.validateToken(token) ``` ### Provider Inventory | Provider | Interface | Shipped Implementations | Singleton? | |----------|-----------|----------------------|------------| | Auth | `AuthProvider` | Supabase Auth **or** built-in managed JWT | Yes | | Database | `DatabaseProvider` | Supabase PostgreSQL **or** plain PostgreSQL | Yes | | Git | `GitProvider` | GitHub App | No (per-repo) | | Git App | `GitAppProvider` | GitHub App Installation | No (per-install) | | AI | `AIProvider` | Anthropic Claude | Yes | | CDN | `CDNProvider` | Cloudflare R2 (ee/) | Yes | | Media | `MediaProvider` | Sharp + R2 (ee/) | Yes | | Email | `EmailProvider` | Resend | Yes | | Payment | `PaymentProvider` | Polar (default) + Stripe (legacy) | Yes | | Connector | `ConnectorProvider` | None yet | N/A | ::: info Provider pairs Auth and Database ship as a matched pair, chosen at boot via `NUXT_AUTH_PROVIDER` / `NUXT_DATABASE_PROVIDER`. Two pairs are supported: `supabase` + `supabase` (default) and `managed` + `postgres` (Supabase-free, plain PostgreSQL 14+). Mixed pairs are rejected at boot. The managed auth implementation lives in `server/providers/managed-auth.ts` and the plain-PostgreSQL implementation in `server/providers/postgres-db/`. ::: ::: warning Rules -- Never Violate - NEVER import `@supabase/supabase-js` outside of `server/providers/` - NEVER use `useSupabaseClient()` or `useSupabaseUser()` in components - ALL auth checks use `AuthProvider.getSession()`, never Supabase directly - Provider instances are resolved via factory functions only ::: ## Workspace Hierarchy Studio organizes data in a three-level hierarchy: ``` User └── Workspace (billing entity) ├── Members (owner, admin, member) ├── GitHub App Installation └── Projects (connected repositories) ├── Members (editor, reviewer, viewer) ├── Conversations ├── Content (via Git) └── Media Assets ``` ### Key Rules - Signup auto-creates a **primary workspace** (type: `primary`) - Each workspace has exactly one **owner** (the GitHub-authenticated user) - GitHub App installation lives on the **workspace**, not the project - Workspace Owner/Admin has **implicit access** to all projects - Workspace Member needs **explicit `project_members` assignment** ## Component Architecture Studio follows atomic design principles, adapted for Radix Vue + Tailwind CSS 4: ``` app/components/ ├── atoms/ # Radix Vue primitives + Tailwind │ ├── HeadingText.vue │ ├── BaseButton.vue │ ├── FormInput.vue │ ├── FormLabel.vue │ ├── Badge.vue │ └── Avatar.vue ├── molecules/ # Composed atoms │ ├── ProviderButtons.vue │ ├── AuthLink.vue │ └── EmailButton.vue └── organisms/ # Business logic components ├── SigninWithProvider.vue ├── ProfileOverviewPanel.vue └── WorkspaceMembersPanel.vue ``` There is no `templates/` layer -- Nuxt layouts (`app/layouts/`) handle page-level wrappers. ### Layout System | Layout | Purpose | Structure | |--------|---------|-----------| | `auth` | Login/callback pages | Split panel (form left, marketing right) | | `default` | Workspace list, settings | Sidebar (240px) + main content | | `workspace` | Project workspace | Three-panel (sidebar 240px, chat, context 400px) | ## Data Flow ### Authentication Flow ``` Client Server Supabase | | | |-- POST /api/auth/login -| | | |-- getOAuthRedirectUrl() --| |<- { url } -------------| | | | | |-- (OAuth redirect) -----|-------------------------->| |<- (callback with code) -| | | | | |-- POST /api/auth/verify | | | |-- exchangeCode() -------->| | |<- session + tokens -------| | |-- setServerSession() -----| |<- { user } ------------| | ``` ### Content Operation Flow ``` User Chat Message | v Intent Classification | v System Prompt Construction (config, models, permissions, UI context, vocabulary) | v AI Provider (streaming) | +--> Tool Call (save_content, save_model, etc.) | | | v | State Machine Guard | | | v | Content Engine | | | v | GitProvider (branch -> commit -> merge) | | | v | Brain Cache Invalidation | v SSE Events -> Client UI ``` ## Key Design Decisions ### Session Management Sessions use AES-256 encrypted httpOnly cookies managed by h3 `useSession()`. The auth middleware automatically refreshes tokens 5 minutes before expiry. ### Git-Based Content All content is stored in Git repositories using the `.contentrain/` directory format. Studio never stores content in its database -- Git is the single source of truth. The `contentrain` branch serves as the SSOT branch, with `cr/*` feature branches for individual operations. ### Enterprise Bridge Enterprise features (`ee/`) are loaded at runtime via a dynamic import bridge pattern. If `ee/` is absent, the bridge returns `null` and features degrade gracefully (returning 403 for EE-only routes). ### Feature Flags All feature gating uses `hasFeature(plan, 'feature.name')`. Plan checks are never hardcoded. The `FEATURE_MATRIX` in `shared/utils/license.ts` is the runtime helper, but it is **derived at build time** from `.contentrain/content/system/plan-features/data.json` -- that content file is the single source of truth for which plans grant each feature (plus the `requires_ee` and `roadmap` flags). ## Related Pages - [Provider Interfaces](/developer/provider-interfaces) -- full interface definitions - [Content Engine](/developer/content-engine) -- write path internals - [Agent System](/developer/agent-system) -- AI chat architecture - [Enterprise Edition](/developer/enterprise-edition) -- open-core model details - [Roles & Permissions](/developer/roles-permissions) -- two-tier role system --- # Self-Hosting Source: https://docs.contentrain.io/developer/self-hosting > Deploy Contentrain Studio on your own infrastructure with Docker, configure external services, and manage production operations. # Self-Hosting Guide Contentrain Studio supports self-hosting of the AGPL core. This guide covers everything you need to deploy and operate Studio in your own environment; managed Pro/Enterprise operation may also be available separately. ## Core Principles - The application is **stateless** -- no local writable state beyond normal container runtime behavior - Git repositories remain **external and authoritative** -- Studio never stores content in its database - Auth/data are supplied through **provider-backed integrations** (swappable) - Enterprise features **degrade safely** when `ee/` functionality or external services are absent ## Prerequisites ### Required | Component | Purpose | |-----------|---------| | Public URL | A reachable HTTPS endpoint for the application | | One auth + database **provider pair** | Either a Supabase-compatible deployment, **or** any plain PostgreSQL 14+ instance with the managed auth provider. See [Choosing a Provider Pair](#choosing-a-provider-pair). | | GitHub App | Repository access, installation lifecycle, commit operations | | `NUXT_SESSION_SECRET` | Minimum 32 characters, used for AES-256 cookie encryption | ### Optional | Component | Purpose | When Needed | |-----------|---------|-------------| | Anthropic API key | Operator-managed AI agent | If users should not need BYOA keys | | Resend API key | Email notifications (invites, reminders); magic-link + invite auth emails on the managed pair | For team invite workflows; **required** on the managed + postgres pair | | Polar or Stripe keys | Billing / subscription management (Polar is the default provider; Stripe is legacy) | For plan-gated deployments | | Cloudflare R2 | Object storage for CDN and media | For CDN delivery and media library | | Redis | Distributed rate limiting | For multi-instance deployments | ## Choosing a Provider Pair Studio's auth and database providers ship as **matched pairs**, selected by environment variables and enforced at boot. Two pairs are supported; mixing them (e.g. Supabase auth with a plain-Postgres database) is rejected at startup. | Pair | Env | Auth | Database | |------|-----|------|----------| | `supabase` + `supabase` (default) | `NUXT_AUTH_PROVIDER=supabase`, `NUXT_DATABASE_PROVIDER=supabase` | Supabase Auth (GitHub/Google OAuth, magic link) | Supabase PostgreSQL | | `managed` + `postgres` | `NUXT_AUTH_PROVIDER=managed`, `NUXT_DATABASE_PROVIDER=postgres` | Built-in JWT auth: HS256 access/refresh tokens, GitHub/Google OAuth run in-app, magic link via Resend | Any plain PostgreSQL 14+ | The **managed + postgres** pair removes the Supabase dependency entirely -- bring any PostgreSQL (RDS, Railway, a container, your own cluster). Both pairs run the same RLS migration lineage, so row-level isolation is identical. ### Supabase pair (default) requires - `NUXT_SUPABASE_URL`, `NUXT_SUPABASE_SERVICE_ROLE_KEY`, `NUXT_SUPABASE_ANON_KEY` ### Managed + postgres pair requires - `NUXT_POSTGRES_URL` -- the connection string - `NUXT_AUTH_JWT_SECRET` -- minimum 32 characters, signs access/refresh tokens - `NUXT_RESEND_API_KEY` -- magic-link + invite emails (required on this pair) - `NUXT_OAUTH_GITHUB_CLIENT_ID` / `NUXT_OAUTH_GITHUB_CLIENT_SECRET` -- a GitHub **OAuth App** for login (callback `{NUXT_PUBLIC_SITE_URL}/api/auth/oauth/github`); Google OAuth is optional - `NUXT_SESSION_PASSWORD` -- minimum 32 characters, session store for the OAuth module (dev auto-generates it; deployed builds must set it) ::: info The managed **auth provider** and the `managed` **deployment profile** are unrelated concepts that happen to share a name. Either provider pair can run under any deployment profile (Community or On-Premise EE). ::: See the [Environment Variables](/developer/environment-variables) reference for the full per-variable detail. ## Deployment Shapes Recommended options: - **Single container** behind a reverse proxy (simplest) - **Railway / Render / Fly.io** Docker deployment - **VM or bare-metal** Docker deployment - **Kubernetes** with one web deployment plus external backing services ## Docker Deployment ### Pull the Prebuilt Image (Recommended) Official container images are published to GHCR on every `v*` release tag. For production, pull an **exact tag** rather than a floating one so upgrades are explicit: ```bash docker pull ghcr.io/contentrain/studio:vX.Y.Z ``` Available tags per release: - `:vX.Y.Z` (and `:vX.Y.Z-prerelease.N`) -- exact release pin (recommended for production) - `:sha-` -- immutable commit pin - `:X.Y`, `:X`, `:latest` -- floating tags, stable releases only (prereleases never publish `:latest`) Deploy a **tagged release**, not `main` HEAD, and avoid `:latest` in production. ### Build the Image (Fork / Dev) For forks, local iteration, or patched builds, build the image yourself: ```bash docker build -t contentrain-studio . ``` The Docker image contains: - Built Nuxt/Nitro server output - Generated `.contentrain` assets for UI strings - `git` binary for repository operations - Non-root `studio` user (security best practice) ### Run the Container ```bash docker run \ --name contentrain-studio \ --env-file .env \ -p 3000:3000 \ contentrain-studio ``` The container listens on port `3000`. ### Docker Compose Example ```yaml version: '3.8' services: studio: image: contentrain-studio build: context: . dockerfile: Dockerfile ports: - "3000:3000" env_file: - .env environment: - NODE_ENV=production restart: unless-stopped healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"] interval: 30s timeout: 10s retries: 3 # If using self-hosted Supabase: # supabase: # image: supabase/postgres:latest # ... ``` ### Health Check The application exposes a health endpoint: ```bash curl http://localhost:3000/api/health # Returns 200 OK ``` ## Editions and Deployment Profiles Self-hosting behavior is driven by two orthogonal concepts: - **Edition** -- whether the proprietary `ee/` directory is present and loaded at runtime. **Community Edition** runs the AGPL core alone; **Enterprise Edition** adds `ee/` under a separate commercial license. - **Deployment profile** -- a named preset (`managed` / `dedicated` / `on-premise` / `community`) that configures billing mode and plan source coherently. Set `NUXT_DEPLOYMENT_PROFILE` explicitly, or leave it unset to auto-detect at boot. Self-hosting typically means one of two profiles: | Profile | Edition | Billing | Plan source | Result | |---------|---------|---------|-------------|--------| | `community` | AGPL only (`ee/` absent) | Off | Fixed | Every workspace resolves to the fixed **`community`** tier with unlimited core usage; `requires_ee` features are force-disabled. Zero-license path. | | `on-premise` | AGPL core + licensed `ee/` | Off | Operator-set | Full feature set on your own infra; operator sets each workspace's plan (default `enterprise`) via the DB. Requires an executed On-Premises Deployment License. | Auto-detection when `NUXT_DEPLOYMENT_PROFILE` is unset: `ee/` missing -> `community`; `ee/` loaded + no billing env -> `on-premise`; `ee/` loaded + Polar/Stripe configured -> `managed`. ### Community Edition (`community` profile) Use this for the AGPL core without the `ee/` proprietary implementations. No agreement with Contentrain required. **Configure:** - One provider pair (Supabase, or plain PostgreSQL via the managed pair) - GitHub App - `NUXT_ANTHROPIC_API_KEY` (operator-provided; the Studio-hosted key is `ee/`-only) - `ee/` directory absent; no Polar / Stripe env vars - Redis only if running multiple instances **This gives you:** - Authenticated app, workspace/project management, repository connection - Full content operations (all 4 model kinds, all 27 field types) - Branch / diff / merge and auto-merge workflows, forms (storage + captcha + notifications) - AI chat with the operator-provided Anthropic key - Every workspace at the fixed `community` tier with unlimited usage Features that require `ee/` (media/CDN, BYOA UI, Conversation API, outbound webhooks, reviewer/viewer roles, spam filter, SSO, white-label) are disabled in Community Edition. See [Enterprise Edition](/developer/enterprise-edition) for the full matrix. ### On-Premise Enterprise (`on-premise` profile) Use this with an executed On-Premises Deployment License to run the full feature set on your own infrastructure. Billing is off; the operator sets the workspace plan directly. **Additionally configure:** - `ee/` directory present (matching the core release tag) - `NUXT_DEPLOYMENT_PROFILE=on-premise` (optional -- auto-detected from `ee/` + empty billing env) - Redis (multi-instance), Resend or internal SMTP, Cloudflare R2 (CDN/media) - Set the plan per workspace, e.g. `UPDATE workspaces SET plan = 'enterprise' WHERE id = '...';` See [Enterprise Edition](/developer/enterprise-edition) for how editions map to plan tiers and which features require `ee/`. ### Operational Surfaces (either profile) Delivery and automation surfaces layer on top of either profile: - Redis -- distributed rate limiting (multi-instance) - Resend (or internal SMTP) -- email notifications - Cloudflare R2 -- CDN and media delivery (`ee/` feature) - Polar or Stripe -- only if you want managed billing on-prem (uncommon; promotes the profile to `dedicated` with subscription-driven plans) ## External Service Setup Complete the setup steps for **your chosen provider pair** only. ### Database + Auth -- Supabase pair (default) 1. Create a Supabase project (or self-host Supabase) 2. Apply all database migrations before first production traffic (see [Migrations](#migrations)) 3. Configure environment variables: ```bash NUXT_SUPABASE_URL=https://your-project.supabase.co NUXT_SUPABASE_SERVICE_ROLE_KEY=your-service-role-key NUXT_SUPABASE_ANON_KEY=your-anon-key ``` 4. Configure OAuth providers in Supabase Dashboard: - **GitHub OAuth**: for workspace owners (needs repo access) - **Google OAuth**: for invited team members - **Email/Magic Link**: for passwordless auth ::: warning The service role key has admin access to your database. Keep it private and never expose it to the client. Studio rejects an anon key pasted into the service-role slot at boot. ::: ### Database + Auth -- managed + postgres pair Use this to run Supabase-free on any plain PostgreSQL 14+ instance. 1. Provision a PostgreSQL 14+ database (RDS, Railway, a container, your own cluster) 2. Create a GitHub **OAuth App** for login (callback `{NUXT_PUBLIC_SITE_URL}/api/auth/oauth/github`); optionally a Google OAuth client 3. Apply migrations against the database (see [Migrations](#migrations)) 4. Configure environment variables: ```bash NUXT_AUTH_PROVIDER=managed NUXT_DATABASE_PROVIDER=postgres NUXT_POSTGRES_URL=postgres://user:password@host:5432/contentrain NUXT_AUTH_JWT_SECRET=your-random-32-character-string NUXT_SESSION_PASSWORD=your-random-32-character-string NUXT_OAUTH_GITHUB_CLIENT_ID=your-github-oauth-app-client-id NUXT_OAUTH_GITHUB_CLIENT_SECRET=your-github-oauth-app-client-secret # Google OAuth is optional (sign-in button hidden when unset): # NUXT_OAUTH_GOOGLE_CLIENT_ID=... # NUXT_OAUTH_GOOGLE_CLIENT_SECRET=... NUXT_RESEND_API_KEY=re_your-resend-api-key # required: magic-link + invite emails ``` ::: warning `NUXT_RESEND_API_KEY` is **required** on the managed pair -- the managed auth provider sends magic-link and invite emails through Resend. Rotating `NUXT_AUTH_JWT_SECRET` invalidates all outstanding tokens (users re-login); `NUXT_SESSION_PASSWORD` stores no durable state and can be rotated freely. ::: ### Migrations Both pairs run the same RLS migration lineage; apply migrations before first production traffic. **Supabase pair** -- uses the Supabase CLI: ```bash pnpm db:migrate # supabase migration up (reads supabase/migrations/*) ``` **Managed + postgres pair** -- uses the bundled plain-PG runner: ```bash pnpm db:migrate:pg # applies the lineage (reads NUXT_POSTGRES_URL, or pass --url) pnpm db:verify:pg # verifies schema, trigger chain, and RLS isolation ``` Both plain-PG commands are idempotent -- re-runs skip already-applied files via `public.schema_migrations`. ### GitHub App 1. Create a GitHub App at `https://github.com/settings/apps/new` 2. Configure permissions: - **Repository contents**: Read & Write - **Pull requests**: Read & Write - **Metadata**: Read-only 3. Set the callback URL to `{SITE_URL}/auth/callback` 4. Set the webhook URL to `{SITE_URL}/api/webhooks/github` 5. Generate a private key (`.pem` file) 6. Base64-encode the private key: ```bash base64 -i your-app.private-key.pem | tr -d '\n' ``` 7. Configure environment variables: ```bash NUXT_GITHUB_APP_ID=your-app-id NUXT_GITHUB_CLIENT_ID=your-client-id NUXT_GITHUB_CLIENT_SECRET=your-client-secret NUXT_GITHUB_PRIVATE_KEY=base64-encoded-pem-content NUXT_GITHUB_WEBHOOK_SECRET=your-hmac-webhook-secret NUXT_PUBLIC_GITHUB_APP_SLUG=your-app-slug ``` ### Email (Resend) ```bash NUXT_RESEND_API_KEY=re_your-resend-api-key NUXT_EMAIL_SENDER_ADDRESS=noreply@yourdomain.com NUXT_EMAIL_SENDER_NAME=Contentrain Studio ``` If using local Supabase auth with SMTP, also set: ```bash RESEND_API_KEY=re_your-resend-api-key ``` ### Billing (Polar / Stripe) Billing is off by default and is uncommon for self-hosts. **Polar is the default payment provider**; Stripe is supported as a legacy plugin. When both are configured, Polar wins. Configuring a payment plugin requires the licensed `ee/` bridge. Polar (default): ```bash NUXT_POLAR_ACCESS_TOKEN=polar_oat_your-organization-access-token NUXT_POLAR_WEBHOOK_SECRET=your-polar-webhook-signing-secret NUXT_POLAR_STARTER_PRODUCT_ID=uuid-of-starter-product NUXT_POLAR_PRO_PRODUCT_ID=uuid-of-pro-product NUXT_POLAR_SERVER=production ``` Stripe (legacy / optional): ```bash NUXT_STRIPE_SECRET_KEY=sk_live_your-stripe-secret-key NUXT_STRIPE_WEBHOOK_SECRET=whsec_your-webhook-signing-secret NUXT_STRIPE_STARTER_PRICE_ID=price_starter-monthly-id NUXT_STRIPE_PRO_PRICE_ID=price_pro-monthly-id ``` `NUXT_PUBLIC_BILLING_ENABLED` is auto-derived at boot from the configured payment plugins; set it explicitly only to override. When **no** payment plugin is configured (the usual self-host case), Studio runs in no-billing mode and the subscription UI is hidden. The resulting plan depends on the deployment profile: - **`community`** profile (`ee/` absent) -- every workspace resolves to the fixed **`community`** tier: unlimited core usage, with all `requires_ee` features force-disabled. - **`on-premise`** profile (`ee/` loaded, no billing) -- the operator sets each workspace's plan directly (default `enterprise`). ### CDN / Object Storage (Cloudflare R2) ```bash NUXT_CDN_R2_ACCOUNT_ID=your-cloudflare-account-id NUXT_CDN_R2_ACCESS_KEY_ID=your-r2-access-key-id NUXT_CDN_R2_SECRET_ACCESS_KEY=your-r2-secret-access-key NUXT_CDN_R2_BUCKET=contentrain-cdn ``` ### Redis For multi-instance production deployments: ```bash REDIS_URL=redis://localhost:6379 # Use REDIS_CA_CERT for custom CA trust with rediss:// ``` Without Redis, rate limiting degrades to in-memory and is only suitable for single-instance setups. ## Production Checklist ### Base - [ ] Set `NODE_ENV=production` - [ ] Set `NUXT_PUBLIC_SITE_URL` to your public application URL - [ ] Set a strong `NUXT_SESSION_SECRET` (minimum 32 characters) - [ ] Run behind TLS (reverse proxy or platform load balancer) ### Database / Auth - [ ] Choose a provider pair via `NUXT_AUTH_PROVIDER` / `NUXT_DATABASE_PROVIDER` - [ ] Supabase pair: provide all three Supabase variables (`URL`, `SERVICE_ROLE_KEY`, `ANON_KEY`) - [ ] Managed pair: provide `NUXT_POSTGRES_URL`, `NUXT_AUTH_JWT_SECRET`, `NUXT_SESSION_PASSWORD`, `NUXT_OAUTH_GITHUB_*`, and `NUXT_RESEND_API_KEY` - [ ] Apply all database migrations before first production traffic (`pnpm db:migrate` for Supabase, `pnpm db:migrate:pg` + `pnpm db:verify:pg` for the managed pair) - [ ] Configure OAuth callback URLs correctly ### GitHub - [ ] Create and configure GitHub App - [ ] Provide all five GitHub variables - [ ] Test webhook delivery to `/api/webhooks/github` ### Reverse Proxy Your reverse proxy should: - Forward HTTPS traffic to port `3000` - Preserve `Host` and standard forwarding headers (`X-Forwarded-For`, `X-Forwarded-Proto`) - Set a sane request body limit for media uploads (50MB+) - Not cache authenticated application routes - Support Server-Sent Events (SSE) for chat streaming ## Secret Rotation For session/encryption secret rotation: 1. Set the new secret in `NUXT_SESSION_SECRET` 2. Keep the old value in `NUXT_SESSION_SECRET_PREVIOUS` 3. Deploy the new version 4. Allow existing sessions to refresh (token auto-refresh handles this) 5. Remove `NUXT_SESSION_SECRET_PREVIOUS` after the migration window ## Backup Strategy Studio itself does not require local writable state. Back up these external components: | Component | Strategy | |-----------|----------| | **Database** | Supabase automated backups or `pg_dump` | | **Object Storage** | R2 bucket versioning / cross-region replication | | **Git Repositories** | Already distributed (GitHub) | | **Deployment Secrets** | Secure vault (AWS Secrets Manager, Vault, etc.) | Do **not** treat the Studio container filesystem as durable state. ## Post-Deploy Smoke Checks After first deploy, verify: 1. `/api/health` returns `200` 2. Login page loads 3. OAuth callback URLs are correct 4. A workspace can be created and listed 5. GitHub App installation flow completes 6. A repository can be scanned and connected 7. At least one chat / content flow works end-to-end 8. Email, CDN, media, and billing surfaces work (if configured) ## Release Validation Before cutting a release image: ```bash pnpm release:check ``` This runs lint, typecheck, and build verification. ## Related Pages - [Environment Variables](/developer/environment-variables) -- complete variable reference - [Architecture](/developer/architecture) -- system design and provider pattern - [Enterprise Edition](/developer/enterprise-edition) -- what requires `ee/` --- # Environment Variables Source: https://docs.contentrain.io/developer/environment-variables > Complete reference for all NUXT_* environment variables used to configure Contentrain Studio. # Environment Variables Contentrain Studio is configured entirely through environment variables. Copy `.env.example` to `.env` and fill in the values for your deployment. ## Core Application | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `NUXT_SESSION_SECRET` | Yes | -- | AES-256 cookie encryption key. Minimum 32 characters. | | `NUXT_SESSION_SECRET_PREVIOUS` | No | -- | Previous secret for key rotation. Set during migration window, remove after. | | `NUXT_PUBLIC_SITE_URL` | Yes | `http://localhost:3000` | Public-facing application URL. Used for OAuth callbacks and email links. | | `NUXT_PUBLIC_GITHUB_APP_SLUG` | No | `contentrain-studio` | GitHub App slug for installation redirect URL. | | `NUXT_PUBLIC_TEMPLATE_OWNER` | No | `Contentrain` | GitHub org that owns starter kit template repos. | | `NUXT_PUBLIC_BILLING_ENABLED` | No | `false` | Billing UI visibility. Auto-derived on boot from configured payment plugins (true when Polar or Stripe is set). Set explicitly only to override. | | `NODE_ENV` | No | `development` | Set to `production` for production deployments. | ::: tip Session Secret Generate a secure session secret: ```bash openssl rand -base64 32 ``` ::: ## Deployment Profile Studio auto-detects its deployment profile at boot from whether the Enterprise bridge (`ee/`) loads and whether a payment plugin is configured. Set these only to pin behaviour explicitly. | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `NUXT_DEPLOYMENT_PROFILE` | No | -- (auto-detect) | `managed` \| `dedicated` \| `on-premise` \| `community`. Server-side profile override. | | `NUXT_PUBLIC_DEPLOYMENT_PROFILE` | No | -- | Client-visible profile. `managed` \| `dedicated` \| `on-premise` \| `community`. | | `NUXT_PUBLIC_DEPLOYMENT_EDITION` | No | -- | Client-visible edition. `ee` \| `agpl`. | | `NUXT_PUBLIC_DEPLOYMENT_BILLING_MODE` | No | -- | Client-visible billing mode. `polar` \| `stripe` \| `flat` \| `off`. | ::: info Production Builds Production (Nitro compiled output) freezes `runtimeConfig.public`, so the auto-derive plugin cannot mutate the snapshot. Set the three `NUXT_PUBLIC_DEPLOYMENT_*` variables explicitly in production to mirror the server-side `resolveDeployment()` result on the client. Dev mode does not need them. ::: ## Provider Selection Studio's auth and database providers ship as **matched pairs**, selected at boot. Two pairs are supported: `supabase` + `supabase` (the default) and `managed` + `postgres` (Supabase-free, plain PostgreSQL 14+). Mixed pairs (e.g. `supabase` auth with `postgres` database) are rejected at boot. | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `NUXT_AUTH_PROVIDER` | No | `supabase` | Auth implementation: `supabase` \| `managed`. | | `NUXT_DATABASE_PROVIDER` | No | `supabase` | Database implementation: `supabase` \| `postgres`. | ::: info Valid pairs Only `supabase`+`supabase` and `managed`+`postgres` are accepted. Setting one without the other, or crossing them, fails boot validation with an explicit error. ::: ## Supabase (Auth + Database) Required only for the **Supabase pair** (`NUXT_DATABASE_PROVIDER=supabase`, the default). Leave these unset when running the managed + postgres pair. | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `NUXT_SUPABASE_URL` | Required (Supabase pair) | -- | Supabase project URL (e.g., `https://your-project.supabase.co`). | | `NUXT_SUPABASE_SERVICE_ROLE_KEY` | Required (Supabase pair) | -- | Service role key with admin database access. Keep private. Must carry the `service_role` JWT claim -- boot rejects an anon key pasted here. | | `NUXT_SUPABASE_ANON_KEY` | Required (Supabase pair) | -- | Anonymous key for client-side auth operations. | ### Supabase OAuth (for local Supabase CLI) These are only needed when running Supabase locally via the CLI: | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `SUPABASE_AUTH_EXTERNAL_GITHUB_CLIENT_ID` | No | -- | GitHub OAuth client ID for Supabase auth. | | `SUPABASE_AUTH_EXTERNAL_GITHUB_SECRET` | No | -- | GitHub OAuth client secret for Supabase auth. | | `SUPABASE_AUTH_EXTERNAL_GOOGLE_CLIENT_ID` | No | -- | Google OAuth client ID for Supabase auth. | | `SUPABASE_AUTH_EXTERNAL_GOOGLE_SECRET` | No | -- | Google OAuth client secret for Supabase auth. | ## Managed + Postgres Pair Required only when running the Supabase-free pair (`NUXT_AUTH_PROVIDER=managed`, `NUXT_DATABASE_PROVIDER=postgres`). The managed auth provider signs its own JWTs, runs the GitHub/Google OAuth dance directly, and sends magic-link/invite emails through Resend. Leave all of these unset on the Supabase pair. | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `NUXT_POSTGRES_URL` | Required (managed pair) | -- | PostgreSQL 14+ connection string (e.g., `postgres://user:pass@host:5432/contentrain`). Required when `NUXT_DATABASE_PROVIDER=postgres`. | | `NUXT_AUTH_JWT_SECRET` | Required (managed pair) | -- | Minimum 32 characters. Signs the managed provider's access/refresh JWTs. Required when `NUXT_AUTH_PROVIDER=managed`. | | `NUXT_SESSION_PASSWORD` | Recommended (managed pair) | -- | Minimum 32 characters. Session store for the `nuxt-auth-utils` OAuth module (separate from `NUXT_SESSION_SECRET`; stores no durable state). Dev auto-generates it; deployed builds must set it or every SSR load of `/api/_auth/session` 500s (boot warns, does not fail). | | `NUXT_OAUTH_GITHUB_CLIENT_ID` | Required (managed pair) | -- | GitHub **OAuth App** client ID for login. Callback: `{NUXT_PUBLIC_SITE_URL}/api/auth/oauth/github`. | | `NUXT_OAUTH_GITHUB_CLIENT_SECRET` | Required (managed pair) | -- | GitHub OAuth App client secret. | | `NUXT_OAUTH_GOOGLE_CLIENT_ID` | No | -- | Google OAuth client ID for login. Google sign-in is disabled (button hidden) when unset. | | `NUXT_OAUTH_GOOGLE_CLIENT_SECRET` | No | -- | Google OAuth client secret. | | `NUXT_REVIEW_ACCOUNT_EMAIL` | No | -- | Directory-review password-login account (managed pair only). The only address the review login accepts. Must be set together with the password. | | `NUXT_REVIEW_ACCOUNT_PASSWORD` | No | -- | Password for the review-login account. Minimum 16 characters (it gates a real password login). Unset means `/auth/review-login` does not exist. | | `NUXT_OPENAI_APPS_CHALLENGE` | No | -- | Domain-verification token served at `/.well-known/openai-apps-challenge` (managed pair, remote MCP surface). | ::: info Resend is required on the managed pair `NUXT_RESEND_API_KEY` (see [Email](#email-resend)) is **required** when `NUXT_AUTH_PROVIDER=managed` -- it delivers magic-link and invite emails. On the Supabase pair it stays optional. ::: ## GitHub App | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `NUXT_GITHUB_APP_ID` | Yes | -- | GitHub App ID (numeric). | | `NUXT_GITHUB_CLIENT_ID` | Yes | -- | GitHub App client ID. | | `NUXT_GITHUB_CLIENT_SECRET` | Yes | -- | GitHub App client secret. | | `NUXT_GITHUB_PRIVATE_KEY` | Yes | -- | Base64-encoded `.pem` private key file. | | `NUXT_GITHUB_WEBHOOK_SECRET` | Recommended | -- | HMAC-SHA256 secret for webhook payload verification. Validated only when `NUXT_GITHUB_APP_ID` is set; if missing, boot warns and webhook signature verification is disabled (it does not fail boot). | ::: tip Encoding the Private Key ```bash base64 -i your-app.private-key.pem | tr -d '\n' ``` ::: ## AI (Anthropic) | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `NUXT_ANTHROPIC_API_KEY` | No | -- | Operator-managed Anthropic API key for the Studio instance. If omitted, users need BYOA (Bring Your Own API key). | ## CDN / Object Storage (Cloudflare R2) | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `NUXT_CDN_R2_ACCOUNT_ID` | No | -- | Cloudflare account ID. Required for CDN and media features. | | `NUXT_CDN_R2_ACCESS_KEY_ID` | No | -- | R2 access key ID. | | `NUXT_CDN_R2_SECRET_ACCESS_KEY` | No | -- | R2 secret access key. | | `NUXT_CDN_R2_BUCKET` | No | `contentrain-cdn` | R2 bucket name. | ::: info CDN and media library features require all four R2 variables to be set. If any are missing, `useCDNProvider()` and `useMediaProvider()` return `null` and those features are gracefully disabled. ::: ## Email (Resend) | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `NUXT_RESEND_API_KEY` | No | -- | Resend API key for application emails (invites, notifications). | | `RESEND_API_KEY` | No | -- | Same key, read by local Supabase CLI for auth SMTP. | | `NUXT_EMAIL_SENDER_ADDRESS` | No | -- | Sender email address (e.g., `noreply@example.com`). | | `NUXT_EMAIL_SENDER_NAME` | No | -- | Sender display name (e.g., `Contentrain Studio`). | ## Billing Studio supports two payment plugins. **Polar is the default provider**; Stripe is supported as a secondary, legacy plugin. When both are configured, Polar takes precedence (the plugin registry preference order is `polar` -> `stripe`). ### Polar (default) | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `NUXT_POLAR_ACCESS_TOKEN` | No | -- | Polar organization access token (`polar_oat_...`). Activates the Polar plugin when set. | | `NUXT_POLAR_WEBHOOK_SECRET` | No | -- | Polar webhook signing secret (Standard Webhooks). | | `NUXT_POLAR_STARTER_PRODUCT_ID` | No | -- | Polar product ID for the Starter plan. | | `NUXT_POLAR_PRO_PRODUCT_ID` | No | -- | Polar product ID for the Pro plan. | | `NUXT_POLAR_SERVER` | No | `production` | Polar environment: `sandbox` for testing, `production` for live. | ### Stripe (legacy / optional) | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `NUXT_STRIPE_SECRET_KEY` | No | -- | Stripe secret key. Activates the Stripe plugin when set. | | `NUXT_STRIPE_WEBHOOK_SECRET` | No | -- | Stripe webhook signing secret for event verification. | | `NUXT_STRIPE_STARTER_PRICE_ID` | No | -- | Stripe Price ID for the Starter plan. | | `NUXT_STRIPE_PRO_PRICE_ID` | No | -- | Stripe Price ID for the Pro plan. | ::: info Self-Hosted Bypass When no payment plugin (Polar or Stripe) is configured, Studio resolves to the on-premise profile (`ee/` loaded) or community profile (`ee/` missing) and hides the subscription UI. Core features work normally; premium features (CDN preview branches, custom variants, spam filter) remain gated by `hasFeature()`. ::: ## Redis | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `REDIS_URL` | No | -- | Redis connection URL (e.g., `redis://localhost:6379`). Strongly recommended for multi-instance production rate limiting; dev falls back to in-memory. | | `REDIS_CA_CERT` | No | -- | Custom CA certificate for TLS Redis connections (`rediss://`). | ## Sentry (Error & Performance Monitoring) Optional. Leave `NUXT_PUBLIC_SENTRY_DSN` empty to disable Sentry entirely (the SDK no-ops on both client and server, and no telemetry leaves your infrastructure) -- this is the intended Community / self-host default. | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `NUXT_PUBLIC_SENTRY_DSN` | No | -- | Your Sentry project DSN. Empty disables Sentry completely. | | `NUXT_PUBLIC_SENTRY_ENVIRONMENT` | No | -- | Environment tag: `development` \| `staging` \| `production`. | | `NUXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE` | No | `0.1` | Performance-tracing sample rate. Errors are always captured at 100%. | | `NUXT_PUBLIC_SENTRY_RELEASE` | No | -- | Optional build/release identifier. | These three are **build-time only**, for source map upload during `nuxt build`. When all three are set, readable stack traces are uploaded. `SENTRY_AUTH_TOKEN` is a secret -- never expose it to forked CI or the client bundle. | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `SENTRY_ORG` | No | -- | Sentry org slug (source map upload). | | `SENTRY_PROJECT` | No | -- | Sentry project slug (source map upload). | | `SENTRY_AUTH_TOKEN` | No | -- | Sentry auth token. Enables source map upload when present. | ## Variable Resolution Nuxt resolves runtime config variables using the `NUXT_` prefix convention: ```ts // nuxt.config.ts runtimeConfig: { sessionSecret: '', // -> NUXT_SESSION_SECRET github: { appId: '', // -> NUXT_GITHUB_APP_ID clientId: '', // -> NUXT_GITHUB_CLIENT_ID privateKey: '', // -> NUXT_GITHUB_PRIVATE_KEY }, polar: { accessToken: '', // -> NUXT_POLAR_ACCESS_TOKEN server: 'production', // -> NUXT_POLAR_SERVER }, public: { siteUrl: 'http://localhost:3000', // -> NUXT_PUBLIC_SITE_URL }, } ``` Access in server code: ```ts const config = useRuntimeConfig() console.log(config.github.appId) // Server-only console.log(config.public.siteUrl) // Both server and client ``` ## Example `.env` File ```bash # Core NUXT_SESSION_SECRET=your-random-32-character-string NUXT_PUBLIC_SITE_URL=https://studio.yourdomain.com # Provider pair — Supabase (default). Omit NUXT_AUTH_PROVIDER / # NUXT_DATABASE_PROVIDER to use it. NUXT_SUPABASE_URL=https://your-project.supabase.co NUXT_SUPABASE_SERVICE_ROLE_KEY=eyJ... NUXT_SUPABASE_ANON_KEY=eyJ... # Provider pair — managed + postgres (Supabase-free). Use INSTEAD of the # Supabase block above: # NUXT_AUTH_PROVIDER=managed # NUXT_DATABASE_PROVIDER=postgres # NUXT_POSTGRES_URL=postgres://user:pass@host:5432/contentrain # NUXT_AUTH_JWT_SECRET=your-random-32-character-string # NUXT_SESSION_PASSWORD=your-random-32-character-string # NUXT_OAUTH_GITHUB_CLIENT_ID=your-github-oauth-app-client-id # NUXT_OAUTH_GITHUB_CLIENT_SECRET=your-github-oauth-app-client-secret # NUXT_RESEND_API_KEY=re_abc123 # required on the managed pair # GitHub App NUXT_GITHUB_APP_ID=123456 NUXT_GITHUB_CLIENT_ID=Iv1.abc123 NUXT_GITHUB_CLIENT_SECRET=abc123def456 NUXT_GITHUB_PRIVATE_KEY=LS0tLS1CRUdJTi... NUXT_GITHUB_WEBHOOK_SECRET=whsec_abc123 # AI (optional) NUXT_ANTHROPIC_API_KEY=sk-ant-... # Email (optional) NUXT_RESEND_API_KEY=re_abc123 NUXT_EMAIL_SENDER_ADDRESS=noreply@yourdomain.com NUXT_EMAIL_SENDER_NAME=Contentrain Studio # Billing — Polar (default; optional) NUXT_POLAR_ACCESS_TOKEN=polar_oat_... NUXT_POLAR_WEBHOOK_SECRET=your-polar-webhook-secret NUXT_POLAR_STARTER_PRODUCT_ID=uuid-of-starter-product NUXT_POLAR_PRO_PRODUCT_ID=uuid-of-pro-product NUXT_POLAR_SERVER=production ``` ## Related Pages - [Self-Hosting Guide](/developer/self-hosting) -- deployment walkthrough - [Architecture](/developer/architecture) -- how providers use these variables --- # Overview Source: https://docs.contentrain.io/developer/api-reference/ > Overview of the Contentrain Studio REST API -- authentication, request format, error handling, and endpoint organization. # API Reference Contentrain Studio exposes a REST API under `/api/`. All endpoints use JSON for request and response bodies. The API is organized around Studio's resource hierarchy: workspaces, projects, and their sub-resources. ## API Surfaces & Stability Studio's HTTP surface splits into two distinct contracts. Read this before integrating against any endpoint. **Public, versioned APIs** live under `/api/{forms,media,cdn,conversation,mcp}/v1/...`. They are key-gated (API key or Bearer token, never session cookies), explicitly versioned with a `/v1/` segment, and form the **stable external contract** intended for third-party integrations. Breaking changes ship under a new version segment. | Surface | Base Path | Auth | |---------|-----------|------| | [Forms](/developer/api-reference/forms) | `/api/forms/v1/` | Public / project-scoped | | [Media](/developer/api-reference/media) | `/api/media/v1/` | API key | | [CDN](/developer/api-reference/cdn) | `/api/cdn/v1/` | API key | | [Conversation API](/developer/api-reference/conversation) | `/api/conversation/v1/` | `crn_conv_` Bearer key (EE-only) | | [MCP Cloud](/developer/mcp-cloud) | `/api/mcp/v1/` | `crn_mcp_` Bearer key | **Internal SPA routes** are everything else -- `/api/workspaces/...`, `/api/auth/...`, and the projects, content, branches, chat, and webhooks management endpoints. These power the Studio dashboard itself. They use cookie/session authentication, are **not** a versioned public contract, and are subject to change without notice. The pages below document them for self-hosters and contributors, not as a stability guarantee. ::: warning Build external integrations against the versioned `/v1/` surfaces only. The internal SPA routes may change shape between releases. ::: ## Base URL ``` {NUXT_PUBLIC_SITE_URL}/api ``` For local development: `http://localhost:3000/api` ## Authentication Most endpoints require a valid session. Authentication is cookie-based using AES-256 encrypted httpOnly cookies -- there are no Bearer tokens for the Studio UI API. ### Session Lifecycle 1. Client calls `POST /api/auth/login` to get an OAuth redirect URL 2. User completes OAuth flow (GitHub or Google) 3. Client calls `POST /api/auth/verify` with the authorization code 4. Server sets an encrypted session cookie 5. All subsequent requests include the cookie automatically 6. The auth middleware auto-refreshes tokens 5 minutes before expiry ### Public Endpoints These endpoints do not require authentication: | Endpoint | Purpose | |----------|---------| | `POST /api/auth/login` | Get OAuth redirect URL | | `POST /api/auth/verify` | Exchange code/token for session | | `POST /api/auth/magic-link` | Send magic link email | | `GET /api/health` | Health check | | `POST /api/webhooks/github` | GitHub webhook receiver | | `POST /api/billing/webhook/:provider` | Billing webhook receiver (per provider) | | `GET /api/cdn/v1/:projectId/*` | CDN content delivery (API key auth; media binaries also support a keyless public-media mode gated by `cdn_public_media`) | | `GET /api/forms/v1/:projectId/:modelId/config` | Form schema (public) | | `POST /api/forms/v1/:projectId/:modelId/submit` | Form submission (public) | | `POST /api/conversation/v1/:projectId/message` | Conversation API (API key auth) | | `GET /api/conversation/v1/:projectId/history` | Conversation history (API key auth) | ## Request Format ### Headers ```http Content-Type: application/json Cookie: ``` ### URL Parameters Path parameters use Nuxt file-based routing convention: ``` /api/workspaces/:workspaceId/projects/:projectId/content/:modelId ``` ### Query Parameters List endpoints support pagination: | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `page` | number | `1` | Page number | | `limit` | number | `20` | Items per page | ## Response Format ### Success Response ```json { "workspace": { "id": "uuid", "name": "My Workspace", "slug": "my-workspace" } } ``` List endpoints return arrays: ```json [ { "id": "uuid", "name": "Project 1" }, { "id": "uuid", "name": "Project 2" } ] ``` ### Error Response ```json { "statusCode": 403, "message": "You do not have permission to perform this action" } ``` ## Error Codes | Status Code | Meaning | |-------------|---------| | `400` | Bad Request -- missing or invalid parameters | | `401` | Unauthorized -- no valid session | | `402` | Payment Required -- billing locked (trial expired, subscription canceled) | | `403` | Forbidden -- insufficient permissions or feature not available on plan | | `404` | Not Found -- resource does not exist | | `409` | Conflict -- duplicate resource (e.g., duplicate project for same repo) | | `429` | Too Many Requests -- rate limit exceeded | | `500` | Internal Server Error | ## Rate Limiting Rate limits are applied per-IP for public endpoints and per-session for authenticated endpoints: | Endpoint Group | Limit | Window | |---------------|-------|--------| | Auth (login/verify) | 10 requests | 1 minute | | Form submission | 10 requests per model | 1 minute | | Form config | 30 requests | 1 minute | | General API | Varies | Per-endpoint | ::: info In production with Redis, rate limiting is distributed across instances. Without Redis, rate limiting is in-memory and per-instance only. ::: ## Server Middleware Every authenticated API request passes through these middleware layers in order: 1. **Auth middleware** (`01.auth.ts`) -- validates session, auto-refreshes tokens 2. **Accept invite middleware** (`02.accept-invite.ts`) -- processes pending workspace invitations 3. **Billing middleware** (`03.billing.ts`) -- resolves workspace billing state, blocks locked workspaces 4. **Audit middleware** (`04.audit.ts`) -- records audit log entries for write operations ## API Sections | Section | Base Path | Description | |---------|-----------|-------------| | [Authentication](/developer/api-reference/authentication) | `/api/auth/` | Login, verify, magic link, logout, session | | [Workspaces](/developer/api-reference/workspaces) | `/api/workspaces/` | Workspace CRUD, members, AI keys | | [MCP Cloud Keys](/developer/api-reference/workspaces#mcp-cloud-keys) | `.../mcp-cloud-keys/` | `crn_mcp_` key create, list, revoke | | [Connected Apps](/developer/api-reference/workspaces#connected-apps) | `.../connected-apps/` | Remote-MCP OAuth grants (managed pair) | | [Usage](/developer/api-reference/workspaces#get-usage) | `.../usage` | Metered usage for the billing period | | [Overage Settings](/developer/api-reference/workspaces#get-overage-settings) | `.../overage-settings` | Per-category overage toggles | | [Projects](/developer/api-reference/projects) | `.../projects/` | Project CRUD, members, configuration | | [Content](/developer/api-reference/content) | `.../content/` | Content save, status updates, brain sync | | [Chat](/developer/api-reference/chat) | `.../chat` | SSE chat endpoint with agent tools | | [Branches](/developer/api-reference/branches) | `.../branches/` | Branch list, diff, merge, reject | | [Media](/developer/api-reference/media) | `.../media/` | Media upload, list, update, delete | | [Forms](/developer/api-reference/forms) | `/api/forms/v1/` | Public form config and submission | | [CDN](/developer/api-reference/cdn) | `.../cdn/` | CDN settings, builds, keys, delivery | | [Webhooks](/developer/api-reference/webhooks) | `.../webhooks/` | Webhook CRUD, test, deliveries | | [Conversation API](/developer/api-reference/conversation) | `/api/conversation/v1/` | Public conversation endpoints | ## Related Pages - [Architecture](/developer/architecture) -- how the API fits into the system - [Roles & Permissions](/developer/roles-permissions) -- who can access what - [Environment Variables](/developer/environment-variables) -- API configuration --- # CDN Delivery Source: https://docs.contentrain.io/developer/api-reference/cdn > API endpoints for CDN settings, content builds, API key management, and content delivery. # CDN API ::: tip Public API · Enterprise Edition The CDN delivery endpoint is part of Studio's **public, versioned, key-gated API** (`/api/cdn/v1/`) — a stable contract intended for external integrations. CDN is an Enterprise-Edition feature (Starter+ on the managed service; not available in a self-hosted Community deployment). ::: Studio provides a built-in CDN for delivering content as JSON via API keys. Content is built from Git and stored in Cloudflare R2 object storage. ## CDN Settings ### Get CDN Settings ``` GET /api/workspaces/:workspaceId/projects/:projectId/cdn/settings ``` ### Response ```json { "cdn_enabled": true, "cdn_branch": "main" } ``` ### Update CDN Settings ``` PATCH /api/workspaces/:workspaceId/projects/:projectId/cdn/settings ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `cdn_enabled` | `boolean` | No | Enable/disable CDN delivery | | `cdn_branch` | `string \| null` | No | Branch the CDN builds from (defaults to the project's default branch) | Returns the updated `{ cdn_enabled, cdn_branch }`. --- ## Builds ### Trigger Build Trigger a **full rebuild** of CDN content from the repository. This endpoint claims the project's single in-flight build slot and returns **409** (`cdn.build_in_progress`) if a build is already running. ``` POST /api/workspaces/:workspaceId/projects/:projectId/cdn/builds/trigger ``` ### Response — SSE progress stream The response is a **Server-Sent Events** stream, not a single JSON body. Each event is a JSON-encoded progress update with a `phase` and `message` (and optional `current`/`total` counters): ```json { "phase": "upload", "message": "Uploading manifest...", "current": 0, "total": 5 } ``` The stream ends with a terminating `complete` event carrying the build result: ```json { "phase": "complete", "message": "Build complete — 12 files in 4200ms", "result": { "buildId": "uuid", "filesUploaded": 12, "totalSizeBytes": 52428800, "durationMs": 4200, "error": null } } ``` If the build throws, an `{ "phase": "error", "message": "..." }` event is emitted instead. The build process reads content from Git, serializes it to JSON, and uploads to R2. Stale objects are cleaned up via diff-based comparison. ### List Builds ``` GET /api/workspaces/:workspaceId/projects/:projectId/cdn/builds ``` ### Response ```json [ { "id": "uuid", "status": "completed", "trigger_type": "manual", "commit_sha": "abc123", "started_at": "2026-01-15T12:00:00Z", "completed_at": "2026-01-15T12:00:05Z" } ] ``` --- ## API Keys ### List CDN Keys ``` GET /api/workspaces/:workspaceId/projects/:projectId/cdn/keys ``` ### Response ```json [ { "id": "uuid", "name": "Production Key", "key_prefix": "crn_live_7K2mX9p", "last_used_at": "2026-01-15T12:00:00Z", "created_at": "2026-01-01T00:00:00Z" } ] ``` ### Create CDN Key ``` POST /api/workspaces/:workspaceId/projects/:projectId/cdn/keys ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | `string` | Yes | Key display name | | `scopes` | `string[]` | No | Scopes the key holds. Valid values: `delivery`, `media:read`, `media:write`. Defaults to `['delivery']`. Unknown scope strings are rejected with `400`. | ### Response ```json { "id": "uuid", "name": "Production Key", "key": "crn_live_7K2mX9pQ4rT6vW3nY8sB1hJ5...", "key_prefix": "crn_live_7K2mX9p" } ``` ::: warning The full API key is only returned once at creation time. Studio stores only the SHA-256 hash; the `key_prefix` (first 16 characters) is kept for display. Store the full key securely. ::: ### Revoke CDN Key ``` DELETE /api/workspaces/:workspaceId/projects/:projectId/cdn/keys/:keyId ``` ### Plan Limits | Plan | API Keys | Bandwidth/Month | |------|----------|----------------| | Starter | 3 | 2 GB | | Pro | 10 | 20 GB | | Enterprise | Unlimited | Unlimited | --- ## Content Delivery (Public) Fetch content through the CDN. Content JSON and manifests are authenticated via API key in the `Authorization` header. Media binaries (paths under `media/`) are the one exception: when the project has **public media** enabled they are served keyless (rate-limited per project + client IP) so a browser `` can load them. ``` GET /api/cdn/v1/:projectId/:path ``` ### Headers ```http Authorization: Bearer crn_live_your-api-key ``` ### Object Paths CDN objects are keyed by model ID (not domain) — the build pipeline writes them flat under `content/`, `meta/`, `documents/`, and `models/`: | Path | Returns | |------|---------| | `content/{modelId}/{locale}.json` | Published entries for an i18n collection/singleton/dictionary, by locale (object map keyed by entry ID) | | `content/{modelId}/data.json` | Published entries for a non-i18n model | | `meta/{modelId}/{locale}.json` | Per-entry metadata for an i18n model, by locale (`data.json` for non-i18n) | | `documents/{modelId}/{slug}/{locale}.json` | A single document entry (`{ frontmatter, body, html }`) | | `documents/{modelId}/_index/{locale}.json` | Frontmatter index for a document model, by locale | | `models/{modelId}.json` | A model definition | | `models/_index.json` | Summary index of all models | | `_manifest.json` | Build manifest (commit SHA, locales, domains, model list) | | `_media_manifest.json` | Media manifest — asset metadata (dimensions, blurhash, alt text) | | `_bundle/{locale}.json` | Per-locale bundle — all JSON content for one locale in a single fetch (see below) | | `media/{...}` | Media binaries; served keyless when public media is enabled | ### Locale bundle (`_bundle/{locale}.json`) Emitted on **every** build, a bundle packs all of a locale's JSON content into a single keyed, ETag'd object so a client can prime its per-path cache with one conditional fetch instead of one request per model. Its shape: ```json { "version": "1", "commitSha": "abc123...", "builtAt": "2026-07-10T12:00:00.000Z", "locale": "en", "paths": { "content/authors/en.json": { "a1b2c3d4e5f6": { "name": "Jane" } }, "content/site-settings/data.json": { "title": "My Site" }, "documents/articles/_index/en.json": [] } } ``` `paths` is keyed by the exact delivery paths listed above, and each body is identical to the standalone artifact. All JSON-kind models ship their full body (non-i18n models under `content/{modelId}/data.json` in every locale bundle); document models contribute only their `_index` (per-slug bodies and `meta/...` are not bundled). Consumers must ignore a bundle whose `version` is not `"1"` and fall back to per-path fetches. ### Examples ```bash # Get a collection (English locale) curl https://studio.example.com/api/cdn/v1/{projectId}/content/blog-post/en.json \ -H "Authorization: Bearer crn_live_..." # Get a singleton (English locale) curl https://studio.example.com/api/cdn/v1/{projectId}/content/hero/en.json \ -H "Authorization: Bearer crn_live_..." # Get model definition curl https://studio.example.com/api/cdn/v1/{projectId}/models/blog-post.json \ -H "Authorization: Bearer crn_live_..." ``` ### Response Returns the raw JSON content file from R2 storage with appropriate `Content-Type` and `ETag` headers. ### Rate Limiting Keyed CDN requests are rate-limited per API key; keyless public-media requests are rate-limited per project + client IP. Usage (request count and bandwidth) is metered per project — keyless public-media bandwidth counts toward the project's totals under a NULL-key bucket. ## Related Pages - [Self-Hosting](/developer/self-hosting) -- R2 storage configuration - [Environment Variables](/developer/environment-variables) -- CDN-related variables - [Enterprise Edition](/developer/enterprise-edition) -- CDN provider implementation --- # Media Source: https://docs.contentrain.io/developer/api-reference/media > API endpoints for media upload, listing, metadata management, and URL import. # Media API ::: tip Public API · Enterprise Edition The Media library exposes a **public, key-gated** surface (`/api/media/v1/`) for external integrations; the workspace-scoped management routes are part of the internal application API. Both are detailed below. Media is an Enterprise-Edition feature. ::: The Media API manages binary assets (images, videos, documents) stored in Cloudflare R2 object storage. Assets are processed with Sharp for optimization and variant generation. There are two surfaces: - **Application API** (this page's main sections) — project-scoped, session-authenticated, used by the Studio SPA: ``` /api/workspaces/:workspaceId/projects/:projectId/media/... ``` - **Public Media API** — key-gated external surface (see [Public Media API](#public-media-api-key-gated) below): ``` /api/media/v1/:projectId/assets/... ``` ::: info Enterprise Feature Media upload and management require the enterprise bridge (`ee/`) to be loaded and R2 credentials to be configured. The media provider is `null` in the Community Edition, so every media route returns `503` there. On the managed service the media library is available on Starter plans and above. ::: ## Upload Media Upload a file to the project media library. ``` POST /api/workspaces/:workspaceId/projects/:projectId/media ``` ### Request Multipart form data: | Field | Type | Required | Description | |-------|------|----------|-------------| | `file` | `File` | Yes | Binary file to upload | | `alt` | `string` | No | Alt text for accessibility | | `tags` | `string` | No | Comma-separated tags | ### Response ```json { "id": "uuid", "filename": "hero-banner.webp", "contentType": "image/webp", "size": 125000, "width": 1920, "height": 1080, "format": "webp", "blurhash": "LEHV6nWB2yk8", "alt": "Hero banner image", "tags": ["hero", "banner"], "originalPath": "media/original/abc123.webp", "variants": { "thumbnail": { "path": "media/thumbnail/abc123.webp", "width": 300, "height": 169, "format": "webp", "size": 12000 } }, "createdAt": "2026-01-01T00:00:00Z" } ``` ### Plan Limits | Plan | Max File Size | Storage | Variants per Field | |------|--------------|---------|-------------------| | Free | — | — | — | | Starter | 5 MB | 1 GB | 4 | | Pro | 50 MB | 15 GB | 10 | | Enterprise | 100 MB | 100 GB | Unlimited | --- ## Upload from URL Import a media asset from an external URL. ``` POST /api/workspaces/:workspaceId/projects/:projectId/media/upload-url ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `url` | `string` | Yes | Source URL to fetch | | `alt` | `string` | No | Alt text | | `tags` | `string[]` | No | Tags for organization | --- ## List Media List media assets with filtering and pagination. ``` GET /api/workspaces/:workspaceId/projects/:projectId/media ``` ### Query Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `page` | `number` | `1` | Page number | | `limit` | `number` | `20` | Items per page | | `search` | `string` | -- | Search by filename, alt text | | `tags` | `string` | -- | Filter by tags (comma-separated) | | `contentType` | `string` | -- | Filter by MIME type prefix (e.g., `image`) | | `sort` | `string` | `newest` | Sort order: `newest`, `oldest`, `name`, `size` | ### Response ```json { "assets": [ ... ], "total": 42 } ``` --- ## Get Asset Get full metadata for a specific media asset. ``` GET /api/workspaces/:workspaceId/projects/:projectId/media/:assetId ``` --- ## Preview Asset Get a binary preview of a media asset (serves the file). ``` GET /api/workspaces/:workspaceId/projects/:projectId/media/:assetId/preview ``` --- ## Update Asset Metadata Update alt text, tags, or focal point for an asset. ``` PATCH /api/workspaces/:workspaceId/projects/:projectId/media/:assetId ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `alt` | `string` | No | New alt text | | `tags` | `string[]` | No | New tags | | `focalPoint` | `{ x: number, y: number }` | No | Focal point coordinates (0-1 range) | --- ## Delete Asset Delete an asset and all its variants from storage and database. ``` DELETE /api/workspaces/:workspaceId/projects/:projectId/media/:assetId ``` --- ## Bulk Operations Perform bulk operations on multiple assets. ``` POST /api/workspaces/:workspaceId/projects/:projectId/media/bulk ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `action` | `'delete'` | Yes | Bulk action type | | `assetIds` | `string[]` | Yes | Asset IDs to process | --- ## Public Media API (Key-Gated) The public media surface lets agents and CI authenticate with a CDN/media API key instead of a session. It is gated by [CDN keys](/developer/api-reference/cdn#api-keys) carrying the right scope: ```http Authorization: Bearer crn_live_your-api-key ``` Auth model (per request): Bearer key validation → the key's project must match `:projectId` → scope check → per-key hourly rate limit → plan feature → media provider. The project must have CDN enabled (`cdn_enabled`); read routes require the `media:read` scope and the `media.library` feature, write routes require `media:write` and `media.upload`. | Method | Path | Scope | |--------|------|-------| | `GET` | `/api/media/v1/:projectId/assets` | `media:read` | | `POST` | `/api/media/v1/:projectId/assets` | `media:write` | | `GET` | `/api/media/v1/:projectId/assets/:assetId` | `media:read` | | `PATCH` | `/api/media/v1/:projectId/assets/:assetId` | `media:write` | | `DELETE` | `/api/media/v1/:projectId/assets/:assetId` | `media:write` | | `POST` | `/api/media/v1/:projectId/assets/bulk` | `media:write` | ### List (`GET /assets`) Query parameters: `search`, `tags` (comma-separated), `type` (MIME prefix), `limit` (default `50`, max `100`), `page`, `sort` (`newest`, `oldest`, `name`, `size`). Returns `{ assets, total }`. ### Upload (`POST /assets`) Accepts either `multipart/form-data` with a `file` part (plus optional `alt`, `tags`, `variants`) or a JSON body `{ url, alt?, tags?, variants? }` for URL import (SSRF-guarded). Returns `201` with the created asset. ### Get / Update / Delete (`/assets/:assetId`) `GET` returns the asset. `PATCH` updates `alt`, `tags`, and/or `focalPoint`. `DELETE` removes the asset and its variants, returning `{ "deleted": true }`. ### Bulk (`POST /assets/bulk`) ```json { "action": "delete", "assetIds": ["uuid1", "uuid2"] } ``` `action` is `delete` or `tag` (the latter also requires a `tags` array). Capped at 50 asset IDs per call; assets outside the key's project are silently skipped. Returns `{ action, affected }`. ## Related Pages - [Chat API](/developer/api-reference/chat) -- agent tools for media operations - [Field Types](/developer/field-types) -- `image`, `video`, and `file` field types - [CDN API](/developer/api-reference/cdn) -- media delivery via CDN --- # Forms Source: https://docs.contentrain.io/developer/api-reference/forms > Public form configuration and submission endpoints, plus admin submission management. # Forms API ::: tip Public API The form **config** and **submit** endpoints are part of Studio's **public, versioned API** (`/api/forms/v1/`), designed to be called directly from your site. Submission *management* (review, approve, convert) is part of the [internal application API](/developer/api-reference/). ::: Studio supports public form submissions that map to collection models. Forms can be embedded on external websites to collect data that flows into the content pipeline. ## Public Endpoints These endpoints require no authentication and are designed for external embedding. ### Get Form Configuration Get the public schema for a form-enabled model. ``` GET /api/forms/v1/:projectId/:modelId/config ``` ### Response ```json { "modelId": "contact-form", "fields": { "name": { "type": "string", "required": true }, "email": { "type": "email", "required": true }, "message": { "type": "text", "required": true }, "newsletter": { "type": "boolean", "required": false } }, "captcha": "turnstile", "successMessage": "Thank you for your submission!", "honeypotField": "_hp" } ``` Only fields listed in the model's `exposedFields` configuration are returned. Internal fields are never exposed. ### Example ```bash curl https://studio.example.com/api/forms/v1/{projectId}/{modelId}/config ``` ### CORS The endpoint returns `Access-Control-Allow-Origin: *` headers for cross-origin embedding. --- ### Submit Form Submit data to a form-enabled model. ``` POST /api/forms/v1/:projectId/:modelId/submit ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `data` | `object` | Yes | Form field values | | `captchaToken` | `string` | Conditional | Turnstile token (when captcha is configured) | | `_hp` | `string` | No | Honeypot field (must be empty -- bots fill it) | ### Response **Success:** ```json { "success": true, "message": "Thank you for your submission!" } ``` **Validation Error:** ```json { "success": false, "errors": [ { "field": "email", "message": "email must be a valid email" } ] } ``` ### Security Features | Feature | Description | |---------|-------------| | **Rate limiting** | Per-IP sliding window (configurable, default 10/min) | | **Honeypot** | Hidden field -- bots fill it, humans do not. Silent 200 rejection. | | **Captcha** | Cloudflare Turnstile verification (Starter+ plan) | | **HTML sanitization** | All string values stripped of HTML tags, JS patterns, entities | | **Field filtering** | Only exposed fields are accepted; others are silently dropped | | **Validation** | Schema-based validation against model field definitions | | **Monthly limits** | Atomic check + insert prevents race conditions | ### Plan Limits | Plan | Form Models | Submissions/Month | |------|-------------|-------------------| | Free | 0 | 0 | | Starter | 1 | 100 | | Pro | 15 | 3,000 | | Enterprise | Unlimited | Unlimited | ### Auto-Approve When configured and the plan supports `forms.auto_approve`, submissions are automatically converted to content entries and merged into the repository. --- ## Admin Endpoints These endpoints require authentication and workspace membership. ### List Submissions ``` GET /api/workspaces/:workspaceId/projects/:projectId/forms/:modelId/submissions ``` ### Query Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `status` | `string` | `pending` | Filter: `pending`, `approved`, `rejected`, `spam` | | `page` | `number` | `1` | Page number | | `limit` | `number` | `20` | Items per page | | `sort` | `string` | `newest` | Sort: `newest`, `oldest` | ### Response ```json { "submissions": [ { "id": "uuid", "model_id": "contact-form", "data": { "name": "John", "email": "john@example.com", "message": "Hello" }, "status": "pending", "source_ip": "192.168.1.1", "created_at": "2026-01-01T00:00:00Z" } ], "total": 15 } ``` --- ### Update Submission Status ``` PATCH /api/workspaces/:workspaceId/projects/:projectId/forms/:modelId/submissions/:submissionId ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `status` | `'approved' \| 'rejected' \| 'spam'` | Yes | New status | --- ### Delete Submission ``` DELETE /api/workspaces/:workspaceId/projects/:projectId/forms/:modelId/submissions/:submissionId ``` --- ### Bulk Update ``` POST /api/workspaces/:workspaceId/projects/:projectId/forms/:modelId/submissions/bulk ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `action` | `'approve' \| 'reject' \| 'spam' \| 'delete'` | Yes | Imperative action to apply to each submission | | `submissionIds` | `string[]` | Yes | Submission IDs to act on (max 50 per request) | The field is `action` (imperative), not `status`. `approve` creates a content entry in Git for each submission; `delete` permanently removes them (with a GDPR audit log entry). ## Related Pages - [Chat API](/developer/api-reference/chat) -- agent tools for submission management - [Field Types](/developer/field-types) -- form field validation rules --- # Conversation API Source: https://docs.contentrain.io/developer/api-reference/conversation > Public conversation endpoints for external AI content operations via API keys. # Conversation API ::: tip Public API · Enterprise Edition The Conversation API is part of Studio's **public, versioned API** (`/api/conversation/v1/`), authenticated with a `crn_conv_` Bearer key. It is an Enterprise-Edition feature available on **Pro and above**. ::: The Conversation API allows external applications to interact with the Studio AI agent programmatically. It provides the same content management capabilities as the chat UI but through a REST API authenticated with project-scoped API keys. ## Authentication Conversation API endpoints use API key authentication, not session cookies: ```http Authorization: Bearer crn_conv_your-conversation-api-key ``` ## Send Message Send a message to the AI agent and receive a response. ``` POST /api/conversation/v1/:projectId/message ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `message` | `string` | Yes | User message text | | `conversationId` | `string` | No | Existing conversation ID for multi-turn | | `context` | `object` | No | UI context hints; the operation locale is derived from `context.activeLocale` (there is no top-level `locale` field) | ### Response The response format depends on the API key configuration. The agent executes tools, performs content operations, and returns the final result. ### Example ```bash curl -X POST https://studio.example.com/api/conversation/v1/{projectId}/message \ -H "Content-Type: application/json" \ -H "Authorization: Bearer crn_conv_your-api-key" \ -d '{ "message": "Create a blog post titled Getting Started with content about onboarding", "context": { "activeLocale": "en" } }' ``` --- ## Conversation History Get the message history for a conversation. ``` GET /api/conversation/v1/:projectId/history ``` ### Query Parameters | Parameter | Type | Description | |-----------|------|-------------| | `conversationId` | `string` | Conversation ID | ### Example ```bash curl https://studio.example.com/api/conversation/v1/{projectId}/history?conversationId=uuid \ -H "Authorization: Bearer crn_conv_your-api-key" ``` --- ## API Key Management Conversation API keys are managed through the project settings: ### List Keys ``` GET /api/workspaces/:workspaceId/projects/:projectId/conversation-keys ``` ### Create Key ``` POST /api/workspaces/:workspaceId/projects/:projectId/conversation-keys ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | `string` | Yes | Key display name | | `role` | `'viewer' \| 'editor' \| 'admin'` | No | Key role (defaults to `editor`) — Conversation API keys use this 3-role model, not the internal agent's workspace/project roles | | `allowedModels` | `string[]` | No | Restrict to specific models | | `allowedLocales` | `string[]` | No | Restrict to specific locales | | `customInstructions` | `string` | No | Custom system prompt instructions | ### Update Key ``` PATCH /api/workspaces/:workspaceId/projects/:projectId/conversation-keys/:keyId ``` ### Revoke Key ``` DELETE /api/workspaces/:workspaceId/projects/:projectId/conversation-keys/:keyId ``` --- ## Plan Limits | Plan | Conversation Keys | Messages/Month | |------|------------------|----------------| | Free | 0 | 0 | | Starter | 0 | 100 | | Pro | 15 | 3,000 | | Enterprise | Unlimited | Unlimited | The Conversation API requires **Pro or above** (Enterprise Edition); Free and Starter workspaces cannot create conversation keys. ## Custom Instructions Each API key can include custom instructions that are appended to the agent's system prompt. This allows project admins to constrain the agent's behavior for specific integrations: ```json { "name": "Blog Integration Key", "customInstructions": "Only create content in the blog-post model. Always set status to draft. Do not delete any content.", "allowedModels": ["blog-post"], "allowedLocales": ["en", "tr"] } ``` ::: info Enterprise Feature The Conversation API routes through the enterprise bridge. If `ee/` is not loaded, these endpoints return `403`. ::: ## Related Pages - [Chat API](/developer/api-reference/chat) -- the SSE chat endpoint for the Studio UI - [Agent System](/developer/agent-system) -- how the agent processes requests - [Enterprise Edition](/developer/enterprise-edition) -- API feature details --- # Authentication Source: https://docs.contentrain.io/developer/api-reference/authentication > Internal endpoints for OAuth login, token verification, token refresh, magic link, session management, logout, and profile/account management. # Authentication API Studio uses cookie-based session authentication. The OAuth flow starts with a redirect URL, completes with code/token exchange, and stores an encrypted httpOnly cookie. ::: warning Internal API These are **internal** endpoints, not a public external API. They are consumed by the Studio SPA (cookie / encrypted-session based) and the Contentrain CLI (token based, via `source: 'cli'`). Shapes may change without notice. ::: ## Login (OAuth Redirect) Get an OAuth redirect URL for GitHub or Google. ``` POST /api/auth/login ``` This endpoint has two modes: - **Web (default):** reads `{ provider, redirectTo }` from the request body. The provider-generated CSRF `state` is stored in an encrypted cookie for validation on callback. - **CLI:** reads `provider`, `redirect_uri`, and `state` from query params. The CLI manages its own CSRF state, so no cookie is set. ### Request Body (Web) | Field | Type | Required | Description | |-------|------|----------|-------------| | `provider` | `'github' \| 'google'` | Yes | OAuth provider | | `redirectTo` | `string` | No | Post-auth redirect path (default: `/auth/callback`) | ### Query Parameters (CLI) | Param | Type | Required | Description | |-------|------|----------|-------------| | `provider` | `'github' \| 'google'` | Yes | OAuth provider | | `redirect_uri` | `string` | Yes | Localhost callback. Must be `http://127.0.0.1` or `http://localhost`, port `9876`-`9899`, path `/callback`. | | `state` | `string` | No | Caller-supplied CSRF state. Falls back to the provider-generated state when omitted. | CLI mode activates automatically when both `provider` and `redirect_uri` query params are present. ### Response ```json { "url": "https://github.com/login/oauth/authorize?client_id=...", "state": "csrf-state-token" } ``` ### Example ```bash curl -X POST http://localhost:3000/api/auth/login \ -H "Content-Type: application/json" \ -d '{"provider": "github"}' ``` ### Error Codes | Status | Condition | |--------|-----------| | `400` | Invalid provider, or (CLI) `redirect_uri` is not an allowed localhost callback | | `429` | Rate limit exceeded | ### Auth Public endpoint. Rate limited: 10 requests per minute per IP. --- ## Verify (Token Exchange) Exchange an OAuth authorization code or magic link tokens for a session. ``` POST /api/auth/verify ``` This endpoint has two modes: - **Web (default):** stores tokens in an encrypted httpOnly cookie and returns `{ user }`. - **CLI (`source: 'cli'`):** returns `{ user, tokens }` and does **not** set a cookie. The CLI stores the tokens locally and calls `/api/auth/refresh` when they expire. ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `code` | `string` | Conditional | OAuth authorization code (code flow) | | `state` | `string` | Conditional | CSRF state token (required with `code` in web mode; CLI manages its own state) | | `accessToken` | `string` | Conditional | Access token (magic link / implicit flow) | | `refreshToken` | `string` | No | Refresh token (magic link / implicit flow) | | `source` | `'cli'` | No | When set to `'cli'`, returns tokens instead of setting a cookie | Either `code` (+ `state` in web mode) OR `accessToken` must be provided. ### Response (Web) ```json { "user": { "id": "uuid", "email": "user@example.com", "avatarUrl": "https://avatars.githubusercontent.com/u/123", "provider": "github", "providerAccountId": "12345" } } ``` The server sets an encrypted httpOnly cookie (`h3-session`) containing the access token, refresh token, and expiration timestamp. ### Response (CLI) ```json { "user": { "id": "uuid", "email": "user@example.com", "provider": "github", "providerAccountId": "12345", "avatarUrl": null }, "tokens": { "accessToken": "...", "refreshToken": "...", "expiresAt": 1735689600 } } ``` ### Error Codes | Status | Condition | |--------|-----------| | `400` | Neither `code` nor `accessToken` provided | | `403` | Invalid or missing CSRF state token (web code flow) | | `429` | Rate limit exceeded | ### Auth Public endpoint. Rate limited: 10 requests per minute per IP. --- ## Refresh (Token Refresh) Exchange a refresh token for a new access + refresh token pair. Primarily for the CLI -- web clients are auto-refreshed by the auth middleware from the encrypted cookie. ``` POST /api/auth/refresh ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `refreshToken` | `string` | Yes | Refresh token from a prior `verify` (CLI) response | ### Response ```json { "accessToken": "...", "refreshToken": "...", "expiresAt": 1735689600 } ``` ### Error Codes | Status | Condition | |--------|-----------| | `400` | `refreshToken` missing | | `401` | Session expired / refresh not possible | | `429` | Rate limit exceeded | ### Auth Public endpoint. Rate limited: 10 requests per minute per IP. --- ## Magic Link Send a passwordless magic link email. ``` POST /api/auth/magic-link ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `email` | `string` | Yes | Email address to send the magic link to | | `redirectTo` | `string` | No | Post-auth redirect path (default: `/auth/callback`) | ### Response ```json { "sent": true } ``` Returns `200` regardless of whether the email exists (prevents enumeration). ### Auth Public endpoint. Rate limited: 5 requests per minute per IP. --- ## Managed-Pair Interactive Sign-In ::: info Managed pair only These are **browser redirect endpoints**, not JSON APIs. They exist only on the **managed** auth pair and return `404` on the self-hosted (Supabase) pair, where GoTrue owns OAuth and magic links. They are the interactive counterparts to the JSON `POST /api/auth/login` and `POST /api/auth/magic-link` documented above -- the browser (or CLI) navigates to them directly and follows the redirects. ::: ### OAuth Sign-In Runs the full OAuth authorization-code dance for GitHub or Google in two legs against the same URL. ``` GET /api/auth/oauth/:provider ``` `:provider` must be `github` or `google`. - **Leg 1** (no `code`): stashes flow context in a sealed cookie, then redirects the browser to the provider's consent screen. A CSRF `state` cookie is generated and validated by the auth module itself. - **Leg 2** (`?code=...`): the provider redirects back here; the module validates its own `state`, exchanges the code, links or creates the user, and then either sets the web session cookie and redirects into the app, or -- for a CLI flow -- redirects to the CLI callback with a one-time code. ### Query Parameters | Param | Type | Required | Description | |-------|------|----------|-------------| | `redirect` | `string` | No | Post-auth in-app redirect path (must start with `/`; default `/`) | | `cli_redirect` | `string` | No | CLI callback URL. When present, sign-in completes by redirecting here with a one-time `code` instead of setting a web cookie | | `cli_state` | `string` | No | CSRF state echoed back on the CLI callback | ### Errors | Status | Condition | |--------|-----------| | `400` | `:provider` is not `github` or `google` | | `404` | Not running on the managed pair | On provider-side failure the handler redirects to `/auth/login?error=oauth` rather than returning an error status. --- ### Magic Link / Invite Verify Landing endpoint for a magic-link or invite email. Consumes the single-use token server-side, sets the session cookie, and `302`s into the app. A server-side `GET` (rather than a client POST) keeps the flow working across browsers and devices. ``` GET /api/auth/magic/verify?token=&redirect=/ ``` ### Query Parameters | Param | Type | Required | Description | |-------|------|----------|-------------| | `token` | `string` | Yes | Single-use, 1-hour magic-link / invite token | | `redirect` | `string` | No | Post-auth in-app redirect path (must start with `/`; default `/`) | A missing or invalid/consumed token redirects to `/auth/login?error=magic-link` (no error status). ### Auth Public endpoint on the managed pair. Rate limited: 10 requests per minute per IP. Returns `404` on the self-hosted pair, `429` when rate limited. --- ## Current User Get the currently authenticated user with profile data. ``` GET /api/auth/me ``` ### Response ```json { "user": { "id": "uuid", "email": "user@example.com", "avatarUrl": "https://avatars.githubusercontent.com/u/123", "provider": "github", "providerAccountId": "12345", "displayName": "Jane Doe", "theme": "system" } } ``` `avatarUrl`, `displayName`, and `theme` are merged from the user's profile row (`theme` defaults to `system`). ### Auth Requires valid session. --- ## Logout End the current session and clear the session cookie. ``` POST /api/auth/logout ``` ### Response ```json { "ok": true } ``` ### Auth Requires valid session. --- ## Update Profile Update the authenticated user's display name and/or theme. ``` PATCH /api/profile ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `displayName` | `string` | No | Display name. Trimmed; must be 1-100 characters. | | `theme` | `'light' \| 'dark' \| 'system'` | No | UI theme preference. | At least one field must be provided. ### Response Returns the updated profile row. ### Error Codes | Status | Condition | |--------|-----------| | `400` | Invalid display name length, invalid theme value, or no fields to update | ### Auth Requires valid session. --- ## Upload Avatar Upload a custom avatar image. The image is resized to 256x256 WebP and stored as a base64 data URI on the profile. ``` POST /api/profile/avatar ``` ### Request `multipart/form-data` with a `file` field. Allowed types: `image/jpeg`, `image/png`, `image/webp`, `image/gif`. Maximum size: 2 MB. ### Response ```json { "avatarUrl": "data:image/webp;base64,..." } ``` ### Error Codes | Status | Condition | |--------|-----------| | `400` | Missing file, unsupported content type, or file larger than 2 MB | ### Auth Requires valid session. --- ## Remove Avatar Remove the custom avatar, reverting to the OAuth provider avatar. ``` DELETE /api/profile/avatar ``` ### Response ```json { "avatarUrl": null } ``` ### Auth Requires valid session. --- ## Owned Workspaces List owned secondary workspaces that have other members. Used by the account deletion flow to surface ownership-transfer requirements. ``` GET /api/profile/owned-workspaces ``` ### Response An array of workspace objects (each including its `workspace_members`). Workspaces with no members other than the owner are filtered out. ### Auth Requires valid session. --- ## Delete Account Permanently delete the authenticated user's account. Cleans R2 storage for every owned workspace's projects, then deletes the user from `auth.users`. A database `CASCADE` removes profiles, workspaces, members, projects, and all child records. The session cookie is cleared. ``` DELETE /api/profile ``` ### Response ```json { "deleted": true } ``` ### Auth Requires valid session. ## Related Pages - [API Reference Overview](/developer/api-reference/) -- authentication model - [Architecture](/developer/architecture) -- session management details --- # Workspaces Source: https://docs.contentrain.io/developer/api-reference/workspaces > API endpoints for workspace CRUD, member management, AI key management, and ownership transfer. # Workspaces API ::: warning Internal Application API These are **internal SPA routes** (cookie/session authenticated, used by the Studio app), not a versioned public contract — they may change between releases. For external integrations, use the [Public API](/developer/api-reference/). ::: Workspaces are the top-level billing entity in Studio. Each user has a primary workspace created at signup, and can create or be invited to secondary workspaces. ## List Workspaces List all workspaces the current user belongs to. ``` GET /api/workspaces ``` ### Response ```json [ { "id": "uuid", "name": "My Workspace", "slug": "my-workspace", "type": "primary", "plan": "starter", "role": "owner", "github_installation_id": 12345 } ] ``` --- ## Create Workspace Create a new secondary workspace. ``` POST /api/workspaces ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | `string` | Yes | Workspace display name | | `slug` | `string` | Yes | URL slug (lowercase, alphanumeric, hyphens) | ### Response ```json { "id": "uuid", "name": "Team Workspace", "slug": "team-workspace", "type": "secondary" } ``` --- ## Get Workspace Get workspace details including billing information. ``` GET /api/workspaces/:workspaceId ``` ### Response ```json { "id": "uuid", "name": "My Workspace", "slug": "my-workspace", "type": "primary", "plan": "starter", "github_installation_id": 12345, "owner_id": "uuid" } ``` ### Auth Requires workspace membership. --- ## Update Workspace Update workspace name or settings. ``` PATCH /api/workspaces/:workspaceId ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | `string` | No | New workspace name | ### Auth Requires workspace `owner` or `admin` role. --- ## Delete Workspace Delete a workspace and all its projects. ``` DELETE /api/workspaces/:workspaceId ``` ### Auth Requires workspace `owner` role. Primary workspaces cannot be deleted (delete the user account instead). --- ## Transfer Ownership Transfer workspace ownership to another member. ``` POST /api/workspaces/:workspaceId/transfer-owner ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `newOwnerId` | `string` | Yes | User ID of the new owner | ### Auth Requires current workspace `owner` role. --- ## Members ### List Members ``` GET /api/workspaces/:workspaceId/members ``` ### Response ```json [ { "id": "uuid", "user_id": "uuid", "role": "owner", "invited_email": "owner@example.com", "accepted_at": "2026-01-01T00:00:00Z", "display_name": "Jane Doe", "avatar_url": "https://..." } ] ``` ### Invite Member ``` POST /api/workspaces/:workspaceId/members ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `email` | `string` | Yes | Email address to invite | | `role` | `'admin' \| 'member'` | Yes | Workspace role | ### Auth Requires workspace `owner` or `admin` role. ### Update Member Role ``` PATCH /api/workspaces/:workspaceId/members/:memberId ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `role` | `'admin' \| 'member'` | Yes | New role | ### Remove Member ``` DELETE /api/workspaces/:workspaceId/members/:memberId ``` ### Resend Invitation ``` POST /api/workspaces/:workspaceId/members/:memberId/resend ``` --- ## AI Keys (BYOA) Manage Bring Your Own API key credentials per workspace. ### List AI Keys ``` GET /api/workspaces/:workspaceId/ai-keys ``` ### Response ```json [ { "id": "uuid", "provider": "anthropic", "key_hint": "sk-ant-...7x2q", "created_at": "2026-01-01T00:00:00Z" } ] ``` ### Add/Update AI Key ``` POST /api/workspaces/:workspaceId/ai-keys ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `provider` | `string` | Yes | AI provider name (e.g., `anthropic`) | | `apiKey` | `string` | Yes | API key (encrypted at rest) | ### Delete AI Key ``` DELETE /api/workspaces/:workspaceId/ai-keys/:keyId ``` ::: info Enterprise Feature AI key management routes through the enterprise bridge. If `ee/` is not loaded, these endpoints return `403`. ::: --- ## MCP Cloud Keys Manage [MCP Cloud](/developer/mcp-cloud) API keys (`crn_mcp_` Bearer keys) that grant programmatic access to the remote MCP endpoint. The plaintext key is shown once at creation and never stored -- the server only keeps a SHA-256 hash plus the prefix. ### List MCP Cloud Keys ``` GET /api/workspaces/:workspaceId/mcp-cloud-keys ``` Optionally narrow to one project with `?projectId=`. ### Response ```json { "keys": [ { "id": "uuid", "name": "Production key", "key_prefix": "crn_mcp_ab12", "project_id": "uuid", "allowed_tools": ["list_models", "get_content"], "media_enabled": false, "rate_limit_per_minute": 60, "monthly_call_limit": null, "last_used_at": "2026-01-01T00:00:00Z", "created_at": "2026-01-01T00:00:00Z", "calls_this_month": 42 } ] } ``` Plaintext keys are never returned here. An empty `allowed_tools` array means all tools are permitted. (The row also carries `created_by` and `revoked_at`, though revoked keys are filtered out of this list.) ### Auth Requires workspace membership (`owner`, `admin`, or `member`). --- ### Create MCP Cloud Key ``` POST /api/workspaces/:workspaceId/mcp-cloud-keys ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `projectId` | `string` | Yes | Project the key is scoped to | | `name` | `string` | Yes | Display name | | `allowedTools` | `string[]` | No | Whitelist of MCP tool names (empty = all tools). Unknown names are rejected. | | `mediaEnabled` | `boolean` | No | Allow media tools (default `false`) | | `rateLimitPerMinute` | `number` | No | 1-600 requests per minute (default `60`) | | `monthlyCallLimit` | `number \| null` | No | Monthly call cap (`null` = unlimited) | ### Response ```json { "id": "uuid", "key": "crn_mcp_ab12...full-plaintext", "keyPrefix": "crn_mcp_ab12", "name": "Production key", "projectId": "uuid", "allowedTools": [], "mediaEnabled": false, "rateLimitPerMinute": 60, "monthlyCallLimit": null, "createdAt": "2026-01-01T00:00:00Z" } ``` ::: warning The plaintext `key` is returned **exactly once**. Store it securely -- the server only keeps its hash. ::: ### Auth Requires workspace `owner` or `admin` role. Gated by the `api.mcp_cloud` plan feature and the `api.mcp_keys` plan limit. ### Error Codes | Status | Condition | |--------|-----------| | `400` | Missing `name`/`projectId`, unknown tool in `allowedTools`, `rateLimitPerMinute` out of range, or invalid `monthlyCallLimit` | | `403` | Plan does not include `api.mcp_cloud`, or the `api.mcp_keys` key limit is reached | | `404` | Workspace or project not found | --- ### Revoke MCP Cloud Key ``` DELETE /api/workspaces/:workspaceId/mcp-cloud-keys/:keyId ``` Sets `revoked_at`; the key is immediately rejected by the MCP endpoint. The row is retained for audit. ### Response ```json { "revoked": true } ``` ### Auth Requires workspace `owner` or `admin` role. --- ## Connected Apps Active OAuth grants for the [remote MCP connector](/developer/mcp-connector) surface -- the client applications (e.g. Claude) a user has connected to this workspace. See the [Connected Apps guide](/guide/connected-apps) for the end-user flow. ::: info Managed deployments only These endpoints back the remote-MCP OAuth server, which only runs on the **managed** auth pair. On the self-hosted (Supabase) pair, `GET` returns `{ "enabled": false, "endpoint": null, "grants": [] }` and `DELETE` returns `404`. ::: ### List Connected Apps ``` GET /api/workspaces/:workspaceId/connected-apps ``` Owners and admins see every grant in the workspace; members see only their own. ### Response ```json { "enabled": true, "endpoint": "https://studio.contentrain.io/api/mcp/remote", "grants": [ { "grantId": "uuid", "clientHost": "claude.ai", "clientName": "Claude", "logoUri": "https://...", "projectId": "uuid", "projectRepo": "org/my-website", "scopes": ["content:read", "content:write", "project:metadata"], "createdAt": "2026-01-01T00:00:00Z", "lastUsedAt": "2026-01-02T00:00:00Z", "callsThisMonth": 128, "mine": true } ] } ``` `mine` indicates whether the grant belongs to the requesting user. ### Auth Requires workspace membership (`owner`, `admin`, or `member`). --- ### Revoke Connected App ``` DELETE /api/workspaces/:workspaceId/connected-apps/:grantId ``` Revokes the grant, kills the refresh-token family, and deletes live access tokens. The connected client's next refresh fails with `invalid_grant` and re-runs the auth flow. ### Response ```json { "revoked": true } ``` ### Auth Owners and admins may revoke any grant; members may revoke only their own (otherwise `403`). Returns `404` on the non-managed pair or when the grant does not exist. --- ## Usage & Overage ### Get Usage Return metered usage for the current billing period (calendar month). ``` GET /api/workspaces/:workspaceId/usage ``` ### Response ```json { "billingPeriod": "2026-07", "categories": [ { "key": "ai_messages", "limitKey": "ai.messages_per_month", "name": "AI Messages", "current": 1234, "limit": 5000, "overageEnabled": false, "overageUnits": 0, "overageUnitPrice": 0, "overageAmount": 0, "unit": "messages", "percentage": 25 } ], "totalOverageAmount": 0, "projectedOverageAmount": 0 } ``` Categories are returned in this order: `ai_messages`, `form_submissions`, `cdn_bandwidth`, `media_storage`, `api_messages`, `mcp_calls`. A `limit` of `-1` means unlimited. #### Simple Format The CLI-friendly format keys each category by camelCase name. Each value is a `{ current, limit, percentage }` object: ``` GET /api/workspaces/:workspaceId/usage?format=simple ``` ```json { "aiMessages": { "current": 1234, "limit": 5000, "percentage": 25 }, "formSubmissions": { "current": 12, "limit": 1000, "percentage": 1 }, "cdnBandwidthGb": { "current": 0.5, "limit": 50, "percentage": 1 }, "mediaStorageGb": { "current": 0.2, "limit": 10, "percentage": 2 }, "apiMessages": { "current": 0, "limit": 1000, "percentage": 0 }, "mcpCalls": { "current": 8, "limit": 10000, "percentage": 0 } } ``` ### Auth Requires workspace `owner` or `admin` role. --- ### Get Overage Settings Return overage preferences, plan limits, and per-category overage pricing. ``` GET /api/workspaces/:workspaceId/overage-settings ``` ### Response ```json { "overageSettings": { "ai_messages": true }, "categories": [ { "limitKey": "ai.messages_per_month", "settingsKey": "ai_messages", "unit": "per message", "unitPrice": 0.03, "planLimit": 1500, "enabled": true } ], "canEnableOverage": true } ``` `canEnableOverage` is `true` only when the workspace has a saved payment method, an active subscription (`trialing` / `active` / `past_due`), and a non-free plan. ### Auth Requires workspace `owner` or `admin` role. --- ### Update Overage Settings Toggle overage billing per category. Submitted keys are merged with existing settings. ``` PATCH /api/workspaces/:workspaceId/overage-settings ``` ### Request Body Any subset of the boolean category toggles: | Field | Type | Description | |-------|------|-------------| | `ai_messages` | `boolean` | Enable overage for AI messages | | `api_messages` | `boolean` | Enable overage for API messages | | `cdn_bandwidth` | `boolean` | Enable overage for CDN bandwidth | | `form_submissions` | `boolean` | Enable overage for form submissions | | `media_storage` | `boolean` | Enable overage for media storage | | `mcp_calls` | `boolean` | Enable overage for MCP Cloud calls | ### Response ```json { "overageSettings": { "ai_messages": true, "cdn_bandwidth": false } } ``` ### Auth Requires workspace `owner` or `admin` role. ### Error Codes | Status | Condition | |--------|-----------| | `400` | Unknown settings key, non-boolean value, or no fields provided | | `402` | No active paid subscription (or no saved payment method) | | `403` | Free plan cannot enable overages | See the [Billing guide](/guide/billing) for how overage pricing is applied. --- ## Billing Billing actions live under `/api/billing/` (they are not scoped under `/api/workspaces/` in the path) but take `workspaceId` in the request body. They route to the active payment provider (Polar by default, Stripe as fallback). ### Create Checkout Session Create a hosted checkout session to subscribe a workspace to a paid plan. ``` POST /api/billing/checkout ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `workspaceId` | `string` | Yes | Target workspace | | `plan` | `'starter' \| 'pro'` | Yes | Plan to subscribe to | ### Response ```json { "url": "https://checkout.provider.com/..." } ``` Redirect the browser to `url`. A free trial is granted only if the workspace has never consumed one. ### Auth Requires workspace `owner` or `admin` role. Rate limited to 1 request per 30 seconds per workspace. ### Error Codes | Status | Condition | |--------|-----------| | `400` | Missing `workspaceId`/`plan`, or invalid plan | | `403` | Not a workspace owner/admin | | `409` | Workspace already has an active subscription | | `429` | Rate limit exceeded | | `502` / `503` | Payment provider unavailable | --- ### Create Portal Session Create a customer portal session so the user can manage their subscription (payment method, cancel, plan change). ``` POST /api/billing/portal ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `workspaceId` | `string` | Yes | Target workspace | ### Response ```json { "url": "https://billing.provider.com/..." } ``` ### Auth Requires workspace `owner` or `admin` role. ### Error Codes | Status | Condition | |--------|-----------| | `400` | Missing `workspaceId`, or the workspace has no payment customer yet | | `403` | Not a workspace owner/admin | | `502` / `503` | Payment provider unavailable | ## Related Pages - [Roles & Permissions](/developer/roles-permissions) -- workspace role definitions - [Projects API](/developer/api-reference/projects) -- project management within workspaces --- # Projects Source: https://docs.contentrain.io/developer/api-reference/projects > API endpoints for project CRUD, member management, configuration, and GitHub repository operations. # Projects API ::: warning Internal Application API These are **internal SPA routes** (cookie/session authenticated, used by the Studio app), not a versioned public contract — they may change between releases. For external integrations, use the [Public API](/developer/api-reference/). ::: Projects represent connected Git repositories within a workspace. Each project maps to one repository and contains content models, entries, media, and conversations. All project endpoints are scoped under a workspace: ``` /api/workspaces/:workspaceId/projects/... ``` ## List Projects List all projects in a workspace. ``` GET /api/workspaces/:workspaceId/projects ``` ### Response ```json [ { "id": "uuid", "name": "my-website", "repo_full_name": "org/my-website", "default_branch": "main", "content_root": "", "status": "active", "created_at": "2026-01-01T00:00:00Z" } ] ``` --- ## Create Project Connect a GitHub repository as a new project. ``` POST /api/workspaces/:workspaceId/projects ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `repoFullName` | `string` | Yes | GitHub `owner/repo` full name | | `name` | `string` | Yes | Display name for the project | | `defaultBranch` | `string` | No | Default branch name (auto-detected if omitted) | | `contentRoot` | `string` | No | Sub-path for `.contentrain/` directory (e.g., `apps/web`) | ### Response ```json { "id": "uuid", "name": "my-website", "repo_full_name": "org/my-website", "default_branch": "main", "content_root": "", "status": "active" } ``` ### Error Codes | Status | Condition | |--------|-----------| | `403` | Plan does not support project creation (`projects.create`) | | `409` | Repository already connected in this workspace | --- ## Get Project Get project details with member information. ``` GET /api/workspaces/:workspaceId/projects/:projectId ``` --- ## Delete Project Delete a project (does not delete the GitHub repository). ``` DELETE /api/workspaces/:workspaceId/projects/:projectId ``` ### Auth Requires workspace `owner` or `admin` role. --- ## Update Project Configuration Update the Contentrain configuration for a project (workflow, locales, etc.). ``` PATCH /api/workspaces/:workspaceId/projects/:projectId/config ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `workflow` | `'auto-merge' \| 'review'` | No | Content workflow mode | | `locales` | `object` | No | Locale configuration with `default` and `supported` | | `domains` | `string[]` | No | Content domains | --- ## Project Health Get project health status including branch health and GitHub connectivity. ``` GET /api/workspaces/:workspaceId/projects/:projectId/health ``` --- ## Brain Sync Refresh the project brain cache from Git. ``` GET /api/workspaces/:workspaceId/projects/:projectId/brain/sync ``` Returns the full project snapshot: models, content, meta, and schema validation results. --- ## Update Vocabulary Update the project vocabulary (shared terminology across locales). ``` PATCH /api/workspaces/:workspaceId/projects/:projectId/vocabulary ``` ### Request Body ```json { "terms": { "brand_name": { "en": "Contentrain", "tr": "Contentrain" } } } ``` --- ## Update Model Metadata Update model-level settings (e.g., form configuration). ``` PATCH /api/workspaces/:workspaceId/projects/:projectId/models/:modelId ``` --- ## Project Members ### List Members ``` GET /api/workspaces/:workspaceId/projects/:projectId/members ``` ### Add Member ``` POST /api/workspaces/:workspaceId/projects/:projectId/members ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `userId` | `string` | Yes | User ID (must be a workspace member) | | `role` | `'editor' \| 'reviewer' \| 'viewer'` | Yes | Project role | | `specificModels` | `boolean` | No | Restrict access to specific models (Pro+) | | `allowedModels` | `string[]` | No | Model IDs the member can access | ### Remove Member ``` DELETE /api/workspaces/:workspaceId/projects/:projectId/members/:memberId ``` ### Auth Requires workspace `owner` or `admin` role to manage project members. --- ## Project Activity Paginated activity feed for a project, sourced from the workspace audit log. ``` GET /api/workspaces/:workspaceId/projects/:projectId/activity ``` ### Query Parameters | Param | Type | Default | Description | |-------|------|---------|-------------| | `page` | number | `1` | Page number | | `limit` | number | `20` | Items per page (max `100`) | | `action` | string | -- | Filter by action name (e.g. `delete_project`) | | `sort` | `'newest' \| 'oldest'` | `newest` | Sort order | ### Response ```json { "data": [ { "id": "uuid", "action": "save_content", "actor": "uuid", "entity": "content_entries", "recordId": "uuid", "origin": "chat", "createdAt": "2026-01-01T00:00:00Z" } ], "total": 128, "page": 1, "limit": 20 } ``` `actor` is the acting user's ID; `entity` is the affected table name. The feed is currently workspace-scoped (the audit log carries `workspace_id`). ### Auth Requires workspace `owner`, `admin`, or `member` role. Returns `404` if the project is not found in the workspace. --- ## GitHub Routes These endpoints are used during the repository connection flow. ### Get Installation ``` GET /api/github/installation ``` Returns the GitHub App installation details for the current workspace. ### List Repositories ``` GET /api/github/repos ``` Lists repositories accessible through the GitHub App installation. ### Scan Repository ``` GET /api/github/scan?repo=owner/repo ``` Scans a repository for `.contentrain/` directory and detects the framework stack. ### Create from Template ``` POST /api/github/create-from-template ``` Creates a new repository from a starter kit template. ### GitHub App Setup ``` GET /api/github/setup ``` Handles the GitHub App installation callback redirect. ### List Available Installations Enumerate GitHub App installations visible to the authenticated user, each annotated with whether it is already bound to a workspace (so the picker can disable in-use installations). ``` GET /api/github/installations/available?workspaceId= ``` ### Response ```json { "installations": [ { "id": 12345, "account": "my-org", "repositorySelection": "selected", "targetType": "Organization", "boundWorkspace": { "id": "uuid", "name": "Other Workspace", "slug": "other-workspace" } } ] } ``` `boundWorkspace` is `null` when the installation is not yet connected to any workspace. ### Auth Requires workspace `owner` or `admin` role, and a valid GitHub user token. ### Error Codes | Status | Condition | |--------|-----------| | `400` | Missing `workspaceId` | | `401` | No usable GitHub user token -- response `data.code` is `GITHUB_REAUTH_REQUIRED` (user must reconnect GitHub) | | `404` | Workspace not found or caller is not an owner/admin of it | ### Connect Installation Bind an existing GitHub App installation to a workspace -- the "Connect existing installation" counterpart to the install-callback flow. On success the workspace's `github_installation_id` is set and its status flips to `active`. ``` POST /api/github/installations/connect ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `workspaceId` | `string` | Yes | Target workspace | | `installationId` | `number` | Yes | GitHub App installation ID | ### Response ```json { "connected": true, "installationId": 12345, "workspaceId": "uuid" } ``` ### Auth Requires workspace `owner` or `admin` role, and a valid GitHub user token. The caller's GitHub-side access to the installation is verified before binding. ### Error Codes | Status | Condition | |--------|-----------| | `400` | Missing `workspaceId` or invalid `installationId` | | `401` | No usable GitHub user token (`data.code` = `GITHUB_REAUTH_REQUIRED`) | | `403` | Caller lacks GitHub-side access to the installation | | `404` | Workspace not found or caller is not an owner/admin of it | | `409` | Installation is already bound to a different workspace | ## Related Pages - [Workspaces API](/developer/api-reference/workspaces) -- workspace management - [Content API](/developer/api-reference/content) -- content operations within projects - [Roles & Permissions](/developer/roles-permissions) -- project role definitions --- # Content Source: https://docs.contentrain.io/developer/api-reference/content > API endpoints for content CRUD operations, status updates, and brain sync. # Content API ::: warning Internal Application API These are **internal SPA routes** (cookie/session authenticated, used by the Studio app), not a versioned public contract — they may change between releases. To read published content externally, use the [CDN Delivery API](/developer/api-reference/cdn). ::: Content operations in Studio are performed through the chat agent (SSE endpoint) or through direct API calls for programmatic use. The direct API provides content save and status management. All content endpoints are project-scoped: ``` /api/workspaces/:workspaceId/projects/:projectId/content/... ``` ## Save Content Save content entries to a model. Creates a `cr/*` branch, commits the changes, and optionally auto-merges based on workflow configuration. ``` POST /api/workspaces/:workspaceId/projects/:projectId/content/:modelId ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `locale` | `string` | No | Locale code (default: `en`) | | `data` | `object` | Yes | Content data (format depends on model kind) | | `slug` | `string` | Conditional | Document slug (required for document kind) | | `body` | `string` | No | Markdown body (document kind only) | ### Data Format by Kind **Collection:** ```json { "data": { "a1b2c3d4e5f6": { "title": "My Blog Post", "excerpt": "A brief summary" } } } ``` **Singleton:** ```json { "data": { "headline": "Welcome to Studio", "tagline": "Conversation-first CMS" } } ``` **Dictionary:** ```json { "data": { "auth.sign_in_title": "Sign in to your account", "auth.sign_in_button": "Sign In" } } ``` ### Response The response is the engine's `WriteResult` (`{ branch, commit, diff, validation }`) spread with the merge outcome (`merged`, `workflow`, `pullRequestUrl`). There is no top-level `commitSha`, `filesChanged`, `valid`, or `errors` — the commit SHA lives under `commit.sha`, the changed files under `diff`, and validity under `validation`: ```json { "branch": "cr/content/blog-post/en/1774800862-27c1", "commit": { "sha": "abc123def456", "message": "contentrain: save blog-post [en]", "author": { "name": "Contentrain Studio", "email": "ai@contentrain.io" }, "timestamp": "2026-01-15T12:00:00.000Z" }, "diff": [ { "path": ".contentrain/content/blog-post/en.json", "status": "modified" } ], "validation": { "valid": true, "errors": [] }, "merged": true, "workflow": "auto-merge", "pullRequestUrl": null } ``` When validation fails, the endpoint returns the same `WriteResult` shape early with `validation.valid: false` and populated `validation.errors` (and an empty `branch`). `pullRequestUrl` is populated only when the merge opens a pull request instead of merging directly; otherwise it is `null`. ### Auth Requires `editor`, `admin`, or `owner` role. --- ## Update Entry Status Change the publish status of content entries. ``` PATCH /api/workspaces/:workspaceId/projects/:projectId/content/:modelId/status ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `locale` | `string` | No | Locale code (default: `en`) | | `entryIds` | `string[]` | Yes | Entry IDs to update | | `status` | `'draft' \| 'published' \| 'archived'` | Yes | Target status | ### Response ```json { "merged": true, "status": "published", "entryIds": ["a1b2c3d4e5f6"] } ``` `status` and `entryIds` echo the request; `merged` reports whether the status-change branch was auto-merged. --- ## Brain Sync Refresh the project brain cache. Returns the full content snapshot used by the chat agent and context panel. ``` GET /api/workspaces/:workspaceId/projects/:projectId/brain/sync ``` ### Response ```json { "models": [ { "id": "blog-post", "name": "Blog Post", "kind": "collection", "domain": "blog", "i18n": true, "fields": { ... } } ], "content": { "blog-post:en": { "a1b2c3d4e5f6": { "title": "My Post", "status": "published" } } }, "meta": { "blog-post:en": { "a1b2c3d4e5f6": { "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-15T00:00:00Z", "createdBy": "user@example.com" } } }, "config": { "stack": "nuxt", "locales": { "default": "en", "supported": ["en", "tr"] }, "domains": ["marketing", "blog"], "workflow": "auto-merge" }, "schemaValidation": { "healthScore": 95, "modelCount": 5, "validModels": 5, "warnings": [] } } ``` ::: tip Brain Cache The brain cache is an in-memory representation of the project content, built from Git on first access and invalidated after any write operation. It is used by the chat agent for fast content reads. ::: ## Content Engine Behind the scenes, all content operations go through the Content Engine (`server/utils/content-engine/`). The engine orchestrates: 1. **Validation** -- schema-based content validation using model field definitions 2. **Serialization** -- canonical JSON output (sorted keys, 2-space indent, trailing newline) 3. **Branching** -- creates a `cr/*` feature branch from the `contentrain` SSOT branch 4. **Commit** -- commits file changes via the GitProvider 5. **Auto-merge** -- merges the branch back if workflow and permissions allow See [Content Engine](/developer/content-engine) for full internals. ## Related Pages - [Chat API](/developer/api-reference/chat) -- content operations via AI agent - [Branches API](/developer/api-reference/branches) -- managing content branches - [Field Types](/developer/field-types) -- supported field type reference - [Content Engine](/developer/content-engine) -- write path internals --- # Chat Source: https://docs.contentrain.io/developer/api-reference/chat > SSE-based chat endpoint for AI-powered content operations with tool use. # Chat API The chat endpoint is Studio's primary interface for AI-powered content management. It uses Server-Sent Events (SSE) to stream the AI response and tool execution results in real time. ::: info Internal SPA endpoint This documents the **internal** chat endpoint that powers the Studio web app. It is authenticated with a session cookie and gated by **workspace + project roles** (`owner` / `admin` / `member`, combined with the project role `editor` / `reviewer` / `viewer`). For programmatic, API-key access, use the [Conversation API](/developer/api-reference/conversation) instead — it has its own 3-role model. ::: ## Chat (SSE) Start a conversation with the AI agent. ``` POST /api/workspaces/:workspaceId/projects/:projectId/chat ``` ### Request Body ```typescript interface ChatRequest { message: string conversationId?: string // Resume existing conversation model?: string // AI model override context: { activeModelId: string | null activeLocale: string activeEntryId: string | null panelState: 'overview' | 'model' | 'branch' | 'vocabulary' activeBranch: string | null contextItems?: ContextItem[] } } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `message` | `string` | Yes | User message text | | `conversationId` | `string` | No | Existing conversation ID for multi-turn | | `model` | `string` | No | AI model override (default: determined by plan) | | `context` | `ChatUIContext` | Yes | Current UI state for context-aware responses | ### Context Items The `contextItems` array allows the client to pin specific content items to the conversation: ```typescript interface ContextItem { type: 'model' | 'entry' | 'field' | 'asset' modelId: string modelName?: string entryId?: string fieldId?: string assetId?: string data?: unknown } ``` ### Response (SSE Stream) The response is a stream of Server-Sent Events with `data:` lines containing JSON: ``` data: {"type":"conversation","conversationId":"uuid"} data: {"type":"text","content":"I'll create a new blog post"} data: {"type":"tool_use","id":"tool_1","name":"save_content"} data: {"type":"tool_result","id":"tool_1","name":"save_content","result":{"branch":"cr/content/blog-post/en/...","merged":true}} data: {"type":"text","content":"Done! I've created the blog post and it has been auto-merged."} data: {"type":"done","usage":{"inputTokens":1234,"outputTokens":567},"affected":{"models":["blog-post"],"locales":["en"],"snapshotChanged":false,"branchesChanged":true}} ``` ### Event Types | Event Type | Description | Key Fields | |------------|-------------|------------| | `conversation` | Conversation created/resumed | `conversationId` | | `text` | AI text chunk (streaming) | `content` | | `tool_use` | Tool execution started | `id`, `name` | | `tool_result` | Tool execution completed | `id`, `name`, `result` | | `done` | Stream complete | `usage`, `affected` | | `error` | Error occurred | `message` | ### Affected Resources The `done` event includes an `affected` object that tells the client what to refresh: ```typescript interface AffectedResources { models: string[] // Model IDs with content changes locales: string[] // Locales that were modified snapshotChanged: boolean // Model definitions changed (reload schema) branchesChanged: boolean // Branch list changed (reload branches) branch?: string // Specific branch created/merged/deleted } ``` ### Available Agent Tools The agent can call these tools based on user permissions: Roles here are the internal agent's effective roles (workspace `admin`/`owner`, or the member's project role `viewer`/`reviewer`/`editor`). "Min Role" is the lowest role that may invoke the tool. | Tool | Description | Min Role | |------|-------------|----------| | `list_models` | List all content models | viewer | | `get_content` | Read content entries | viewer | | `save_content` | Create/update content | editor | | `delete_content` | Delete entries or dictionary keys | editor | | `save_model` | Create/update model definitions | admin | | `validate` | Validate content against schemas | viewer | | `list_branches` | List pending `cr/*` branches | viewer | | `merge_branch` | Merge a content branch | reviewer | | `reject_branch` | Reject/delete a branch | reviewer | | `init_project` | Initialize `.contentrain/` structure | admin | | `copy_locale` | Copy content between locales | editor | | `brain_query` | Read from brain cache | viewer | | `brain_search` | Full-text search across content | viewer | | `brain_analyze` | Content analysis (SEO, parity, stale, quality) | viewer | | `validate_schema` | Comprehensive schema validation | viewer | | `search_media` | Search media library | viewer | | `upload_media` | Upload media from URL | editor | | `get_media` | Get media asset metadata | viewer | | `update_status` | Publish/unpublish/archive entries | editor | | `update_media` | Update asset alt text, tags, focal point | editor | | `delete_media` | Delete an asset and its variants | editor | | `branch_health` | Check pending-branch warn/block thresholds | viewer | | `relation_expand` | Resolve forward/reverse relations for an entry | viewer | | `vocabulary` | Read or update the project glossary | editor | | `add_locale` | Register a new supported locale | admin | | `delete_model` | Permanently delete a model and its content | admin | | `list_submissions` | List form submissions | viewer | | `approve_submission` | Approve form submission | reviewer | | `reject_submission` | Reject form submission | reviewer | ### Error Codes | Status | Condition | |--------|-----------| | `400` | Missing message or invalid context | | `401` | No valid session | | `403` | No project access or AI feature not available on plan | | `429` | Monthly message limit exceeded | ### Example ```bash curl -X POST http://localhost:3000/api/workspaces/{wsId}/projects/{projId}/chat \ -H "Content-Type: application/json" \ -H "Cookie: h3-session=..." \ -d '{ "message": "Create a blog post titled Hello World", "context": { "activeModelId": "blog-post", "activeLocale": "en", "activeEntryId": null, "panelState": "model", "activeBranch": null } }' \ --no-buffer ``` ## Attachments Pre-upload files or pasted links before sending a chat message. This endpoint converts each input into AI content blocks (`AttachmentRef[]`); the client then carries those blocks in the [chat](#chat-sse) POST body, keeping the SSE handler JSON-only and letting the UI show per-attachment progress and errors. ``` POST /api/workspaces/:workspaceId/projects/:projectId/attachments ``` The channel is selected by `Content-Type`: - **Link paste (JSON):** `{ "url": "https://..." }` or `{ "urls": ["https://...", ...] }`. Remote fetches are SSRF-guarded. - **File upload (`multipart/form-data`):** one or more `file` parts, plus an optional `intent` part. `intent=media` persists images to the media library; any other value (or absent) treats them as ephemeral `context`. ### Limits | Limit | Value | |-------|-------| | Max items per request | `10` (URLs or files) | | Max file size | `32 MB` per file | ### Response ```json { "attachments": [ { "id": "att_diagram.png", "source": "upload", "filename": "diagram.png", "mime": "image/png", "kind": "image", "blocks": [ /* AIContentBlock[] */ ], "error": null } ] } ``` `source` is `upload` (multipart) or `link` (URL). Per-item failures (e.g. a file over the size limit) are reported inline via the `error` field rather than failing the whole request. ::: info Plan gating The endpoint itself is not feature-gated -- text, PDF, and link attachments ride the existing chat token metering. Only the image-to-CDN branch is plan-gated: in Community Edition it degrades to inline base64 instead of a CDN upload. ::: ### Auth Requires workspace `owner`, `admin`, or `member` role. A `member` must also belong to the project. ### Error Codes | Status | Condition | |--------|-----------| | `400` | No URL provided, more than 10 items, or no `file` part in the multipart body | | `403` | Member is not a project member | | `404` | Project not found in the workspace | --- ## Conversations ### List Conversations ``` GET /api/workspaces/:workspaceId/projects/:projectId/conversations ``` ### Delete Conversation ``` DELETE /api/workspaces/:workspaceId/projects/:projectId/conversations/:conversationId ``` ### Get Conversation Messages ``` GET /api/workspaces/:workspaceId/projects/:projectId/conversations/:conversationId/messages ``` ## Related Pages - [Agent System](/developer/agent-system) -- how the agent works internally - [Content API](/developer/api-reference/content) -- direct content operations - [Conversation API](/developer/api-reference/conversation) -- public conversation endpoint --- # Branches Source: https://docs.contentrain.io/developer/api-reference/branches > API endpoints for listing, diffing, merging, and rejecting content branches. # Branches API ::: warning Internal Application API These are **internal SPA routes** (cookie/session authenticated, used by the Studio app), not a versioned public contract — they may change between releases. For external integrations, use the [Public API](/developer/api-reference/). ::: Content operations in Studio create `cr/*` feature branches. These branches can be reviewed and merged (in review workflow) or are auto-merged (in auto-merge workflow). All branch endpoints are project-scoped: ``` /api/workspaces/:workspaceId/projects/:projectId/branches/... ``` ## Git Architecture Studio uses a two-branch strategy: 1. **`contentrain`** -- the SSOT (Single Source of Truth) branch for content 2. **`cr/*`** -- feature branches for individual operations (e.g., `cr/content/blog-post/en/1774800862-27c1`) The merge flow is: `cr/*` -> `contentrain` -> `main` --- ## List Branches List all pending `cr/*` content branches. ``` GET /api/workspaces/:workspaceId/projects/:projectId/branches ``` ### Response ```json { "branches": [ { "name": "cr/content/blog-post/en/1774800862-27c1", "sha": "abc123def456", "protected": false } ] } ``` --- ## Get Branch Diff Get the file-level diff for a branch compared to the `contentrain` base branch. ``` GET /api/workspaces/:workspaceId/projects/:projectId/branches/:branch/diff ``` ### URL Encoding Branch names contain `/` characters. Encode them in the URL: ``` /branches/cr%2Fcontent%2Fblog-post%2Fen%2F1774800862-27c1/diff ``` ### Response ```json { "diff": [ { "path": ".contentrain/content/blog/blog-post/en.json", "status": "modified", "before": "{\"a1b2c3d4e5f6\":{\"title\":\"Old Title\"}}", "after": "{\"a1b2c3d4e5f6\":{\"title\":\"New Title\"}}" } ] } ``` --- ## Merge Branch Merge a `cr/*` branch. Two-step process: merges into `contentrain`, then advances `contentrain` to `main`. ``` POST /api/workspaces/:workspaceId/projects/:projectId/branches/:branch/merge ``` ### Response ```json { "merged": true, "sha": "abc123def456", "pullRequestUrl": null } ``` ::: info Branch Protection If the `main` branch has protection rules requiring pull requests, Studio creates a PR and attempts to merge it. The `pullRequestUrl` field contains the PR URL. ::: ### Auth Requires `reviewer`, `admin`, or `owner` role. --- ## Reject Branch Reject and delete a `cr/*` branch, discarding all changes. ``` POST /api/workspaces/:workspaceId/projects/:projectId/branches/:branch/reject ``` ### Response ```json { "rejected": true } ``` ### Auth Requires `reviewer`, `admin`, or `owner` role. The permanent `contentrain` branch cannot be rejected. --- ## Branch Health Get branch health metrics for the project. ``` GET /api/workspaces/:workspaceId/projects/:projectId/branches/health ``` --- ## Branch Cleanup Clean up merged `cr/*` branches that are no longer needed. ``` POST /api/workspaces/:workspaceId/projects/:projectId/branches/cleanup ``` ## Related Pages - [Content API](/developer/api-reference/content) -- content operations that create branches - [Chat API](/developer/api-reference/chat) -- agent tools for branch management - [Content Engine](/developer/content-engine) -- branch operations internals --- # Webhooks Source: https://docs.contentrain.io/developer/api-reference/webhooks > API endpoints for outbound webhook management, testing, and delivery tracking. # Webhooks API ::: warning Internal Application API · Enterprise Edition Webhook **management** is an internal SPA route (session-authenticated) — there is no public, key-gated management surface. Outbound webhook delivery is an Enterprise-Edition feature. To learn how to consume webhook events, see the [Webhooks guide](/guide/webhooks). ::: Studio supports outbound webhooks that notify external services when content events occur. Webhooks are project-scoped and managed through the enterprise bridge. All webhook management endpoints are project-scoped: ``` /api/workspaces/:workspaceId/projects/:projectId/webhooks/... ``` These are part of Studio's authenticated application API (session-based, used by the Studio SPA) — there is no public, key-gated webhook management surface. ::: info Enterprise Feature Webhook management requires the enterprise bridge (`ee/`) and the `api.webhooks_outbound` feature flag. Outbound webhooks are not available in the self-hosted Community Edition. ::: ## List Webhooks ``` GET /api/workspaces/:workspaceId/projects/:projectId/webhooks ``` ### Response ```json [ { "id": "uuid", "name": "Production Webhook", "url": "https://api.example.com/hooks/contentrain", "events": ["content.saved", "content.deleted", "form.submitted"], "active": true, "created_at": "2026-01-01T00:00:00Z" } ] ``` --- ## Create Webhook ``` POST /api/workspaces/:workspaceId/projects/:projectId/webhooks ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | `string` | Yes | Display name | | `url` | `string` | Yes | Delivery URL (HTTPS) | | `events` | `string[]` | Yes | Events to subscribe to | ### Supported Events | Event | Description | |-------|-------------| | `content.saved` | Content entry created or updated | | `content.deleted` | Content entry deleted | | `model.saved` | Model definition created or updated | | `branch.merged` | Content branch merged | | `branch.rejected` | Content branch rejected (deleted) | | `cdn.build_complete` | A CDN build finished | | `media.uploaded` | A media asset was uploaded | | `form.submitted` | Form submission received | ### Response ```json { "id": "uuid", "name": "Production Webhook", "url": "https://api.example.com/hooks/contentrain", "events": ["content.saved"], "secret": "whsec_generated-hmac-secret", "active": true } ``` ::: warning The `secret` is only returned once at creation time. Use it to verify webhook payloads via HMAC-SHA256. ::: --- ## Update Webhook ``` PATCH /api/workspaces/:workspaceId/projects/:projectId/webhooks/:webhookId ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | `string` | No | New display name | | `url` | `string` | No | New delivery URL | | `events` | `string[]` | No | New event subscriptions | | `active` | `boolean` | No | Enable/disable | --- ## Delete Webhook ``` DELETE /api/workspaces/:workspaceId/projects/:projectId/webhooks/:webhookId ``` --- ## Test Webhook Send a test payload to verify connectivity. ``` POST /api/workspaces/:workspaceId/projects/:projectId/webhooks/:webhookId/test ``` --- ## List Deliveries Get delivery history for a webhook. ``` GET /api/workspaces/:workspaceId/projects/:projectId/webhooks/:webhookId/deliveries ``` ### Response ```json { "deliveries": [ { "id": "uuid", "event": "content.saved", "status": "delivered", "response_status": 200, "duration_ms": 150, "created_at": "2026-01-15T12:00:00Z" } ], "total": 25 } ``` ### Delivery Statuses | Status | Description | |--------|-------------| | `pending` | Awaiting delivery | | `delivered` | Successfully delivered (2xx response) | | `failed` | Delivery failed (non-2xx or timeout) | | `retrying` | Scheduled for retry | ### Plan Limits | Plan | Webhook Endpoints | |------|------------------| | Starter | 3 | | Pro | 10 | | Enterprise | Unlimited | ## Webhook Payload Format ```json { "event": "content.saved", "projectId": "uuid", "timestamp": "2026-01-15T12:00:00.000Z", "data": { "models": ["blog-post"], "locale": "en", "source": "api" } } ``` The top-level envelope is always `{ event, projectId, timestamp, data }`. The `data` object is event-specific (see [Webhooks guide](/guide/webhooks#event-specific-data)). ### Signature Verification Webhook payloads include an `X-Contentrain-Signature` header containing an HMAC-SHA256 signature: ```ts import { createHmac } from 'crypto' function verifyWebhook(payload: string, signature: string, secret: string): boolean { const expected = createHmac('sha256', secret).update(payload).digest('hex') return signature === expected } ``` ## Related Pages - [Enterprise Edition](/developer/enterprise-edition) -- webhook implementation details - [Chat API](/developer/api-reference/chat) -- webhook events from agent operations --- # Content Engine Source: https://docs.contentrain.io/developer/content-engine > Internals of Studio's content write path -- validation, serialization, branching, and Git operations. # Content Engine The Content Engine is Studio's write path for all content operations. It orchestrates validation, serialization, branching, and commit operations through the GitProvider interface. Source: `server/utils/content-engine/` ## Architecture ``` Content Engine ├── index.ts # Factory function + public API ├── types.ts # Shared types and constants ├── helpers.ts # Common utilities ├── save-content.ts # Collection/singleton/dictionary writes ├── save-document.ts # Document kind (markdown + frontmatter) ├── save-model.ts # Model definition writes ├── delete-content.ts # Content deletion ├── init-project.ts # Project initialization ├── update-status.ts # Status changes + locale copying └── branch-ops.ts # Branch guard, merge, reject, list ``` ## Creating an Engine Instance The engine is created per-request with a `GitProvider` scoped to the target repository: ```ts import { createContentEngine } from '~/server/utils/content-engine' const engine = createContentEngine({ git: useGitProvider({ installationId: workspace.github_installation_id, owner: 'org', repo: 'my-repo', }), contentRoot: '', // or 'apps/web' for monorepos projectId: 'uuid', }) ``` ## Public API ```ts interface ContentEngineAPI { // Content CRUD saveContent(modelId: string, locale: string, data: Record, userEmail: string, options?: { autoPublish?: boolean }): Promise saveDocument(modelId: string, locale: string, slug: string, frontmatter: Record, body: string, userEmail: string, options?: { autoPublish?: boolean }): Promise deleteContent(modelId: string, locale: string, entryIds: string[], userEmail: string): Promise // Model operations saveModel(definition: ModelDefinition, userEmail: string): Promise // Status management updateEntryStatus(modelId: string, locale: string, entryIds: string[], status: 'draft' | 'published' | 'archived', userEmail: string): Promise copyLocale(modelId: string, from: string, to: string, userEmail: string): Promise // Branch operations listContentBranches(): Promise mergeBranch(branch: string): Promise rejectBranch(branch: string): Promise // Project initialization initProject(stack: string, locales: string[], domains: string[], models: ModelDefinition[], userEmail: string): Promise } ``` ## Write Result Every write operation returns a `WriteResult`: ```ts interface WriteResult { branch: string // Branch name (e.g., 'cr/content/blog-post/en/...') commit: Commit // Git commit with SHA diff: FileDiff[] // Changed files validation: ValidationResult // Schema validation result } ``` ## Write Flow Every content write follows this pipeline: ``` 1. Validate Input ↓ 2. Read Existing Content (from Git via brain cache) ↓ 3. Merge Changes (new data into existing) ↓ 4. Validate Against Schema ↓ 5. Serialize to Canonical JSON ↓ 6. Ensure 'contentrain' SSOT Branch Exists ↓ 7. Create cr/* Feature Branch ↓ 8. Commit Files to Feature Branch ↓ 9. Update Meta Files (timestamps, authors) ↓ 10. Update context.json (stats, last operation) ↓ 11. Return WriteResult ``` ## Branch Strategy ### Constants ```ts const CONTENT_BRANCH = 'contentrain' // SSOT branch const BRANCH_PREFIX = 'cr/' // Feature branch prefix ``` ### Branch Naming Convention ``` cr/{operation}/{modelId}/{locale}/{timestamp}-{random} ``` Examples: - `cr/content/blog-post/en/1774800862-27c1` - `cr/model/blog-post/1774800862-a3b4` - `cr/new/init/1774800862-c5d6` ### Merge Flow ``` cr/* feature branch ↓ merge contentrain SSOT branch ↓ merge main (or default branch) ``` If `main` has branch protection requiring pull requests, the engine creates a PR and attempts to merge it programmatically. ### Branch Guard The engine ensures the `contentrain` branch exists before any operation. If it does not exist, it is created from the default branch head. ```ts // Called once per engine instance (cached flag) await engine.ensureContentBranch() ``` ## Canonical Serialization All JSON output follows the canonical format from `@contentrain/types`: ```ts const CANONICAL_JSON = { indent: 2, // 2-space indentation encoding: 'utf-8', trailingNewline: true, // Final newline character omitNull: true, // Remove null values omitDefaults: true, // Remove fields matching their default value sortKeys: true, // Alphabetical key ordering } ``` This ensures Studio-written JSON is **byte-identical** to MCP-written JSON, preventing unnecessary diffs. ```ts import { serializeCanonical } from '~/server/utils/content-serialization' const json = serializeCanonical(data, fieldDefs) // Output: sorted keys, 2-space indent, trailing newline, nulls omitted ``` ## Entry ID Generation Collection entries use 12-character hex IDs: ```ts import { generateEntryId } from '~/server/utils/content-serialization' const id = generateEntryId() // e.g., 'a1b2c3d4e5f6' ``` ## Content Validation Validation runs against model field definitions before every write: ```ts import { validateContent } from '~/server/utils/content-validation' const result = validateContent(data, fields, modelId, locale, entryId, { allEntries: existingEntries, // For unique checks currentEntryId: entryId, models: allModels, // For relation resolution }) if (!result.valid) { // result.errors contains detailed validation errors } ``` See [Field Types](/developer/field-types) for validation rules per field type. ## Path Resolution File paths are resolved using patterns from `@contentrain/types`: ```ts import { resolveContentPath, resolveModelPath } from '~/server/utils/content-paths' const ctx = { contentRoot: '' } resolveModelPath(ctx, 'blog-post') // → '.contentrain/models/blog-post.json' resolveContentPath(ctx, model, 'en') // → '.contentrain/content/blog/blog-post/en.json' resolveContentPath(ctx, model, 'en', 'getting-started') // → '.contentrain/content/blog/blog-post/getting-started/en.md' (document kind) ``` ### Custom Content Path Models can override their content path with `content_path`: ```json { "id": "docs", "kind": "document", "content_path": "content/docs" } ``` Path traversal and sensitive directory access are blocked by validation. ## Document Support Document-kind content uses markdown files with YAML frontmatter: ```ts import { parseMarkdownFrontmatter, serializeMarkdownFrontmatter } from '~/server/utils/content-serialization' const { frontmatter, body } = parseMarkdownFrontmatter(raw) const serialized = serializeMarkdownFrontmatter(frontmatter, body) ``` ## Context Tracking Every operation updates `.contentrain/context.json` with: - Last operation details (tool, model, locale, timestamp) - Project stats (model count, entry count, active locales) This context is included in the agent system prompt for conversation continuity. ## Bot Author All commits are authored by the Studio bot: ```ts const BOT_AUTHOR = { name: 'Contentrain Studio[bot]', email: 'bot@contentrain.io', } ``` ## Related Pages - [Contentrain Format](/developer/contentrain-format) -- directory structure specification - [Field Types](/developer/field-types) -- validation rules per type - [Agent System](/developer/agent-system) -- how the agent invokes the engine - [Branches API](/developer/api-reference/branches) -- REST endpoints for branches --- # Agent System Source: https://docs.contentrain.io/developer/agent-system > Architecture of Studio's Bounded Task Executor -- tool definitions, state machine, intent classification, system prompt construction, and cache invalidation. # Agent System Studio's AI agent is a **Bounded Task Executor** -- an LLM with access to a curated set of content management tools, constrained by a state machine, permissions, and context-aware system prompt. ## Architecture Overview ``` User Message + UI Context | v Intent Classification | v System Prompt Construction (role + architecture + context + intent + state + config + schema + relations + vocabulary + permissions + rules) | v Conversation Engine (async generator) | +--→ AI Provider (streaming / non-streaming) | | | v | Tool Call Decision | | | v | State Machine Guard | | | v | Tool Execution (Content Engine, Brain Cache, etc.) | | | v | Affected Resources Accumulation | | | v | Auto-Merge Decision (workflow + role) | v SSE Events → Client ``` ## Key Files | File | Purpose | |------|---------| | `server/utils/agent-types.ts` | Type definitions for all agent types | | `server/utils/agent-tools.ts` | Tool definitions with orchestration metadata | | `server/utils/agent-permissions.ts` | Role-based tool filtering | | `server/utils/agent-state-machine.ts` | Project phase enforcement | | `server/utils/agent-context.ts` | Intent classification | | `server/utils/agent-system-prompt.ts` | System prompt construction | | `server/utils/conversation-engine.ts` | Conversation loop (async generator) | | `server/utils/brain-cache.ts` | In-memory content cache | | `server/utils/content-strings.ts` | Server-side dictionary reader | ## Tool Definitions Each tool carries orchestration metadata beyond what the LLM sees: ```ts interface StudioTool extends AITool { name: string description: string // LLM-facing description inputSchema: Record // JSON Schema requiredPhase: ProjectPhase[] // Which phases allow this tool defaultAffects: Partial // Cache invalidation hints workflowBehavior: 'auto-merge' | 'workflow-dependent' | 'manual' | 'none' } ``` ### Tool Inventory | Tool | Description | Phases | Workflow | |------|-------------|--------|----------| | `list_models` | List content models | all | none | | `get_content` | Read content entries | active | none | | `save_content` | Create/update content | active | workflow-dependent | | `delete_content` | Delete entries or dictionary keys | active | workflow-dependent | | `save_model` | Create/update models | active | workflow-dependent | | `validate` | Validate content | all | none | | `list_branches` | List `cr/*` branches | active, init_pending, error | none | | `merge_branch` | Merge a branch | active, init_pending | manual | | `reject_branch` | Reject a branch | active, init_pending | manual | | `init_project` | Initialize `.contentrain/` | uninitialized | auto-merge | | `copy_locale` | Copy between locales | active | workflow-dependent | | `brain_query` | Read from brain cache | active | none | | `brain_search` | Full-text search | active | none | | `search_media` | Search media library | active | none | | `upload_media` | Upload from URL | active | none | | `get_media` | Get asset metadata | active | none | | `update_status` | Publish/unpublish/archive entries | active | workflow-dependent | | `update_media` | Update asset alt/tags/focal point | active | none | | `delete_media` | Delete an asset and its variants | active | none | | `branch_health` | Check pending-branch warn/block thresholds | active, init_pending, error | none | | `relation_expand` | Resolve forward/reverse relations | active | none | | `vocabulary` | Read/update the project glossary | active | workflow-dependent | | `add_locale` | Register a new supported locale | active | workflow-dependent | | `delete_model` | Delete a model and all its content | active | workflow-dependent | | `brain_analyze` | Content analysis (SEO, parity, stale, quality) | active | none | | `validate_schema` | Schema-level validation | active | none | | `list_submissions` | List form submissions | active | none | | `approve_submission` | Approve submission | active | workflow-dependent | | `reject_submission` | Reject submission | active | none | ## State Machine The project state machine enforces valid operation sequences: ```ts type ProjectPhase = 'uninitialized' | 'init_pending' | 'active' | 'error' ``` ### Phase Derivation ```ts function deriveProjectPhase(config, pendingBranches, projectStatus): ProjectPhase { if (projectStatus === 'error') return 'error' if (!config) { const hasInitBranch = pendingBranches.some(b => b.name.startsWith('cr/new/init/')) return hasInitBranch ? 'init_pending' : 'uninitialized' } return 'active' } ``` ### Transition Rules | Phase | Allowed Tools | Blocked Tools | |-------|--------------|---------------| | `uninitialized` | `init_project`, read-only tools | All write tools except init | | `init_pending` | `merge_branch`, `reject_branch`, read-only | All content writes | | `active` | All tools except `init_project` | `init_project` | | `error` | `merge_branch`, `reject_branch`, read-only | All write tools | When a tool is blocked, the state machine returns a clear reason and suggestion: ```ts { allowed: false, reason: 'Project is not initialized. No .contentrain/ directory exists.', suggestion: 'Call init_project first to set up the content structure.' } ``` ## Intent Classification Before building the system prompt, the agent classifies the user's intent: ```ts type IntentCategory = | 'content_operation' | 'model_operation' | 'branch_operation' | 'project_operation' | 'query' | 'out_of_scope' ``` Classification uses keyword matching against message text in both English and Turkish. When no keywords match but a model is selected in the UI, the system defaults to `content_operation` with medium confidence. ## System Prompt Structure The system prompt is assembled from 11 sections: 1. **Role Definition** -- strict, bounded task executor identity 2. **Contentrain Architecture** -- data format, field types, content kinds, relations, storage 3. **UI Context** -- what the user is currently viewing (model, locale, entry, panel state, pinned items) 4. **Inferred Intent** -- classified intent with default parameters 5. **Project State** -- phase, initialization status, pending branches, content stats 6. **Project Configuration** -- stack, locales, domains, workflow mode 7. **Content Schema** -- full model definitions with all fields and constraints 8. **Relation Graph** -- cross-model reference map 9. **Vocabulary** -- shared terminology from `.contentrain/vocabulary.json` 10. **Permissions** -- user role, available tools, model restrictions 11. **Rules** -- workflow rules, plan-aware guidance, upgrade hints ### Context-Aware Defaults When the UI has a model selected, the agent uses it as a default without asking: ``` ## Inferred Intent: content_operation Default model: blog-post Default locale: en When confidence is high, use these defaults without asking. ``` ### Pinned Context Items Users can pin content items from the context panel. These appear in the system prompt: ``` ### Pinned Context - Entry "a1b2c3d4e5f6" from Blog Post: {"title":"Hello World","excerpt":"..."} - Asset: hero.webp (webp, 1920x1080) -> path: media/original/abc123.webp ``` ## Tool Role Matrix This matrix governs the **internal chat agent** (`agent-permissions.ts`), which combines workspace roles (`owner` / `admin` / `member`) with project roles (`editor` / `reviewer` / `viewer`). Workspace owners and admins get every tool; workspace members fall back to their project role (defaulting to `viewer`). This is distinct from the [Conversation API](/developer/api-reference/conversation), whose API keys use a separate 3-role model (`viewer` / `editor` / `admin`). | Tool | viewer | reviewer | editor | admin | owner | |------|--------|----------|--------|-------|-------| | `list_models` | Yes | Yes | Yes | Yes | Yes | | `get_content` | Yes | Yes | Yes | Yes | Yes | | `brain_query` | Yes | Yes | Yes | Yes | Yes | | `brain_search` | Yes | Yes | Yes | Yes | Yes | | `brain_analyze` | Yes | Yes | Yes | Yes | Yes | | `validate` | Yes | Yes | Yes | Yes | Yes | | `validate_schema` | Yes | Yes | Yes | Yes | Yes | | `list_branches` | Yes | Yes | Yes | Yes | Yes | | `branch_health` | Yes | Yes | Yes | Yes | Yes | | `relation_expand` | Yes | Yes | Yes | Yes | Yes | | `search_media` | Yes | Yes | Yes | Yes | Yes | | `get_media` | Yes | Yes | Yes | Yes | Yes | | `list_submissions` | Yes | Yes | Yes | Yes | Yes | | `save_content` | -- | -- | Yes | Yes | Yes | | `delete_content` | -- | -- | Yes | Yes | Yes | | `copy_locale` | -- | -- | Yes | Yes | Yes | | `update_status` | -- | -- | Yes | Yes | Yes | | `upload_media` | -- | -- | Yes | Yes | Yes | | `update_media` | -- | -- | Yes | Yes | Yes | | `delete_media` | -- | -- | Yes | Yes | Yes | | `vocabulary` | -- | -- | Yes | Yes | Yes | | `merge_branch` | -- | Yes | -- | Yes | Yes | | `reject_branch` | -- | Yes | -- | Yes | Yes | | `approve_submission` | -- | Yes | -- | Yes | Yes | | `reject_submission` | -- | Yes | -- | Yes | Yes | | `save_model` | -- | -- | -- | Yes | Yes | | `init_project` | -- | -- | -- | Yes | Yes | | `add_locale` | -- | -- | -- | Yes | Yes | | `delete_model` | -- | -- | -- | Yes | Yes | ## Conversation Engine The conversation engine is an `AsyncGenerator` that yields events: ```ts async function* runConversationLoop( config: ConversationConfig, toolCtx: ToolExecutionContext, ): AsyncGenerator ``` ### Loop Behavior 1. **First iteration**: uses `AIProvider.streamCompletion` for real-time streaming 2. **Subsequent iterations** (tool use continuations): uses `AIProvider.createCompletion` (non-streaming) 3. **Max iterations**: 5 (prevents runaway tool loops) 4. **Tool result truncation**: 2000 characters per result (context window management) 5. **Abort signal**: supports client disconnect detection ### Auto-Merge Decision ```ts function shouldAutoMerge(workflow: string, permissions: AgentPermissions): boolean { if (workflow === 'auto-merge') return true // In review workflow, only Owner/Admin can auto-merge return permissions.workspaceRole === 'owner' || permissions.workspaceRole === 'admin' } ``` ## Affected Resources Every tool execution returns affected resources for targeted cache invalidation: ```ts interface AffectedResources { models: string[] // Model IDs with content changes locales: string[] // Locales that were modified snapshotChanged: boolean // Model definitions changed branchesChanged: boolean // Branch list changed branch?: string // Specific branch affected } ``` Resources are accumulated across all tool calls in a conversation turn and sent in the final `done` event. The client uses this to selectively refresh only the changed parts of the UI. ## Brain Cache The brain cache provides fast in-memory access to project content: ```ts const brain = await getOrBuildBrainCache(git, contentRoot, projectId) // brain.models: Map // brain.content: Map // key: "modelId:locale" // brain.meta: Map // key: "modelId:locale" // brain.schemaValidation: ValidationResult ``` The cache is built from Git on first access and invalidated after any write operation via `invalidateBrainCache(projectId)`. ## Content Strings Server-side text for agent prompts and error messages comes from Contentrain dictionaries: ```ts agentPrompt('role.definition') // → agent-prompts dictionary agentMessage('forms.approved') // → agent-messages dictionary errorMessage('auth.unauthorized') // → error-messages dictionary ``` These are read from pre-generated SDK output -- synchronous, zero I/O. ## Related Pages - [Content Engine](/developer/content-engine) -- write path internals - [Roles & Permissions](/developer/roles-permissions) -- permission model - [Chat API](/developer/api-reference/chat) -- SSE endpoint - [Field Types](/developer/field-types) -- content schema types --- # MCP Cloud Source: https://docs.contentrain.io/developer/mcp-cloud > Hosted HTTP MCP endpoint that lets external AI agents and IDEs operate on a project's content over the Model Context Protocol -- Bearer key auth, tool allowlists, rate limits, and monthly quotas. # MCP Cloud MCP Cloud is Studio's hosted [Model Context Protocol](https://modelcontextprotocol.io) endpoint. It lets an **external AI agent or IDE** -- Claude Desktop, Claude Code, Cursor, or any MCP-capable client -- connect over HTTP and operate directly on a project's content, without running the local `@contentrain/mcp` server or cloning the repository. Studio boots a real MCP server on a loopback port at startup and exposes it through an authenticated public proxy. The client speaks plain MCP; Studio handles repository identity, authentication, plan gating, and the merge/review lifecycle on the caller's behalf. ::: tip Who is this for? Teams that want their agents to read and write content over a managed connection -- no local checkout, no GitHub App tokens in the client, no MCP server to run. The connecting client only needs the endpoint URL and a Bearer key. ::: ## Endpoint Each project has its own MCP Cloud endpoint: ``` POST https://studio.example.com/api/mcp/v1/{projectId}/mcp ``` The `/mcp` suffix is the MCP [Streamable HTTP transport](https://modelcontextprotocol.io) mount point. The route is a catch-all, so the transport owns `/mcp`, its SSE stream, and session teardown under the same prefix. ## Authentication MCP Cloud uses Bearer API keys -- not session cookies: ```http Authorization: Bearer crn_mcp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` Keys are prefixed `crn_mcp_` and are roughly 50 characters. Only the SHA-256 hash is stored server-side; the plaintext key is shown **exactly once** at creation time and cannot be recovered afterward. Each key is scoped to a single project -- a key whose `projectId` does not match the route returns `403`. ### Creating a key Keys are created in the project they belong to: **Project settings → API keys → MCP keys**. Workspace `owner` or `admin` role is required; members see the keys and a note. The tab shows how many of the workspace's keys are used (the key limit is per workspace, the key itself is scoped to this project). When you create a key you can set: ![The project's API keys tab: the per-project endpoint URL, an existing key with its monthly call count, and the create-key form](/screenshots/mcp-cloud-keys.png) | Option | Description | |--------|-------------| | `name` | Display label for the key (required) | | `projectId` | The project the key is scoped to (required) | | `allowedTools` | Per-key tool allowlist (optional; empty = unrestricted) | | `mediaEnabled` | Allow media tools (optional; default off; only meaningful where the media stack is available) | | `rateLimitPerMinute` | Per-minute request cap (optional; default `60`, range `1`–`600`) | | `monthlyCallLimit` | Per-key monthly `tools/call` cap (optional; only ever *tightens* the plan cap, never exceeds it) | ## Connecting a client The MCP keys section surfaces ready-to-paste connection snippets after a key is created. Claude Code (and any client supporting `claude mcp add`): ```bash claude mcp add --transport http contentrain \ https://studio.example.com/api/mcp/v1/{projectId}/mcp \ --header "Authorization: Bearer crn_mcp_..." ``` JSON config (Claude Desktop, Cursor, and similar): ```json { "mcpServers": { "contentrain": { "type": "http", "url": "https://studio.example.com/api/mcp/v1/{projectId}/mcp", "headers": { "Authorization": "Bearer crn_mcp_..." } } } } ``` ## Tool allowlist Each key carries an `allowed_tools` list: - **Empty** -- the key may call any tool in the remote surface (see below) **except the media tools**. An empty ("unrestricted") allowlist never reaches `contentrain_media_*`: media access requires the key's `mediaEnabled` opt-in, or those tool names listed explicitly. - **Non-empty** -- the key is restricted to exactly those tool names. A call to any other tool returns `403`. Allowlists are validated against the call body **before** the monthly quota is touched, so a denied call never consumes quota. Use a narrow allowlist for read-only or CI keys (for example, `contentrain_content_list` + `contentrain_describe` for a key that only audits content). ## Rate limits and quota Two independent limits apply. **Per-minute rate limit.** Every request -- protocol traffic included -- is checked against a per-key sliding window (the key's `rateLimitPerMinute`, default 60). Exceeding it returns `429` with a `Retry-After` header (in seconds): ```http HTTP/1.1 429 Too Many Requests Retry-After: 30 ``` **Monthly call quota.** Each plan grants a number of MCP calls per month, and a per-key `monthlyCallLimit` may tighten it further. The quota is incremented atomically; once exhausted, `tools/call` requests return `429`. ::: info Quota semantics **Only `tools/call` requests consume the monthly quota and produce usage/meter events.** Protocol traffic -- `initialize`, `tools/list`, the SSE `GET` stream, and session `DELETE` -- is still rate-limited but is **free**. You pay for tool calls, not handshakes. ::: ## Remote tool surface MCP Cloud runs against Studio's GitHub-backed provider, which has **no local worktree**. The merge and review lifecycle is owned by Studio, not the remote agent, so the remote surface is deliberately reduced. **Available over MCP Cloud:** | Tool | Purpose | |------|---------| | `contentrain_status` | Project status and stats | | `contentrain_describe` | Inspect models and content shape | | `contentrain_describe_format` | Directory format reference | | `contentrain_content_list` | List entries | | `contentrain_content_save` | Create or update content | | `contentrain_content_delete` | Delete content | | `contentrain_model_save` | Create or update a model | | `contentrain_model_delete` | Delete a model | | `contentrain_validate` | Validate content (**without** `fix`) | | `contentrain_media_list` | List media assets | | `contentrain_media_get` | Get asset metadata | | `contentrain_media_ingest` | Ingest an asset from a URL | | `contentrain_media_update` | Update asset alt text or tags | | `contentrain_media_delete` | Delete an asset | ::: info Media tools are conditional The five `contentrain_media_*` tools (added in `@contentrain/mcp` 1.10.0) surface only where the deployment's **media stack** is configured (Enterprise + object storage), the workspace plan grants **`media.upload`**, and the project's [CDN delivery](/developer/api-reference/cdn) is enabled. Calling them additionally requires the key's **`mediaEnabled`** opt-in -- an unrestricted allowlist alone never reaches them (see [Tool allowlist](#tool-allowlist)). Ingest is **URL-only**; the fetch is SSRF-guarded, MIME-whitelisted, and size-capped server-side. ::: **Not available over MCP Cloud** -- these return a capability error: `contentrain_merge`, `contentrain_branch_list`, `contentrain_branch_delete`, `contentrain_submit`, `contentrain_init`, `contentrain_scaffold`, `contentrain_bulk`, `contentrain_doctor`, `contentrain_scan`, `contentrain_apply`, and `contentrain_validate` with `fix: true`. These are local-worktree or Studio-owned lifecycle operations. The merge/review flow happens in Studio, not in the remote client. ## How remote writes land The remote write path always reports `pending-review`: a write does not go straight to the content branch. Instead it lands as a `cr/*` branch, exactly like any other content change. Studio then reconciles it according to the project's **effective workflow**: - If the project resolves to **auto-merge**, the `cr/*` branch is landed automatically (fire-and-forget, after the agent has already received its response). - If the project resolves to **review** (the `workflow.review` plan feature is available **and** `config.workflow === "review"`), the branch stays pending until a human approves it in Studio. This keeps MCP Cloud writes consistent with Studio's native write paths and the dashboard's [branches and review](/developer/api-reference/branches) flow. ## Plan gating MCP Cloud is gated by the `api.mcp_cloud` feature plus the `api.mcp_keys` and `api.mcp_calls_per_month` limits. | Plan | MCP Cloud | API keys | Calls / month | |------|-----------|----------|---------------| | Free | No | 0 | 0 | | Community (self-host) | Yes | Unlimited | Unlimited | | Starter | Yes | 1 | 5,000 | | Pro | Yes | 15 | 150,000 | | Enterprise | Yes | Unlimited | Unlimited | MCP Cloud is **not** an Enterprise-only feature: it is available on Starter and above, and unlimited on the AGPL **Community** self-host tier. Only the Free tier has it disabled. ## MCP Cloud vs the Conversation API Both are public, key-gated `/v1/` surfaces, but they serve different needs: | | MCP Cloud | [Conversation API](/developer/api-reference/conversation) | |---|-----------|-----------------| | Protocol | Raw MCP tool access (the agent runs in *your* client) | Chat over REST (Studio's agent runs server-side) | | Key prefix | `crn_mcp_` | `crn_conv_` | | Edition | Available Starter+ and Community | Enterprise Edition only | | Use case | Bring your own agent/IDE | Drive Studio's built-in agent programmatically | ## Next Steps - [MCP Connector](/developer/mcp-connector) -- the OAuth alternative: one shared endpoint, browser sign-in, no keys in the client - [API Reference](/developer/api-reference/) -- the full HTTP surface and the public-vs-internal split - [Conversation API](/developer/api-reference/conversation) -- the EE-only chat alternative - [Contentrain Format](/developer/contentrain-format) -- the `.contentrain/` structure MCP tools read and write - [Branches & Review](/developer/api-reference/branches) -- where `cr/*` pending-review branches are approved - [Agent System](/developer/agent-system) -- how Studio's own agent uses the same tools --- # MCP Connector Source: https://docs.contentrain.io/developer/mcp-connector > Studio's single OAuth-authenticated remote MCP endpoint -- connect Claude, ChatGPT, and Codex to a project with browser sign-in, per-connection scopes, and Studio's own authorization server. No API keys in the client. # Connect Claude, ChatGPT and Codex to Contentrain Studio Studio exposes a single OAuth-authenticated MCP endpoint that AI apps connect to. Unlike the API-key surface ([MCP Cloud](/developer/mcp-cloud), `/api/mcp/v1/{projectId}`), the remote endpoint is the **same URL for everyone** -- which project it operates on is decided during sign-in, not by the URL. ``` https://studio.contentrain.io/api/mcp/remote ``` Self-hosting? Replace `studio.contentrain.io` with your own host. The client only needs this URL. There is no key to paste and no MCP server to run: the app discovers Studio's OAuth server, opens a browser for sign-in and consent, and receives its own tokens. ::: tip Connector vs. MCP Cloud Both surfaces speak the same MCP tools against the same content, but they authenticate differently. The **connector** (this page) uses interactive OAuth -- the user signs in and picks a project in the browser, ideal for directory-listed apps like Claude and ChatGPT. **[MCP Cloud](/developer/mcp-cloud)** uses long-lived `crn_mcp_` Bearer keys scoped to one project -- ideal for CI and headless automation. ::: ## Before you start The remote connector requires: - **The managed provider pair** -- `NUXT_AUTH_PROVIDER=managed` + `NUXT_DATABASE_PROVIDER=postgres`. Studio itself acts as the OAuth authorization server; the Supabase pair delegates auth to GoTrue and therefore cannot host it. See [Self-Hosting](/developer/self-hosting) and [Environment Variables](/developer/environment-variables). - **A connected project** with content -- the connection is bound to a workspace and project chosen at sign-in. - **A plan that includes `api.mcp_cloud_oauth`** -- Community, Starter, Pro, and Enterprise. The **Free** tier does not have it. ## How connecting works The flow is standard OAuth discovery → consent → token: 1. You add the endpoint URL to your AI app (steps below). The app discovers Studio's OAuth server automatically ([RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) protected-resource metadata / [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) authorization-server metadata). 2. Your browser opens Studio. You sign in (if needed) and land on the consent screen. 3. You pick the **workspace and project** this connection may access and approve the requested permissions. 4. The app receives its tokens and starts calling MCP tools. Access tokens expire after **1 hour**; apps refresh silently for up to **30 days** (the `offline_access` scope). ::: info One connection = one project The project is fixed at consent time. To operate on a different project, connect again and pick another one -- the old connection stays active until you disconnect it. ::: ## Connecting each client **Claude (claude.ai / Desktop)** -- Settings → Connectors → *Add custom connector* → paste the endpoint URL. Claude shows a **Connect** card; approve in the browser popup. **Claude Code** -- and any client supporting `claude mcp add`: ```bash claude mcp add --transport http contentrain \ https://studio.contentrain.io/api/mcp/remote ``` The OAuth window opens on first use; the callback returns to an ephemeral localhost port automatically ([RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252) loopback redirect). **ChatGPT (developer mode)** -- Settings → *Security and login* → enable Developer mode, then add the endpoint as an MCP server under Settings → Plugins. **Codex:** ```toml # ~/.codex/config.toml [mcp_servers.contentrain] url = "https://studio.contentrain.io/api/mcp/remote" ``` Then run `codex mcp login contentrain`. **MCP Inspector (debugging)** -- point it at the endpoint URL; it walks the same discovery + consent flow and lets you exercise every tool. ## Permissions (scopes) At consent you approve a set of scopes. Each scope unlocks a group of MCP tools: | Scope | Grants | Tools | |-------|--------|-------| | `project:metadata` | Project structure reads | `contentrain_status`, `contentrain_describe`, `contentrain_describe_format` | | `content:read` | Content reads | `contentrain_content_list` | | `content:write` | Content and model writes | `contentrain_content_save`, `contentrain_content_delete`, `contentrain_model_save`, `contentrain_model_delete`, `contentrain_validate` | | `media:read` | Media reads | `contentrain_media_list`, `contentrain_media_get` | | `media:write` | Media writes | `contentrain_media_ingest` (from a URL), `contentrain_media_update`, `contentrain_media_delete` | | `offline_access` | A refresh token -- stay connected without re-approving | — | ::: warning Media scopes are conditional The `media:*` scopes are **advertised only on deployments where the media stack is configured** (Enterprise + object storage). The media tools additionally require the workspace plan's `media.upload` feature and the project's [CDN delivery](/developer/api-reference/cdn) being enabled -- where any of those is missing, the tools do not appear at all. Ingest is **URL-only** (MCP has no binary channel); the fetch is SSRF-guarded, MIME-whitelisted, and size-capped server-side. ::: Remote connections and API keys draw on the **same** `api.mcp_calls_per_month` workspace quota. ## What the connector can and cannot do **Can:** - Read project structure, content, and models. - Write content and models -- with `content:write` approved. - Read and write media -- where the media stack, plan feature, and CDN are all enabled. **Cannot -- these tools are never exposed over the remote endpoint:** - The merge/review lifecycle tools -- there is **no merge tool**, no branch listing/deletion, no submit/apply. Content writes always land as `cr/*` branches and follow the project's **effective workflow** -- auto-merged, or held for a human to approve -- exactly like the API-key surface. Media writes go through the media service, not git. The merge/review flow happens **in Studio**, not in the connected client. See [Branches & Review](/developer/api-reference/branches) and [Roles & Permissions](/developer/roles-permissions). ## Managing connections Workspace Settings → **Connected apps** lists every active connection with its client, project, scopes, and monthly call count. - Workspace **owners/admins** see and can disconnect every connection in the workspace; **members** see and manage only their own. - **Disconnect** revokes the connection's refresh-token family and deletes its live access tokens immediately -- the app's next request fails with a clean re-authorization prompt; nothing breaks silently. - Deleting the project or removing the user cascades the connection away. ## Troubleshooting | Symptom | Cause & fix | |---------|-------------| | **The app can't reach the server** | The endpoint requires **HTTPS** in production and the **managed provider pair** (see [Before you start](#before-you-start)). | | **"Plan required" errors** | The workspace plan doesn't include `api.mcp_cloud_oauth` -- upgrade in Settings → [Billing](/guide/billing). | | **`insufficient_scope` / permission errors** | The connection's scopes don't cover the tool. Clients that support step-up authorization re-prompt for consent automatically; otherwise disconnect and reconnect with the missing scope (e.g. write access) approved. | | **`429` responses** | The per-connection rate limit (**60 requests/min**) or the workspace's monthly MCP call quota. Usage is visible in Settings → [Billing](/guide/billing) and on each Connected Apps row. | ## Next Steps - [MCP Cloud](/developer/mcp-cloud) -- the API-key surface for CI and headless automation - [Branches & Review](/developer/api-reference/branches) -- where `cr/*` pending-review branches are approved - [Roles & Permissions](/developer/roles-permissions) -- who can connect and disconnect apps - [Contentrain Format](/developer/contentrain-format) -- the `.contentrain/` structure the MCP tools read and write - [Agent System](/developer/agent-system) -- how Studio's own agent uses the same tools --- # Field Types Source: https://docs.contentrain.io/developer/field-types > Complete reference for all 27 content field types supported by Contentrain Studio, with storage format, validation rules, and examples. # Field Types Contentrain supports 27 field types organized into 10 categories. Each field type has specific validation rules, a JSON storage type, and UI representation. ## Field Definition Schema Every field in a model definition follows this schema: ```ts interface FieldDef { type: string // One of the 27 types required?: boolean // Field is required unique?: boolean // Value must be unique in collection min?: number // Min length (string), min value (number), min items (array) max?: number // Max length (string), max value (number), max items (array) pattern?: string // Regex validation pattern options?: string[] // Valid values for select type default?: unknown // Default value (omitted from JSON when matched) description?: string // Human-readable description model?: string | string[] // Relation target model(s) items?: string | FieldDef // Array item type or definition fields?: Record // Nested object fields accept?: string // MIME type filter for media fields } ``` ## Type Reference ### Text Types | Type | JSON Storage | Description | Validation | |------|-------------|-------------|------------| | `string` | `string` | Single-line text | `min`/`max` length, `pattern` regex | | `text` | `string` | Multi-line plain text | `min`/`max` length, `pattern` regex | | `markdown` | `string` | Markdown-formatted text | `min`/`max` length | | `richtext` | `string` | HTML rich text | `min`/`max` length | | `code` | `string` | Code snippet | `min`/`max` length | | `slug` | `string` | URL slug | Lowercase, alphanumeric, hyphens only (`/^[a-z0-9]+(?:-[a-z0-9]+)*$/`) | **Example field definition:** ```json { "title": { "type": "string", "required": true, "min": 1, "max": 200, "description": "Blog post title" } } ``` ### Number Types | Type | JSON Storage | Description | Validation | |------|-------------|-------------|------------| | `number` | `number` | Any number (int or float) | `min`/`max` range | | `integer` | `number` | Integer only | Must be `Number.isInteger()`, `min`/`max` range | | `decimal` | `number` | Floating-point number | `min`/`max` range | | `percent` | `number` | Percentage (0-100) | Must be between 0 and 100 | | `rating` | `number` | Star rating (1-5) | Must be integer between 1 and 5 | **Example:** ```json { "price": { "type": "decimal", "required": true, "min": 0 }, "rating": { "type": "rating" }, "progress": { "type": "percent", "default": 0 } } ``` ### Boolean Type | Type | JSON Storage | Description | Validation | |------|-------------|-------------|------------| | `boolean` | `boolean` | True/false toggle | Must be `true` or `false` | ### Date Types | Type | JSON Storage | Description | Validation | |------|-------------|-------------|------------| | `date` | `string` | Date (ISO format) | Must be valid date string (`Date.parse()`) | | `datetime` | `string` | Date + time (ISO format) | Must be valid datetime string | **Example values:** ```json { "publishDate": "2026-01-15", "createdAt": "2026-01-15T12:00:00.000Z" } ``` ### Contact Types | Type | JSON Storage | Description | Validation | |------|-------------|-------------|------------| | `email` | `string` | Email address | Regex: `/^[^\s@]+@[^\s@]+\.[^\s@]+$/` (warning-level) | | `url` | `string` | URL | Must start with `http://`, `https://`, or `/` (warning-level) | | `phone` | `string` | Phone number | `min`/`max` length, `pattern` regex | ### Visual Types | Type | JSON Storage | Description | Validation | |------|-------------|-------------|------------| | `color` | `string` | Hex color code | Must match `/^#[0-9a-fA-F]{6}$/` | | `icon` | `string` | Icon identifier | `min`/`max` length | **Example:** ```json { "brandColor": "#3B82F6", "menuIcon": "icon-[annon--home-2]" } ``` ### Selection Type | Type | JSON Storage | Description | Validation | |------|-------------|-------------|------------| | `select` | `string` | Single value from options | Must be one of defined `options[]` | **Example:** ```json { "status": { "type": "select", "options": ["draft", "review", "published"], "default": "draft", "required": true } } ``` ### Media Types | Type | JSON Storage | Description | Validation | |------|-------------|-------------|------------| | `image` | `string` | Image file path | Must be a string (file path) | | `video` | `string` | Video file path | Must be a string (file path) | | `file` | `string` | Generic file path | Must be a string (file path) | Media field values store the path to the asset in R2 storage: ```json { "cover": "media/original/abc123.webp", "thumbnail": "media/thumbnail/abc123.webp" } ``` The `accept` property can filter allowed MIME types: ```json { "avatar": { "type": "image", "accept": "image/png,image/jpeg,image/webp" } } ``` ### Relation Types | Type | JSON Storage | Description | Validation | |------|-------------|-------------|------------| | `relation` | `string` or `object` | Reference to another entry | Must be entry ID, slug, or `{ model, ref }` for polymorphic | | `relations` | `string[]` | Array of references | Each item must be string ID/slug | #### Single Relation ```json { "author": { "type": "relation", "model": "team-members", "required": true } } ``` Value: `"a1b2c3d4e5f6"` (entry ID for collections) or `"john-doe"` (slug for documents) #### Polymorphic Relation When `model` is an array, the value must be an object: ```json { "relatedContent": { "type": "relation", "model": ["blog-post", "page"] } } ``` Value: `{ "model": "blog-post", "ref": "a1b2c3d4e5f6" }` #### Multi-Relation ```json { "tags": { "type": "relations", "model": "tags", "min": 1, "max": 10 } } ``` Value: `["tag-id-1", "tag-id-2", "tag-id-3"]` ### Structured Types | Type | JSON Storage | Description | Validation | |------|-------------|-------------|------------| | `object` | `object` | Nested object | Validates nested `fields` (max 2 levels deep) | | `array` | `array` | Array of items | `min`/`max` count, validates `items` type | #### Object Field ```json { "address": { "type": "object", "fields": { "street": { "type": "string", "required": true }, "city": { "type": "string", "required": true }, "zip": { "type": "string", "pattern": "^\\d{5}$" } } } } ``` #### Array Field (Simple) ```json { "tags": { "type": "array", "items": "string", "min": 1, "max": 20 } } ``` #### Array Field (Object Items) ```json { "socialLinks": { "type": "array", "items": { "type": "object", "fields": { "platform": { "type": "select", "options": ["twitter", "github", "linkedin"] }, "url": { "type": "url", "required": true } } }, "max": 5 } } ``` ## System Fields These fields are managed by Studio and must **never** be included in `save_content` data: | Field | Description | |-------|-------------| | `id` | Entry ID (12-char hex, auto-generated) | | `slug` | Document slug (set via `slug` parameter, not in `data`) | | `status` | Publish status (`draft`, `published`, `archived`) | | `source` | Origin indicator (`mcp-local`, `studio`, etc.) | ## Validation Summary | Check | Applies To | Severity | |-------|-----------|----------| | Required | All types | error | | Unique | All types (collection-level) | error | | Min/Max length | string, text, markdown, richtext, code, phone | error | | Min/Max value | number, integer, decimal | error | | Min/Max items | array, relations | error | | Pattern (regex) | string, text, phone | error | | Options list | select | error | | Email format | email | warning | | URL format | url | warning | | Slug format | slug | error | | Color format | color | error | | Date validity | date, datetime | error | | Integer check | integer, rating | error | | Percent range | percent | error | | Rating range | rating | error | | Relation target | relation, relations | warning (referential integrity) | | Nested validation | object, array (object items) | error | ## Related Pages - [Contentrain Format](/developer/contentrain-format) -- how fields are stored in model files - [Content Engine](/developer/content-engine) -- validation implementation - [Agent System](/developer/agent-system) -- how the agent uses field definitions --- # Contentrain Format Source: https://docs.contentrain.io/developer/contentrain-format > Complete specification of the .contentrain/ directory structure -- config, models, content, meta, vocabulary, and context files. # 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" } ``` | Field | Type | Description | |-------|------|-------------| | `version` | `number` | Config format version (currently `1`) | | `stack` | `string` | Framework: `nuxt`, `next`, `astro`, `sveltekit`, `remix`, `vue`, `react`, `other` | | `locales.default` | `string` | Default locale code | | `locales.supported` | `string[]` | All supported locale codes | | `domains` | `string[]` | Content domains (directory grouping) | | `workflow` | `string` | `auto-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 | Kind | Storage | Entry ID | Key Format | Fields | |------|---------|----------|------------|--------| | `collection` | JSON object map | 12-char hex | `{ entryId: { field: value } }` | Defined in model | | `singleton` | JSON flat object | N/A | `{ field: value }` | Defined in model | | `dictionary` | JSON flat object | N/A | `{ "key": "string value" }` | None (free-form) | | `document` | Markdown files | Slug | YAML frontmatter + body | Defined 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. | Kind | Path | Structure | |------|------|-----------| | `collection` | `.contentrain/meta/{modelId}/{locale}.json` | Object-map: `{ entryId: EntryMeta }` | | `singleton` | `.contentrain/meta/{modelId}/{locale}.json` | Single `EntryMeta` object | | `dictionary` | `.contentrain/meta/{modelId}/{locale}.json` | Single `EntryMeta` object | | `document` | `.contentrain/meta/{modelId}/{slug}/{locale}.json` | Single `EntryMeta` object | ### EntryMeta fields | Field | Type | Description | |-------|------|-------------| | `status` | `string` | `draft`, `in_review`, `published`, `rejected`, or `archived` | | `source` | `string` | Who authored the entry: `agent`, `human`, or `import` | | `updated_by` | `string` | Identity of the last writer -- an email or an agent id such as `contentrain-mcp` | | `approved_by` | `string \| null` | Identity of the approver, or `null` when unapproved (optional) | | `version` | `string` | Optional content version tag | | `publish_at` | `string` | Optional ISO timestamp for scheduled publishing | | `expire_at` | `string` | Optional 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": "user@example.com" } } ``` Singleton, dictionary, and document meta -- a single object: ```json { "status": "draft", "source": "human", "updated_by": "user@example.com" } ``` ::: 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" } } ``` | Field | Type | Description | |-------|------|-------------| | `version` | `string` | Context format version | | `lastOperation.tool` | `string` | Tool that performed the write (e.g. `contentrain_content_save`) | | `lastOperation.model` | `string` | Model the operation touched | | `lastOperation.locale` | `string` | Locale the operation touched | | `lastOperation.entries` | `string[]` | Entry IDs affected (optional) | | `lastOperation.timestamp` | `string` | ISO timestamp of the operation | | `lastOperation.source` | `string` | Client that performed the write: `mcp-local`, `mcp-studio`, or `studio-ui` | | `stats.models` | `number` | Total model count | | `stats.entries` | `number` | Total entry count | | `stats.locales` | `string[]` | Locales in use | | `stats.lastSync` | `string` | ISO 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: | Rule | Value | |------|-------| | Indent | 2 spaces | | Encoding | UTF-8 | | Trailing newline | Yes | | Null values | Omitted | | Default values | Omitted | | Key ordering | Alphabetically 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. ## Related Pages - [Field Types](/developer/field-types) -- field definition schema and validation - [Content Engine](/developer/content-engine) -- how files are read and written - [Agent System](/developer/agent-system) -- how content is used in the system prompt --- # Provider Interfaces Source: https://docs.contentrain.io/developer/provider-interfaces > Full TypeScript interface definitions for all provider contracts -- Auth, Database, Git, AI, CDN, Media, Email, Payment, and Connector. # Provider Interfaces Studio accesses all external services through provider interfaces. This page documents every interface, its purpose, current implementation, and how to create a custom provider. ## Architecture ``` server/providers/ ├── auth.ts # AuthProvider interface ├── database.ts # DatabaseProvider interface ├── git.ts # GitProvider + GitAppProvider interfaces ├── ai.ts # AIProvider interface ├── cdn.ts # CDNProvider interface ├── media.ts # MediaProvider interface ├── email.ts # EmailProvider interface ├── connector.ts # ConnectorProvider interface ├── payment/ # PaymentProvider interface + plugin registry │ ├── types.ts # PaymentProvider + plugin contract │ ├── registry.ts # plugin registry + default resolution │ ├── index.ts # bootstrap + public API │ └── plugins/ # polar.ts (default), stripe.ts (legacy) ├── supabase-auth.ts # AuthProvider impl (Supabase pair) ├── supabase-client.ts # Shared Supabase client ├── supabase-db/ # DatabaseProvider impl (Supabase pair) ├── managed-auth.ts # AuthProvider impl (managed pair — built-in JWT) ├── postgres-db/ # DatabaseProvider impl (managed pair — plain PostgreSQL) ├── github-app.ts # GitProvider + GitAppProvider implementation ├── anthropic-ai.ts # AIProvider implementation ├── resend-email.ts # EmailProvider implementation └── index.ts # Re-exports ``` All providers are resolved via singleton factories in `server/utils/providers.ts`. ## AuthProvider Handles token validation, OAuth flows, magic links, and user management. **Shipped implementations:** Supabase Auth (`supabase-auth.ts`) **or** built-in managed JWT auth (`managed-auth.ts`). The active implementation is selected at runtime from `NUXT_AUTH_PROVIDER` (`supabase` | `managed`); the managed implementation signs its own HS256 access/refresh tokens, runs the GitHub/Google OAuth dance itself, and sends magic-link/invite emails via Resend. Auth pairs with a matching database provider -- see [Self-Hosting → Choosing a provider pair](/developer/self-hosting#choosing-a-provider-pair). ```ts interface AuthUser { id: string email: string | null avatarUrl: string | null provider: 'github' | 'google' | 'email' | null providerAccountId: string | null } interface AuthTokens { accessToken: string refreshToken: string | null expiresAt: number // Unix timestamp in seconds } interface AuthSession { user: AuthUser tokens: AuthTokens // Provider-side OAuth tokens (e.g. GitHub gho_*/ghu_*), captured at // exchange time only. Null for magic-link / non-OAuth flows. The caller // persists these via DatabaseProvider; the AuthProvider does not. providerTokens?: ProviderTokens | null } interface AuthProvider { validateToken(accessToken: string): Promise refreshSession(refreshToken: string): Promise // Refresh an expired OAuth provider token (e.g. GitHub user-to-server token). refreshProviderToken(provider: 'github' | 'google', refreshToken: string): Promise getOAuthRedirectUrl(provider: 'github' | 'google', redirectTo: string): Promise exchangeCode(code: string, state?: string): Promise exchangeTokens(accessToken: string, refreshToken?: string): Promise sendMagicLink(email: string, redirectTo: string): Promise inviteUserByEmail(email: string, options?: { redirectTo?: string }): Promise<{ userId: string }> getUserById(userId: string): Promise getUserByEmail(email: string): Promise deleteUser(userId: string): Promise } ``` ::: tip Session Management Session management (encrypted cookies, refresh orchestration) lives in `server/utils/session.ts`, not in the AuthProvider. The provider is responsible only for token operations. ::: ## DatabaseProvider The largest provider interface. Handles all persistent state: profiles, workspaces, projects, members, conversations, messages, media, forms, webhooks, CDN, and audit logs. **Shipped implementations:** Supabase PostgreSQL (`supabase-db/`) **or** plain PostgreSQL 14+ (`postgres-db/`). The active implementation is selected at runtime from `NUXT_DATABASE_PROVIDER` (`supabase` | `postgres`). Both run the **same RLS migration lineage**, so row-level isolation is identical across pairs; the Supabase impl authorizes user-scoped queries with Supabase-issued JWTs, while the plain-PostgreSQL impl uses the managed auth provider's JWTs. The database provider is chosen together with the auth provider as a matched pair. The interface is organized into sections: | Section | Key Methods | |---------|-------------| | **Profiles** | `getProfile`, `updateProfile` | | **Workspaces** | `listUserWorkspaces`, `createWorkspace`, `getWorkspaceForUser`, `updateWorkspace`, `deleteWorkspace` | | **Workspace Members** | `listWorkspaceMembers`, `createWorkspaceMember`, `updateWorkspaceMemberRole`, `deleteWorkspaceMember`, `acceptPendingInvitations` | | **Projects** | `getProjectForWorkspace`, `createProject`, `updateProject`, `deleteProject`, `listWorkspaceProjects` | | **Project Members** | `listProjectMembers`, `getProjectMember`, `createProjectMember`, `deleteProjectMember` | | **AI Keys** | `listUserAIKeys`, `upsertUserAIKey`, `deleteUserAIKey`, `getBYOAKey` | | **Conversations** | `createConversation`, `getConversation`, `listConversations`, `deleteConversation` | | **Messages** | `loadConversationMessages`, `insertMessage` | | **Agent Usage** | `getAgentUsage`, `upsertAgentUsage`, `incrementAgentUsageIfAllowed`, `updateAgentUsageTokens` | | **Media Assets** | `createMediaAsset`, `getMediaAsset`, `listMediaAssets`, `updateMediaAsset`, `deleteMediaAsset` | | **Media Usage** | `trackMediaUsage`, `removeMediaUsage`, `getMediaUsage` | | **Form Submissions** | `createFormSubmission`, `listFormSubmissions`, `updateFormSubmissionStatus`, `createFormSubmissionIfAllowed` | | **Webhooks** | `createWebhook`, `listProjectWebhooks`, `updateWebhook`, `deleteWebhook` | | **Webhook Deliveries** | `createWebhookDelivery`, `listWebhookDeliveries`, `updateWebhookDelivery` | | **CDN Keys** | `validateCDNKeyHash`, `createCDNKey`, `listCDNKeys`, `revokeCDNKey` | | **CDN Builds** | `createCDNBuild`, `updateCDNBuild`, `listCDNBuilds` | | **Conversation Keys** | `validateConversationKeyHash`, `createConversationKey`, `revokeConversationKey` | | **Audit Logs** | `createAuditLog` | ::: warning Every database query MUST scope by `workspace_id`, not just `project_id`. This is enforced at the Postgres RLS level -- the same migration lineage runs on both the Supabase pair and the plain-PostgreSQL (managed) pair. ::: ## GitProvider Handles all Git operations scoped to a specific repository. Unlike other providers, GitProvider is created **per-repository**, not as a singleton. **Current implementation:** GitHub App (`github-app.ts`) ```ts interface GitProvider { // Tree operations getTree(ref?: string): Promise readFile(path: string, ref?: string): Promise listDirectory(path: string, ref?: string): Promise fileExists(path: string, ref?: string): Promise // Branch operations createBranch(name: string, fromRef?: string): Promise listBranches(prefix?: string): Promise getBranchDiff(branch: string, base?: string): Promise mergeBranch(branch: string, into: string): Promise deleteBranch(branch: string): Promise isMerged(branch: string, into?: string): Promise // Commit operations commitFiles(branch: string, files: FileChange[], message: string, author: CommitAuthor): Promise // PR operations createPR(head: string, base: string, title: string, body: string): Promise<{ id: string, url: string }> mergePR(id: string): Promise // Permissions & config getPermissions(): Promise getBranchProtection(branch: string): Promise getDefaultBranch(): Promise // Detection detectFramework(): Promise } ``` ::: info `GitProvider` extends the MCP `RepoProvider` contract (from `@contentrain/types`) with Studio-specific extensions. The Studio factory (`createStudioGitProvider` in `git.ts`) wraps MCP's `GitHubProvider` over a shared Octokit client. The canonical write method is `applyPlan(input)`; `commitFiles(...)` is a backward-compatibility shim that delegates to it. ::: **Usage:** ```ts const git = useGitProvider({ installationId: workspace.github_installation_id, owner: 'org', repo: 'my-repo', }) ``` ## GitAppProvider Manages GitHub App installation-level operations (not repository-specific). ```ts interface GitAppProvider { getInstallationDetails(): Promise listInstallationRepositories(): Promise createRepositoryFromTemplate(input: TemplateRepositoryInput): Promise canAccessRepository(owner: string, repo: string): Promise // Revoke (uninstall) the GitHub App from the bound account/org. // Returns true on success (and on idempotent 404 "already gone"). revokeInstallation(): Promise } ``` ## AIProvider Abstracts AI model interaction for chat with tool use. **Current implementation:** Anthropic Claude (`anthropic-ai.ts`) ```ts interface AIProvider { streamCompletion(request: AICompletionRequest, apiKey: string): AsyncGenerator createCompletion(request: AICompletionRequest, apiKey: string): Promise } interface AICompletionRequest { model: string // String = a single uncached system block. Array form lets callers // place prompt-cache breakpoints between blocks (up to 4 per request). system: string | AISystemBlock[] messages: AIMessage[] tools: AITool[] maxTokens: number abortSignal?: AbortSignal } interface AIUsage { inputTokens: number outputTokens: number cacheCreationInputTokens: number cacheReadInputTokens: number } interface AIStreamEvent { type: 'text' | 'tool_use_start' | 'tool_use_input' | 'tool_use_end' | 'message_end' | 'error' content?: string toolId?: string toolName?: string toolInput?: unknown stopReason?: 'end_turn' | 'tool_use' | 'max_tokens' usage?: AIUsage error?: string } ``` ## CDNProvider Abstracts object storage for CDN content delivery. **Current implementation:** Cloudflare R2 (`ee/cdn/cloudflare-cdn.ts`) ```ts interface CDNProvider { putObject(projectId: string, path: string, data: string | Buffer, contentType: string): Promise getObject(projectId: string, path: string): Promise<{ data: Buffer, contentType: string, etag: string } | null> deleteObject(projectId: string, path: string): Promise deletePrefix(projectId: string, prefix: string): Promise listObjects(projectId: string, prefix?: string): Promise purgeCache(projectId: string, paths?: string[]): Promise getStorageKey(projectId: string, path: string): string } ``` ## MediaProvider Manages media upload, processing, variant generation, and metadata. **Current implementation:** Sharp + R2 (`ee/media/sharp-processor.ts`) ```ts interface MediaProvider { upload(options: UploadOptions): Promise regenerateVariants(assetId: string, variants: Record): Promise delete(projectId: string, assetId: string): Promise getAsset(assetId: string): Promise listAssets(projectId: string, options?: MediaListOptions): Promise<{ assets: MediaAsset[], total: number }> updateMetadata(assetId: string, metadata: { alt?: string, tags?: string[], focalPoint?: { x: number, y: number } }): Promise trackUsage(assetId: string, usage: MediaUsageRef): Promise removeUsage(assetId: string, usage: MediaUsageRef): Promise } ``` ## EmailProvider Sends application-level emails (invites, notifications). How auth emails (magic link) are delivered depends on the provider pair: on the **Supabase pair** they are sent by Supabase SMTP; on the **managed pair** the managed auth provider sends magic-link and invite emails through Resend (`NUXT_RESEND_API_KEY` is required for that pair). **Current implementation:** Resend (`resend-email.ts`) ```ts interface EmailProvider { sendEmail(options: EmailSendOptions): Promise } interface EmailSendOptions { to: string subject: string html: string from?: string } ``` ## PaymentProvider Abstracts payment/subscription management. The interface is provider-agnostic; concrete plugins live under `payment/plugins/` and self-register in the registry. The active plugin is resolved by preference order (`polar` -> `stripe`); when both are configured Polar wins, and when none is configured Studio runs in no-billing mode. **Current implementations:** Polar (`payment/plugins/polar.ts`, default) + Stripe (`payment/plugins/stripe.ts`, legacy) ```ts interface PaymentProvider { createCheckoutSession(input: CheckoutInput): Promise createPortalSession(input: PortalInput): Promise // `headers` carries the raw request headers; each plugin picks the // signature/timestamp headers it needs (Stripe: `stripe-signature`; // Polar / Standard Webhooks: `webhook-signature` + `webhook-timestamp` + `webhook-id`). handleWebhook(payload: string, headers: Record): Promise cancelSubscription(subscriptionId: string): Promise // Record a usage event for metered/overage billing. Polar ingests to a // meter via its events API; Stripe logs a warning (no real-time metering). ingestUsageEvent(input: UsageEventInput): Promise } ``` `handleWebhook` returns a `WebhookResult` whose `event` is a canonical name (`subscription.created` / `subscription.updated` / `subscription.canceled` / `invoice.paid` / `invoice.payment_failed` / `noop`); each plugin maps its native events to these values. ::: tip Adding a Payment Provider Implement `PaymentProviderPlugin` (with `isConfigured()` + `create()`) in a new file under `payment/plugins/`, then add a `registerPlugin(...)` line in `payment/index.ts`. No core code changes. ::: ## ConnectorProvider Bridges external AI/design tools into Studio's content pipeline. Not yet implemented. ```ts interface ConnectorProvider { id: string name: string icon: string auth: 'oauth2' | 'api_key' | 'none' featureKey: string authorize?(workspaceId: string, redirectUri: string): Promise<{ redirectUrl: string }> browse(token: string, query?: string): Promise fetch(token: string, itemId: string): Promise } ``` ## Creating a Custom Provider To add a new provider implementation: 1. Create a file in `server/providers/` (e.g., `clerk-auth.ts`) 2. Implement the full interface 3. Update the factory in `server/utils/providers.ts`: ```ts import { createClerkAuthProvider } from '../providers/clerk-auth' export function useAuthProvider(): AuthProvider { if (!_authProvider) _authProvider = createClerkAuthProvider() // Swap this line return _authProvider } ``` 4. Zero application code changes required -- all routes, composables, and components continue working. ::: tip Providers that return `null` (CDN, Media, Email, Payment) enable graceful degradation. Features that depend on them simply become unavailable without errors. ::: ## Related Pages - [Architecture](/developer/architecture) -- provider pattern overview - [Enterprise Edition](/developer/enterprise-edition) -- CDN and Media implementations in `ee/` - [Self-Hosting](/developer/self-hosting) -- configuring provider dependencies --- # Roles & Permissions Source: https://docs.contentrain.io/developer/roles-permissions > Two-tier role system, workspace and project roles, tool access matrix, model-specific access, and permission enforcement. # Roles & Permissions Studio uses a two-tier role system: workspace roles determine organizational access, and project roles control content operations. ## Workspace Roles | Role | Description | Access Level | |------|-------------|-------------| | **Owner** | Creator of the workspace. Full control. | Implicit access to all projects. Can manage billing, members, settings. | | **Admin** | Trusted team lead. Nearly full control. | Implicit access to all projects. Can manage members and settings. Cannot transfer ownership or delete workspace. | | **Member** | Standard team member. | No implicit project access. Needs explicit `project_members` assignment. | ### Workspace Role Hierarchy ``` Owner > Admin > Member ``` - A workspace has exactly **one owner** (transferable) - Admins and members are added via invitation (email) - Invited members must accept before gaining access ## Project Roles Project roles are assigned per project to workspace members: | Role | Description | Content Access | |------|-------------|---------------| | **Editor** | Content creator. | Can create, update, delete content and media. Cannot merge/reject branches. | | **Reviewer** | Content approver. | Can read all content, merge/reject branches, approve/reject form submissions. Cannot create content. | | **Viewer** | Read-only observer. | Can read content, models, and submissions. Cannot modify anything. | ::: info Role Resolution Workspace Owner and Admin get **all project permissions** regardless of project_members assignment. Only workspace Members need explicit project roles. ::: ## Tool Access Matrix This matrix shows which roles can use each agent tool: | Tool | Viewer | Reviewer | Editor | Admin | Owner | |------|--------|----------|--------|-------|-------| | **Read Operations** | | | | | | | `list_models` | Yes | Yes | Yes | Yes | Yes | | `get_content` | Yes | Yes | Yes | Yes | Yes | | `brain_query` | Yes | Yes | Yes | Yes | Yes | | `brain_search` | Yes | Yes | Yes | Yes | Yes | | `brain_analyze` | Yes | Yes | Yes | Yes | Yes | | `validate` | Yes | Yes | Yes | Yes | Yes | | `validate_schema` | Yes | Yes | Yes | Yes | Yes | | `list_branches` | Yes | Yes | Yes | Yes | Yes | | `branch_health` | Yes | Yes | Yes | Yes | Yes | | `relation_expand` | Yes | Yes | Yes | Yes | Yes | | `search_media` | Yes | Yes | Yes | Yes | Yes | | `get_media` | Yes | Yes | Yes | Yes | Yes | | `list_submissions` | Yes | Yes | Yes | Yes | Yes | | **Content Operations** | | | | | | | `save_content` | -- | -- | Yes | Yes | Yes | | `delete_content` | -- | -- | Yes | Yes | Yes | | `copy_locale` | -- | -- | Yes | Yes | Yes | | `update_status` | -- | -- | Yes | Yes | Yes | | `vocabulary` | -- | -- | Yes | Yes | Yes | | `upload_media` | -- | -- | Yes | Yes | Yes | | `update_media` | -- | -- | Yes | Yes | Yes | | `delete_media` | -- | -- | Yes | Yes | Yes | | **Review Operations** | | | | | | | `merge_branch` | -- | Yes | -- | Yes | Yes | | `reject_branch` | -- | Yes | -- | Yes | Yes | | `approve_submission` | -- | Yes | -- | Yes | Yes | | `reject_submission` | -- | Yes | -- | Yes | Yes | | **Admin Operations** | | | | | | | `save_model` | -- | -- | -- | Yes | Yes | | `delete_model` | -- | -- | -- | Yes | Yes | | `init_project` | -- | -- | -- | Yes | Yes | | `add_locale` | -- | -- | -- | Yes | Yes | ## Permission Resolution ```ts async function resolveAgentPermissions( userId: string, workspaceId: string, projectId: string, accessToken: string, ): Promise ``` ### Resolution Flow ``` 1. Get workspace member role | +-- Owner/Admin? → Full access (all tools) | +-- Member? → Continue to step 2 | 2. Get project member record | +-- No record? → No access (empty tools list) | +-- Has role? → Continue to step 3 | 3. Normalize role (EE bridge gate) | +-- EE bridge present? → reviewer/viewer kept as-is on all paid plans | (Starter, Pro, Enterprise). specificModels is | honored only when the plan grants it (Pro+). | +-- No EE bridge (Community Edition)? → reviewer/viewer downgrade to | editor, specificModels forced false | 4. Filter tools by effective role ``` ### AgentPermissions Object ```ts interface AgentPermissions { workspaceRole: 'owner' | 'admin' | 'member' projectRole: 'editor' | 'reviewer' | 'viewer' | null specificModels: boolean // Model-level restriction active? allowedModels: string[] // Restricted model IDs allowedLocales: string[] // Restricted locale codes availableTools: string[] // Tools this user can invoke } ``` ## Model-Specific Access On Pro and Enterprise plans, project members can be restricted to specific content models: ```ts { role: 'editor', specificModels: true, allowedModels: ['blog-post', 'team-members'] } ``` When `specificModels` is `true`: - The agent only allows tools on models in `allowedModels` - Content reads for other models return access denied errors - The system prompt informs the agent of the restriction ::: info Pro feature, EE-gated Model-specific access requires the `roles.specific_models` feature flag, granted on **Pro** and **Enterprise** plans. The flag is also `requires_ee`, so it only functions with the Enterprise Edition bridge present. On Free and Starter plans, and in a self-hosted Community Edition deployment, `specificModels` is always treated as `false` (full model access). ::: ## Workflow Interaction Permissions interact with the workflow configuration: ### Auto-Merge Workflow All write operations (from any permitted role) are automatically merged through the branch pipeline. ### Review Workflow | Role | Write Behavior | |------|---------------| | Owner / Admin | Writes auto-merge (authorized to approve their own work) | | Editor | Writes create `cr/*` branches that wait for review | | Reviewer | Can merge/reject branches created by editors | ```ts function shouldAutoMerge(workflow: string, permissions: AgentPermissions): boolean { if (workflow === 'auto-merge') return true return permissions.workspaceRole === 'owner' || permissions.workspaceRole === 'admin' } ``` ## API Route Enforcement Permissions are enforced at multiple levels: ### Server Middleware The auth middleware (`01.auth.ts`) validates session tokens on every request. Public endpoints are explicitly allowlisted. ### Route-Level Checks ```ts // In a route handler const session = requireAuth(event) const db = useDatabaseProvider() // Verify workspace membership with required role await db.requireWorkspaceRole( session.accessToken, session.user.id, workspaceId, ['owner', 'admin'], ) ``` ### Agent-Level Enforcement The agent system filters available tools based on resolved permissions before sending them to the LLM: ```ts const permissions = await resolveAgentPermissions(userId, workspaceId, projectId, accessToken) const tools = filterToolsByPermissions(STUDIO_TOOLS, permissions.availableTools) const phaseTools = filterToolsByPhase(tools, phase) ``` ### Billing Enforcement The billing middleware (`03.billing.ts`) checks workspace billing state and blocks requests when subscriptions are locked: | Billing State | Effect | |--------------|--------| | `subscribed` | Full access | | `trial_active` | Full access | | `past_due` | Full access (payment overdue warning) | | `trial_expired` | 402 blocked | | `grace_expired` | 402 blocked | | `canceled_expired` | 402 blocked | ## Plan Limits Permissions are further constrained by plan-based numeric limits: | Limit | Free | Starter | Pro | Enterprise | |-------|------|---------|-----|------------| | Team Members | 1 | 3 | 25 | Unlimited | | AI Messages/Month | 0 | 150 | 1,500 | Unlimited | | Media Storage | — | 1 GB | 15 GB | 100 GB | | CDN API Keys | 0 | 3 | 25 | Unlimited | | CDN Bandwidth | 0 | 2 GB | 60 GB | Unlimited | | Form Models | 0 | 1 | 15 | Unlimited | | Form Submissions/Month | 0 | 100 | 3,000 | Unlimited | | Outbound Webhooks | 0 | 3 | 25 | Unlimited | | Conversation API Keys | 0 | 0 | 15 | Unlimited | | API Messages/Month | 0 | 100 | 3,000 | Unlimited | | MCP Cloud Keys | 0 | 1 | 15 | Unlimited | | MCP Cloud Calls/Month | 0 | 5,000 | 150,000 | Unlimited | ## Related Pages - [Agent System](/developer/agent-system) -- how permissions flow into the agent - [Enterprise Edition](/developer/enterprise-edition) -- advanced roles and model access - [API Reference](/developer/api-reference/) -- API route authentication --- # Enterprise Edition Source: https://docs.contentrain.io/developer/enterprise-edition > Open-core model, AGPL core vs proprietary ee/, feature flags, enterprise bridge, and building enterprise features. # Enterprise Edition Contentrain Studio follows an **Open Core** model. The core product is licensed under AGPL-3.0 and is a fully functional CMS. The `ee/` directory contains proprietary enterprise features under a separate license. ## Directory Structure ``` studio/ ├── app/ # AGPL — core application ├── server/ # AGPL — server routes, providers, utils ├── shared/ # AGPL — shared code (license system) ├── ee/ # Proprietary — enterprise features │ ├── LICENSE # Proprietary license │ ├── README.md # Enterprise module documentation │ ├── cdn/ # Cloudflare R2 CDN provider │ ├── media/ # Sharp image processing │ └── enterprise/ # Bridge + route handlers └── ... ``` ## Core vs Enterprise Boundary ### What Stays in Core (AGPL) These features work without `ee/`: - All auth flows (GitHub OAuth, Google OAuth, Magic Link) - Workspace and project CRUD - Chat engine and all agent tools - Content CRUD (all 4 kinds, all 27 field types) - Content Engine (validate, serialize, branch, commit) - Two-step merge workflow (`cr/*` -> `contentrain` -> `main`) - Content editor modal and all UI components - Owner and Editor roles - URL fetch connector - Single and multi-locale (config-driven, not plan-gated) - All provider interfaces - Feature flag system ### What Belongs in `ee/` These require the enterprise bridge: | Feature | Module | |---------|--------| | CDN content delivery | `ee/cdn/` | | Media processing (Sharp) | `ee/media/` | | BYOA API key management | `ee/enterprise/` | | Outbound webhooks | `ee/enterprise/` | | Conversation API | `ee/enterprise/` | | Advanced roles (reviewer, viewer, specificModels) | `ee/enterprise/` | | CDN usage metering | `ee/enterprise/` | | SSO (SAML, OIDC) | Future | | Approval chains, scheduled publish | Future | | Audit log, activity feed | Future | | White-label branding | Future | | Premium connectors (Canva, Figma, Notion) | Future | ## Feature Flag System All feature gating uses `hasFeature()` -- **never hardcode plan checks**. ### Single Source of Truth `FEATURE_MATRIX` is **not** a hardcoded literal. It is **derived at build time** from the `.contentrain/` content layer — specifically `system/plan-features/data.json` — so editing a feature row in Contentrain (via MCP) changes gating with no code edit: ```ts // shared/utils/license.ts import planFeaturesData from '../../.contentrain/content/system/plan-features/data.json' export interface FeatureMatrixEntry { plans: StudioPlan[] // which plan tiers grant the flag requires_ee: boolean // needs the enterprise bridge to function roadmap: boolean // announced but not yet GA } // Built by iterating plan-features rows of type 'feature': export const FEATURE_MATRIX: Record = (() => { const matrix: Record = {} for (const row of Object.values(planFeatures)) { if (row.type !== 'feature') continue matrix[row.key] = { plans: PLAN_SLUGS.filter(plan => valueForPlan(row, plan) === 'true'), requires_ee: row.requires_ee === 'true', roadmap: (row.roadmap ?? 'false') === 'true', } } return matrix })() ``` ::: warning Content IDs vs. feature keys In `data.json` each row's entry **ID** is hyphenated (`ai-byoa`, `roles-specific-models`), but its `key` field — the identifier `hasFeature()` uses — is **dotted** (`ai.byoa`, `roles.specific_models`). Always gate against the dotted `key`, e.g. `hasFeature(plan, 'roles.specific_models')`. ::: A few entries derived from the current `data.json`: | Feature key | Plans that grant it | `requires_ee` | |-------------|---------------------|:-------------:| | `ai.byoa` | pro, enterprise | Yes | | `cdn.delivery` | starter, pro, enterprise | Yes | | `workflow.review` | community, starter, pro, enterprise | No | | `roles.specific_models` | pro, enterprise | Yes | | `api.conversation` | pro, enterprise | Yes | | `sso.saml` | enterprise | Yes | `community` is the AGPL-only self-host tier; it appears in a feature's `plans` list only for capabilities that work without the enterprise bridge (`requires_ee: false`). The matrix layer never filters `requires_ee` itself — that gate is applied by `hasFeature()` when the caller supplies the runtime edition. ### Usage ```ts // Server-side import { hasFeature, getWorkspacePlan, getPlanLimit } from '~/server/utils/license' const plan = getWorkspacePlan(workspace) if (!hasFeature(plan, 'media.upload')) { throw createError({ statusCode: 403, message: 'Upgrade required' }) } const limit = getPlanLimit(plan, 'forms.submissions_per_month') ``` ### Plan Hierarchy ``` free < starter < pro < enterprise ``` ::: tip `community` is an edition-specific fixed tier that sits **outside** this purchasable ladder. It is the AGPL self-host tier — assigned automatically when the enterprise bridge is absent — and is never sold on the managed service. See `StudioPlan` in `shared/utils/license.ts`. ::: | Plan | Price | Description | |------|-------|-------------| | Free | $0 | Structural signup shell. Browse the dashboard; 0 AI messages, no media or project connection until a trial starts. | | Starter | $9/mo | Full platform access. Git, projects, chat (Haiku), media, forms, CDN. | | Pro | $49/mo | Advanced features. Custom variants, spam filter, specific models, BYOA. | | Enterprise | Custom | Everything. SSO, white-label, custom CDN domain. | ### Self-Hosted Plan Resolution `getWorkspacePlan` resolves a workspace's effective plan from the **deployment profile**, not from a payment-key check. `resolveDeployment().planSource` decides where the plan comes from: ```ts // server/utils/license.ts export function getWorkspacePlan(workspace: { plan?: string | null }): Plan { const d = resolveDeployment() switch (d.planSource) { case 'fixed': // Community Edition → fixed `community` tier return d.fixedPlan ?? d.defaultPlan case 'operator': // on-prem / dedicated → operator sets workspace.plan return normalizePlan(workspace?.plan ?? d.defaultPlan) case 'subscription': // managed service → billing webhooks sync workspace.plan return normalizePlan(workspace?.plan) } } ``` A self-hosted Community Edition deployment uses `planSource: 'fixed'` and resolves every workspace to the `community` tier — the AGPL-only self-host tier — rather than the old behavior of forcing `starter`. Because the enterprise bridge is absent, the edition is `agpl` and every `requires_ee` feature is force-disabled regardless of tier. ## Enterprise Bridge The bridge pattern connects core code to enterprise implementations at runtime: ```ts // server/utils/enterprise.ts interface EnterpriseBridge { // Route handlers listWorkspaceAiKeys: EnterpriseRouteHandler createWorkspaceAiKey: EnterpriseRouteHandler listProjectWebhooks: EnterpriseRouteHandler createProjectWebhook: EnterpriseRouteHandler handleConversationApiMessage: EnterpriseRouteHandler // ... more route handlers // Provider factories createCDNProvider?(config: R2Config): CDNProvider createMediaProvider?(config: { cdn: CDNProvider, db: DatabaseProvider }): MediaProvider // Utility functions trackCDNUsage?(projectId: string, apiKeyId: string, bytes: number): Promise emitWebhookEvent?(projectId: string, workspaceId: string, event: string, data: Record): Promise normalizeProjectMemberAccess?(input: RoleInput): EnterpriseProjectMemberAccess resolveChatApiKey?(input: KeyInput): Promise<{ apiKey: string, usageSource: string } | null> } ``` ### Loading the Bridge The bridge is loaded lazily via dynamic import: ```ts async function loadEnterpriseBridge(): Promise { try { const mod = await import('../../ee/enterprise') return mod.createEnterpriseBridge() } catch { return null // ee/ not present — graceful null } } ``` ### Using the Bridge in Routes Core routes delegate to enterprise handlers: ```ts // server/api/workspaces/[workspaceId]/ai-keys/index.get.ts import { runEnterpriseRoute } from '~/server/utils/enterprise' export default defineEventHandler(event => runEnterpriseRoute('listWorkspaceAiKeys', 'aikeys.upgrade', event) ) ``` If the bridge is not loaded, `runEnterpriseRoute` throws a 403 error. ## Graceful Degradation When `ee/` is absent, the following behavior applies: | Feature | Degradation | |---------|-------------| | CDN delivery | `useCDNProvider()` returns `null`, CDN routes return 404 | | Media upload | `useMediaProvider()` returns `null`, upload returns error | | AI key management | Routes return 403 | | Webhooks | Routes return 403, `emitWebhookEvent` is a no-op | | Conversation API | Routes return 403 | | Reviewer role | Downgrades to editor (can still read/write, just no review separation) | | Viewer role | Downgrades to editor | | Specific models | `specificModels` treated as `false` (full model access) | ::: tip Degradation is always **safe** -- it never causes errors or data loss. Features become unavailable, not broken. ::: ## Building Enterprise Features ### Rules 1. **Provider interfaces live in core.** Implementations can be in `ee/`. 2. **Database schema stays in core.** Enterprise columns exist but are unused/RLS-gated in free tier. 3. **UI conditional rendering** uses `hasFeature()` in computed properties. 4. **Core must work without `ee/`.** Run the full test suite with `ee/` removed. 5. **Never mix `ee/` code into core.** No imports from `ee/` in `server/` or `app/`. ### Adding a New Enterprise Feature 1. Define the feature key in `FEATURE_MATRIX` (e.g., `'audit.log'`) 2. Add the bridge method to `EnterpriseBridge` interface in `server/utils/enterprise.ts` 3. Create the implementation in `ee/enterprise/` 4. Create a thin route in `server/api/` that delegates via `runEnterpriseRoute` 5. Add UI gating in components: `v-if="hasFeature(plan, 'audit.log')"` ### Testing Enterprise Features ```ts import { setEnterpriseBridgeForTesting } from '~/server/utils/enterprise' // Inject a fake bridge for testing setEnterpriseBridgeForTesting({ listWorkspaceAiKeys: async () => [], // ... mock other methods }) ``` ## Source Availability & the About Page (AGPL §13) The AGPL-3.0 core satisfies the network-use source-offer requirement of AGPL §13 through a public, unauthenticated **`/about`** page (plus a source link in the app footer). Every deployment exposes: - A link to the source repository (`github.com/Contentrain/studio`) - A modified-source note -- if an operator changes the core, §13 requires offering that modified source to users interacting with it over the network - The license files: the core under **AGPL-3.0** (`LICENSE` + the §7 additional terms in `LICENSE-EXCEPTIONS` -- attribution §7(c) and no-trademark §7(e)) and the proprietary **`ee/LICENSE`** for the Enterprise Edition - Deployment info: the resolved `profile`, `edition`, and `billingMode` ::: warning Self-hosters If you deploy a **modified** core, keep `/about` reachable and point it at your modified source per §13. Do not remove the source link or the §7 attribution / no-trademark terms without legal review. ::: ## Related Pages - [Architecture](/developer/architecture) -- provider pattern and system design - [Roles & Permissions](/developer/roles-permissions) -- how roles are gated by plan - [Provider Interfaces](/developer/provider-interfaces) -- interface definitions - [Self-Hosting](/developer/self-hosting) -- deploying without enterprise features --- # Contributing Guide Source: https://docs.contentrain.io/developer/contributing > Development setup, code style, testing, conventional commits, PR process, and architecture rules for contributing to Contentrain Studio. # Contributing Guide Contentrain Studio welcomes community contributions to the AGPL core. This guide covers everything you need to set up a development environment, follow project conventions, and submit quality pull requests. ## What We Accept | Accepted | Not Accepted | |----------|-------------| | Bug fixes | Proprietary `ee/` contributions (unless coordinated with maintainers) | | Tests | Changes that violate architecture rules | | Documentation | Hardcoded strings (use Contentrain dictionary) | | Developer tooling improvements | Supabase imports outside `server/providers/` | | Accessibility and UX improvements | Raw Tailwind colors (use semantic palette) | | Performance and maintainability | Breaking changes without discussion | ## Development Setup ### Prerequisites | Tool | Version | Purpose | |------|---------|---------| | Node.js | 22+ | Runtime | | pnpm | 10+ | Package manager | | Supabase CLI | Latest | Local database | | Git | Latest | Version control | ### Local Setup ```bash git clone https://github.com/Contentrain/studio.git cd studio pnpm install cp .env.example .env pnpm db:start pnpm db:migrate npx contentrain generate pnpm dev ``` The app starts at `http://localhost:3000`. ### Available Commands ```bash # Development pnpm dev # Start dev server # Code Quality pnpm lint # Run ESLint pnpm lint:fix # Auto-fix ESLint issues pnpm typecheck # TypeScript type checking # Testing pnpm test:unit # Pure logic and route handler tests pnpm test:integration # Route integration and server wiring pnpm test:nuxt # Composables and Nuxt runtime behavior pnpm test:rls # Row-Level Security behavior pnpm test:e2e # Browser and user-flow regressions pnpm test:ci # Full CI test suite # Build pnpm build # Production build # Release pnpm release:check # Pre-release validation (lint + typecheck + build) pnpm release # Full release gate + changelog + version + tag ``` ## Code Style ### ESLint + Stylistic Studio uses `@nuxt/eslint` with Stylistic rules. There is **no Prettier** -- ESLint handles both linting and formatting. ```bash pnpm lint:fix # Always run before committing ``` ### Key Style Rules - **No semicolons** (Stylistic enforced) - **Single quotes** for strings - **2-space indentation** - **Trailing commas** in multi-line - **No `as any`** type assertions -- fix the actual type issue - **No `@deprecated` markers** -- the project is in active development, remove things directly ## Architecture Rules These are **non-negotiable** -- PRs that violate them will not be merged. ### Provider Pattern ``` NEVER import @supabase/supabase-js outside server/providers/ NEVER use provider-specific composables in routes, pages, or components NEVER leak vendor details into application logic NEVER hardcode plan checks -- use hasFeature() ``` All application code depends on provider interfaces and factories in `server/utils/providers.ts`. ### UI and Design - Use the **semantic color system** (`primary-*`, `secondary-*`, `success-*`, `warning-*`, `danger-*`, `info-*`), never raw Tailwind colors (`gray-*`, `red-*`, etc.) - Follow **atomic component structure**: atoms, molecules, organisms - Use `NuxtImg` for all images (not ``) - All `