Skip to content

Types

npm version npm downloads

@contentrain/types is the shared type contract for the Contentrain ecosystem. Every package — MCP, CLI, SDK, Rules — imports its domain types from here instead of redefining them. If you are building tooling on top of Contentrain or authoring a framework integration, this is the package you depend on.

Why a Shared Types Package?

Without a single source of truth, each package would define its own ModelDefinition, FieldDef, or ContentrainConfig — and they would inevitably drift. @contentrain/types ensures:

  • One vocabulary — every package speaks the same domain language
  • Breaking changes are visible — a type change here is an ecosystem-level change
  • Zero runtime cost — most exports are type-only, tree-shaken away in production

Ecosystem Role

  • MCP validates and writes ModelDefinition
  • CLI reads ContentrainConfig and ContextJson
  • SDK codegen consumes ModelDefinition and FieldDef
  • Rules align with the same model and workflow vocabulary
  • Contentrain Studio operates on the same type contract — schemas defined locally work identically in team workflows

Install

bash
pnpm add @contentrain/types

For type-only usage (no runtime exports needed):

bash
pnpm add -D @contentrain/types

Requirements:

  • Node.js 22+
  • TypeScript 5.0+

Quick Example

ts
import type {
  ContentrainConfig,
  FieldDef,
  ModelDefinition,
  ValidationResult,
} from '@contentrain/types'

const fields: Record<string, FieldDef> = {
  title: { type: 'string', required: true },
  slug: { type: 'slug', required: true, unique: true },
}

const model: ModelDefinition = {
  id: 'blog-post',
  name: 'Blog Post',
  kind: 'collection',
  domain: 'blog',
  i18n: true,
  title_field: 'title',
  fields,
}

const config: ContentrainConfig = {
  version: 1,
  stack: 'next',
  workflow: 'review',
  locales: { default: 'en', supported: ['en', 'tr'] },
  domains: ['blog'],
}

const result: ValidationResult = {
  valid: true,
  errors: [],
}

Export Catalog

Core Unions

TypeValuesReference
FieldType27 field types (string, number, boolean, relation, ...)Field Types
ModelKindsingleton, collection, document, dictionaryModel Kinds
ContentStatusdraft, in_review, published, rejected, archived
ContentSourceagent, human, import
WorkflowModeauto-merge, reviewConfiguration
StackTypenuxt, next, astro, sveltekit, remix, + 25 moreConfiguration
Platformweb, mobile, api, desktop, static, other
ContextSourcemcp-local, mcp-studio, studio-ui
CollectionRuntimeFormatmap, array
LocaleStrategyfile, suffix, directory, none
FileFrameworkvue, svelte, jsx, astro, script

Core Interfaces

InterfacePurpose
FieldDefField schema definition (type, required, unique, constraints)
ModelDefinitionFull model schema (id, kind, domain, fields, i18n, locale strategy)
ContentrainConfigProject configuration (stack, workflow, locales, domains)
VocabularyShared terms for content consistency
EntryMetaPer-entry metadata (status, source, timestamps)
AssetEntryAsset registry entry (path, type, size, alt)
ValidationErrorStructured validation issue — severity is error, warning, or notice (notices flag drift like drafts beside published entries)
ValidationResultValidation outcome (valid flag + error list)
ContextJsonLast operation context written by MCP
ModelSummaryLightweight model info for listing operations

Provider Contract Types

Third-party developers can implement custom providers by implementing these interfaces:

Interface / TypePurpose
RepoProviderFull provider contract: read, write, branch, merge, diff operations, plus optional media?: MediaProvider, getMergeBase? and createMergeCommit? (reconcile) members
RepoReaderRead-only interface (readFile, listDirectory, fileExists)
RepoWriterWrite interface (applyPlan for atomic commits)
ProviderCapabilitiesCapability flags (localWorktree, sourceRead, sourceWrite, pushRemote, branchProtection, pullRequestFallback, astScan, optional mergeCommit)
FileChangeA single file addition, modification, or deletion ({ path, content: string | null })
ApplyPlanInputInput for a single atomic commit (branch, changes, message, author, optional base)
CommitResult of a commit operation (sha, message, author, timestamp)
BranchGit branch metadata (name, sha, protected)
FileDiffFile change within a plan (path, status, before, after)
MergeResultMerge outcome (merged flag, sha, pullRequestUrl, optional sync?: SyncResult for LocalProvider, optional remote? source-branch cleanup outcome)
SyncResultSelective file sync result (synced, skipped, optional warning)
BaseAdvance'advanced' | 'blocked_diverged' — what happened to the base branch after a write (shared vocabulary with Studio; a PR is an attachment, never a third state)
RemotePush'pushed' | 'rejected' | 'no-remote' — outcome of pushing the contentrain branch
ConflictItemOne surviving reconcile conflict — position (path, key, field, locale), the three values, a CLOSED code union (Studio keys localized editor questions on it), and a value-derived id
ConflictCodeClosed union of conflict kinds — adding a value is a minor + changelog entry; renaming or removing one is breaking
ConflictResolutionA decision keyed by conflict id: { id, choose: 'ours'|'theirs' } or { id, value } — stale ids (values changed since the dry-run) are dropped and re-reported
CommitAuthorCommit author metadata (name, email)

