Kolbo.AIKolbo.AI Docs
Developer API

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/:id

All 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:

ActionNeeds on the doc's project
Getview or higher
Update (PUT)edit or higher
Share, Deletefull, 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/docs

Request Body

FieldTypeRequiredDescription
titlestringYesDocument title. Must be a non-empty string; trimmed, then truncated to 200 characters rather than rejected.
contentstringNoFull 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_idstringNoProject 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
ParameterTypeRequiredDescription
project_idstringNoRestrict to one project. Must be a valid ObjectId (400 otherwise) and you must be a member (403 otherwise). Also accepted as projectId.
pagenumberNo1-indexed. Default 1.
limitnumberNoResults 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/:id

Returns 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
FieldTypeRequiredDescription
titlestringNo*New title. Must be a non-empty string; truncated to 200 characters.
contentstringNo*Full replacement HTML — replaces the whole document. Read first, apply edits, send the complete result. Same 5 MB cap as create (413).
base_revisionintegerNoThe 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_idstringNoYour 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_intentobjectNo{ "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 an incoming_summary of 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
FieldTypeRequiredDescription
sharedbooleanYestrue = publicly shared, false = private (the link stops working). Explicit set, not a toggle — a non-boolean returns 400.
editablebooleanNoAllow 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/:id

Soft 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

StatusCause
400title 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.
403On 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.
404The 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).
409Update 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.
413DOCUMENT_TOO_LARGE — content over 5 MB on create or update. The body carries max_bytes and received_bytes.
428Update 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 user

create_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.