AI Docs (Magic Pad)
Create, edit, and share project-scoped documents — the same docs users see in the Kolbo app's Magic Pad editor.
AI Docs are HTML documents that live inside a project, editable in the Kolbo app's Magic Pad editor. Through the API you (or an LLM driving the MCP) author the document content directly — production plans, briefs, scripts, research summaries — and the user picks it up in the app, fully formatted and editable.
Content is HTML and is sanitized server-side against a tag allow-list before it is stored. Stick to semantic elements the editor understands: <h1>–<h6>, <p>, <br>, <ul>/<ol>/<li>, <table> (with <thead>/<tbody>/<tfoot>/<tr>/<th>/<td>/<colgroup>/<col>), <blockquote>, <code>/<pre>, <strong>/<b>, <em>/<i>, <u>, <s>/<strike>/<del>, <mark>, <sub>/<sup>, <small>, <a>, <img>, <video>/<audio>/<source>, <hr>, <div>/<span>, <figure>/<figcaption>, <details>/<summary>, <label>.
What the sanitizer actually does: <script> and <style> tags, on* event handlers, and
javascript: URLs are removed. Inline style attributes are kept for a fixed list of safe CSS
properties (colors, alignment, font, spacing, borders, sizing). <iframe> is kept but only for
a fixed embed allow-list (YouTube and youtube-nocookie, Vimeo, Spotify, SoundCloud, CodePen,
CodeSandbox, Google Maps, Figma, Loom) over https. <input> survives only as a disabled
checkbox, for task lists. Every <a> is rewritten with rel="noopener noreferrer", and its href
may only be http, https, or mailto. <img src> additionally allows data: URIs, so small
inline images survive. <video>, <audio> and <source> keep http/https sources (with
controls, preload, title, plus poster/width/height/playsinline on video), so embedded
project clips survive. Protocol-relative (//host/…) URLs are stripped everywhere.
Endpoint
POST /api/v1/docs
GET /api/v1/docs
GET /api/v1/docs/:id
PUT /api/v1/docs/:id
PATCH /api/v1/docs/:id/share
DELETE /api/v1/docs/:idAll six routes are limited to 120 requests per minute, keyed by authenticated user — several API keys on one account share the counter, and it is the same bucket as the media-library routes (see Errors & Limits). Requests that fail (4xx/5xx) do not count against it.
Access to a doc is decided by your permission on the doc's project, not by who wrote it:
| Action | Needs on the doc's project |
|---|---|
| Get | view or higher |
Update (PUT) | edit or higher |
| Share, Delete | full, or project owner |
So a teammate with edit can rewrite your doc but cannot share or delete it — and neither can you, if you only have edit on a project someone else owns.
On the four /docs/:id routes, a malformed id returns 400 with "Invalid doc id." rather than 404.
Create a Doc
POST /api/v1/docsRequest Body
| Field | Type | Required | Description |
|---|---|---|---|
title | string | Yes | Document title. Must be a non-empty string; trimmed, then truncated to 200 characters rather than rejected. |
content | string | No | Full HTML body of the document. Must be a string when present (400 otherwise). Max 5 MB (5,242,880 bytes, UTF-8, measured before sanitizing) — larger returns 413 DOCUMENT_TOO_LARGE. Omitted → the doc is created empty. |
project_id | string | No | Project to create the doc in — needs edit permission or higher (see Projects). An invalid id returns 400 SDK_PROJECT_INVALID_ID; one you cannot write to returns 403 SDK_PROJECT_ACCESS_DENIED or 404 SDK_PROJECT_NOT_FOUND. Omitted → your auto-created "API Generations" project, created on the spot if it does not exist yet. |
Any other field in the body is ignored. Creating a doc also bumps the parent project's updated-at timestamp so it surfaces at the top of the project list — that bump is debounced to once per 30 seconds per project per server process, so a burst of creates only moves it once. Updating a doc does not bump it.
curl -X POST https://api.kolbo.ai/api/v1/docs \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Production Plan",
"content": "<h1>Production Plan</h1><p>Shot list and schedule…</p>",
"project_id": "65f1c8a2e4b0a3c1d9f5e123"
}'Response (201):
{
"success": true,
"doc": {
"id": "6a53c31f49989427bdaf199b",
"title": "Production Plan",
"project_id": "65f1c8a2e4b0a3c1d9f5e123",
"is_shared": false,
"is_editable_by_link": false,
"share_url": null,
"revision": 0,
"content_hash": "9f2c4e1b7a0d3c5e8f6a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6a",
"saved_at": "2026-07-12T16:38:55.871Z",
"created_at": "2026-07-12T16:38:55.871Z",
"updated_at": "2026-07-12T16:38:55.871Z"
}
}Every doc response uses this shape. revision starts at 0 and goes up by one on every content write; content_hash is the SHA-256 hex of the stored (sanitized) HTML; saved_at is when the content was last saved. Pass revision back as base_revision on update (see below).
List Docs
GET /api/v1/docs| Parameter | Type | Required | Description |
|---|---|---|---|
project_id | string | No | Restrict to one project. Must be a valid ObjectId (400 otherwise) and you must be a member (403 otherwise). Also accepted as projectId. |
page | number | No | 1-indexed. Default 1. |
limit | number | No | Results per page. Default 20, max 50. |
Returns metadata only (no content), most recently updated first, with pagination: { page, limit, total, pages }.
Scope depends on project_id. With project_id, you get every member's docs in that shared
project, and each row carries owner ({ userId, name, avatar }) plus is_mine. Without it, you
get only your own docs across all projects — a teammate's doc will not appear.
Get a Doc
GET /api/v1/docs/:idReturns the doc metadata plus its full HTML content, and always includes owner ({ userId, name, avatar }) / is_mine. Readable by any member of the doc's project (view or higher). An id you cannot access returns 404 (existence is never leaked).
Update a Doc
PUT /api/v1/docs/:id| Field | Type | Required | Description |
|---|---|---|---|
title | string | No* | New title. Must be a non-empty string; truncated to 200 characters. |
content | string | No* | Full replacement HTML — replaces the whole document. Read first, apply edits, send the complete result. Same 5 MB cap as create (413). |
base_revision | integer | No | The revision you read. The write only lands if the doc is still at that revision; otherwise 409 CONCURRENT_EDIT_CONFLICT. Also accepted as baseRevision. Content updates only. |
mutation_id | string | No | Your idempotency key for this write, at least 8 characters. While it is still the doc's most recent write, retrying with the same mutation_id and the same content returns 200 with acknowledgement.idempotent: true and writes nothing; reusing it for different content is 409 MUTATION_ID_REUSE. Also accepted as mutationId. Content updates only. |
destructive_intent | object | No | { "kind": "replace-document" } (also "ai-apply", "restore", "clear-document"). Needed for an update that wipes most of the document — see below. Also accepted as destructiveIntent. |
* At least one of title / content must be present, otherwise 400. A title that is present but empty or non-string is 400; a non-string content is 400.
Writable by project members with edit permission or higher. A doc you cannot write to returns 404, not 403 — existence is never leaked. The response is the metadata shape; it does not echo content back.
Change title and content in separate requests. A PUT that carries content together with a
title different from the current one is rejected with 409 MUTATION_ID_REUSE, even with a fresh
mutation_id or no concurrency fields at all. Sending the unchanged title alongside content is fine.
Concurrency. Send base_revision and mutation_id together, or neither. With neither, the server runs a last-write-wins update against the current revision (this is what the MCP update_doc tool does). Sending only one, a negative or non-integer base_revision, or a mutation_id shorter than 8 characters returns 428 DOCUMENT_REVISION_REQUIRED with server_revision and server_content_hash. Every 409 also carries server_revision / server_content_hash, so you can re-read and retry.
Destructive-change guard. Without destructive_intent, a content update is rejected with 409 when it:
- empties a document that had content (
DOCUMENT_CLEAR_REQUIRES_INTENT), or - cuts a document of 200+ characters of text to fewer than a quarter of that (or fewer than 40 characters, whichever is larger), or removes all of its tables, images, or video/audio/iframe embeds (
DESTRUCTIVE_DOCUMENT_REPLACEMENT, with anincoming_summaryof what you sent).
replace-document, ai-apply and restore allow both. clear-document allows emptying any document completely; a partial rewrite that trips the second rule still needs replace-document.
A content write returns an acknowledgement next to doc and records an autosave checkpoint in the doc's version history in the app (writes less than a minute apart update the same checkpoint). A title-only update does not bump revision and returns no acknowledgement.
curl -X PUT https://api.kolbo.ai/api/v1/docs/6a53c31f49989427bdaf199b \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"content": "<h1>Production Plan</h1><p>Updated shot list…</p>",
"base_revision": 0,
"mutation_id": "plan-edit-0001"
}'{
"success": true,
"doc": {
"id": "6a53c31f49989427bdaf199b",
"title": "Production Plan",
"project_id": "65f1c8a2e4b0a3c1d9f5e123",
"is_shared": false,
"is_editable_by_link": false,
"share_url": null,
"revision": 1,
"content_hash": "4b1d7c0e9a8f6e5d4c3b2a19087f6e5d4c3b2a19087f6e5d4c3b2a19087f6e5d",
"saved_at": "2026-07-12T16:52:10.004Z",
"created_at": "2026-07-12T16:38:55.871Z",
"updated_at": "2026-07-12T16:52:10.004Z"
},
"acknowledgement": {
"revision": 1,
"content_hash": "4b1d7c0e9a8f6e5d4c3b2a19087f6e5d4c3b2a19087f6e5d4c3b2a19087f6e5d",
"saved_at": "2026-07-12T16:52:10.004Z",
"mutation_id": "plan-edit-0001"
}
}Updating a doc does not move it between projects. There is no route for that — create the doc in the right project, or move it in the app.
Share a Doc
PATCH /api/v1/docs/:id/share| Field | Type | Required | Description |
|---|---|---|---|
shared | boolean | Yes | true = publicly shared, false = private (the link stops working). Explicit set, not a toggle — a non-boolean returns 400. |
editable | boolean | No | Allow link visitors to edit the doc. Must be a boolean when present (400 otherwise). Omitted → unchanged (false on a new share). Ignored when shared is false. |
Requires full permission on the doc's project (or project ownership) — edit is not enough. Sharing an already-shared doc keeps its token, so the URL stays the same and you can flip editable freely.
curl -X PATCH https://api.kolbo.ai/api/v1/docs/6a53c31f49989427bdaf199b/share \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "shared": true }'{
"success": true,
"doc": {
"id": "6a53c31f49989427bdaf199b",
"title": "Production Plan",
"project_id": "65f1c8a2e4b0a3c1d9f5e123",
"is_shared": true,
"is_editable_by_link": false,
"share_url": "https://app.kolbo.ai/shared/magicpad/6d5f837497f742393cb766b0c59f728a?utm_source=sdk&utm_medium=shared_link&utm_campaign=sdk_docs&utm_content=shared_magicpad&attribution_link_id=sdk_doc_shared_magicpad&attribution_trigger=sdk_document_shared",
"revision": 1,
"content_hash": "4b1d7c0e9a8f6e5d4c3b2a19087f6e5d4c3b2a19087f6e5d4c3b2a19087f6e5d",
"saved_at": "2026-07-12T16:52:10.004Z",
"created_at": "2026-07-12T16:38:55.871Z",
"updated_at": "2026-07-12T17:04:02.118Z"
}
}share_url carries attribution query parameters; hand it out as-is.
share_url is only populated while is_shared is true, and only the share route returns it —
create, list, get and update always report share_url: null. Setting shared: false discards
the token and resets editable to false: the old link stops working permanently, and sharing
again issues a new URL.
Delete a Doc
DELETE /api/v1/docs/:idSoft delete. Requires full permission on the doc's project (or project ownership) — members with only edit get 404, even for a doc they wrote. Deleting also turns off any public share link. Deleting an already-deleted doc returns 404.
{ "success": true, "doc": { "id": "6a53c31f49989427bdaf199b" } }The response echoes only the id; there is no undelete route in the API.
Errors
| Status | Cause |
|---|---|
| 400 | title missing or empty on create, or present but empty / non-string on update; neither title nor content on update; content not a string; shared or editable not a boolean; malformed doc id or project_id. |
| 403 | On create, project_id names a project you can only view (SDK_PROJECT_ACCESS_DENIED). On list, project_id names a project you are not a member of — the list route answers 403 here, while create answers 404. |
| 404 | The doc does not exist, is soft-deleted, or you lack the required access — read needs view, update needs edit, share and delete need full or project ownership. Also returned by create when project_id is a project you cannot see at all (SDK_PROJECT_NOT_FOUND). |
| 409 | Update only: CONCURRENT_EDIT_CONFLICT (stale base_revision), MUTATION_ID_REUSE (also returned when one request changes both title and content), DOCUMENT_CLEAR_REQUIRES_INTENT or DESTRUCTIVE_DOCUMENT_REPLACEMENT. |
| 413 | DOCUMENT_TOO_LARGE — content over 5 MB on create or update. The body carries max_bytes and received_bytes. |
| 428 | Update only: DOCUMENT_REVISION_REQUIRED — base_revision / mutation_id incomplete or invalid. |
MCP Tools
The matching @kolbo/mcp tools are create_doc, list_docs, get_doc, update_doc, share_doc, and delete_doc. The intended flow for an LLM:
1. (user names a project?) list_projects -> capture id
2. create_doc { title, content: "<full HTML you authored>", project_id }
3. (user wants a link?) share_doc { doc_id, shared: true } -> give share_url to the usercreate_doc makes content required, which is stricter than the HTTP route — over MCP you
cannot create an empty doc. Its inline guidance also tells the model that <iframe> is stripped;
in reality the sanitizer keeps allow-listed https embeds, so a YouTube or Figma embed you write
will survive. update_doc takes only doc_id, title and content, so it always runs
last-write-wins and cannot pass destructive_intent — an MCP rewrite that guts a long document is
rejected with 409 and has to go through the HTTP route instead. Changing the title and the content in
one update_doc call also fails with 409, so make two calls.