Media facet types (implemented by providers exposing a media stack — drives the contentrain_media_* tools):

Interface / TypePurpose
MediaProviderOptional RepoProvider.media facet: list / get / ingest / update / delete
MediaAssetOne asset — id, path (media/...), optional url, mime, size, alt, tags, createdAt, meta
MediaListOptionsList filters (search, tag, limit, cursor)
MediaListResultList page (assets, optional nextCursor, total)
MediaIngestInputURL-based ingest input (url, optional filename, alt, tags)
MediaUpdateInputMetadata patch (alt, tags, filename)

Pre-built capability set:

  • LOCAL_CAPABILITIES — Capability set for LocalProvider: localWorktree, sourceRead, sourceWrite, pushRemote, astScan and mergeCommit enabled; branchProtection and pullRequestFallback are false (a local worktree has no remote protection or PR flow). Exported from @contentrain/types for custom providers that back onto the local filesystem.

See RepoProvider Reference for the complete interface definitions and a minimum-viable provider recipe.

Storage Types

These types define the canonical JSON structure for each model kind on disk:

TypeModel KindShape
SingletonContentFileSingletonRecord<string, unknown>
CollectionContentFileCollectionRecord<string, Record<string, unknown>> (object-map by entry ID)
DictionaryContentFileDictionaryRecord<string, string> (flat key-value, all strings)

Output Types

How MCP and SDK return content to consumers (different from storage format):

TypeDescription
CollectionEntry{ id: string } & Record<string, unknown>
CollectionContentOutputCollectionEntry[] (array format)
DocumentEntry{ slug, frontmatter, body } — parsed markdown
DocumentContentOutputDocumentEntry[]
PolymorphicRelationRef{ model, ref } — cross-model relation storage

Metadata Types

TypeDescription
SingletonMetaAlias for EntryMeta
CollectionMetaRecord<string, EntryMeta> — per-entry metadata map
DocumentMetaAlias for EntryMeta
DictionaryMetaAlias for EntryMeta

Scan & Graph Types

Used by the normalize flow (scan, extract, reuse):

TypePurpose
ScanCandidateHardcoded string candidate with file, line, column, context
DuplicateGroupGroup of repeated strings with occurrence locations
GraphNodeFile node in the project graph (category, imports, strings)
ProjectGraphFull project structure graph (pages, components, layouts)
ScanCandidatesResultScan output with candidates, duplicates, and stats
ScanSummaryResultHigh-level scan summary (directory breakdown, top repeated)
StringContextWhere a string appears (jsx_text, template_attribute, ...)
FileCategoryFile classification (page, component, layout, other)
NormalizePlanNormalize plan exchanged between scan and apply
NormalizePlanModelModel proposal inside a normalize plan
NormalizePlanExtractionOne extraction target (content entry to create)
NormalizePlanPatchOne source patch inside a normalize plan

Runtime Constants

Beyond types, the package ships a small runtime surface: constants plus pure, dependency-free validate/serialize functions (browser-compatible — Studio shares the same validation contract through them). Constants first:

ts
import {
  CONTENTRAIN_DIR,       // '.contentrain'
  CONTENTRAIN_BRANCH,    // 'contentrain'
  PATH_PATTERNS,         // Canonical file path patterns
  SLUG_PATTERN,          // /^[a-z0-9]+(?:-[a-z0-9]+)*$/
  ENTRY_ID_PATTERN,      // /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,39}$/
  LOCALE_PATTERN,        // /^[a-z]{2}(?:-[A-Z]{2})?$/
  CANONICAL_JSON,        // { indent: 2, encoding: 'utf-8', ... }
} from '@contentrain/types'
ConstantValuePurpose
CONTENTRAIN_DIR'.contentrain'Root directory name
CONTENTRAIN_BRANCH'contentrain'Dedicated content branch name
PATH_PATTERNSObjectCanonical paths for config, models, content, meta
SLUG_PATTERNRegExpValidates slug format
ENTRY_ID_PATTERNRegExpValidates entry IDs
LOCALE_PATTERNRegExpValidates ISO locale codes
CANONICAL_JSONObjectDeterministic serialization rules
RESERVED_PATHSreadonly string[]Four .contentrain/ files this repository claims but does not yet write — see Reserved paths
SECRET_PATTERNSReadonlyArray<RegExp>Provider-shaped patterns behind detectSecrets — extend for custom secret detection. The generic api_key = … rule is not in this list: it fires only when looksLikeCredential accepts the captured tail

