Working with AI agents
Coding agents can change Alinea content in two ways: through the MCP server of alinea dev, or by writing the content JSON files themselves. This page is the playbook for both: connect the MCP server first, and use the rules below when an agent has to edit files by hand.
Edit content through the MCP server
While alinea dev runs it also serves an MCP server that coding agents such as Claude Code, Cursor or Codex use to read the schema and to create, edit, publish and delete entries through the same save path as the dashboard. It fills in internal fields such as _id, _index, list row ids, rich text nodes, link objects and media metadata, so the result is always valid and an open dashboard updates live. Connect your agent to it with:
claude mcp add --transport http alinea http://localhost:4500/mcpPrefer it over editing JSON files by hand. The MCP server page covers the setup for other agents, every tool and the value formats. The rules on the rest of this page remain the reference for manual edits.
Scope and source priority
When documenting or generating Alinea content, use source of truth in this order:
The docs that ship with the package:
node_modules/alinea/docs/holds a Markdown file per docs page for the installed version, start atindex.md. Online, llms.txt lists every page as Markdown and llms-full.txt holds all documentation in one file (including this playbook). Use these as primary source of truth.In case of ambiguity or missing documentation, consult the alinea core source code.
Projects using alinea will typically have alinea as a bundled node_modules dependency, which means the (compiled) source code can be accessed directly.
In case the source code can not be retrieved, or the compiled code is unclear, consult the source code on https://github.com/alineacms/alinea
Consult the schema and existing content files in the target project (for example
content/**) for examples, and follow the same structure when suggesting changes.The source code of the Alinea website is publicly available in the
apps/webdirectory of https://github.com/alineacms/alinea. The website, including these docs, is itself built with Alinea and Next.js and can serve as a useful example.
alinea init adds a short Alinea section to the project's AGENTS.md that points agents to the bundled docs and the MCP server, so agents find them without being told.
Project structure conventions
In new Next.js projects, agents should follow the tutorial file structure as closely as possible to keep the codebase readable and maintainable.
In existing codebases, first scan the current structure and then align new files and changes to the established coding guidelines and architectural principles.
Entry metadata rules
Top-level entries are JSON records with required meta fields. In Alinea core this is defined by EntryMeta in src/core/EntryRecord.ts.
{
"_id": "<createId()>",
"_type": "<schema type name>",
"_index": "<fractional index>",
"_root": "pages",
"_seeded": "/index.json",
"title": "..."
}_id: unique entry id fromcreateId()(alinea/core/Id)._type: exact schema type key._index: fractional ordering key. Generate withgenerateKeyBetweenfromalinea/core/util/FractionalIndexing._root: written on root-level entries (entries without a parent), include it when you create one. Value is the root key, for examplepagesormedia._seeded: only on entries created from a seeded page in the config (Config.page(...)in thechildrenof a root or page). Keep the value unchanged.The file name is the entry's path (url slug):
docs/reference/cli.jsonhas pathcli. Published entry files don't store apathfield, draft files (*.draft.json) do.Translations of an entry share the same
_id, one file per locale folder.
ID and index generation
# New id
node --input-type=module -e "import {createId} from 'alinea/core/Id'; console.log(createId())"
# Index between two siblings
node --input-type=module -e "import {generateKeyBetween} from 'alinea/core/util/FractionalIndexing'; console.log(generateKeyBetween('a0', 'a1'))"
# Append after last sibling
node --input-type=module -e "import {generateKeyBetween} from 'alinea/core/util/FractionalIndexing'; console.log(generateKeyBetween('a0', null))"Alinea sorts sibling entries by _index ascending. Never hand-pick _index by eye when inserting between entries.
Internal and external links
Links appear in two places: rich text marks and link fields (Field.link / Field.link.multiple).
Rich text link marks
[
{
"_type": "paragraph",
"content": [
{
"_type": "text",
"text": "Internal doc",
"marks": [
{
"_type": "link",
"_id": "<createId()>",
"_link": "entry",
"_entry": "<target entry id>"
}
]
},
{
"_type": "text",
"text": " and external site",
"marks": [
{
"_type": "link",
"_id": "<createId()>",
"_link": "url",
"href": "https://example.com",
"target": "_blank",
"title": ""
}
]
}
]
}
]Rich text link mark shape is defined by LinkMark in src/core/TextDoc.ts: _type: link, _id, _link (entry | file | url), _entry for entry and file links, and href, target and title for URLs. Optional _anchor links to an anchor on the target page.
Link field objects
// Field.link('Link') -> single entry link
{
"_id": "<createId()>",
"_type": "entry",
"_entry": "<target entry id>",
"label": "Optional extra field"
}
// Field.link('Link') -> single external url
{
"_id": "<createId()>",
"_type": "url",
"_url": "https://example.com",
"_title": "Example",
"_target": "_blank",
"label": "Optional extra field"
}
// Field.link.multiple('Links') row
{
"_id": "<createId()>",
"_index": "<fractional index>",
"_type": "entry",
"_entry": "<target entry id>",
"label": "Optional extra field"
}For Field.link.multiple, each row is also a list row, so _index is required.
Lists and union/list row metadata
List rows are defined by ListRow in src/core/shape/ListShape.ts. Every row must include _id, _type, _index.
{
"items": [
{
"_id": "<createId()>",
"_index": "a0",
"_type": "Item",
"title": "First"
},
{
"_id": "<createId()>",
"_index": "a1",
"_type": "Item",
"title": "Second"
}
]
}Union values (from UnionShape) require _id and _type. If a union is inside a list, it still needs list row _index as well.
Rich text JSON format
Alinea rich text is a TextDoc array (src/core/TextDoc.ts). Common nodes are heading, paragraph, text, bulletList, orderedList, listItem and hardBreak. Blocks defined in the field's schema appear as nodes with the block's type name as _type (for example CodeBlock) and an _id.
[
{
"_type": "heading",
"level": 2,
"content": [{"_type": "text", "text": "Heading"}]
},
{
"_type": "paragraph",
"textAlign": "left",
"content": [
{"_type": "text", "text": "Normal text "},
{
"_type": "text",
"text": "bold",
"marks": [{"_type": "bold"}]
},
{"_type": "text", "text": " "},
{
"_type": "text",
"text": "italic",
"marks": [{"_type": "italic"}]
},
{"_type": "hardBreak"},
{
"_type": "text",
"text": "anchor",
"marks": [
{
"_type": "link",
"_id": "<createId()>",
"_link": "url",
"href": "https://example.com",
"target": "_blank",
"title": ""
}
]
}
]
},
{
"_type": "bulletList",
"content": [
{
"_type": "listItem",
"content": [
{
"_type": "paragraph",
"content": [{"_type": "text", "text": "Bullet item"}]
}
]
}
]
},
{
"_type": "orderedList",
"start": 1,
"content": [
{
"_type": "listItem",
"content": [
{
"_type": "paragraph",
"content": [{"_type": "text", "text": "Ordered item"}]
}
]
}
]
},
{
"_type": "CodeBlock",
"_id": "<createId()>",
"code": "console.log('block nodes need _id')",
"language": "javascript",
"fileName": "",
"compact": false
}
]If generating from HTML, Alinea's parser maps common tags to these node/mark types (src/core/field/RichTextField.ts), for example <p> -> paragraph, <a> -> link, <ul>/<ol>/<li> -> list nodes, <strong> -> bold.
Roots and workspaces
Config.workspace and Config.root define where content is stored and which root key each entry belongs to (src/core/Workspace.ts, src/core/Root.ts).
// Example: the content of the Alinea website
content/
main/
pages/
docs.json
docs/
reference/
cli.json
media/
dashboard.json
demo/
pages/
en/
index.json
products.json
products/
otto-stool.json
nl/
...
authors/
maya-janssens.json
media/
...
// Workspace key -> source
main -> content/main
demo -> content/demo
// Root key -> folder under each workspace source
pages -> <workspace>/pages
media -> <workspace>/media
// A translated root has a folder per locale
demo pages (en, nl, fr) -> content/demo/pages/en, content/demo/pages/nl, ...Manual generation rules:
When creating a root-level entry file, include
_rootwith the matching root key.Place files under the workspace source directory and root directory that match config.
For seeded pages, keep
_seededstable and matching the configured seed path.Do not remove nested identity fields (
_id,_index,_type) from list rows, union values, or rich text block nodes.
Validation workflow
# Fill in missing or incorrect properties with their defaults
npx alinea build --fix
# Final validation: your build script runs alinea build before next build
npm run buildBefore commit: ensure JSON parses, _type matches schema, _index order is correct among siblings, and no duplicate _id values were introduced in edited scope.