Extending Nibble
The management API and MCP
How apps, scripts and AI agents change a site's content as a person — the endpoints, signing in, operations, errors, and the rules that keep them safe.
The content API serves published content to other websites. This API is the other
half: it changes content, always as a person, through the same checks as the Control Plane. The nibble command and
AI apps use it; so can anything you build.
After reading this guide, you will know:
- The two ways in: HTTP and MCP.
- How an app signs in.
- The operations, and the rules every change follows.
- What errors look like, and how to act on them.
- How a change waits for a person, and how to follow it.
Agent access must be turned on; until then these endpoints answer 404.
1. Two ways in
| Endpoint | For |
|---|---|
POST /mcp |
MCP clients — Claude, ChatGPT, Codex, Cursor. Stateless JSON-RPC over HTTP; each operation is a tool |
GET /api/v1/operations |
the operations this token may use, with their arguments as JSON Schema |
GET /api/v1/operations/<operation> |
an operation that only reads; arguments in the query string |
POST /api/v1/operations/<operation> |
any operation; arguments as a JSON body |
PUT /api/v1/uploads |
a file’s bytes, held for upload_asset |
Both are built from one list of operations, so they never disagree. Answers are never cached. Every /api/v1/operations answer,
refusals included, carries Nibble-Api-Version: the management API’s contract number. It changes only when a client
written for the old number would misread the new answers; a new operation or argument doesn’t change it, because
clients read those from the list. The nibble CLI checks it on every answer and says whether the CLI or the site needs
upgrading.
2. Signing in
The site is its own OAuth authorization server, for public clients only:
- Discovery:
/.well-known/oauth-protected-resource/mcp(or/api/v1) names the site as the authorization server;/.well-known/oauth-authorization-serverdescribes it. - Authorization code with PKCE (S256), the only flow a browser takes. Loopback redirects may use any port.
- Clients identify themselves with a client metadata document (an
httpsURL asclient_id), register dynamically, or — thenibblecommand — are built in. - Tokens: an access token lasts an hour; a refresh token rotates on every use, and reusing an old one disconnects the app. Tokens are bound to the endpoint they were issued for: an MCP token doesn’t open the operations endpoints.
- Device code, only if the site allows it.
Send the token in the Authorization header. A token in a URL is refused; the one address that carries its own
permission is create_upload’s, and it can only send files.
3. Operations
| Group | Operations |
|---|---|
| Site | whoami, describe_site, describe_schema, get_guide, get_approval |
| Entries | list_entries, get_entry, create_entry, update_entry, transition_entry, list_revisions |
| Terms | list_terms, get_term, create_term, update_term, transition_term |
| Globals and navigation | get_global, update_global, get_navigation, update_navigation |
| Assets | list_assets, get_asset, create_upload, upload_asset, update_asset, transition_asset |
| Forms | list_form_submissions |
list_form_submissions gives a form’s submissions, newest first and spam left out. since (an ISO 8601 date or
date-time) keeps those made at or after it, and q those whose values contain the text, ignoring case, as the
Control Plane’s submissions search finds them: values in encrypted fields aren’t searched.
A token sees only the operations it may use. There is no search operation: search belongs to a theme’s search page.
GET /api/v1/operations also lists, under unavailable, the operations this token can’t use and what each needs, in
the words of the Agent access page, so an app can tell its person what to change:
{ "data": [ … ],
"unavailable": [ { "name": "upload_asset", "title": "Upload an asset", "needs": "Assets: Write" } ],
"meta": { … } }
The MCP tool list has only the tools the connection can call.
Sending files
upload_asset takes the file in exactly one of three ways:
upload: the id of a file sent toPUT /api/v1/uploads. The way to send a file on disk.url: anhttporhttpsaddress the site fetches, with the checks every outbound request has:outbound.allowed_hosts, no private or reserved addresses, and the site’s upload size limit.filenamenames the file when the address doesn’t.content_base64withfilename: the file inside the request. Only for small files: base64 is a third larger, and a request waiting for approval holds the whole file.
Send a file’s bytes as the body of PUT /api/v1/uploads?filename=<name>, with Content-Length and the token in
Authorization:
curl -T photo.jpg -H "Authorization: Bearer $TOKEN" "https://example.com/api/v1/uploads?filename=photo.jpg"
{ "data": { "upload": "eyJfcmFpbHMi…", "filename": "photo.jpg", "size": 612034, "expires_at": "2026-10-30T08:10:24Z" },
"meta": { "site": "https://example.com", "api_version": 2 } }
The type and size are checked before anything is kept: an unaccepted type is 415 unsupported_type, a file over
the limit 413 too_large, and a request without Content-Length 411 length_required. A sent file has no address
and is no asset yet. Only the connection that sent it can pass its upload to upload_asset, once, within a day;
the nightly clean-up deletes it after that, unless an approval is still waiting on it.
When upload_asset waits for approval, a url is fetched and content_base64 decoded once, and the approval holds
the file as an upload: the person approves the file that will be made, and nothing is fetched again.
An MCP client never shows its token to the agent, so create_upload (with filename) answers with a url that
carries its own permission for 15 minutes, with method, max_bytes, accepted_types and expires_in. PUT the
file there with no Authorization header: curl -T photo.jpg "<url>".
4. Rules every change follows
- Values follow
describe_schema. Unknown fields and arguments are refused with the names that would work. - Rich text is Markdown, at any depth: a
rich_textfield in a replicator set or a grid row, however deeply nested, is written and read the same way as one at the top. Images are; links may be web, email, phone or site paths, orglobal:<set>.<field>to follow a global field that holds an address, such as[Apply](global:site.apply_url). Content Markdown can’t hold comes back as editor JSON, and nodes sent as nodes are kept as they are. describe_schemalists every set. A replicator’sitemsis aoneOfwith one schema per set:typeas aconst(the set’s handle) beside the set’s fields. A grid’sitemslists its fields. Three levels of nesting are described; deeper ones are plain objects.describe_schemadescribes a menu’s items. For a navigation menu,treeis the JSON Schema of its items:kind, the link’stypeand its target (id,urlorref),title,new_tab,hidden,fields,query,show_from,show_untilandchildren.fieldslists the menu’sitem_fields, andkindslists each custom kind’s fields. Aqueryitem’squeryholdscollection, and optionallyparent,term,sortandlimit(1 to 50); the dates are date-times such as2026-12-01T09:00:00+10:00.describe_sitegives the sametreeandkindsfor every menu it lists.describe_schemacarries each field’s rules:maxLengthfromcharacter_limit,format(email,uri,dateordate-time),minimum,maximumandmultipleOffor integers, andreadOnly. A choice’s values are itsenum, and its labels, when they differ, are listed in itsdescription. Thedescriptionalso says when a field only applies (“Only when hero_kind is video”) or is read-only on a condition; a field that only applies on a condition is never in a row’srequired.- Read-only fields are kept. A value sent for a read-only field is ignored and the rest of the change is saved.
create_entry,update_entry,create_term,update_term,update_globalandupdate_assetanswer with anignoredlist of the fields whose value was kept, as paths such ascodeorrows.0.checksum; it is left out when nothing was ignored. Rows in grids and replicators keep their read-only values byid, so send each row’sidback asget_entrygave it. lock_versionis required to change something. If someone saved it since you read it, the answer islock_conflictwith the currentlock_version, and nothing is overwritten.dry_run: truechecks a change completely and saves nothing. The preview has awarningslist for what is valid but probably a mistake: a replicator row whosetypeisn’t one of its sets, or a rich text imageasset:IDthat doesn’t exist.preview_urlcomes back fromcreate_entryandupdate_entry: the Control Plane preview of the entry’s draft, for the person signed in there.idempotency_keymakes a retry safe: the same key returns the first answer instead of repeating the change.- Entries change as drafts. Publishing is a separate operation.
- Consequential changes wait for a person when an app makes them, unless the person let it make them without asking. See Approvals.
- Nothing is deleted. Trashed records can be restored.
5. Errors
HTTP answers are problem documents; MCP tool results set isError. Both
carry the same fields:
{ "code": "lock_conflict", "message": "someone saved this after you read it",
"hint": "Read it again, reapply your change to the current data, and send the new lock_version.",
"details": { "lock_version": 7 } }
code is stable; hint says what to do next.
Calling an operation the connection can’t use answers forbidden (HTTP 403), naming what it needs:
{ "code": "forbidden", "message": "this connection can't upload an asset: it needs Assets: Write",
"hint": "Change this connection's access under Account → Connected apps, or sign in again.",
"details": { "needs": "Assets: Write" } }
On HTTP the message is the problem document’s detail. What a connection needs is the smallest of its person’s
roles, the access chosen for it and the site’s Agent access settings; see
Agent access.
6. Approvals
Publishing, unpublishing, trashing, and changes that are live at once — terms, globals, navigation, assets — wait for the person an app acts for, unless they connected it to make changes without asking. A script token never waits.
The first answer is approval_required (HTTP 409):
{ "code": "approval_required", "message": "Ada has to approve this before it happens",
"details": { "approval": "appr_…", "approval_url": "https://example.com/cp/approvals/appr_…",
"expires_at": "2026-10-30T09:00:00Z" } }
Show the person approval_url. There is nothing more to send: when they approve, Nibble makes the change, checking
every permission again first. get_approval with the approval id says how it went:
{ "approval": "appr_…", "status": "done", "operation": "transition_entry",
"result": { "id": 42, "status": "published", "…": "…" }, "error": null, "expires_at": "2026-10-30T09:00:00Z" }
status is pending, approved (being made), done with the operation’s usual answer in result, failed with
the error in error, denied or expired. A change interrupted while being made shows as failed with the code
interrupted after ten minutes: it may not have been made, so check the record before asking again. An app sees only
its own approvals.
Sending the same request again while it waits answers approval_required with the same id; it never asks twice.
Sending it with its approval id, or with the idempotency_key it was first sent with, answers from that approval:
the stored result once it is done, the stored error once it has failed, and otherwise:
| Code | Means |
|---|---|
approval_required |
the request is still waiting (sent with its idempotency_key) |
approval_pending |
the approval sent hasn’t been decided yet |
approval_denied |
the person declined it; nothing changed |
approval_expired |
nobody decided in time; send the request without approval or its key to ask again |
approval_mismatch |
the approval sent belongs to another connection or a different request |
All are HTTP 409. Once an approval is finished, the same request sent without its id or key is a new request: it asks again.
7. Telling agents about the site
Agents get a guide to the site — how to work, its content model — as the MCP server’s instructions, as a skill
through MCP’s Skills extension, and from get_guide. It’s written from the schema and from the site’s own notes in
site/agents/*.md, and never from content, so nothing a visitor writes can become an instruction. Put the editorial
rules you’d tell a new writer in site/agents/voice.md.