Runtime Functions

Validate functions (pure, dependency-free):

FunctionPurpose
validateSlug(slug)Kebab-case slug validation
validateEntryId(id)Entry ID format validation
validateLocale(locale, config)Locale format + config support check
detectSecrets(value)Detect potential secrets in field values
looksLikeCredential(tail)Whether a value assigned to an API-key setting reads as a credential (has a digit, mixes letters and digits in a token) rather than a placeholder or setting name
validateFieldValue(value, fieldDef)Full field schema validation (type, required, min/max, pattern, select)
validateSemanticType(value, type)Semantic checks for typed values (integer, date, email, url, ...)
validateAccept(value, accept)Extension-based accept constraint check for media paths
isMediaType(type)Whether a field type is media-backed

Serialize functions (pure, dependency-free):

FunctionPurpose
sortKeys(obj, fieldOrder?)Recursive key sorting for canonical output
canonicalStringify(data, fieldOrder?)Deterministic JSON serialization
generateEntryId()12-char hex entry ID generation
parseMarkdownFrontmatter(content)Parse YAML frontmatter + body from markdown
serializeMarkdownFrontmatter(data, body)Serialize data + body into markdown frontmatter
parseFrontmatterScalar(raw)One frontmatter scalar: booleans, null, numbers, quoted strings with escapes decoded
parseFrontmatterScalarString(raw)The same, always as a string — a SKU of "007" must not become 7
splitFrontmatterList(inner)Split an inline array on commas outside quotes

Execution/approval functions (pure; computePlanHash uses Web Crypto):

FunctionPurpose
riskRank(risk)Position on the risk ladder; higher is more severe
highestRisk(risks)The worst class in a list — how a multi-step plan is rated
isTerminalRunStatus(status)Whether a run will move on its own
isReservedPath(path)Whether a path is one of RESERVED_PATHS
approversFor(receipt, gate)Approvers recorded on a receipt for one gate
planHashPayload(plan)The exact canonical-JSON bytes plan_hash covers
computePlanHash(plan)Promise<string> — SHA-256 of that payload, lowercase hex
effectiveRisk(plan)The class a plan is judged at — its own, or its worst step's
requiredApprovals(plan, policy?)What a policy demands of a plan, before any decision
evaluateApproval(input)May this proceed? Requirements, who met them, and why a decision did not count

Unique constraints and relation references need external state (all entries / target existence), so they stay in MCP's validator — validateFieldValue covers everything schema-level.

Git Transaction Types

TypePurpose
SyncResultResult of selective file sync (synced files, skipped files, warning)
ContentrainErrorStructured error with code, message, agent hint, and developer action
ScaffoldTemplateTemplate definition for project scaffolding

Frontmatter Round Trip

A document's fields live in YAML frontmatter, and two readers open them: the content engine through parseMarkdownFrontmatter (re-exported by @contentrain/mcp), and @contentrain/query's client generator and Astro loader. The property both depend on is that a value written and read back is the same value.

It did not hold

ValueCame back as
He said "Hi"He said \"Hi\" — quotes stripped without decoding the escapes
C:\path\toC:\\path\\to, doubling again on every further save
line oneline twoline one — the rest was written as lines the reader skipped
padded padded
'42' (a string)42 (a number)
true (a boolean)'true' (a string)

The guarantee is now explicit: for every value serializeMarkdownFrontmatter can write, parseMarkdownFrontmatter returns it unchanged, and a second round trip produces identical bytes. The second trip is part of the test on purpose — backslash doubling only diverges on the trip after the one that introduced it, so a single-trip test passes on content that corrupts a little more with every export.

  • A quoted scalar's escapes are decoded (\\, \", \n, \r, \t, \uXXXX). An unrecognised escape keeps its backslash rather than erroring: hand-written frontmatter says "C:\Users". Text that merely starts and ends with a quote ("a" and "b") is not treated as one scalar.
  • A value carrying a newline, tab, backslash or edge whitespace is quoted and escaped, so it occupies one line and no part of it is silently dropped.
  • A string that would read back as another type is quoted; a real boolean, number or null is not, so each reads back as itself.
  • An empty array is written key: []. A bare key: is genuinely ambiguous — empty array, empty object, or null — and the two readers guessed differently.

