Documentation

Build with it

The site is a directory of readable YAML. Put it in git, review changes in a pull request, apply them from CI.

my-site/
site.yaml            name, theme, layout
content/service.yaml a content type per file
pages/home.yaml      a page per file
logic/confirm.yaml   an automation per file

npx @forinda/fcms-cli init my-site writes all of that, already valid and already canonical. Every field type, block and operator the spec accepts is listed in the reference.

The CLI

fcms validateCheck the spec. Reports file, line and column.
fcms fmtRewrite every file canonically. --check in CI.
fcms devServe it locally, reloading on save. No database needed.
fcms diff a bDescribe the difference between two spec directories in plain language.
fcms link / loginPoint this directory at a site and sign in.
fcms planWhat applying these files would do. Exits 2 if anything is destructive, so CI can gate on it without parsing output.
fcms applyApply them. Destructive changes need --yes; --content also sends the rows back.
fcms pullWrite the live spec back out as canonical files. --content also writes every row to data/<type>.yaml.

fcms login stores a session token under your config directory, mode 0600, one per server. The link — which names the server and nothing secret — lives in fcms.json in the project and is safe to commit.

There are deliberately no bulk content commands. Automating content belongs to the MCP server below; two machine doors for one audience is two surfaces to keep in step.

Agents — the MCP server

{
"mcpServers": {
"forinda-cms": { "command": "fcms-mcp", "args": ["/path/to/my-site"] }
}
}

It exposes ten tools, and the list is the safety model:

site_status site_spec site_plan site_historyRead.
site_apply site_undoChange the site. Destructive changes are refused unless confirmed.
entry_list entry_create entry_update entry_deleteContent.

There is no write_file, no run_sql, nothing that takes a path or a command. An agent describes the site it wants and the server works out the difference — so a model with a stale idea of your page cannot corrupt it, and everything it does lands in the same history with the same undo.

It authenticates with the session fcms login already stored, so an agent’s access is a session you can see and revoke, not a permanent key.

The API

Both of the above talk to the same five endpoints — login, status, spec, plan, apply, plus content — over HTTP with a bearer token. If you want to drive it from something else, that is the surface, and the client library is @forinda-cms/sdk.

What the site publishes to machines

/robots.txt, /sitemap.xml and /llms.txt are generated from the spec, so none of them can go stale. The sitemap carries a lastmod taken from the row behind each page; llms.txt describes the site for a model rather than a crawler, from the same content types and pages you already wrote.

seo:
indexable: true   # false: every page noindex, robots.txt disallows, no sitemap
sitemap: true
llms: true

analytics:
gtag: G-ABC1234567

indexable: false is one switch for a staging copy or a site before launch, rather than noindex on every page and one forgotten.

There is no field for a pasted <script>, and there will not be. A spec is data an agent may propose and a form may submit; arbitrary JavaScript in it is a cross-site scripting hole with an approval workflow in front of it. Analytics providers are named — Google, Plausible, Umami — and the snippet is ours.

Extending it

Blocks, actions and availability kinds are registries, and a plugin adds to them. A plugin declares what it provides and what it needs, contributes block types, and never imports the engine, the database or a repository — what it gets is what a block gets, and the whole of its authority is the shape of that function:

import { definePlugin } from '@forinda-cms/plugin'

export default definePlugin({
manifest: {
name: 'acme-bookings',
version: '1.0.0',
api: '1',
provides: { blocks: ['acme-bookings-form'] },
capabilities: { network: ['api.acme.com'] },
},
blocks: [ /* the same shape core registers */ ],
})

Every contribution is namespaced to the plugin, so two plugins cannot collide and none can redefine what form means on a site that installs it. Today a plugin is a dependency plus one line — a code review and a deploy — which is a feature at this stage rather than a missing button: a block’s render is code, and there will be no install-from-the-internet until the isolation behind it is real.

Scripts inside an automation

A script.run step is JavaScript over the values a pipeline has, in a separate process with the filesystem, child processes and native modules denied at the runtime level, and no network, database or credentials in scope. It gets its input and returns a value; two seconds, a fixed heap, and a cap on what it may return. See automations.