Projects
Create and manage projects, feed them a knowledge base, and drop SDK generations into a specific project instead of the auto-created "API Generations" bucket.
Everything in Kolbo lives inside a project — sessions, generations, media, and AI Docs are all project-scoped. By default every generation made through the SDK lands in an auto-created project called "API Generations", so every generation endpoint accepts an optional project_id body field that routes the result into the project of your choice.
Endpoint
GET /api/v1/projects
GET /api/v1/projects/:id
GET /api/v1/project/lightweight
POST /api/v1/projects
PUT /api/v1/projects/:id
PUT /api/v1/projects/:id/archive
PUT /api/v1/projects/:id/unarchive
POST /api/v1/projects/:projectId/context/url
POST /api/v1/projects/:projectId/context/text
GET /api/v1/projects/:projectId/context
DELETE /api/v1/projects/:projectId/context/:fileKey
GET /api/v1/projects/:projectId/profile
POST /api/v1/projects/:projectId/profile/regenerate
GET /api/v1/projects/:projectId/assets
POST /api/v1/projects/:projectId/assets/link
PUT /api/v1/projects/:projectId/assets/:assetType/:assetId/note
DELETE /api/v1/projects/:projectId/assets/:assetType/:assetId
PATCH /api/v1/sessions/:sessionId/projectDeletion is intentionally not exposed. Deleting a project cascades a soft-delete across every
session, generation, and media item inside it, so it stays an in-app, human-confirmed action.
archive is the reversible API equivalent.
Listing Projects
The API has no concept of project names — only ObjectIds. Call GET /v1/projects to discover the ID for any project the API key's user can write to (owned, or shared with edit / full / owner permission).
| Parameter | Type | Required | Description |
|---|---|---|---|
search | string | No | Case-insensitive substring match on the project name. Regex-escaped server-side, so user text is safe to pass through. This is the cheap way to resolve one known name. |
page | number | No | 1-indexed page number. Default 1. |
limit | number | No | Results per page, 1–200. Default 50. |
include_archived | string | No | The literal string "true" also returns archived projects. Anything else (including omitted) excludes them. |
curl "https://api.kolbo.ai/api/v1/projects?search=acme" \
-H "X-API-Key: YOUR_API_KEY"Response:
{
"success": true,
"projects": [
{ "id": "65f1c8a2e4b0a3c1d9f5e123", "name": "Acme Campaign", "description": "Q3 launch film. Tone: warm, documentary…", "role": "owner", "is_default": false, "is_archived": false },
{ "id": "65f1c8a2e4b0a3c1d9f5e456", "name": "Shared Brand Kit", "role": "edit", "is_default": false, "is_archived": false },
{ "id": "65f1c8a2e4b0a3c1d9f5e789", "name": "API Generations", "role": "owner", "is_default": true, "is_archived": false }
],
"pagination": { "page": 1, "limit": 50, "total": 3, "returned": 3, "has_more": false }
}| Field | Type | Notes |
|---|---|---|
id | string | Project ObjectId — this is what you pass as project_id. |
name | string | Project name. |
description | string | null | User-written brief, clipped to ~400 characters in the list. Call GET /v1/projects/:id for the full text. |
role | string | owner | full | edit. |
is_default | boolean | true only for the project literally named API Generations that you own — the bucket SDK calls fall into when project_id is omitted. |
is_archived | boolean | true when the project has been archived. Only ever true if you asked for include_archived=true. |
Sorted by last-updated first. Archived projects are hidden by default — same rule as the web app.
Auto-created review projects are still included here (unlike GET /project/lightweight).
GET /project/lightweight
A richer, searchable list that returns the raw in-app project shape. Useful when you need each project's description, archive state, share state, or lastOpenedAt.
| Parameter | Type | Required | Description |
|---|---|---|---|
query | string | No | Case-insensitive name filter. Regex-escaped server-side, so user text is safe to pass through. |
sortBy | string | No | date | created_at | updated_at | name. date and created_at both sort on the creation timestamp. Omitted → unsorted. |
sortDirection | string | No | desc for descending; any other value (including omitted) sorts ascending. |
includeArchived | string | No | The literal string "true" includes archived projects. Anything else excludes them. |
Each row carries _id, name, description, user, owners, createdAt, updatedAt, lastOpenedAt, isShared, isArchived, and sharedUsers — nothing else is projected.
curl "https://api.kolbo.ai/api/v1/project/lightweight?query=acme&sortBy=updated_at&sortDirection=desc" \
-H "X-API-Key: YOUR_API_KEY"This route is a passthrough to the in-app controller, so it returns the app envelope
{ "status": true, "data": [ … ] } — not the { "success": true, "projects": [ … ] } shape of
GET /v1/projects. It also has no pagination, no per-endpoint rate limiter, and auto-created
review projects are hidden. Unlike GET /v1/projects it does include view-only shares, and it
also matches projects that list you in owners. Prefer GET /v1/projects for the id lookup you
need before a generation.
Using project_id
Add project_id to the body of any generation endpoint:
curl -X POST https://api.kolbo.ai/api/v1/generate/image \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "A neon-lit alleyway at midnight",
"project_id": "65f1c8a2e4b0a3c1d9f5e123"
}'The generation will appear in that project in the web app, mixed with sessions created from the UI.
project_id is per-call, not sticky — there is no server-side "current project". Once you are working inside a named project, pass its id on every generation, upload, doc, and chat call; any call that omits it falls back to "API Generations".
Creating a Project
POST /api/v1/projects| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Project name. Must be a non-empty string; trimmed server-side. No length cap on this route. |
description | string | No | Project brief. Must be a string when present. Max 10,000 characters — that ceiling comes from the database schema, so an over-long value fails validation rather than being truncated. Markdown accepted; it feeds the project's AI profile. |
Any other field in the body is discarded — the handler rebuilds the request body from name and description only.
curl -X POST https://api.kolbo.ai/api/v1/projects \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Acme Campaign",
"description": "Q3 launch film. Tone: warm, documentary, handheld."
}'Response:
{
"success": true,
"project": {
"id": "65f1c8a2e4b0a3c1d9f5e123",
"name": "Acme Campaign",
"description": "Q3 launch film. Tone: warm, documentary, handheld.",
"is_archived": false,
"created_at": "2026-07-12T16:38:55.871Z",
"updated_at": "2026-07-12T16:38:55.871Z"
}
}Project creation is limited to 10 requests per minute per user, and your plan's project cap is
enforced — exceeding it returns 403 with code: "PROJECT_LIMIT_EXCEEDED" and a data object
containing current, limit, and upgradeRequired. That response is emitted by the limit
middleware, so it uses the app envelope ("status": false, "message") rather than the SDK's
"success": false / "error" shape.
Get one project
GET /api/v1/projects/:idReturns the full project record, including the unclipped description. Use this before
PUT /v1/projects/:id when you need to edit the brief. Same write-access rule as the list
(owned or shared with edit / full / owner). MCP: get_project.
{
"success": true,
"project": {
"id": "65f1c8a2e4b0a3c1d9f5e123",
"name": "Acme Campaign",
"description": "Q3 launch film. Tone: warm, documentary, handheld.",
"role": "owner",
"is_default": false,
"is_archived": false,
"thumbnail_url": null,
"created_at": "2026-07-12T16:38:55.871Z",
"updated_at": "2026-07-12T16:38:55.871Z"
}
}Updating, Archiving, Unarchiving
PUT /api/v1/projects/:id| Field | Type | Required | Description |
|---|---|---|---|
name | string | No | New name. |
description | string | No | New description — replaces the old one. Max 10,000 characters. |
At least one of name / description must be present, otherwise 400. Any other field in the body is dropped before the update runs. Requires edit permission or higher on the project. Changing the description to a different value schedules a (debounced) rebuild of the project's AI profile; renaming alone does not.
There is no per-endpoint rate limiter on update, archive, or unarchive — only the global 5,000/min ceiling described in Errors & Limits.
PUT /api/v1/projects/:id/archive
PUT /api/v1/projects/:id/unarchiveBoth take an empty body and require full permission or ownership — edit is not enough. Archiving hides the project from the app's active list without deleting anything, and is fully reversible.
All three routes answer with the same { "success": true, "project": { id, name, description, is_archived, created_at, updated_at } } shape as create.
Non-members get 404 on all three routes (existence is never leaked), as does a malformed :id; members with an insufficient permission tier get 403.
Moving a Session Between Projects
If a session ended up in the wrong project (usually the "API Generations" default), move it — together with all of its media library items — instead of regenerating:
PATCH /api/v1/sessions/:sessionId/project| Field | Type | Required | Description |
|---|---|---|---|
project_id | string | Yes | Target project ObjectId. You need edit / full / owner permission on it. Also accepted as newProjectId. |
type | string | No | Session type hint to speed up the lookup. Must be one of the 17 session types listed in Agents & Sessions; an unknown value returns 400. Omit if unsure — all types are probed. |
Works for any session type: generation sessions (the session_id returned on every generation submit), chat conversations, transcription sessions, and so on. You need edit / full / owner permission on both the source and the target project — a member of a shared project can move a teammate's session. Creative Director sessions are the one exception: they own nested child sessions and return 400 UNSUPPORTED_TOOL.
The move is transactional. The session, every generation's project reference, and every media library item linked to the session move together or not at all.
curl -X PATCH https://api.kolbo.ai/api/v1/sessions/65f1c8a2e4b0a3c1d9f5e999/project \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "project_id": "65f1c8a2e4b0a3c1d9f5e123" }'Response:
{
"success": true,
"session": {
"id": "65f1c8a2e4b0a3c1d9f5e999",
"name": "API Image Generation - 2026-07-12",
"project_id": "65f1c8a2e4b0a3c1d9f5e123",
"previous_project_id": "65f1c8a2e4b0a3c1d9f5e789",
"moved_media_count": 3,
"moved_generations_count": 2
}
}moved_media_count is the number of media library items that moved with the session; moved_generations_count is the number of generations whose project reference was rewritten. name is null when the session was never named. Moving a session to the project it is already in is a no-op that returns success with both counts at 0 and project_id === previous_project_id.
A successful move also drops the session from the SDK's daily-session cache, so the next generation of that type does not silently reuse the session you just relocated.
This endpoint is limited to 20 requests per minute, keyed by authenticated user — several API keys on one account share the counter, and it is the same bucket as the bulk media operations (see Errors & Limits).
To move individual media items (rather than a whole session), see the media move endpoints in Media Library: PATCH /v1/media/:id/project, POST /v1/media/bulk/move, and POST /v1/media/folders/:id/move-contents.
To enumerate sessions before moving them, see Agents & Sessions.
Moving Many Sessions at Once
Reorganizing a library one session at a time runs into the 20/min limit fast. Move up to 100 sessions in a single call instead — types may be mixed:
POST /api/v1/sessions/move| Field | Type | Required | Description |
|---|---|---|---|
session_ids | string[] | Yes | Up to 100 session ObjectIds. Mixed types are fine. Also accepted as sessionIds. |
project_id | string | Yes | Target project ObjectId; you need edit+ on it. |
type | string | No | Session type hint, only useful when every id in the batch is the same type. Omit for mixed batches. |
Each session moves in its own transaction, so one that cannot move never rolls back the others:
{
"success": true,
"project_id": "65f1c8a2e4b0a3c1d9f5e123",
"moved_sessions_count": 38,
"moved_generations_count": 204,
"moved_media_count": 511,
"operation_ids": ["65f1a01", "65f1a02"],
"skipped": [
{ "session_id": "65f1b01", "code": "SAME_PROJECT", "message": "That session is already in this project." }
],
"failed": [
{ "session_id": "65f1c01", "code": "GENERATIONS_IN_FLIGHT", "message": "1 generation(s) are still running. Wait for them to finish before moving them." }
]
}skipped holds sessions that were already in the target project (nothing to do). failed holds real problems — a running generation, a session type that cannot be moved, or one you lack access to. Always surface failed to the user: a 200 here does not mean everything moved.
operation_ids carries one id per moved session; see Undoing a Reorganization.
Reorganizing Work Between Sessions
Moving whole sessions between projects is one axis. The other is moving generations between sessions — merging scattered work, or splitting one overgrown session into several.
These three routes are available for image, image-to-video, lipsync and video-to-video sessions. Other types return 400 UNSUPPORTED_TOOL and should be moved whole with the routes above.
Listing a session's generations
GET /api/v1/sessions/:sessionId/generationsReturns each generation as a complete group — its prompt plus every output it produced. A generation is never separable from its own outputs, so you always move whole entries.
{
"success": true,
"session": {
"id": "65f1999",
"name": "Hero shots",
"project_id": "65f1123",
"can_edit": true,
"total_media_count": 12
},
"generations": [
{
"id": "65f1g01",
"prompt": "wide desert shot, golden hour",
"status": "completed",
"in_flight": false,
"created_at": "2026-08-11T09:14:02.000Z",
"output_urls": ["https://media.kolbo.ai/hero-01.png"],
"thumbnail_url": "https://media.kolbo.ai/hero-01.png",
"output_count": 1,
"media_count": 1
}
]
}in_flight: true means the generation is still running and cannot be moved yet.
Moving generations into an existing session
POST /api/v1/sessions/:sessionId/generations/move| Field | Type | Required | Description |
|---|---|---|---|
generation_ids | string[] | Yes | From the listing above. Whole entries only. |
target_session_id | string | Yes | Destination session, same session kind. May live in another project you can edit. |
type | string | No | Session type hint. |
Only the selected generations and their own output media move. Uploads and reference images stay with the source session, so a reference still used by generations left behind is never dragged away.
Splitting a session
POST /api/v1/sessions/:sessionId/split| Field | Type | Required | Description |
|---|---|---|---|
generation_ids | string[] | Yes | Generations to carve out. |
name | string | Yes | Name for the new session (1-120 chars). |
project_id | string | No | Put the new session in a different project. Defaults to the source session's project. |
type | string | No | Session type hint. |
Creates the session and moves the generations into it in one transaction:
{
"success": true,
"operation_id": "65f1a09",
"session": { "id": "65f1new", "name": "Approved takes", "project_id": "65f1123" },
"moved_generations_count": 4,
"moved_media_count": 9
}Undoing a Reorganization
Every move and split returns an operation_id, reversible for 15 minutes:
POST /api/v1/sessions/organize/undo/:operationIdPermissions are re-checked on both projects at undo time — the receipt is not a capability. If the work has moved again since, the undo refuses with 409 UNDO_STATE_CHANGED rather than yanking records out of wherever they now live.
{ "success": true, "operation_id": "65f1a09", "kind": "split", "undone": true }A batch move returns one operation_id per session; call undo once per id you want to reverse.
Project cast (Visual DNAs + moodboards)
A project's cast roster is the Visual DNAs (@Name) and moodboards (#Name)
tagged onto that project. The AI cast list (chat / help / generation context)
is built from this roster: each DNA's identity description plus an optional
project-scoped purpose note.
This does not copy the asset. Linking only tags an existing DNA or
moodboard onto the project. Unlinking removes the tag; the asset stays in the
library. To change a tagged DNA's identity description, update the DNA (or use
the MCP update_project_asset description field) — never unlink and relink.
GET /api/v1/projects/:projectId/assets
POST /api/v1/projects/:projectId/assets/link
PUT /api/v1/projects/:projectId/assets/:assetType/:assetId/note
DELETE /api/v1/projects/:projectId/assets/:assetType/:assetIdGET requires project access. Link / note / unlink require edit.
List
Returns a flat visual_dnas[] (id, name, dna_type, thumbnail_url, description,
note) and moodboards[] (id, name, thumbnail_url, summary, note).
Link
{ "asset_type": "visual_dna", "asset_id": "6601a1b2c3d4e5f6a7b8c9d0" }asset_type is visual_dna or moodboard. Also accepts camelCase
assetType / assetId.
Purpose note
PUT …/note with { "note": "hero, use on dark backgrounds" } (max 1000
chars). Empty string clears the note. This is not the DNA's identity
description — that lives on the Visual DNA (PUT /v1/visual-dna/:id with
prompt_helper, or MCP update_project_asset / update_visual_dna).
Unlink
Removes the tag only. MCP: unlink_project_asset.
Project Knowledge Base (Context / RAG)
Feed domain knowledge into a project — scripts, briefs, research, URLs — and the platform synthesizes a living markdown profile used to ground AI work in the project.
Add a URL source
POST /api/v1/projects/:projectId/context/url| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | Public web page to fetch and analyze. Trimmed server-side, and validated against internal/private addresses — a blocked target returns 400. |
title | string | No | Display label for the source. Defaults to the URL. For a YouTube link the video title replaces it once the transcript is fetched. |
Extraction order: YouTube links use the video's captions; publicly shared Google Docs / Sheets / Slides are exported as text; anything else has its page text extracted. Only the first 60,000 characters of the extracted text are kept and indexed — anything past that is discarded (the source's stored size still reports the full extracted length). If less than 20 characters of content can be read, the source settles with an explanatory note instead of a summary (a Google file that is not shared publicly gets its own note saying so).
Add a text source
POST /api/v1/projects/:projectId/context/text| Field | Type | Required | Description |
|---|---|---|---|
text | string | Yes | Raw text (script, notes, brand facts). Also accepted as content. Trimmed, then stored for retrieval — only the first 60,000 characters are kept and indexed. |
title | string | No | Display label. Defaults to "Note". |
Both add routes require edit permission or higher, and both return immediately — analysis, embedding, and profile synthesis run in the background.
curl -X POST https://api.kolbo.ai/api/v1/projects/65f1c8a2e4b0a3c1d9f5e123/context/text \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "title": "Launch brief", "text": "The film opens on a rain-slick street…" }'Response:
{
"success": true,
"source": {
"file_key": "text-source/1752338335871-9f2c1ab4e5d6",
"type": "text",
"title": "Launch brief",
"url": null,
"summary": null,
"status": "analyzing",
"created_at": null
}
}List and delete sources
GET /api/v1/projects/:projectId/context
DELETE /api/v1/projects/:projectId/context/:fileKeyGET takes no parameters and returns { "success": true, "sources": [ … ], "count": n }. Each source has the shape above; type is one of image, document, url, text, video, audio, and status is analyzing until a summary lands, then completed. Any project member can read.
DELETE takes the source's file_key in the path (URL-encode it). An unknown key returns 404.
Deleting a context source requires you to be the project's owner (or listed in its owners).
Shared members — even with edit — get 404 from the delete route, despite the route's own
middleware only checking project access.
created_at is currently always null in the SDK source shape: the stored subdocument timestamps
the source as uploadedAt, and the SDK response mapper looks for createdAt / addedAt.
Project profile
GET /api/v1/projects/:projectId/profile
POST /api/v1/projects/:projectId/profile/regenerateThe profile is the synthesized living brief the platform maintains from the project's description, its context sources, and its activity. Reading it requires any project access; regenerating requires edit or higher.
GET returns:
{
"success": true,
"profile": {
"content": "## Acme Campaign\nA Q3 launch film…",
"generated_at": null,
"is_manually_edited": false
}
}content is markdown (the synthesizer trims it to 4,000 characters before storing) and is null when the project has not accumulated enough material yet. is_manually_edited is true when someone edited the profile in the app, which freezes automatic re-synthesis.
POST …/profile/regenerate clears that manual-edit lock and forces a fresh synthesis. It runs synchronously and can take several seconds. It responds with { "success": true, "profile": { "content": …, "regenerated": true } }, and still returns 200 when there is not enough project material to build a profile at all.
Two fields are currently always null regardless of the underlying data: profile.generated_at on
GET …/profile (the stored timestamp is lastSynthesized, which the SDK mapper does not read), and
profile.content on POST …/profile/regenerate. regenerated: true is a constant, not a signal
that anything changed. After regenerating, read the new profile back with GET …/profile.
Editing the profile by hand is not exposed over the API — that stays an in-app action.
Errors
| Status | Code | Cause |
|---|---|---|
| 400 | SDK_PROJECT_INVALID_ID | project_id is not a valid ObjectId. |
| 403 | SDK_PROJECT_ACCESS_DENIED | You have view permission on the project but not edit or higher. |
| 404 | SDK_PROJECT_NOT_FOUND | The project does not exist, is soft-deleted, or you have no access at all. (We return 404 for both cases so the API does not leak project existence.) |
| 403 | PROJECT_LIMIT_EXCEEDED | Your plan's project cap is reached. |
| 400 | — | (Create) name missing or not a non-empty string; description not a string. (Update) neither name nor description supplied. |
| 403 | — | (Archive / unarchive) you are a member but below the required full tier. |
| 400 | — | (Session move) sessionId is not a valid ObjectId, project_id is missing/invalid, or type is not one of the 17 session keys — the error message lists the valid keys. |
| 404 | — | (Session move) the session does not exist or is not owned by you. |
| 400 | — | (Context) url missing on the URL route, or neither text nor content on the text route. A URL that resolves to a private/internal address is also rejected here. |
MCP Tools
If you are using Kolbo through the @kolbo/mcp server (Claude Desktop, Claude Code, claude.ai connector), the matching tools are list_projects, get_project, create_project, update_project, archive_project, unarchive_project, move_session, list_project_assets, link_project_asset, unlink_project_asset, update_project_asset, add_project_context, list_project_context, delete_project_context, get_project_profile, and regenerate_project_profile. Every generate_* tool accepts an optional project_id arg whose value comes from list_projects.
add_project_context is one tool over both add routes — pass exactly one of url / text and it picks the endpoint for you. Its title arg is only forwarded on a text source; to title a URL source, call POST …/context/url over HTTP.
list_projects and create_project add an open_url field to their output that the HTTP API does not return — a client-side deep link of the form https://app.kolbo.ai/media?project=<id>. It is omitted for the is_default "API Generations" bucket.
No MCP tool calls GET /api/v1/project/lightweight — list_projects is the project-discovery tool. The lightweight list is reachable only over HTTP; there is no App Builder project-picker tool on the MCP server (App Builder is HTTP-only, as noted in the overview).
1. list_projects
2. pick the project the user named -> capture its `id`
3. generate_image (or any other generate_* tool)
{ prompt: "...", project_id: "<id from step 2>" }When the user does not mention a project, omit project_id and the generation lands in the user's "API Generations" default. If something landed in the wrong project, move_session relocates a whole session (plus its media), and move_media / bulk_move_media relocate individual items.
Related
Agents & Sessions
Enumerate sessions across every generation type; manage custom chat agents
AI Docs
Project-scoped documents you author through the API
Media Library
List and move individual media items between projects
Color Palettes
Palettes are activated per project — find the id here first
Errors & Limits
Rate limits and the shared error envelope