The scalar grammar is exported and imported by the SDK reader rather than replicated. That is the actual fix: when each side had its own copy, correcting one would have turned a shared bug into a silent disagreement between the generated client and the content engine. A parity suite in @contentrain/query asserts both readers return the same values for the same bytes.

Body text keeps its internal blank lines; only leading and trailing whitespace is normalised, which is markdown behaviour rather than loss.

Execution & Approval Contracts

@contentrain/types is the contract layer for operations, not just for content shapes. The migration engine produces plans and receipts, Studio renders the plan card and collects approvals, and MCP is where a plan's steps run. If each defined its own RiskClass, "destructive" would mean three different things and the approval guarding it would be theatre.

Risk and approval

ts
import { RISK_CLASSES, highestRisk } from '@contentrain/types'

// A survey that ends in a deploy is a deploy.
highestRisk(['read_only', 'bulk_content', 'deployment'])  // 'deployment'

RiskClass is an ordered ladder — read_onlylow_risk_contentbulk_contentdestructive_schemaexternal_effectfinancially_materialdeployment — and a policy written for one rung is expected to cover everything above it.

ApprovalGate keeps three questions separate: plan (before the work starts, on scope and cost), change (on the diff the agent produced), and release (on production effect). Approving what will be done is not approving what was produced, and neither is permission to publish it.

TypePurpose
ApprovalRule, ApprovalPolicyFile.contentrain/approval-policies.json — which risk needs whose approval, in which mode (auto / single / quorum). Lives in git beside the content it governs, so the policy in force is the policy on the branch. Rules are additive: a policy file can only make a project stricter
ApprovalRequirementAn outstanding demand, carrying because — the risk class of the rule that produced it — so a UI can say why a gate appeared
ApprovalGrantA decision actually given, bound to an exact plan_hash. Change the plan and its grants stop applying
ActorRefWho is acting. kind (human / agent / system) is load-bearing: an agent may never approve its own work

Plans and receipts

TypePurpose
ExecutionPlanAn operation fully described before it runs: steps, union scope, risk, estimate, rollback, assumed repository state
ExecutionStepOne tool invocation, with its own risk and scope
ExecutionScopeWhat is touched — models, locales, entries, routes, files, assets, providers, external domains. An absent field means "none", not "unknown"
ExecutionEstimate / ExecutionCostCost metered before it is priced: tokens, duration_ms, items (a count), bytes_stored (at rest), bytes_out (moved). Storage and egress are separate fields because they are billed by different rates
ExecutionReceiptWhat happened: status, approvals, checkpoints, verification, measured cost, and the scope actually touched — the same ExecutionScope shape, so prediction and outcome can be subtracted
RollbackPlanThe undo as a command, not a promise. available: false tells the approver before deciding
RunStatusdraft → planned → awaiting_approval → approved → scheduled → queued → running → verifying → completed, plus the interrupted states
DeploymentTargetWhere a build is published. Carries a secret_ref, never a secret — this document is written to git
AutomationDefinitionReserved shape for .contentrain/automations.json; nothing reads it yet

The evaluator

ts
import { evaluateApproval, requiredApprovals } from '@contentrain/types'

// For the plan card, before anyone has decided:
requiredApprovals(plan, policy)
// → [{ gate: 'release', mode: 'quorum', min_approvals: 2, because: 'deployment' }]

// At the gate:
const decision = evaluateApproval({ plan, policy, grants, commit_sha, now })
decision.allowed          // every requirement met and the plan has not expired
decision.outstanding      // what is still missing, with who has signed so far
decision.rejected_grants  // decisions that did not count, each with a reason
decision.reasons          // one line per blocker, written for a person

It replaces a role check. Asking "is this person an owner?" cannot express "a bulk publish needs a second pair of eyes even from the owner", and cannot tell a typo fix from a domain cutover. The evaluator asks about the action instead: its risk, its scope, and what the project's policy says about that combination.

