Content as code
Everything you can do to a content model in the editor, you can also do over HTTP: create Contracts and Templates, write Components, build the Experience tree, upload media and publish. This page covers the Content Management API, which is how a script, a CI job or an AI agent does that work.
It needs an ebitex organization on the Pro plan or above, which a trial counts towards. Set up your organization covers signing up.
All routes on this page are under one prefix:
https://api.ebitex.io/content/management/v1Minting a management key
Section titled “Minting a management key”Every request carries a management key as a bearer token:
Authorization: Bearer frm_live_…Create one in Content under Settings → API Keys, in the Management keys section. Management keys need the Pro plan or above. That is checked when the key is created and again on every request, so a key stops working if the organization moves to a lower plan. An organization on a Pro trial can create them straight away.
A key is defined by four choices.
The environment it is bound to. A key works in exactly one authoring environment: whichever one is selected in the environment switcher at the top of Content when you create it. There is no header for picking an environment per request, so which environment you write to depends only on which key you use. To run the same pipeline against staging and production, mint two keys. A staging key cannot write to production, even by mistake.
The role it acts as. A key has the permissions of an organization role, chosen under Acts as role. Folder and page access rules and approval workflows apply to it exactly as they would to a person with that role. A key whose role cannot edit a folder cannot edit that folder, and content that a workflow blocks cannot be published by a script any more than by a person. You cannot give a key a role that has permissions you do not hold yourself.
Its permissions, or scopes. These decide which kinds of request the key may make:
| Permission | Scope | Lets the key |
|---|---|---|
| Read | content.management.read |
List and read content, export bundles, plan imports, look up blobs, and check job status |
| Write | content.management.write |
Import bundles and upload blobs |
| Publish | content.management.publish |
Plan and run publishes |
| Authoring | content.management.authoring |
Create, update and delete individual items: Contracts, Templates, Components, folders, pages, Adapters, streams, audiences and categories |
Scopes and the role are separate checks, and a request has to pass both. The scope decides which routes the key may call, and the role decides which content it may touch. A key with Authoring whose role grants no Content permissions authenticates successfully, and every write it makes is then refused.
Authoring is the only permission that can delete anything. An import only adds or updates items and never removes one.
A key with only Read cannot change anything, which makes it safe for a pull-request check that
reports what an import would change. A publishing key usually needs Read as well, because
checking on a publish job (GET /operations/{id}) is a read.
IP ranges. Turn on Restrict to IP ranges and list one range per line, as CIDR blocks or
single addresses. Requests from anywhere else are refused with 403 ip_denied. Do this whenever
your build server has a fixed address.
Expiry. Choose how long the key should last: 30 days, 90 days, a year, or never. It defaults to
90 days. An expired key stops working the same way a revoked one does, answering
401 invalid_api_key — so if a pipeline that has been running for months suddenly cannot
authenticate, check the key’s expiry before anything else. There is no way to extend a key: mint a
new one, which also gives you a fresh secret, and revoke the old.
The key is shown once, when you create it. Store it in your CI system’s secret store. Revoke one from the same section when you no longer need it, and it stops working on the next request. Deleting the role a key acts as also stops the key working.
Check what a key holds
Section titled “Check what a key holds”A key is an opaque string. Before you act on a key you did not mint yourself, ask the API what it
is. GET /whoami works with any valid key, whatever its scopes:
curl -s -H "Authorization: Bearer $EBITEX_CONTENT_MANAGEMENT_KEY" \ https://api.ebitex.io/content/management/v1/whoami{ "organizationId": "…", "organizationName": "Acme", "organizationSlug": "acme", "environmentId": "…", "environmentName": "Staging", "roleId": "…", "roleName": "Editor", "keyId": "…", "keyName": "Marketing site CI", "scopes": ["content.management.publish", "content.management.read"]}If environmentName is not the environment you expected, stop: nothing else about a request will
tell you where it is about to write. GET /topology
(Read) goes one step further. It lists the delivery environments a publish from this environment
lands in, the environments this one promotes to and is promoted from, and the environments it
inherits from and is inherited by. Content does not record which environment is production, so
judge that from the names.
Two ways to write
Section titled “Two ways to write”The API offers two ways to change content, and both are fully supported. Choose per task.
Per-entity requests create, update or delete one item at a time. They are the same operations the editor itself uses, so every rule the editor enforces applies. Examples: create a Contract, create a Component, update a Component, create a page node and set its payload. These need the Authoring permission to write, and Read to read.
Transfer bundles move a whole set of items in one request. You export a bundle, edit it or generate it, plan the import to see what would change, then import it. An import is one transaction. The bundle is the same document the editor’s Transfer tool exports.
| Per-entity requests | Bundles | |
|---|---|---|
| The model is | being discovered | already known |
| Runs are | exploratory, one change at a time | repeated, converging on the same result |
| You get | fast feedback, one refused document at a time, partial progress | atomicity, a dry run, detection of edits made in the editor |
| Typical caller | an interactive tool, an AI agent | a build pipeline that keeps the model in source control |
| Deletes | yes, with Authoring | never |
Both validate documents against their Contracts the same way the editor does. They report failures differently. A per-entity write refuses one document and names its failing fields. An import that contains invalid documents writes nothing, and its error lists every invalid item with its failing fields, so you can fix them all and resubmit once.
To move content between two environments, use two keys: export with the source environment’s key and import with the target environment’s key. A single key can only ever act on its own environment.
Per-entity requests
Section titled “Per-entity requests”Before creating anything, ask the server what exists. The shape of your requests comes from these responses, so read them rather than guessing:
GET /field-typeslists every field type with a JSON Schema for its settings.GET /contractsandGET /templatesshow what this environment already has. Check them first, because a duplicate is harder to undo than to avoid.
Build in dependency order:
- Contracts, parents before children.
- Templates.
- Components.
- A site if the environment has none, then nodes under it.
- Node payloads.
A refusal names the failing field and what is wrong with it. Correct that one field and send the request again.
A few routes do not follow the usual create/update pattern:
- An Adapter is updated with
PATCH, notPUT. - A page node and its payload are separate:
PUT /experience/nodes/{id}changes a node’s name, slug and location, andPUT /experience/nodes/{id}/payloadchanges what the page renders. - A site is created with
POST /experience/sites, not as a node.
A delete is refused while anything still uses the item, and the refusal names what depends on it. A published Component or node cannot be deleted through the API. Take it down in the editor first, because the API has no unpublish operation.
Bundles: the plan and import loop
Section titled “Bundles: the plan and import loop”This is the loop for a content model kept in source control.
First, adopt the environment’s current state:
KEY="$EBITEX_CONTENT_MANAGEMENT_KEY"API=https://api.ebitex.io/content/management/v1
curl -s -X POST -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \ -d '{"wholeEnvironment": true}' "$API/export" > bundle.jsonTo export only part of an environment, send roots instead: a list of { "kind", "id" }. Each root
brings its dependencies with it, but a page node does not bring its child pages. Name every node,
stream and standalone Component you want as a root of its own.
Then, on every run, plan and import. Planning needs only Read and writes nothing:
curl -s -X POST -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \ -d "{\"identityMode\":\"externalId\",\"bundle\":$(cat bundle.json),\"baseTokens\":$(cat state.json)}" \ "$API/import-plan"identityMode says how the bundle’s items are matched to what the environment already holds:
| Mode | Matches on | Use it when |
|---|---|---|
externalId (the default) |
Each item’s own natural key: its external id, or for a category, folder or node, its key or name within its parent | The model lives in source control, and must converge onto whatever the target already has under the same names, including items someone created by hand in the editor |
preserve |
The bundle’s ids | Moving content between environments you control |
fresh |
Nothing; every item is new | Copying content into a different organization |
Each plan item looks like this:
{ "kind": "contract", "id": "…", "externalId": "article", "naturalKey": "contract:article", "name": "Article", "implicit": false, "state": "update", "stateToken": "…"}The plan also carries blockers, which would make the import refuse.
Base tokens
Section titled “Base tokens”state is new, identical, update or conflict. When the bundle and the environment differ,
the difference has one of two causes: your bundle changed, or somebody edited the environment. The
server cannot tell these apart by comparing the two, so you supply the evidence.
Every plan item carries a stateToken, a hash of that item’s current state in the environment.
After a successful import, record each item’s stateToken under its naturalKey, and send the map
back as baseTokens next time:
{ "contract:article": "…", "category:topics/coffee": "…", "node:Main site/about": "…"}| Bundle compared with the environment | baseTokens entry |
state |
|---|---|---|
| equal | any | identical |
| differs | matches the item’s current token | update: only your bundle moved |
| differs | does not match | conflict: the environment changed too |
| differs | absent | conflict: no evidence, so the server assumes the worse case |
That last row is why the token file matters. Without base tokens every difference is reported as a conflict on every run, and you end up confirming overwrites by habit, until the check no longer protects anything. Commit the token file alongside the bundle, so every machine that runs the import works from the same evidence.
Use the naturalKey strings exactly as the server reports them; do not construct them yourself.
Categories, folders and nodes have no external id, so their natural keys are paths, and the server
controls how those paths are spelled and escaped. GET /items
also reports every item’s naturalKey, without exporting anything.
Values are compared as JSON, so object key order does not matter, and an omitted optional member
equals an explicit null. The constraint lists allowedTemplateIds, allowedContractIds,
allowedModes and allowedCategoryGroupIds, and a stream’s sourceContractIds, are compared as
sets. Order matters in every other array, including a Contract’s field list and an allowedValues
list.
A Presentation value records a templateVersion, and an inline value records a contractVersion.
A bundle written by hand cannot know either number, so write 0. It means “the latest version this
import produces”, and the plan resolves it the same way the import does.
Import
Section titled “Import”Send the same bundle, identityMode and baseTokens, plus items, which selects what is written:
{ "identityMode": "externalId", "bundle": { "formatVersion": "…" }, "baseTokens": { "contract:article": "…" }, "items": [ { "kind": "contract", "id": "…" }, { "kind": "component", "id": "…", "confirmOverwrite": true } ]}- Name every item the plan did not call
identical. An item the plan callsnewmust be named, because nothing else would create it; leaving one out is400 required_item_omitted. - An item planned as
conflictis written only withconfirmOverwrite: true, which means “overwrite what is there”. Read the conflict list before you confirm anything: it is how the API tells you somebody has been editing in the environment. - The server plans again itself. It never trusts the plan you saw earlier.
A plan describes the environment at the moment it ran. Plan immediately before each import, and plan
again after any other write to the same environment, including your own per-entity writes. An item
you changed per-entity reports conflict against tokens recorded before that change.
A clean plan does not guarantee a valid import. The plan does not validate documents; the import does.
Publishing
Section titled “Publishing”Importing or writing content changes the draft. Publishing makes it live, and takes two jobs.
1. Plan. POST /publish-plan takes the Components and
page nodes you want to publish:
curl -s -X POST -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \ -d '{"roots":[{"kind":"node","id":"…"}]}' "$API/publish-plan"{ "id": "…", "status": "queued" }If this environment publishes to more than one delivery environment, add deliveryEnvironmentId.
GET /topology lists them.
2. Poll. GET /operations/{id} with the returned id,
until status is succeeded or failed:
curl -s -H "Authorization: Bearer $KEY" "$API/operations/<id>"A succeeded plan job’s result holds the plan. The plan covers everything your roots depend on,
not only the roots, and reports each item’s publish status, validation errors and workflow state,
including whether a workflow blocks it. Read it before publishing: it is the only place the API tells
you what will change beyond what you named.
3. Publish. POST /publish takes the items to
publish, built from the plan:
{ "items": [ { "kind": "node", "id": "…", "versionNumber": 7, "include": true }, { "kind": "component", "id": "…", "versionNumber": 3, "include": true } ]}- Set
versionNumberto the plan item’sactiveRunPinnedVersionNumberwhen it has one, and to itsheadVersionNumberotherwise. - Set
include: trueon every item. An item without it is skipped, and a job that skips every item still reportssucceededafter publishing nothing. - Send only these four properties. Passing plan items through unchanged is refused with
400 unknown_property, because plan items carry extra members. - Send the same
deliveryEnvironmentIdthe plan used.
This returns another job id to poll. The job publishes items in order and stops at the first one it
cannot publish. Items before it stay published, and the failed job’s error names the item and the
reason.
A bundle carries blob ids, never file bytes, so upload media separately. Ask first which files the
organization already holds. GET /blobs takes up to 100
SHA-256 digests and needs only Read:
HASH=$(sha256sum hero.png | cut -d' ' -f1)curl -s -H "Authorization: Bearer $KEY" "$API/blobs?hash=$HASH"{ "blobs": [{ "hash": "…", "blobId": "…", "contentType": "image/png", "sizeBytes": 88213 }] }A digest that is missing from blobs has no stored file. That is the answer, not an error. Upload
the missing files with POST /blobs, sending the raw bytes
as the body with the file’s media type (Write):
curl -s -X POST -H "Authorization: Bearer $KEY" -H 'Content-Type: image/png' \ --data-binary @hero.png "$API/blobs"The response, { "blobId", "contentType", "sizeBytes" }, is what a media field stores. Storage is
content-addressed, so uploading bytes the organization already holds stores nothing new and returns
the existing id. Looking files up first is what lets a Read-only key plan a model that carries media.
Limits
Section titled “Limits”Requests are limited per organization per minute, and writes per day. Imports, publishes and blob
uploads count against one daily limit, and per-entity writes against a second, larger one: one
import of a whole model is one write, while building the same model item by item is one write per
item. Reads are limited only per minute. A request over a limit is refused with
429 quota_exceeded, and the response names the limit.
Every change a key makes is recorded in the organization’s activity log and in each item’s version history under the key’s name, never a person’s.
AI agents: @ebitex/content-mcp
Section titled “AI agents: @ebitex/content-mcp”@ebitex/content-mcp is an MCP server that gives an AI agent the
per-entity API as a small set of tools. It runs on your own machine over stdio, so your key is never
sent anywhere except the Content Management API.
Add it to your MCP client’s configuration. For Claude Desktop that is claude_desktop_config.json.
For Claude Code, use a project .mcp.json or claude mcp add. Other clients take the same
command, args and env values:
{ "mcpServers": { "ebitex-content": { "command": "npx", "args": ["-y", "@ebitex/content-mcp"], "env": { "EBITEX_CONTENT_MANAGEMENT_KEY": "frm_live_…" } } }}| Variable | |
|---|---|
EBITEX_CONTENT_MANAGEMENT_KEY |
Required. The management key the agent acts as |
EBITEX_CONTENT_API_BASE |
The API origin. Defaults to https://api.ebitex.io |
EBITEX_CONTENT_MCP_READ_ONLY |
Set to 1 to hide every write tool, whatever the key’s scopes allow |
EBITEX_CONTENT_TARGET_MANAGEMENT_KEY |
Optional. A second key, bound to the environment content_transfer imports into |
Keep the key out of any configuration file you commit.
At startup the server calls GET /whoami and prints the organization, environment, role and
scopes it resolved to stderr. Every write result also names where it landed. If the environment is
not the one you expected, stop the agent.
The tools the agent sees depend on the key’s scopes. A tool the key cannot use is not offered at all, rather than offered and then refused:
| Tool | Offered with | Does |
|---|---|---|
content_describe |
Read | Lists every field type with its settings schema, plus the environment’s Contracts and Templates |
content_find |
Read | Finds items by kind, or Components by search text or external id |
content_get |
Read | Reads one item in full |
content_topology |
Read | Reports where the environment sits: where a publish lands, and its promotion and inheritance neighbours |
content_operation_status |
Read | Polls a publish job |
content_write |
Authoring | Creates or updates one item |
content_delete |
Authoring | Deletes one item; the agent must pass confirm: true |
content_publish |
Publish | Plans a publish, then publishes the plan’s items |
content_transfer |
Write | Exports a bundle, and with a target key configured, imports it into the target environment. identityMode takes the same three values the HTTP API does: externalId (the default), preserve, fresh |
content_write passes the agent’s payload to the API as-is; the tool itself does not describe what
belongs in it. The agent learns each Contract’s shape from content_describe, and the API
validates what arrives. A refusal reaches the agent whole, with every failing field path, so it can
correct the one field named and try again.
The server also offers the Content documentation as MCP resources. Point the agent at
ebitex-content://help/agent-authoring before it builds a model: it explains what Contracts,
Templates, Presentations, Adapters and the Experience tree are for, and the order to create them in.
Mint the agent’s key against a non-production authoring environment, give it a role with no more access than the job needs, and leave Authoring off unless the agent has to write. The API has no unpublish, so an agent can publish content that it cannot then take down.
Embedding the server
Section titled “Embedding the server”The ebitex-content-mcp executable is the usual way to run the server. If your host already owns an
MCP transport, build the server yourself and connect it:
// Embedding the server in a host that owns its own transport. This file is typechecked against the// published package, so the imports it shows are the ones the package's `exports` map actually// serves.//// The ordinary way to use this package is the `ebitex-content-mcp` executable configured in an MCP// client; this entry exists for a host that already owns a transport.
import { buildServer, ContentApiError, readConfig, WRITABLE_KINDS } from '@ebitex/content-mcp'
export async function start(): Promise<void> { // Throws ConfigError naming the missing variable rather than failing later on a 401. const config = readConfig()
const { server, session, banner } = await buildServer({ config })
// Never stdout: on a stdio transport that stream is the protocol itself. console.error(banner) console.error(`writing into ${session.who.environmentName ?? session.who.environmentId}`)
// Connect `server` to whatever transport the host owns. void server}
export function kinds(): readonly string[] { return WRITABLE_KINDS}
export function describe(error: unknown): string { // A refusal carries the API's own body whole — the field paths a corrected retry needs are in // `message`, unmodified. return error instanceof ContentApiError ? `${error.status} ${error.code ?? ''}: ${error.message}` : String(error)}