RuleWhy
A plan cannot understate itselfeffectiveRisk() takes the worst of the plan's declared class and its steps', so a plan labelled read_only carrying a deploy step is evaluated as a deploy
Each matching rule is its own requirement, all must be metMerging two rules needs a way to combine modes, roles and counts — and every such rule has a case where the result is looser than one of its inputs
auto does not climb the ladderEvery other mode covers its class and everything above it. If auto did too, one auto rule on a low rung would exempt every heavier operation above it
An agent never approvesNot its own work, not anyone's. A plan's author cannot approve it either unless the project sets allow_self_approval — which does not extend to agents
A change decision is about a diffPresented with a different branch tip than the one reviewed, it does not count
now is an inputNothing reads the clock, so a blocked run can be explained months later by replaying the same arguments

Every rejected decision carries a machine-readable GrantRejection reason — plan_hash_mismatch, commit_mismatch, expired, agent_approver, self_approval, role_not_permitted, duplicate_approver, no_matching_requirement — because the useful question is never "is it blocked" but "I approved this, why is it still blocked".

With no .contentrain/approval-policies.json, DEFAULT_APPROVAL_POLICY applies: read-only work proceeds, everything else wants one reviewer on the diff. Whether a project consults the evaluator at all is still governed by its workflow setting.

plan_hash

ts
import { computePlanHash } from '@contentrain/types'

const plan_hash = await computePlanHash(plan)

SHA-256 over canonical JSON (sorted keys, 2-space indent, trailing newline) of the plan's semantic fields. Excluded: plan_hash itself, id, created_at, created_by, idempotency_key — who built a plan, when, under which run id and with which deduplication key do not change what the plan will do, and regenerating the same operation must produce the same hash or idempotency and approval binding both break.

Everything else is covered, so a widened scope, an added step, a raised estimate or a withdrawn rollback each invalidate every approval the plan had collected.

Async because it uses Web Crypto, which works in Node 18+, Deno, Bun, workers and browsers alike — this package is consumed in all of them and must not reach for node:crypto. A non-cryptographic hash was rejected: approvals are pinned to this value, so a collision is an approval bypass.

SourceDeltaPlan

The WordPress→repository delta — not contentrain_reconcile, which merges two git branches through their common ancestor and knows nothing about WordPress. A source delta must be written to the repository before reconcile runs on the git side.

It carries explicit deletion tombstones, because modified_after is a filter on changed records and never reports a deletion: a post deleted in WordPress simply stops appearing. deletions_detectable: false must not be read as "nothing was deleted" — it means this cursor could not tell. It also carries slug moves (which generate redirects) and semantic conflicts, where the same record changed at the origin and in the repository.

Reserved paths

RESERVED_PATHS names four files under .contentrain/ that this repository has claimed but does not yet write:

PathWill hold
.contentrain/capabilities.jsonCapabilityManifest
.contentrain/automations.jsonAutomationDefinition[]
.contentrain/approval-policies.jsonApprovalPolicyFile
.contentrain/redirects.jsonSource→destination URL map

.contentrain/ is a shared namespace — Studio, the migration engine and a customer's own tooling all write into it — so a name claimed here cannot later be taken for something else, and the tools that walk the directory know these four are expected rather than stray.

Until a tool owns one, the behaviour is narrow and pinned by tests in @contentrain/mcp:

  • contentrain_doctor and contentrain_validate ignore them completely. They are not orphans, not broken content, and not the validator's business.
  • contentrain_reconcile treats each as one opaque file: it takes the side that changed it, and reports file_conflict when both sides did. It never merges their interiors — a field-level union on an approval policy would produce a policy nobody wrote.

Migration contracts

The sibling family (RawIR, ProjectIR, CapabilityManifest, MigrationHandoff) is documented in the package README.

Import Style

Type-only imports (recommended for application code):

ts
import type { ModelDefinition, ContentrainConfig, FieldDef } from '@contentrain/types'

Runtime imports (when you need constants):

ts
import { PATH_PATTERNS, CANONICAL_JSON, CONTENTRAIN_DIR } from '@contentrain/types'

Stability

This package is the shared public contract across the ecosystem:

  • Types exported from the package root are the public surface
  • Packages depend on these shared definitions instead of redefining domain types
  • Breaking changes here are ecosystem-level breaking changes
  • The package should stay small, dependency-light, and stable

Development

From the monorepo root:

bash
pnpm --filter @contentrain/types build
pnpm --filter @contentrain/types test
pnpm --filter @contentrain/types typecheck
  • MCP Tools — Validates and writes models using these types
  • CLI — Reads config and context using these types
  • Query SDK — Codegen consumes model definitions and field types
  • Rules — Aligns with the same vocabulary
  • Skills — Workflow procedures built on these contracts
  • Model Kinds — Detailed specification of the four model kinds
  • Field Types — Comprehensive field type reference
  • Configuration — Config file schemas and directory layout