@kolbo/mcp 1.61.0 → 1.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolbo/mcp",
3
- "version": "1.61.0",
3
+ "version": "1.63.0",
4
4
  "description": "Kolbo AI MCP Server - Generate images, videos, music, speech, and sound effects from Claude Code",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/skill/SKILL.md CHANGED
@@ -177,6 +177,7 @@ Everything in Kolbo — sessions, generations, media, docs — lives inside a PR
177
177
  1. **User names a project** ("in my Acme project", "for the film") → call `list_projects` ONCE to resolve the name to an ObjectId, then pass that id as `project_id` on **EVERY** subsequent `generate_*` / `upload_media` / `create_doc` / `chat_send_message` call in the conversation. It is **per-call, NOT sticky** — any call that omits it silently lands in the default "API Generations" bucket (`is_default: true`).
178
178
  2. **No project mentioned** → omit `project_id`; the default bucket is correct. Don't ask unless intent is ambiguous.
179
179
  3. **Work landed in the wrong project? MOVE it, never regenerate**: `move_session` relocates a whole session + all its media (works for any session type — the `session_id` from generation responses, chats, transcriptions); `move_media` / `bulk_move_media` / `move_folder_contents` relocate individual media items.
180
+ 4. **Inside the project, keep a related set in ONE session.** Every generation response returns a `session_id`. When a set needs several calls — shot 2, 3, 4 of a sequence, or more takes of the same idea — pass the FIRST call's `session_id` back on each follow-up. Without it every call opens its own session and the user's sidebar fills with near-identical single-item sessions. Prefer a real batch where one exists (`prompts` on `generate_image` / `generate_video`, `items` on `generate_video_from_image` — up to 8 `{image_url, prompt}` pairs animated in one call and one widget — `num_images`, `generate_creative_director`); use `session_id` for the tools that animate/edit one thing per call (`generate_elements`, `generate_first_last_frame`, `generate_lipsync`, `generate_video_from_video`, `edit_image`, `edit_video`), and across batches when a sequence runs longer than 8. Start a NEW session (omit it) when the next generation is unrelated.
180
181
 
181
182
 
182
183
  ## Cost Awareness — Quick Rules
@@ -53,6 +53,12 @@ URLs to any generation tool.
53
53
  curl -X POST "<upload_url>" -H "Authorization: Bearer <token>" -F "file=@/abs/path/clip.mp3"
54
54
  ```
55
55
 
56
+ **Pace a batch.** The upload endpoint is rate limited — the ticket response says by
57
+ how much in `rate_limit` (currently 40 uploads per 60s). Firing 55 files back to
58
+ back stalls at file 41. Sleep ~2s between files, or read the 429: it carries a
59
+ `Retry-After` header and `retry_after_seconds` in the body. Wait exactly that long
60
+ and continue — a 429 is a "not yet", not a failed upload, and nothing was charged.
61
+
56
62
  Do **not** fall back to `upload_media`'s `source_base64` for anything but a tiny file —
57
63
  it pushes the whole file through the model's context, twice. And do not reach for
58
64
  cloud credentials or a bucket of your own; the ticket is the sanctioned path.
package/src/client.js CHANGED
@@ -47,6 +47,35 @@ function isAbortError(err) {
47
47
  return err && (err.name === 'TimeoutError' || err.name === 'AbortError');
48
48
  }
49
49
 
50
+ // ---------------------------------------------------------------------------
51
+ // 429 handling
52
+ // ---------------------------------------------------------------------------
53
+ // A 429 is rejected by the rate-limit middleware BEFORE the handler runs: no
54
+ // credits spent, no generation started, nothing half-done. That makes it the
55
+ // one status it is safe to replay — but only ONCE, and only when the server
56
+ // told us how long to wait. An unbounded backoff loop would hide a real
57
+ // capacity problem and stall the tool call past the host's own timeout.
58
+ const RETRY_429_MAX_WAIT_S = 65;
59
+
60
+ function retryAfterSeconds(response) {
61
+ const raw = response.headers.get('retry-after');
62
+ if (!raw) return null;
63
+ const n = Number(raw);
64
+ return Number.isFinite(n) && n >= 0 ? n : null;
65
+ }
66
+
67
+ async function retryOnce429(attempt) {
68
+ try {
69
+ return await attempt();
70
+ } catch (err) {
71
+ const wait = err && err.status === 429 ? err.retryAfterSeconds : null;
72
+ if (wait === null || wait === undefined || wait > RETRY_429_MAX_WAIT_S) throw err;
73
+ await progress.tick(); // keepalive — the wait can be up to a minute
74
+ await new Promise((resolve) => setTimeout(resolve, (wait + 1) * 1000));
75
+ return attempt();
76
+ }
77
+ }
78
+
50
79
  // ---------------------------------------------------------------------------
51
80
  // Partner / whitelabel resolution (mirrors CLI's brand/partner.ts)
52
81
  // ---------------------------------------------------------------------------
@@ -326,11 +355,13 @@ class KolboClient {
326
355
  data._kolbo_auth_expired = true;
327
356
  fullMessage = `${fullMessage} [KOLBO_AUTH_EXPIRED]`;
328
357
  }
329
- throw new KolboApiError(fullMessage, {
358
+ const apiError = new KolboApiError(fullMessage, {
330
359
  code,
331
360
  status: response.status,
332
361
  data
333
362
  });
363
+ if (response.status === 429) apiError.retryAfterSeconds = retryAfterSeconds(response);
364
+ throw apiError;
334
365
  }
335
366
 
336
367
  const generationId = data?.generation_id || data?.generationId;
@@ -360,13 +391,21 @@ class KolboClient {
360
391
  return this.request('DELETE', reqPath, body);
361
392
  }
362
393
 
394
+ // Uploads are the one thing agents genuinely do in a tight loop (`upload_media`
395
+ // per file), and /v1/media/upload shares the per-minute SDK generation bucket —
396
+ // so a batch trips 429 long before the user's patience does. Absorb exactly one
397
+ // of those, honouring the server's Retry-After. Everything else (including the
398
+ // poll loop, which does its own capped backoff in polling.js) still surfaces
399
+ // the 429 straight to the caller, now with the wait spelled out in the message.
363
400
  async postMultipart(reqPath, formData) {
364
401
  if (!this.apiKey) await this._ensureLogin();
365
- const result = await this._doMultipart(reqPath, formData);
366
- if (result._status === 401 && this._tryRefreshKey()) {
367
- return this._doMultipart(reqPath, formData);
368
- }
369
- return result;
402
+ return retryOnce429(async () => {
403
+ const result = await this._doMultipart(reqPath, formData);
404
+ if (result._status === 401 && this._tryRefreshKey()) {
405
+ return this._doMultipart(reqPath, formData);
406
+ }
407
+ return result;
408
+ });
370
409
  }
371
410
 
372
411
  async _doMultipart(reqPath, formData) {
@@ -428,11 +467,13 @@ class KolboClient {
428
467
  data._kolbo_auth_expired = true;
429
468
  fullMessage = `${fullMessage} [KOLBO_AUTH_EXPIRED]`;
430
469
  }
431
- throw new KolboApiError(fullMessage, {
470
+ const apiError = new KolboApiError(fullMessage, {
432
471
  code,
433
472
  status: response.status,
434
473
  data
435
474
  });
475
+ if (response.status === 429) apiError.retryAfterSeconds = retryAfterSeconds(response);
476
+ throw apiError;
436
477
  }
437
478
 
438
479
  return data;
package/src/index.js CHANGED
@@ -124,7 +124,7 @@ function createServer(opts = {}) {
124
124
  '6. TIMEOUT HANDLING (applies to EVERY generate_* / edit_* / chat_send_message / transcribe_audio tool): each tool blocks and polls internally, then gives up after its own window if the job is not yet terminal. A timeout is NOT a failure — it returns `_timed_out:true` with the `generation_id` (not an error), because the job is almost always STILL RUNNING on the server (or already finished). Call `get_generation_status` with that `generation_id` and `wait=true` to keep checking until state="completed". NEVER conclude the generation failed and re-run the same tool from scratch after a `_timed_out:true` result — that wastes the user\'s credits by paying twice. DIRECTOR / BATCH JOBS follow the same convention through a dedicated tool: generate_creative_director runs its scenes (image OR video) in parallel and only reports state="completed" once EVERY scene is terminal; on `_timed_out:true` call `get_creative_director_status` (not get_generation_status) with the returned generation_id and keep checking. If scenes already carry image_urls/video_urls, they are done; do not regenerate.',
125
125
  '7. SESSION CONTINUITY — one task, one session, always: every generation tool returns a `session_id`. For ANY follow-up, refinement, retry, or next step on the SAME task, pass that session_id back — never start fresh. BATCH RULE (critical): when a single user request produces multiple parallel generations (e.g. "animate these 5 images", "generate 3 variants"), do NOT launch them all at once without a session_id. Instead: (1) run the FIRST generation without session_id to create the session, (2) capture the session_id from its response, (3) pass that session_id to ALL remaining generations in the batch. This keeps the entire batch in one session. Exception: only omit session_id and start fresh when the user explicitly starts an unrelated new task.',
126
126
  '8. LOCAL FILES / REFERENCE MEDIA — HOW TO HANDLE EVERY CASE. (A) User has a LOCAL file (audio, video, image, document) on their machine. What matters is WHERE THIS SERVER RUNS, not what your client can do — your own filesystem access is irrelevant if the server is somewhere else. On a LOCAL stdio install (server and client share a machine) → call `upload_media` with the absolute path, or pass the path straight to tools like `transcribe_audio` that accept local paths. Over a REMOTE connector the server cannot see that path no matter how capable you are, so a local path will always fail: if you can run shell commands or issue HTTP requests → call `create_upload_ticket` and POST the file to the returned upload_url yourself (fastest, no user interaction); if you cannot → call `media_upload_widget` IMMEDIATELY, the user uploads, and a `media.kolbo.ai` CDN URL comes back for any follow-up call. (B) You already have a public URL (media.kolbo.ai, any CDN, any direct link) → pass it directly; all Kolbo tools accept public URLs. NEVER search for DO Spaces keys, DigitalOcean credentials, or server-side upload credentials. NEVER ask the user to put the file on Google Drive, Dropbox, or Loom. NEVER invent or guess a URL. NEVER base64 anything but a tiny file — it costs context in proportion to file size; use the ticket or the widget instead.',
127
- '9. MODEL SELECTION: ALWAYS pass a specific `model` on every generation tool — do NOT omit it. Omitting falls back to "Smart Select" auto-routing, which we deliberately avoid because it hides the model choice from the user and often picks a generic default. Choose the model that best fits the task and the user\'s intent (quality, speed, style, capability). If you are unsure which model to use for a given type, call `list_models` with the matching `type` and pick the recommended/flagship one, then pass its `identifier`. Only use Smart Select (omit `model`) if the user EXPLICITLY asks you to auto-pick.',
127
+ '9. MODEL SELECTION — ROUTE BY THE STRENGTHS SUMMARY, NEVER BY THE BADGE OR THE PRICE TAG: ALWAYS pass a specific `model` on every generation tool — do NOT omit it (omitting falls back to "Smart Select" auto-routing, which hides the choice from the user; use it ONLY if the user explicitly asks you to auto-pick). To choose: call `list_models` with the matching `type` and read each model\'s STRENGTHS SUMMARY the "— …" clause printed after the credit cost. That summary IS the routing instruction: match it against what the user actually asked for (subject, style, motion, length, quality bar, speed), then pick the CHEAPEST model whose summary covers the task. `[NEW]` and `[RECOMMENDED]` badges, a high credit number, and "flagship"/"most intelligent" wording are NOT selection signals — never pick a model because it is newest, biggest or most expensive. Escalate to a premium/frontier model only when the user explicitly asks for maximum quality, or when no cheaper summary covers the requirement. Models printed under "Named-only" (no summary) are opt-in: use them only when the user names them. TEXT/CHAT: `chat_send_message` bills PER TOKEN, so the listed credit number is not the cost — a frontier text model (Claude Fable 5, GPT-5.6 Sol, Pro-class) costs 5-30x a mid-tier one per reply. Default ordinary chat (writing, brainstorming, Q&A, summarising) to a balanced mid-tier model and reserve the frontier tier for hard reasoning or long-form code the user asked for.',
128
128
  '10. IMAGE EDITING: for ANY prompt-driven / content edit of an existing image — "make it night", changing scene/lighting/colors, adding/removing/replacing objects, restyling — use `generate_image_edit` (it runs on strong dedicated editing models, same as image generation). Do NOT use `edit_image` for content edits — `edit_image` is ONLY for mechanical enhancements (upscale, expand/outpaint, remove-background, skin retouch). Its `magic_edit` operation is deprecated in favor of `generate_image_edit`. EXPANDING AN IMAGE: to widen/extend/uncrop an image or fit it into a wider frame while KEEPING the existing artwork, use `edit_image` with operation="zoom_out" (outpainting — original pixels preserved; size it with `zoom_out_percentage` or the `expand_left/right/top/bottom` pixel args). The "reframe" operation is NOT this: it re-generates the whole picture at a new aspect ratio and the subject comes back re-imagined. Only pick "reframe" when the user wants the shot re-taken, never when they want their image extended.'
129
129
  ].join('\n')
130
130
  });
@@ -356,6 +356,16 @@ const projectIdField = z.string().optional().describe(
356
356
  'Project ObjectId to drop this generation into. Call `list_projects` to discover IDs (the API has no concept of project names — only ObjectIds). IMPORTANT: this is per-call, NOT sticky — once the user has named a working project, pass its id on EVERY generation call in the conversation; any call that omits it silently lands in the default "API Generations" project instead. Requires owner / edit / full permission on the project; view-only is rejected.'
357
357
  );
358
358
 
359
+ // Shared zod schema for the optional `session_id` arg on generation tools.
360
+ // WHY IT EXISTS: without it, each generation call gets its own session, so a
361
+ // set of related clips lands in the app's left rail as a stack of near-identical
362
+ // single-item sessions. (The server has a per-day fallback bucket, but it is not
363
+ // something a caller can rely on — see kolbo-api sdkSessionManager.) Threading
364
+ // the id returned by the FIRST call is the deterministic way to group a batch.
365
+ const sessionIdField = z.string().optional().describe(
366
+ 'Existing session to add this generation to, so a related set lands in ONE session instead of a stack of single-item sessions in the Kolbo sidebar. HOW TO USE: omit it on the FIRST call of a set, read `session_id` off that call\'s result, then pass that SAME value on every follow-up call belonging to the same set (e.g. shot 2, 3, 4 of one sequence). Only group things that genuinely belong together — an unrelated generation should start a fresh session by omitting this. The id must come from a session of the same kind (image tools share one session type, video tools another); `list_sessions` also returns ids. When set, `project_id` is ignored — the session\'s own project wins.'
367
+ );
368
+
359
369
  // Read-scope variant for list/get tools that can surface a SHARED project's
360
370
  // assets (a teammate's Visual DNAs / moodboards). Pass a project id you have
361
371
  // edit+ on to also see that project owner's shared assets; omit to see only your
@@ -543,6 +553,9 @@ async function uiGenerating(p) {
543
553
  const text = JSON.stringify({
544
554
  status: 'submitted',
545
555
  generation_id: p.gen.generation_id,
556
+ // The session this landed in. Pass it back as `session_id` on the next call
557
+ // of the same set to keep the whole set in one session (see sessionIdField).
558
+ session_id: p.gen.session_id,
546
559
  ...(Array.isArray(p.generation_ids) && p.generation_ids.length > 1
547
560
  ? { batch: true, generation_ids: p.generation_ids } : {}),
548
561
  ...(p.failed_submissions && p.failed_submissions.length
@@ -661,6 +674,7 @@ module.exports = {
661
674
  pollOrTimedOut,
662
675
  creditFields,
663
676
  projectIdField,
677
+ sessionIdField,
664
678
  projectScopeReadField,
665
679
  inlineImageBlocks,
666
680
  buildOpenUrl,
package/src/tools/chat.js CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  const { z } = require('zod');
7
7
  const { pollOrTimedOut, creditFields, projectIdField } = require('./_shared');
8
+ const { canonicalModelId } = require('../apps');
8
9
 
9
10
  function registerChatTools(server, client) {
10
11
  // ─── chat_send_message ─────────────────────────────────────
@@ -13,7 +14,7 @@ function registerChatTools(server, client) {
13
14
  'Send a chat message to Kolbo AI. Starts a new conversation (omit session_id) or continues an existing one. Returns the assistant response when complete. Supports image/video/audio analysis via media_urls — pass public URLs and the model auto-routes to a vision-capable model (e.g. Gemini) when media is detected. Supports web search and deep think modes.',
14
15
  {
15
16
  message: z.string().describe('The user message to send'),
16
- model: z.string().optional().describe('Model identifier (e.g. "gpt-4o", "claude-sonnet-4-6"). Prefer passing a SPECIFIC model (list_models type="text") — omitting falls back to Smart Select auto-routing, which we avoid unless the user explicitly asks for auto-pick. Exception: when media_urls contains video or audio, omitting is fine — routing goes to a Gemini vision model regardless of this field.'),
17
+ model: z.string().optional().describe('Model identifier from list_models type="text". Identifiers resolve leniently, so the DISPLAY NAME that list_models shows works too ("Grok 4.5" → its identifier). Do NOT hardcode an id you have not seen in list_models — the text catalog turns over fast. Prefer passing a SPECIFIC model — omitting falls back to Smart Select auto-routing, which we avoid unless the user explicitly asks for auto-pick. Choose it by matching the task to each model\'s STRENGTHS SUMMARY in `list_models type="text"` — not by the newest/biggest name. Chat bills PER TOKEN, so the listed credit number is not the cost: a frontier model (Claude Fable 5, GPT-5.6 Sol, Pro-class) costs 5-30x a mid-tier one per reply. Default ordinary chat (writing, brainstorming, Q&A, summarising) to a balanced mid-tier model whose summary covers the task (Claude Sonnet, Gemini Flash, GPT nano/mini class) and reserve the frontier tier for hard reasoning or long-form code the user asked for. Exception: when media_urls contains video or audio, omitting is fine — routing goes to a Gemini vision model regardless of this field.'),
17
18
  session_id: z.string().optional().describe('Existing chat session ID to continue. Omit to start a new conversation.'),
18
19
  system_prompt: z.string().optional().describe('System prompt for the conversation. Only applied when creating a new session.'),
19
20
  web_search: z.boolean().optional().describe('Enable web search for this message. Default: false'),
@@ -23,6 +24,13 @@ function registerChatTools(server, client) {
23
24
  project_id: projectIdField
24
25
  },
25
26
  async ({ message, model, session_id, system_prompt, web_search, deep_think, enhance_prompt = false, media_urls, project_id }) => {
27
+ // Every generate_* tool resolves its model this way; chat was the one
28
+ // `model` arg that went straight to the API, which has no fuzzy matching.
29
+ // So the display names list_models hands back ("Claude Fable 5") came
30
+ // back as a bare `Model not found: Claude Fable 5 [MODEL_NOT_FOUND]` —
31
+ // discovery had no path to use.
32
+ model = await canonicalModelId(client, model, 'text'); // lenient id resolution ("Grok 4.5" → its identifier)
33
+
26
34
  const gen = await client.post('/v1/chat', {
27
35
  message,
28
36
  model,
@@ -6,7 +6,7 @@
6
6
  const { z } = require('zod');
7
7
  const FormData = require('form-data');
8
8
  const { pollUntilDone } = require('../polling');
9
- const { resolveToBuffer, pollOrTimedOut, creditFields, projectIdField, inlineImageBlocks, buildOpenUrl, uiGenerating, appsEnabled } = require('./_shared');
9
+ const { resolveToBuffer, pollOrTimedOut, creditFields, projectIdField, sessionIdField, inlineImageBlocks, buildOpenUrl, uiGenerating, appsEnabled } = require('./_shared');
10
10
  const { UI, uiResult, canonicalModelId, modelInfo, voiceInfo } = require('../apps');
11
11
 
12
12
  // ─── Cinematic Dimensions schema (shared by generate_image + generate_image_edit) ───
@@ -37,8 +37,8 @@ const CINEMATIC_SCHEMA = z.object({
37
37
  'validated against their dimension server-side. Dimensions are data-driven — never hardcode ids.'
38
38
  );
39
39
 
40
- // ─── Batch fan-out (prompts[]) ──────────────────────────────────────────────
41
- // One tool call, N DIFFERENT prompts → N generations tracked by ONE widget.
40
+ // ─── Batch fan-out (prompts[] / items[]) ────────────────────────────────────
41
+ // One tool call, N DIFFERENT inputs → N generations tracked by ONE widget.
42
42
  // The manual-control twin of generate_creative_director: no orchestration pass,
43
43
  // the user's exact prompts verbatim. Submit failures never sink the batch —
44
44
  // successful ids proceed, failed prompts are reported alongside.
@@ -47,22 +47,32 @@ const CINEMATIC_SCHEMA = z.object({
47
47
  // the caller had no way to know which prompt vanished. `promptsField` also caps
48
48
  // the array in the schema (so hosts see `maxItems` before calling); the guard
49
49
  // below is the choke point EVERY batch tool routes through, and names the count.
50
+ //
51
+ // An item is EITHER a bare prompt string (generate_image / generate_video, where
52
+ // only the prompt varies) OR an object carrying its own per-item inputs
53
+ // alongside the prompt (generate_video_from_image: `{ image_url, prompt }` — the
54
+ // image is what varies, and that is the whole point). Either way the widget
55
+ // captions each tile with the item's prompt, so that is the label we carry.
50
56
  const MAX_BATCH_PROMPTS = 8;
51
- async function submitBatch(rawPrompts, submitOne) {
52
- if (rawPrompts.length > MAX_BATCH_PROMPTS) {
57
+ async function submitBatch(rawItems, submitOne) {
58
+ if (rawItems.length > MAX_BATCH_PROMPTS) {
53
59
  throw new Error(
54
- `Too many prompts: ${rawPrompts.length} received, max ${MAX_BATCH_PROMPTS} per call. ` +
55
- `Split them across ${Math.ceil(rawPrompts.length / MAX_BATCH_PROMPTS)} calls of at most ${MAX_BATCH_PROMPTS}.`
60
+ `Too many prompts: ${rawItems.length} received, max ${MAX_BATCH_PROMPTS} per call. ` +
61
+ `Split them across ${Math.ceil(rawItems.length / MAX_BATCH_PROMPTS)} calls of at most ${MAX_BATCH_PROMPTS}.`
56
62
  );
57
63
  }
58
- const prompts = rawPrompts.map((s) => String(s).trim()).filter(Boolean);
59
- const settled = await Promise.allSettled(prompts.map((p) => submitOne(p)));
64
+ const items = rawItems
65
+ .map((it) => (typeof it === 'object' && it !== null
66
+ ? { input: it, label: String(it.prompt || '').trim() }
67
+ : { input: String(it).trim(), label: String(it).trim() }))
68
+ .filter((x) => x.label);
69
+ const settled = await Promise.allSettled(items.map((x) => submitOne(x.input)));
60
70
  const ok = [], failed = [];
61
71
  settled.forEach((s, i) => {
62
- if (s.status === 'fulfilled' && s.value && s.value.generation_id) ok.push({ prompt: prompts[i], gen: s.value });
63
- else failed.push({ prompt: prompts[i], error: (s.reason && s.reason.message) || 'submit failed' });
72
+ if (s.status === 'fulfilled' && s.value && s.value.generation_id) ok.push({ prompt: items[i].label, gen: s.value });
73
+ else failed.push({ prompt: items[i].label, error: (s.reason && s.reason.message) || 'submit failed' });
64
74
  });
65
- if (!ok.length) throw new Error(`All ${prompts.length} batch submissions failed: ${failed[0].error}`);
75
+ if (!ok.length) throw new Error(`All ${items.length} batch submissions failed: ${failed[0].error}`);
66
76
  return { ok, failed, ids: ok.map((o) => o.gen.generation_id) };
67
77
  }
68
78
 
@@ -77,6 +87,7 @@ async function pollBatch(client, batch, { interval, timeout }) {
77
87
  type: 'text',
78
88
  text: JSON.stringify({
79
89
  batch: true,
90
+ session_id: batch.ok[0].gen.session_id,
80
91
  generations,
81
92
  failed_submissions: batch.failed.length ? batch.failed : undefined
82
93
  }, null, 2)
@@ -135,14 +146,15 @@ function registerGenerateTools(server, client, options = {}) {
135
146
  preset_id: z.string().optional().describe('Preset ID from list_presets type="image" to apply a saved style preset to this generation.'),
136
147
  cinematic: CINEMATIC_SCHEMA,
137
148
  skip_color_palette: z.boolean().optional().describe('Opt this single call OUT of the account\'s active Color DNA palette (see list_color_palettes / activate_color_palette). By default, if the user has an active palette it strict-grades every generation automatically — pass true only when the user explicitly wants this one image ungraded.'),
138
- project_id: projectIdField
149
+ project_id: projectIdField,
150
+ session_id: sessionIdField
139
151
  },
140
- async ({ prompt, prompts, model, aspect_ratio, enhance_prompt = false, num_images, reference_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, preset_id, cinematic, skip_color_palette, project_id }) => {
152
+ async ({ prompt, prompts, model, aspect_ratio, enhance_prompt = false, num_images, reference_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, preset_id, cinematic, skip_color_palette, project_id, session_id }) => {
141
153
  if (!prompt && !(prompts && prompts.length)) throw new Error('Provide prompt or prompts');
142
154
  model = await canonicalModelId(client, model, 'text_to_img'); // lenient id resolution ("z-image" → "z-image/turbo")
143
155
  const shared = {
144
156
  model, aspect_ratio, enhance_prompt,
145
- reference_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, preset_id, cinematic, skip_color_palette, project_id
157
+ reference_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, preset_id, cinematic, skip_color_palette, project_id, session_id
146
158
  };
147
159
 
148
160
  // Batch mode: N different prompts, one widget owning all generation ids.
@@ -180,6 +192,7 @@ function registerGenerateTools(server, client, options = {}) {
180
192
  type: 'text',
181
193
  text: JSON.stringify({
182
194
  ...creditFields(result),
195
+ session_id: gen.session_id,
183
196
  urls: result.result.urls,
184
197
  model: result.result.model,
185
198
  prompt_used: result.result.prompt_used,
@@ -207,13 +220,14 @@ function registerGenerateTools(server, client, options = {}) {
207
220
  resolution: z.string().optional().describe('Image resolution tier: "1K" / "2K" / "3K" / "4K". Model-dependent — call list_models and read supported_resolutions. Default: "1K" for most edit models.'),
208
221
  cinematic: CINEMATIC_SCHEMA,
209
222
  skip_color_palette: z.boolean().optional().describe('Opt this single call OUT of the account\'s active Color DNA palette (see list_color_palettes / activate_color_palette). By default, if the user has an active palette it strict-grades every generation automatically — pass true only when the user explicitly wants this one edit ungraded.'),
210
- project_id: projectIdField
223
+ project_id: projectIdField,
224
+ session_id: sessionIdField
211
225
  },
212
- async ({ prompt, model, source_images, aspect_ratio, enhance_prompt = false, num_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, cinematic, skip_color_palette, project_id }) => {
226
+ async ({ prompt, model, source_images, aspect_ratio, enhance_prompt = false, num_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, cinematic, skip_color_palette, project_id, session_id }) => {
213
227
  model = await canonicalModelId(client, model, 'image_editing'); // lenient id resolution ("z-image" → "z-image/turbo")
214
228
  const gen = await client.post('/v1/generate/image-edit', {
215
229
  prompt, model, source_images, aspect_ratio, enhance_prompt, num_images,
216
- visual_dna_ids, moodboard_id, enable_web_search, resolution, cinematic, skip_color_palette, project_id
230
+ visual_dna_ids, moodboard_id, enable_web_search, resolution, cinematic, skip_color_palette, project_id, session_id
217
231
  });
218
232
 
219
233
  if (ui()) return uiGenerating({
@@ -240,6 +254,7 @@ function registerGenerateTools(server, client, options = {}) {
240
254
  type: 'text',
241
255
  text: JSON.stringify({
242
256
  ...creditFields(result),
257
+ session_id: gen.session_id,
243
258
  urls: result.result.urls,
244
259
  model: result.result.model,
245
260
  prompt_used: result.result.prompt_used,
@@ -419,13 +434,14 @@ function registerGenerateTools(server, client, options = {}) {
419
434
  preset_id: z.string().optional().describe('Preset ID from list_presets type="video" to apply a saved motion/style preset to this generation.'),
420
435
  sound_enabled: z.boolean().optional().describe('Enable (`true`) or disable (`false`) AI-generated synced audio on the output video. Only honored by models with `sound_generation_type: "native"` from list_models (e.g. Veo 3.1, Kling V3/2.6, PixVerse V6). On `sound_generation_type: "none"` models the flag has no effect. Omit to use the model\'s `sound_enabled_by_default`. Pass `false` when the user says no sound / silent / mute / without audio. Enabling sound may apply `sound_credit_multiplier` to cost.'),
421
436
  skip_color_palette: z.boolean().optional().describe('Opt this single call OUT of the account\'s active Color DNA palette (see list_color_palettes / activate_color_palette). By default, if the user has an active palette it strict-grades every generation automatically — pass true only when the user explicitly wants this one video ungraded.'),
422
- project_id: projectIdField
437
+ project_id: projectIdField,
438
+ session_id: sessionIdField
423
439
  },
424
- async ({ prompt, prompts, model, aspect_ratio, duration, enhance_prompt = false, reference_images, resolution, preset_id, sound_enabled, skip_color_palette, project_id }) => {
440
+ async ({ prompt, prompts, model, aspect_ratio, duration, enhance_prompt = false, reference_images, resolution, preset_id, sound_enabled, skip_color_palette, project_id, session_id }) => {
425
441
  if (!prompt && !(prompts && prompts.length)) throw new Error('Provide prompt or prompts');
426
442
  model = await canonicalModelId(client, model, 'text_to_video'); // lenient id resolution ("z-image" → "z-image/turbo")
427
443
  const shared = {
428
- model, aspect_ratio, duration, enhance_prompt, reference_images, resolution, preset_id, sound_enabled, skip_color_palette, project_id
444
+ model, aspect_ratio, duration, enhance_prompt, reference_images, resolution, preset_id, sound_enabled, skip_color_palette, project_id, session_id
429
445
  };
430
446
 
431
447
  // Batch mode: N different prompts, one widget owning all generation ids.
@@ -465,6 +481,7 @@ function registerGenerateTools(server, client, options = {}) {
465
481
  type: 'text',
466
482
  text: JSON.stringify({
467
483
  ...creditFields(result),
484
+ session_id: gen.session_id,
468
485
  urls: result.result.urls,
469
486
  model: result.result.model,
470
487
  duration: result.result.duration,
@@ -480,10 +497,16 @@ function registerGenerateTools(server, client, options = {}) {
480
497
  // ─── generate_video_from_image ─────────────────────────────
481
498
  server.tool(
482
499
  'generate_video_from_image',
483
- 'Animate an existing still image into a video using Kolbo AI. The image comes from `image_url`; `prompt` describes the motion (not the subject — the subject is already in the image). For generating a video from scratch, use generate_video. Returns the final video URL when complete.',
500
+ 'Animate an existing still image into a video using Kolbo AI. The image comes from `image_url`; `prompt` describes the motion (not the subject — the subject is already in the image). For generating a video from scratch, use generate_video. ANIMATING SEVERAL SHOTS OF THE SAME SEQUENCE? Pass them ALL in `items` in ONE call (one combined widget) — never a series of separate calls, which buries the chat under one widget per clip. Across calls (a sequence longer than the batch cap), make the first call without `session_id`, take the `session_id` from its result, and pass that same `session_id` on every following call — otherwise each clip becomes its own session and the user gets a stack of near-identical single-clip sessions in the Kolbo sidebar. Returns the final video URL(s) when complete.',
484
501
  {
485
- image_url: z.string().describe('URL of the source image to animate'),
486
- prompt: z.string().describe('Text description of the desired MOTION (e.g., "camera slowly pans right while the character walks forward")'),
502
+ image_url: z.string().optional().describe('URL of the source image to animate. Required unless `items` is provided.'),
503
+ prompt: z.string().optional().describe('Text description of the desired MOTION (e.g., "camera slowly pans right while the character walks forward"). Required unless `items` is provided.'),
504
+ items: z.array(z.object({
505
+ image_url: z.string().describe('URL of the source image to animate for THIS clip.'),
506
+ prompt: z.string().describe('Text description of the desired MOTION for THIS clip.'),
507
+ })).max(MAX_BATCH_PROMPTS).optional().describe(
508
+ `BATCH MODE — several DIFFERENT stills (2–${MAX_BATCH_PROMPTS}) animated concurrently in ONE call and rendered together in ONE combined widget. Unlike the \`prompts\` array on generate_image / generate_video, each entry pairs its OWN \`image_url\` with its OWN motion \`prompt\` — the image is what varies, and that is the point. **Hard cap: ${MAX_BATCH_PROMPTS} items per call — more than that is REJECTED with an error (never silently truncated), so split a longer sequence across several calls of at most ${MAX_BATCH_PROMPTS}.** Whenever the user wants several stills animated (a shot sequence, a storyboard, an animatic), ALWAYS pass them all here instead of making several separate calls — separate calls clutter the chat with stacked widgets. All items share the same model / duration / resolution / aspect_ratio / sound_enabled / project_id / session_id. When set, \`image_url\` and \`prompt\` are ignored.`
509
+ ),
487
510
  model: z.string().optional().describe('Model identifier — pick a SPECIFIC model, do NOT omit (omitting = Smart Select auto-pick, which we avoid). Strong current defaults: "seedance-2" (versatile) or "veo3" (Veo 3.1, cinematic + native audio); the Kling family (call list_models for exact ids like kling-video/v3/pro/image-to-video) is strongest for motion. Call list_models type="img_to_video" to see all options and choose per the user\'s intent.'),
488
511
  aspect_ratio: z.string().optional().describe('Output aspect ratio (e.g., "16:9", "9:16", "1:1"). Must be in the chosen model\'s `supported_aspect_ratios` from list_models. Default: "16:9"'),
489
512
  duration: z.number().optional().describe('Duration in seconds. Must be in `supported_durations` from list_models, OR within `min_output_duration`-`max_output_duration`. Default: 5'),
@@ -492,13 +515,34 @@ function registerGenerateTools(server, client, options = {}) {
492
515
  resolution: z.string().optional().describe('Video resolution tier (vertical pixels): "720p" / "1080p" / "1440p" / "2160p". Some models use labels like "512P"/"1024P"/"768P"/"1080P". Model-dependent — call list_models and read supported_resolutions.'),
493
516
  sound_enabled: z.boolean().optional().describe('Enable (`true`) or disable (`false`) AI-generated synced audio on the output video. Only honored by models with `sound_generation_type: "native"` from list_models (e.g. Veo 3.1 Lite, Kling V3 4K, PixVerse V6, Kling 2.6/v3). On `sound_generation_type: "none"` models the flag has no effect. Omit to use the model\'s `sound_enabled_by_default`. Pass `false` when the user says no sound / silent / mute / without audio. Enabling sound may apply `sound_credit_multiplier` to cost.'),
494
517
  skip_color_palette: z.boolean().optional().describe('Opt this single call OUT of the account\'s active Color DNA palette (see list_color_palettes / activate_color_palette). By default, if the user has an active palette it strict-grades every generation automatically — pass true only when the user explicitly wants this one video ungraded.'),
495
- project_id: projectIdField
518
+ project_id: projectIdField,
519
+ session_id: sessionIdField
496
520
  },
497
- async ({ image_url, prompt, model, aspect_ratio, duration, enhance_prompt = false, visual_dna_ids, resolution, sound_enabled, skip_color_palette, project_id }) => {
521
+ async ({ image_url, prompt, items, model, aspect_ratio, duration, enhance_prompt = false, visual_dna_ids, resolution, sound_enabled, skip_color_palette, project_id, session_id }) => {
522
+ if (!(items && items.length) && !(image_url && prompt)) throw new Error('Provide image_url + prompt, or items');
498
523
  model = await canonicalModelId(client, model, 'img_to_video'); // lenient id resolution ("z-image" → "z-image/turbo")
499
- const gen = await client.post('/v1/generate/video/from-image', {
500
- image_url, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution, sound_enabled, skip_color_palette, project_id
501
- });
524
+ const shared = {
525
+ model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution, sound_enabled, skip_color_palette, project_id, session_id
526
+ };
527
+
528
+ // Batch mode: N different stills, one widget owning all generation ids.
529
+ // Same fan-out as generate_video's prompts[], except the varying part is
530
+ // the (image_url, prompt) PAIR — submitBatch carries the prompt as the
531
+ // per-tile caption either way.
532
+ if (items && items.length) {
533
+ const batch = await submitBatch(items, (it) => client.post('/v1/generate/video/from-image', { ...shared, image_url: it.image_url, prompt: it.prompt }));
534
+ if (ui()) return uiGenerating({
535
+ tool: 'generate_video_from_image', kind: 'video', gen: batch.ok[0].gen, client, model,
536
+ count: batch.ids.length, settings: { duration, resolution, aspect_ratio },
537
+ generation_ids: batch.ids, prompts: batch.ok.map((o) => o.prompt),
538
+ failed_submissions: batch.failed,
539
+ status_args: { generation_ids: batch.ids, wait: true },
540
+ reference_image: items[0].image_url
541
+ });
542
+ return pollBatch(client, batch, { interval: (batch.ok[0].gen.poll_interval_hint || 8) * 1000, timeout: 900000 });
543
+ }
544
+
545
+ const gen = await client.post('/v1/generate/video/from-image', { ...shared, image_url, prompt });
502
546
 
503
547
  if (ui()) return uiGenerating({
504
548
  tool: 'generate_video_from_image', kind: 'video', gen, client, model, prompt,
@@ -520,11 +564,12 @@ function registerGenerateTools(server, client, options = {}) {
520
564
  type: 'text',
521
565
  text: JSON.stringify({
522
566
  ...creditFields(result),
567
+ session_id: gen.session_id,
523
568
  urls: result.result.urls,
524
569
  model: result.result.model,
525
570
  duration: result.result.duration,
526
571
  thumbnail_url: result.result.thumbnail_url,
527
- _followup_hint: 'If the user asks to edit/restyle/extend this video next, pass urls[0] to edit_video or generate_video_from_video. Do NOT re-run generate_video_from_image unless they want a fresh animation from a different source image.'
572
+ _followup_hint: 'If the user asks to edit/restyle/extend this video next, pass urls[0] to edit_video or generate_video_from_video. Do NOT re-run generate_video_from_image unless they want a fresh animation from a different source image. Animating more shots of THIS same sequence? Pass the session_id above back on each of those calls so they all land in one session.'
528
573
  }, null, 2)
529
574
  }]
530
575
  };
@@ -555,15 +600,16 @@ function registerGenerateTools(server, client, options = {}) {
555
600
  use_composition_plan: z.boolean().optional().describe('Suno: enable structured composition planning (verse/chorus structure).'),
556
601
  singing_dna_id: z.string().optional().describe('Visual DNA character id whose singing voice to use (must be owned by the caller).'),
557
602
  singing_voice_id: z.string().optional().describe('Custom cloned singing-voice id (must be owned by the caller).'),
558
- project_id: projectIdField
603
+ project_id: projectIdField,
604
+ session_id: sessionIdField
559
605
  },
560
- async ({ prompt, model, style, title, instrumental, lyrics, vocal_gender, negative_tags, duration_seconds, enhance_prompt = false, preset_id, style_weight, weirdness, audio_weight, persona_id, use_composition_plan, singing_dna_id, singing_voice_id, project_id }) => {
606
+ async ({ prompt, model, style, title, instrumental, lyrics, vocal_gender, negative_tags, duration_seconds, enhance_prompt = false, preset_id, style_weight, weirdness, audio_weight, persona_id, use_composition_plan, singing_dna_id, singing_voice_id, project_id, session_id }) => {
561
607
  model = await canonicalModelId(client, model, 'music_gen'); // lenient id resolution ("z-image" → "z-image/turbo")
562
608
  const gen = await client.post('/v1/generate/music', {
563
609
  prompt, model, style, title, instrumental, lyrics, vocal_gender, negative_tags,
564
610
  duration_seconds, enhance_prompt, preset_id,
565
611
  style_weight, weirdness, audio_weight, persona_id, use_composition_plan,
566
- singing_dna_id, singing_voice_id, project_id
612
+ singing_dna_id, singing_voice_id, project_id, session_id
567
613
  });
568
614
 
569
615
  if (ui()) return uiGenerating({
@@ -583,6 +629,7 @@ function registerGenerateTools(server, client, options = {}) {
583
629
  type: 'text',
584
630
  text: JSON.stringify({
585
631
  ...creditFields(result),
632
+ session_id: gen.session_id,
586
633
  urls: result.result.urls,
587
634
  title: result.result.title,
588
635
  duration: result.result.duration,
@@ -627,9 +674,10 @@ function registerGenerateTools(server, client, options = {}) {
627
674
  minimax_vol: z.number().optional().describe('MiniMax volume, 0–10. Default 1.'),
628
675
  minimax_intensity: z.number().optional().describe('MiniMax voice intensity.'),
629
676
  minimax_timbre: z.number().optional().describe('MiniMax voice timbre.'),
630
- project_id: projectIdField
677
+ project_id: projectIdField,
678
+ session_id: sessionIdField
631
679
  },
632
- async ({ text, voice, model, language, style_instructions, selected_style, emotion, speaking_speed, similarity_boost, style, use_speaker_boost, variance, tempo, promptBoost, seed, accentControl, voiceTitle, minimax_pitch, minimax_vol, minimax_intensity, minimax_timbre, project_id }) => {
680
+ async ({ text, voice, model, language, style_instructions, selected_style, emotion, speaking_speed, similarity_boost, style, use_speaker_boost, variance, tempo, promptBoost, seed, accentControl, voiceTitle, minimax_pitch, minimax_vol, minimax_intensity, minimax_timbre, project_id, session_id }) => {
633
681
  model = await canonicalModelId(client, model, 'text_to_speech'); // lenient id resolution ("z-image" → "z-image/turbo")
634
682
  // Resolve the requested voice against the REAL catalog (cached) so the card
635
683
  // can show its display name + portrait instead of a raw id, and so an id
@@ -647,7 +695,7 @@ function registerGenerateTools(server, client, options = {}) {
647
695
  similarity_boost, style, use_speaker_boost,
648
696
  variance, tempo, promptBoost, seed, accentControl, voiceTitle,
649
697
  minimax_pitch, minimax_vol, minimax_intensity, minimax_timbre,
650
- project_id
698
+ project_id, session_id
651
699
  });
652
700
 
653
701
  if (ui()) return uiGenerating({
@@ -669,6 +717,7 @@ function registerGenerateTools(server, client, options = {}) {
669
717
  type: 'text',
670
718
  text: JSON.stringify({
671
719
  ...creditFields(result),
720
+ session_id: gen.session_id,
672
721
  urls: result.result.urls,
673
722
  voice: result.result.voice,
674
723
  duration: result.result.duration,
@@ -701,15 +750,16 @@ function registerGenerateTools(server, client, options = {}) {
701
750
  seed_pitch: z.number().optional().describe('FAL Seed-Audio: pitch shift in semitones.'),
702
751
  seed_reference_audio_urls: z.array(z.string()).optional().describe('FAL Seed-Audio: up to 3 reference audio URLs to condition the sound.'),
703
752
  seed_reference_image_url: z.string().optional().describe('FAL Seed-Audio: a reference image URL to condition the sound.'),
704
- project_id: projectIdField
753
+ project_id: projectIdField,
754
+ session_id: sessionIdField
705
755
  },
706
- async ({ prompt, model, duration, prompt_influence, cfg_strength, sound_loop, sound_tempo, sound_key, seed_voice, seed_speed, seed_volume, seed_pitch, seed_reference_audio_urls, seed_reference_image_url, project_id }) => {
756
+ async ({ prompt, model, duration, prompt_influence, cfg_strength, sound_loop, sound_tempo, sound_key, seed_voice, seed_speed, seed_volume, seed_pitch, seed_reference_audio_urls, seed_reference_image_url, project_id, session_id }) => {
707
757
  model = await canonicalModelId(client, model, 'text_to_sound'); // lenient id resolution ("z-image" → "z-image/turbo")
708
758
  const gen = await client.post('/v1/generate/sound', {
709
759
  prompt, model, duration, prompt_influence,
710
760
  cfg_strength, sound_loop, sound_tempo, sound_key,
711
761
  seed_voice, seed_speed, seed_volume, seed_pitch,
712
- seed_reference_audio_urls, seed_reference_image_url, project_id
762
+ seed_reference_audio_urls, seed_reference_image_url, project_id, session_id
713
763
  });
714
764
 
715
765
  if (ui()) return uiGenerating({
@@ -729,6 +779,7 @@ function registerGenerateTools(server, client, options = {}) {
729
779
  type: 'text',
730
780
  text: JSON.stringify({
731
781
  ...creditFields(result),
782
+ session_id: gen.session_id,
732
783
  urls: result.result.urls,
733
784
  duration: result.result.duration
734
785
  }, null, 2)
@@ -917,9 +968,10 @@ function registerGenerateTools(server, client, options = {}) {
917
968
  image_url: z.string().describe('Public URL of the keyframe image'),
918
969
  timestamp_seconds: z.number().describe('Moment on the OUTPUT timeline (seconds, 0 = first frame) where this image is pinned')
919
970
  })).optional().describe('Timeline-pinned keyframes for multi-keyframe models (e.g. "flux-3-keyframes", "luma-ray-3-2-storyboard"): the model generates the motion BETWEEN the pinned images. Only models with `supports_keyframes: true` in list_models accept this; cap = `max_keyframes` (FLUX 3: 10). Requires an explicit `duration` — timestamps beyond it are clamped. Ignored by ordinary elements models. OPTIONAL for flux-3-keyframes: if omitted, pass the images via reference_images instead — they are played through IN ORDER, timed from timing language in the prompt or spaced evenly. Pass explicit keyframes only when you need exact control.'),
920
- project_id: projectIdField
971
+ project_id: projectIdField,
972
+ session_id: sessionIdField
921
973
  },
922
- async ({ prompt, model, reference_images, reference_videos, reference_audio_urls, audio_url, files, duration, aspect_ratio, motion, preset_id, enhance_prompt = false, visual_dna_ids, resolution, keyframes, project_id }) => {
974
+ async ({ prompt, model, reference_images, reference_videos, reference_audio_urls, audio_url, files, duration, aspect_ratio, motion, preset_id, enhance_prompt = false, visual_dna_ids, resolution, keyframes, project_id, session_id }) => {
923
975
  model = await canonicalModelId(client, model, 'elements'); // lenient id resolution ("z-image" → "z-image/turbo")
924
976
  if (!prompt) throw new Error('prompt is required');
925
977
 
@@ -943,6 +995,7 @@ function registerGenerateTools(server, client, options = {}) {
943
995
  if (resolution) form.append('resolution', resolution);
944
996
  if (keyframes) form.append('keyframes', JSON.stringify(keyframes));
945
997
  if (project_id) form.append('project_id', project_id);
998
+ if (session_id) form.append('session_id', session_id);
946
999
  for (const f of resolved) {
947
1000
  form.append('files', f.buffer, { filename: f.filename, contentType: f.contentType });
948
1001
  }
@@ -950,7 +1003,7 @@ function registerGenerateTools(server, client, options = {}) {
950
1003
  } else {
951
1004
  // URL-only mode: plain JSON.
952
1005
  startResponse = await client.post('/v1/generate/elements', {
953
- prompt, model, reference_images, reference_videos, reference_audio_urls, audio_url, duration, aspect_ratio, motion, preset_id, enhance_prompt, visual_dna_ids, resolution, keyframes, project_id
1006
+ prompt, model, reference_images, reference_videos, reference_audio_urls, audio_url, duration, aspect_ratio, motion, preset_id, enhance_prompt, visual_dna_ids, resolution, keyframes, project_id, session_id
954
1007
  });
955
1008
  }
956
1009
 
@@ -972,6 +1025,7 @@ function registerGenerateTools(server, client, options = {}) {
972
1025
  type: 'text',
973
1026
  text: JSON.stringify({
974
1027
  ...creditFields(result),
1028
+ session_id: startResponse.session_id,
975
1029
  urls: result.result?.urls || [],
976
1030
  thumbnail_url: result.result?.thumbnail_url || null,
977
1031
  duration: result.result?.duration || null,
@@ -998,9 +1052,10 @@ function registerGenerateTools(server, client, options = {}) {
998
1052
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt. Default: false — only pass true if the user explicitly asks to enhance/improve the prompt.'),
999
1053
  visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply. **Cap: pass at most `max_visual_dna` IDs from list_models for the chosen model; if `supports_visual_dna: false`, DNA is silently ignored.**'),
1000
1054
  resolution: z.string().optional().describe('Video resolution tier (vertical pixels): "720p" / "1080p" / "1440p" / "2160p". Model-dependent — call list_models and read supported_resolutions.'),
1001
- project_id: projectIdField
1055
+ project_id: projectIdField,
1056
+ session_id: sessionIdField
1002
1057
  },
1003
- async ({ first_frame_url, last_frame_url, first_frame, last_frame, prompt, model, duration, aspect_ratio, enhance_prompt = false, visual_dna_ids, resolution, project_id }) => {
1058
+ async ({ first_frame_url, last_frame_url, first_frame, last_frame, prompt, model, duration, aspect_ratio, enhance_prompt = false, visual_dna_ids, resolution, project_id, session_id }) => {
1004
1059
  model = await canonicalModelId(client, model, 'firstlastgenerations'); // lenient id resolution ("z-image" → "z-image/turbo")
1005
1060
  const urlMode = first_frame_url && last_frame_url;
1006
1061
  const fileMode = first_frame && last_frame;
@@ -1028,10 +1083,11 @@ function registerGenerateTools(server, client, options = {}) {
1028
1083
  if (visual_dna_ids) form.append('visual_dna_ids', JSON.stringify(visual_dna_ids));
1029
1084
  if (resolution) form.append('resolution', resolution);
1030
1085
  if (project_id) form.append('project_id', project_id);
1086
+ if (session_id) form.append('session_id', session_id);
1031
1087
  startResponse = await client.postMultipart('/v1/generate/first-last-frame', form);
1032
1088
  } else {
1033
1089
  startResponse = await client.post('/v1/generate/first-last-frame', {
1034
- first_frame_url, last_frame_url, prompt, model, duration, aspect_ratio, enhance_prompt, visual_dna_ids, resolution, project_id
1090
+ first_frame_url, last_frame_url, prompt, model, duration, aspect_ratio, enhance_prompt, visual_dna_ids, resolution, project_id, session_id
1035
1091
  });
1036
1092
  }
1037
1093
 
@@ -1053,6 +1109,7 @@ function registerGenerateTools(server, client, options = {}) {
1053
1109
  type: 'text',
1054
1110
  text: JSON.stringify({
1055
1111
  ...creditFields(result),
1112
+ session_id: startResponse.session_id,
1056
1113
  urls: result.result?.urls || [],
1057
1114
  thumbnail_url: result.result?.thumbnail_url || null,
1058
1115
  duration: result.result?.duration || null,
@@ -1088,9 +1145,10 @@ function registerGenerateTools(server, client, options = {}) {
1088
1145
  bounding_boxes_url: z.string().optional().describe('URL to a JSON file with per-frame boxes.'),
1089
1146
  face_image: z.string().optional().describe('Base64-encoded reference face image.')
1090
1147
  }).optional().describe('Sync-3 only: choose which speaker gets synced in a multi-person video. Use auto_detect:true for automatic, or coordinates + frame_number to pin a specific face.'),
1091
- project_id: projectIdField
1148
+ project_id: projectIdField,
1149
+ session_id: sessionIdField
1092
1150
  },
1093
- async ({ source, audio, text_prompt, model, bounding_box_target, sync_mode, model_mode, emotion, temperature, occlusion_detection_enabled, active_speaker_detection, project_id }) => {
1151
+ async ({ source, audio, text_prompt, model, bounding_box_target, sync_mode, model_mode, emotion, temperature, occlusion_detection_enabled, active_speaker_detection, project_id, session_id }) => {
1094
1152
  model = await canonicalModelId(client, model, ['lipsync-image', 'lipsync-video']); // lenient id resolution ("z-image" → "z-image/turbo")
1095
1153
  if (!source) throw new Error('source is required (URL or absolute local path to image/video)');
1096
1154
  if (!audio) throw new Error('audio is required (URL or absolute local path to audio file)');
@@ -1114,7 +1172,7 @@ function registerGenerateTools(server, client, options = {}) {
1114
1172
  temperature,
1115
1173
  occlusion_detection_enabled,
1116
1174
  active_speaker_detection,
1117
- project_id
1175
+ project_id, session_id
1118
1176
  });
1119
1177
  } else {
1120
1178
  // File mode (or mixed — resolve any local paths, pass URLs through as body fields)
@@ -1144,6 +1202,7 @@ function registerGenerateTools(server, client, options = {}) {
1144
1202
  if (occlusion_detection_enabled !== undefined) form.append('occlusion_detection_enabled', String(occlusion_detection_enabled));
1145
1203
  if (active_speaker_detection) form.append('active_speaker_detection', JSON.stringify(active_speaker_detection));
1146
1204
  if (project_id) form.append('project_id', project_id);
1205
+ if (session_id) form.append('session_id', session_id);
1147
1206
  startResponse = await client.postMultipart('/v1/generate/lipsync', form);
1148
1207
  }
1149
1208
 
@@ -1165,6 +1224,7 @@ function registerGenerateTools(server, client, options = {}) {
1165
1224
  type: 'text',
1166
1225
  text: JSON.stringify({
1167
1226
  ...creditFields(result),
1227
+ session_id: startResponse.session_id,
1168
1228
  urls: result.result?.urls || [],
1169
1229
  thumbnail_url: result.result?.thumbnail_url || null,
1170
1230
  duration: result.result?.duration || null,
@@ -1209,9 +1269,10 @@ function registerGenerateTools(server, client, options = {}) {
1209
1269
  highlighted: z.object({ font: z.string().optional(), weight: z.number().int().min(100).max(900).optional(), color: z.string().optional() }).optional().describe('Highlighted word tier styling.'),
1210
1270
  }).optional(),
1211
1271
  }).optional().describe('VEED Subtitles only: style overrides. Any omitted field keeps the preset default. Best supported by Basic presets.'),
1212
- project_id: projectIdField
1272
+ project_id: projectIdField,
1273
+ session_id: sessionIdField
1213
1274
  },
1214
- async ({ source_video, prompt, model, aspect_ratio, duration, enhance_prompt = false, visual_dna_ids, resolution, reference_images, reference_videos, elements, preset, source_language, translation_language, srt_content, srt_file_url, vocabulary, customization, project_id }) => {
1275
+ async ({ source_video, prompt, model, aspect_ratio, duration, enhance_prompt = false, visual_dna_ids, resolution, reference_images, reference_videos, elements, preset, source_language, translation_language, srt_content, srt_file_url, vocabulary, customization, project_id, session_id }) => {
1215
1276
  model = await canonicalModelId(client, model, 'video_to_video'); // lenient id resolution ("z-image" → "z-image/turbo")
1216
1277
  if (!source_video) throw new Error('source_video is required');
1217
1278
 
@@ -1221,7 +1282,7 @@ function registerGenerateTools(server, client, options = {}) {
1221
1282
  startResponse = await client.post('/v1/generate/video-from-video', {
1222
1283
  video_url: source_video, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution,
1223
1284
  reference_images, reference_videos, elements, preset, source_language, translation_language,
1224
- srt_content, srt_file_url, vocabulary, customization, project_id
1285
+ srt_content, srt_file_url, vocabulary, customization, project_id, session_id
1225
1286
  });
1226
1287
  } else {
1227
1288
  const resolved = await resolveToBuffer(source_video, 'video');
@@ -1245,6 +1306,7 @@ function registerGenerateTools(server, client, options = {}) {
1245
1306
  if (reference_videos) form.append('reference_videos', JSON.stringify(reference_videos));
1246
1307
  if (elements) form.append('elements', JSON.stringify(elements));
1247
1308
  if (project_id) form.append('project_id', project_id);
1309
+ if (session_id) form.append('session_id', session_id);
1248
1310
  startResponse = await client.postMultipart('/v1/generate/video-from-video', form);
1249
1311
  }
1250
1312
 
@@ -1267,6 +1329,7 @@ function registerGenerateTools(server, client, options = {}) {
1267
1329
  type: 'text',
1268
1330
  text: JSON.stringify({
1269
1331
  ...creditFields(result),
1332
+ session_id: startResponse.session_id,
1270
1333
  urls: result.result?.urls || [],
1271
1334
  thumbnail_url: result.result?.thumbnail_url || null,
1272
1335
  duration: result.result?.duration || null,
@@ -1291,15 +1354,16 @@ function registerGenerateTools(server, client, options = {}) {
1291
1354
  words_per_line: z.number().optional().describe('SRT: max words per subtitle line, 1–18. Default: 12.'),
1292
1355
  lines_per_subtitle: z.number().optional().describe('SRT: max lines per subtitle cue, 1–4. Default: 2.'),
1293
1356
  stretch_captions: z.boolean().optional().describe('SRT: extend each cue\'s end time to the next cue\'s start (gap-free subtitles). Default: true.'),
1294
- project_id: projectIdField
1357
+ project_id: projectIdField,
1358
+ session_id: sessionIdField
1295
1359
  },
1296
- async ({ source, language, diarize, tag_audio_events, remove_punctuation, generate_srt, words_per_line, lines_per_subtitle, stretch_captions, project_id }) => {
1360
+ async ({ source, language, diarize, tag_audio_events, remove_punctuation, generate_srt, words_per_line, lines_per_subtitle, stretch_captions, project_id, session_id }) => {
1297
1361
  if (!source) throw new Error('source is required (URL or absolute local path)');
1298
1362
 
1299
1363
  // Advanced transcription controls forwarded when provided (undefined keys are dropped by the client).
1300
1364
  const opts = {
1301
1365
  language, diarize, tag_audio_events, remove_punctuation,
1302
- generate_srt, words_per_line, lines_per_subtitle, stretch_captions, project_id
1366
+ generate_srt, words_per_line, lines_per_subtitle, stretch_captions, project_id, session_id
1303
1367
  };
1304
1368
 
1305
1369
  const isUrl = /^https?:\/\//i.test(source);
@@ -1343,6 +1407,7 @@ function registerGenerateTools(server, client, options = {}) {
1343
1407
  type: 'text',
1344
1408
  text: JSON.stringify({
1345
1409
  ...creditFields(result),
1410
+ session_id: startResponse.session_id,
1346
1411
  text: result.result?.text || '',
1347
1412
  srt_url: result.result?.srt_url || null,
1348
1413
  word_by_word_srt_url: result.result?.word_by_word_srt_url || null,
@@ -1506,13 +1571,14 @@ function registerGenerateTools(server, client, options = {}) {
1506
1571
  ai_optimize: z.boolean().optional()
1507
1572
  .describe('Whether to let Kolbo AI enhance your prompt before sending to the model. Default: false — your prompt reaches the model exactly as written. Only pass true if the user explicitly asks to enhance/improve the prompt.'),
1508
1573
 
1509
- project_id: projectIdField
1574
+ project_id: projectIdField,
1575
+ session_id: sessionIdField
1510
1576
  },
1511
1577
  async ({
1512
1578
  image_url, operation, model, scale, aspect_ratio, skin_strength, prompt,
1513
1579
  mask_image_url, additional_images, generate_all_angles, resolution, quality, ai_optimize = false,
1514
1580
  zoom_out_percentage, expand_left, expand_right, expand_top, expand_bottom,
1515
- project_id
1581
+ project_id, session_id
1516
1582
  }) => {
1517
1583
  // No `type` argument: these are operation-routed tools (upscale / reframe /
1518
1584
  // removebg / …), each operation with its own model family — there is no single
@@ -1530,7 +1596,7 @@ function registerGenerateTools(server, client, options = {}) {
1530
1596
  image_url, operation, model, scale, aspect_ratio, skin_strength, prompt,
1531
1597
  mask_image_url, additional_images, generate_all_angles, resolution, quality, ai_optimize,
1532
1598
  zoom_out_percentage, expand_left, expand_right, expand_top, expand_bottom,
1533
- project_id
1599
+ project_id, session_id
1534
1600
  });
1535
1601
 
1536
1602
  if (ui()) return uiGenerating({
@@ -1552,6 +1618,7 @@ function registerGenerateTools(server, client, options = {}) {
1552
1618
  type: 'text',
1553
1619
  text: JSON.stringify({
1554
1620
  ...creditFields(result),
1621
+ session_id: gen.session_id,
1555
1622
  urls: result.result?.urls || [],
1556
1623
  edit_type: result.result?.edit_type || null,
1557
1624
  model: result.result?.model || null
@@ -1656,7 +1723,8 @@ function registerGenerateTools(server, client, options = {}) {
1656
1723
  start_time: z.number().optional()
1657
1724
  .describe('Start time in seconds of the segment to retake. Used with operation="retake".'),
1658
1725
 
1659
- project_id: projectIdField
1726
+ project_id: projectIdField,
1727
+ session_id: sessionIdField
1660
1728
  },
1661
1729
  async ({
1662
1730
  video_url, operation, model, aspect_ratio, scale, prompt,
@@ -1668,7 +1736,7 @@ function registerGenerateTools(server, client, options = {}) {
1668
1736
  text_prompt, context,
1669
1737
  mask_video_url, object_prompt, video_strength,
1670
1738
  start_time,
1671
- project_id
1739
+ project_id, session_id
1672
1740
  }) => {
1673
1741
  // No `type` argument: these are operation-routed tools (upscale / reframe /
1674
1742
  // removebg / …), each operation with its own model family — there is no single
@@ -1693,7 +1761,7 @@ function registerGenerateTools(server, client, options = {}) {
1693
1761
  text_prompt, context,
1694
1762
  mask_video_url, object_prompt, video_strength,
1695
1763
  start_time,
1696
- project_id
1764
+ project_id, session_id
1697
1765
  });
1698
1766
 
1699
1767
  if (ui()) return uiGenerating({
@@ -1715,6 +1783,7 @@ function registerGenerateTools(server, client, options = {}) {
1715
1783
  type: 'text',
1716
1784
  text: JSON.stringify({
1717
1785
  ...creditFields(result),
1786
+ session_id: gen.session_id,
1718
1787
  urls: result.result?.urls || [],
1719
1788
  download_url: result.result?.download_url || null,
1720
1789
  edit_type: result.result?.edit_type || null,
@@ -27,17 +27,26 @@ async function mintUploadTicket(client) {
27
27
  // holding a token they must use immediately, and telling one of them to go call
28
28
  // another tool to learn the POST shape would spend the round trip this whole
29
29
  // path exists to remove.
30
+ // The ticket is reusable for a whole batch, but the endpoint is rate limited —
31
+ // and a batch uploader that only learns that from the 41st POST has already
32
+ // stalled halfway through. kolbo-api sends the real numbers in `rate_limit`;
33
+ // this fallback covers an older deployment that predates that field.
34
+ const DEFAULT_UPLOAD_RATE = { max_uploads: 40, per_seconds: 60 };
35
+
30
36
  function uploadTicketPayload(ticket) {
37
+ const rate = ticket.rate_limit || DEFAULT_UPLOAD_RATE;
31
38
  return {
32
39
  upload_url: ticket.upload_url,
33
40
  token: ticket.token,
34
41
  expires_in_seconds: ticket.expires_in,
35
42
  max_file_mb: ticket.max_file_mb || DEFAULT_MAX_FILE_MB,
36
43
  accepted: ticket.accepted,
44
+ rate_limit: rate,
37
45
  how_to_upload: {
38
46
  example: 'curl -X POST "<upload_url>" -H "Authorization: Bearer <token>" -F "file=@/absolute/path/to/file.mp3"',
39
47
  optional_fields: ['project_id', 'description'],
40
48
  response: 'JSON — the stable CDN URL is at media.url. One POST per file; reuse the ticket for a batch.',
49
+ pacing: `RATE LIMIT: ${rate.max_uploads} uploads per ${rate.per_seconds}s. For a batch larger than that, pace it (e.g. sleep ${Math.max(1, Math.ceil(rate.per_seconds / rate.max_uploads))}s between files) instead of firing them back to back. Over the limit you get HTTP 429 with a Retry-After header and retry_after_seconds in the body — wait that long, then continue; do not guess a backoff and do not treat it as a failed upload.`,
41
50
  // Git Bash hands curl a POSIX-style /c/Users/... path that Windows curl
42
51
  // cannot open (exit 26). Real trap — it cost a round trip to find.
43
52
  windows_note: 'Give curl a native path (C:/Users/...) — a Git Bash /c/Users/... path fails to open.',
@@ -119,7 +128,7 @@ function registerMediaTools(server, client, options = {}) {
119
128
  // ─── create_upload_ticket ──────────────────────────────────
120
129
  server.tool(
121
130
  'create_upload_ticket',
122
- 'Get a short-lived ticket for uploading LOCAL files straight into the user\'s Kolbo media library, with NO upload card and no user interaction. ' + ticketRouting + ' Why it exists: when the server cannot read the caller\'s disk, the only other ways in are making the user click an upload card (`media_upload_widget`) or inlining the file as base64 via `upload_media` — base64 is slow and burns context in proportion to file size, so do not use it for anything but a tiny file. Returns `upload_url` + `token`; POST each file as multipart field `file` with header `Authorization: Bearer <token>` and read the CDN URL from `media.url` in the response. One POST per file; the ticket is reusable until it expires. Then pass those URLs to any generation tool (transcribe_audio, generate_image_edit, generate_video_from_image, generate_lipsync, visual DNA, …).',
131
+ 'Get a short-lived ticket for uploading LOCAL files straight into the user\'s Kolbo media library, with NO upload card and no user interaction. ' + ticketRouting + ' Why it exists: when the server cannot read the caller\'s disk, the only other ways in are making the user click an upload card (`media_upload_widget`) or inlining the file as base64 via `upload_media` — base64 is slow and burns context in proportion to file size, so do not use it for anything but a tiny file. Returns `upload_url` + `token`; POST each file as multipart field `file` with header `Authorization: Bearer <token>` and read the CDN URL from `media.url` in the response. One POST per file; the ticket is reusable until it expires. The endpoint is RATE LIMITED (the exact cap and window come back in the ticket\'s `rate_limit` field — currently 40 uploads/minute): pace a batch bigger than that rather than firing every file at once, and on HTTP 429 honour the `Retry-After` header / `retry_after_seconds` body field instead of guessing a backoff. Then pass those URLs to any generation tool (transcribe_audio, generate_image_edit, generate_video_from_image, generate_lipsync, visual DNA, …).',
123
132
  {},
124
133
  async () => {
125
134
  const ticket = await mintUploadTicket(client);
@@ -67,6 +67,10 @@ function buildCatalogStructured(models, type, compact) {
67
67
  if (g.models.length >= 6) continue; // curated cap — full list lives in the text payload
68
68
  g.models.push({
69
69
  name: m.name,
70
+ // The widget renders `name`; the AGENT reads the same rows (hosts hand it
71
+ // structuredContent). Without the identifier the default call was a dead
72
+ // end — it named six models and gave no way to pass any of them on.
73
+ identifier: m.identifier,
70
74
  icon: resolveAvatarUrl(m.avatar),
71
75
  description: String(m.smartSelect_StrengthsSummary || m.summary || m.description || '').slice(0, 90),
72
76
  chips: modelChips(m),
@@ -321,8 +325,14 @@ function registerModelTools(server, client, options = {}) {
321
325
  const flat = String(s).replace(/\s+/g, ' ').trim();
322
326
  return flat.length > 130 ? flat.slice(0, 127).trimEnd() + '…' : flat;
323
327
  };
328
+ // Text models bill per token — the flat `credit` is not what the user pays,
329
+ // so show the real per-1K rates when the API supplies them. Without this the
330
+ // "cheapest model that fits" rule is unusable for chat.
331
+ const cost = m => (m.output_token_rate != null
332
+ ? `${m.input_token_rate ?? '?'}/${m.output_token_rate} credits per 1K tokens (in/out)`
333
+ : `${m.credit} credits`);
324
334
  const formatModel = m =>
325
- `${m.identifier} (${m.name}) - ${m.credit} credits${m.recommended ? ' [RECOMMENDED]' : ''}${m.new_model ? ' [NEW]' : ''}${m.summary ? ` — ${detailed ? m.summary : brief(m.summary)}` : ''}${detailed ? formatSpecs(m) : ''}`;
335
+ `${m.identifier} (${m.name}) - ${cost(m)}${m.recommended ? ' [RECOMMENDED]' : ''}${m.new_model ? ' [NEW]' : ''}${m.summary ? ` — ${detailed ? m.summary : brief(m.summary)}` : ''}${detailed ? formatSpecs(m) : ''}`;
326
336
 
327
337
  const sections = [];
328
338
 
@@ -337,7 +347,10 @@ function registerModelTools(server, client, options = {}) {
337
347
  }
338
348
  const text = `Kolbo model catalog — ${result.count} models total.\n\n`
339
349
  + `${sections.join('\n\n')}\n\n`
340
- + 'This is the curated shortlist, not the full catalog. To see everything in a '
350
+ + 'This shortlist is BADGE-BASED (recommended/new) it is not a recommendation to '
351
+ + 'use the newest or biggest model. To pick properly, re-call with `type` and choose by '
352
+ + 'each model\'s strengths summary, taking the cheapest one that covers the task. '
353
+ + 'To see everything in a '
341
354
  + 'category (with per-model resolutions, durations, aspect ratios and reference-image '
342
355
  + 'caps), re-call with `type`:\n'
343
356
  + ' text_to_img · image_editing · text_to_video · img_to_video · video_to_video ·\n'
@@ -350,7 +363,7 @@ function registerModelTools(server, client, options = {}) {
350
363
  }
351
364
 
352
365
  if (withSummary.length > 0) {
353
- sections.push(`Auto-selectable models (${withSummary.length}) — safe to pick based on quality + cost:\n${withSummary.map(formatModel).join('\n')}`);
366
+ sections.push(`Auto-selectable models (${withSummary.length}) — CHOOSE BY THE SUMMARY after each "—": match it to what the user asked for, then take the CHEAPEST model that fits. Credit cost, [NEW] and [RECOMMENDED] are not reasons to pick a model:\n${withSummary.map(formatModel).join('\n')}`);
354
367
  }
355
368
  if (withoutSummary.length > 0) {
356
369
  sections.push(`Named-only models (${withoutSummary.length}) — only use if the user explicitly requests by name:\n${withoutSummary.map(formatModel).join('\n')}`);
@@ -30,7 +30,7 @@ function tracksResult(ui, title, tracks, total) {
30
30
  if (!tracks.length) return { content: [{ type: 'text', text: 'No SYNCI tracks found.' }] };
31
31
  const text = [
32
32
  `Found ${tracks.length} track${tracks.length === 1 ? '' : 's'}${total ? ` (of ${total})` : ''}.`,
33
- 'Playback URLs are watermarked previews. Use acquire_clean_music_track for final use; it consumes one SYNCI vendor credit.',
33
+ 'Playback URLs are watermarked previews. Use acquire_clean_music_track for final use. ' + "Free for subscribers, org members and anyone who already bought the track; for everyone else it COSTS CREDITS (the search response carries the exact price in cleanTrackCredits, and cleanAccess:false means this caller will be charged). State the cost and get the user's agreement BEFORE calling it.",
34
34
  '',
35
35
  tracks.map(trackLine).join('\n\n'),
36
36
  ].join('\n');
@@ -68,12 +68,12 @@ function registerMusicLibraryTools(server, client, options = {}) {
68
68
  'search_music_library',
69
69
  'Search the licensed SYNCI catalog — the PAID third-party option. ' +
70
70
  '⚠️ NOT the default for music. Kolbo has its OWN large AI music library that is FREE and ' +
71
- 'costs no vendor credit: call search_stock_media with source="kolbo-ai" and mediaType="music" ' +
71
+ 'is usually cheaper or free: call search_stock_media with source="kolbo-ai" and mediaType="music" ' +
72
72
  '(it also supports natural-language vibe search, e.g. "uplifting hopeful corporate background"). ' +
73
73
  'Reach for SYNCI only when the user explicitly asks for the licensed/SYNCI catalog, names a real ' +
74
74
  'artist or commercial track, or needs stems / a specific licensed cue. ' +
75
75
  'Results here contain watermarked preview audio only; any download or timeline use requires ' +
76
- 'acquire_clean_music_track, which consumes one SYNCI vendor credit.',
76
+ 'acquire_clean_music_track. ' + "Free for subscribers, org members and anyone who already bought the track; for everyone else it COSTS CREDITS (the search response carries the exact price in cleanTrackCredits, and cleanAccess:false means this caller will be charged). State the cost and get the user's agreement BEFORE calling it.",
77
77
  {
78
78
  query: z.string().max(200).optional(),
79
79
  mood: z.string().optional(),
@@ -171,7 +171,7 @@ function registerMusicLibraryTools(server, client, options = {}) {
171
171
 
172
172
  server.tool(
173
173
  'acquire_clean_music_track',
174
- 'Acquire a clean, unwatermarked SYNCI MP3 or WAV for download or Adobe timeline use. This immediately consumes one SYNCI vendor credit with no confirmation dialog. Reuse request_id when retrying the same intended action.',
174
+ 'Acquire a clean, unwatermarked SYNCI MP3 or WAV for download or Adobe timeline use. Charges IMMEDIATELY with no confirmation dialog. ' + "Free for subscribers, org members and anyone who already bought the track; for everyone else it COSTS CREDITS (the search response carries the exact price in cleanTrackCredits, and cleanAccess:false means this caller will be charged). State the cost and get the user's agreement BEFORE calling it." + ' Once bought, the track is owned permanently: the other format, its stems and every re-download are free. Reuse request_id when retrying the same intended action.',
175
175
  {
176
176
  track_id: z.string().min(1).max(64),
177
177
  format: z.enum(['mp3', 'wav']).optional().describe('Default mp3. Use wav only when the search result reports hqAvailable=true.'),
@@ -193,7 +193,7 @@ function registerMusicLibraryTools(server, client, options = {}) {
193
193
 
194
194
  server.tool(
195
195
  'import_music_track_to_library',
196
- 'Acquire one clean SYNCI file and copy it into the Kolbo media library. This immediately consumes one SYNCI vendor credit unless the track is already in the library. Defaults to clean MP3.',
196
+ 'Acquire one clean SYNCI file and copy it into the Kolbo media library. Charges IMMEDIATELY unless the track is already in the library or already owned. ' + "Free for subscribers, org members and anyone who already bought the track; for everyone else it COSTS CREDITS (the search response carries the exact price in cleanTrackCredits, and cleanAccess:false means this caller will be charged). State the cost and get the user's agreement BEFORE calling it." + ' Defaults to clean MP3.',
197
197
  {
198
198
  track_id: z.string().min(1).max(64),
199
199
  format: z.enum(['mp3', 'wav']).optional(),
@@ -12,21 +12,34 @@ function registerProjectTools(server, client, options = {}) {
12
12
  // ─── list_projects ─────────────────────────────────────────
13
13
  server.tool(
14
14
  'list_projects',
15
- 'List the user\'s platform projects (owned + shared with edit/full/owner permission). Use this to resolve a project NAME the user mentioned ("put this in my Acme Campaign project") into the project ObjectId you pass back as `project_id` on generation / chat / upload / move tools. Whenever the user mentions a project by name OR location, you MUST call this first — those tools accept only ObjectIds, not names — and then pass the resolved `project_id` on EVERY subsequent call in the conversation (it is per-call, not sticky; omitting it drops work into the default bucket). Returns id, name, role, and is_default. The project flagged `is_default: true` is the auto-created "API Generations" bucket every SDK generation lands in when project_id is omitted.',
16
- {},
17
- async () => {
18
- const result = await client.get('/v1/projects');
15
+ 'List the user\'s platform projects (owned + shared with edit/full/owner permission). Use this to resolve a project NAME the user mentioned ("put this in my Acme Campaign project") into the project ObjectId you pass back as `project_id` on generation / chat / upload / move tools. Whenever the user mentions a project by name OR location, you MUST call this first — those tools accept only ObjectIds, not names — and then pass the resolved `project_id` on EVERY subsequent call in the conversation (it is per-call, not sticky; omitting it drops work into the default bucket). Returns id, name, role, is_default, and is_archived. The project flagged `is_default: true` is the auto-created "API Generations" bucket every SDK generation lands in when project_id is omitted. Accounts routinely have HUNDREDS of projects, so this is paginated: when you already know the name, pass `search` — it is far cheaper than listing everything. Default page size is 50; use `page` to walk the rest (`pagination.has_more` tells you when to stop). Archived projects are hidden unless you pass `include_archived: true`.',
16
+ {
17
+ search: z.string().optional().describe('Case-insensitive substring match on the project name. Use this whenever the user named a project — it turns a full listing into a one-item answer.'),
18
+ page: z.number().optional().describe('Page number, 1-indexed. Default: 1'),
19
+ limit: z.number().optional().describe('Results per page, max 200. Default: 50'),
20
+ include_archived: z.boolean().optional().describe('Also return archived projects. Default false — archived projects are hidden here exactly as they are in the web app.')
21
+ },
22
+ async ({ search, page, limit, include_archived }) => {
23
+ const params = new URLSearchParams();
24
+ if (search) params.set('search', search);
25
+ if (page) params.set('page', String(page));
26
+ if (limit) params.set('limit', String(limit));
27
+ if (include_archived) params.set('include_archived', 'true');
28
+ const qs = params.toString();
29
+ const result = await client.get(`/v1/projects${qs ? '?' + qs : ''}`);
19
30
  const projects = (result.projects || []).map(p => ({
20
31
  id: p.id,
21
32
  name: p.name,
22
33
  role: p.role,
23
34
  is_default: !!p.is_default,
35
+ is_archived: !!p.is_archived,
24
36
  open_url: buildProjectUrl(p.id, { is_default: !!p.is_default })
25
37
  }));
26
38
  const text = JSON.stringify({
27
39
  projects,
28
40
  count: projects.length,
29
- _hint: 'Pass the chosen `id` as `project_id` on any generate_* tool to drop the generation into that project. Omit project_id to use the project flagged is_default:true. `open_url` opens that project\'s media in the web app (share it with the user).'
41
+ pagination: result.pagination || null,
42
+ _hint: 'Pass the chosen `id` as `project_id` on any generate_* tool to drop the generation into that project. Omit project_id to use the project flagged is_default:true. `open_url` opens that project\'s media in the web app (share it with the user). If `pagination.has_more` is true there are more projects — narrow with `search` rather than paging through everything.'
30
43
  }, null, 2);
31
44
 
32
45
  if (ui()) {
@@ -36,7 +49,7 @@ function registerProjectTools(server, client, options = {}) {
36
49
  items: projects.map(p => ({
37
50
  id: p.id,
38
51
  title: p.name,
39
- subtitle: p.role + (p.is_default ? ' · default' : ''),
52
+ subtitle: p.role + (p.is_default ? ' · default' : '') + (p.is_archived ? ' · archived' : ''),
40
53
  open_url: p.open_url,
41
54
  use_hint: 'Use my "{TITLE}" project (project_id: {ID}) for what I do next.'
42
55
  })),
@@ -7,12 +7,17 @@ const { compactList } = require('./_shared');
7
7
 
8
8
  // Compact one-line render of a normalized stock asset.
9
9
  function assetLine(a) {
10
+ // What THIS caller would pay, stamped per asset by the API. 0/absent = free
11
+ // (subscriber, org member, or already bought). Surfaced in the text the agent
12
+ // reads so it can warn before spending someone's credits.
13
+ const price = a.meta && a.meta.priceCredits;
14
+ const cost = (a.meta && a.meta.owned) ? ' [purchased]' : (price ? ` [${price} credits]` : '');
10
15
  const dims = a.width && a.height ? `${a.width}x${a.height}` : null;
11
16
  const dur = a.durationSeconds != null ? `${Math.round(a.durationSeconds)}s` : null;
12
17
  const meta = [a.mediaType, dims, dur].filter(Boolean).join(' · ');
13
18
  const by = a.author?.name ? ` by ${a.author.name}` : '';
14
19
  const variants = Array.isArray(a.downloadVariants) ? a.downloadVariants.map((v) => v.label).join('/') : '';
15
- return `[${a.source}:${a.sourceId}] ${a.title || '(untitled)'}${by}\n ${meta}${variants ? ` variants: ${variants}` : ''}${a.thumbnailUrl ? `\n thumb: ${a.thumbnailUrl}` : ''}`;
20
+ return `[${a.source}:${a.sourceId}] ${a.title || '(untitled)'}${by}${cost}\n ${meta}${variants ? ` variants: ${variants}` : ''}${a.thumbnailUrl ? `\n thumb: ${a.thumbnailUrl}` : ''}`;
16
21
  }
17
22
 
18
23
  // Returns the raw querystring (no leading '?'). Callers inline it as
@@ -37,7 +42,7 @@ function registerStockLibraryTools(server, client, options = {}) {
37
42
  server.tool(
38
43
  'search_stock_media',
39
44
  'Search the Kolbo unified stock media library and return matching assets. ' +
40
- '★ THE DEFAULT TOOL FOR MUSIC: Kolbo\'s own AI music library is FREE (no vendor credit) — ' +
45
+ '★ THE DEFAULT TOOL FOR MUSIC: Kolbo\'s own AI music library, free for subscribers and org members and priced per track otherwise (every result states its own cost) — ' +
41
46
  'use source="kolbo-ai" with mediaType="music" for background/score/bed requests. Prefer it over ' +
42
47
  'search_music_library (SYNCI), which is the paid licensed catalog and should only be used when the ' +
43
48
  'user explicitly asks for it. ' +
@@ -84,7 +89,12 @@ function registerStockLibraryTools(server, client, options = {}) {
84
89
  media_type: mt,
85
90
  url: (a.downloadVariants?.[0]?.url) || a.thumbnailUrl,
86
91
  preview_audio: mt === 'audio' ? (a.previewUrl || a.downloadVariants?.[0]?.url) : undefined,
87
- use_hint: 'Import this stock asset into my media library: import_stock_asset source="' + a.source + '" id="' + a.sourceId + '" ("{TITLE}")'
92
+ badge: (a.meta && a.meta.owned) ? 'Purchased' : ((a.meta && a.meta.priceCredits) ? a.meta.priceCredits + ' credits' : undefined),
93
+ price_credits: (a.meta && a.meta.priceCredits) || undefined,
94
+ owned: (a.meta && a.meta.owned) || undefined,
95
+ use_hint: (a.meta && a.meta.priceCredits)
96
+ ? 'Costs ' + a.meta.priceCredits + ' credits for this account. Confirm with the user, then: import_stock_asset source="' + a.source + '" id="' + a.sourceId + '" ("{TITLE}")'
97
+ : 'Import this stock asset into my media library: import_stock_asset source="' + a.source + '" id="' + a.sourceId + '" ("{TITLE}")'
88
98
  };
89
99
  });
90
100
  return uiResult(UI.mediaGrid, text, {
@@ -206,7 +216,7 @@ function registerStockLibraryTools(server, client, options = {}) {
206
216
  // ─── import_stock_asset ───────────────────────────────────────
207
217
  server.tool(
208
218
  'import_stock_asset',
209
- "Copy a stock asset into the account's Kolbo media library (downloaded to Kolbo's CDN with a stable URL) so it can be used in projects/generations. Free. Returns the created media library item. Works for Kolbo SFX (source='kolbo-ai', mediaType='sfx') and supported external visual/audio sources. For SYNCI music use import_music_track_to_library; that paid action acquires a clean licensed file.",
219
+ "Copy a stock asset into the account's Kolbo media library (downloaded to Kolbo's CDN with a stable URL) so it can be used in projects/generations. FREE for images, video and 3D. Kolbo's own MUSIC and SFX are free for subscribers, org members and anyone who already bought the track; for everyone else they COST CREDITS (search results carry the exact price in meta.priceCredits; 0/absent means free for this caller). When a price is shown, tell the user the cost and get agreement BEFORE calling this. Returns the created media library item. Works for Kolbo SFX (source='kolbo-ai', mediaType='sfx') and supported external visual/audio sources. For SYNCI music use import_music_track_to_library; that paid action acquires a clean licensed file.",
210
220
  {
211
221
  source: z.enum(['kolbo-ai', 'pexels', 'pixabay', 'sketchfab', 'freesound']).describe('The asset source.'),
212
222
  id: z.string().describe('The provider asset id (sourceId).'),
@@ -87,15 +87,19 @@ function registerVisualDnaTools(server, client, options = {}) {
87
87
  + 'because dumping them buries the user\'s own handful. Only pass scope="global" (optionally with '
88
88
  + '`collection` and `search`) when the user explicitly wants to BROWSE the preset cast — e.g. "find me a '
89
89
  + 'character", "show me street style models", "I need a location DNA" — and they have not named one of '
90
- + 'their own. Use scope="all" only if the user genuinely wants both at once.',
90
+ + 'their own. Use scope="all" only if the user genuinely wants both at once. '
91
+ + 'The response reports `total` and `_truncated`; when there are more matches than one '
92
+ + 'page holds, raise `limit` or walk `page` — do not tell the user the extras do not exist.',
91
93
  {
92
94
  scope: z.enum(['all', 'personal', 'global', 'organization']).optional().describe('Default: "personal" — the user\'s own DNAs (plus a shared project\'s when project_id is set). "global" = the ~1000 system cast/preset DNAs, for browsing when the user needs a character and has none of their own. "organization" = org-shared. "all" = everything, rarely wanted.'),
93
- search: z.string().optional().describe('Search by name, tags, or description (case-insensitive)'),
95
+ search: z.string().optional().describe('Search by name, tags, or description (case-insensitive). Matches at WORD STARTS, so "man" finds "Man"/"Manager" but not "woman" or "romantic". Name matches are ranked first.'),
94
96
  collection: z.string().optional().describe('Filter global presets by collection: cast, influencers, props, locations, styles, glamour, street'),
95
97
  tags: z.string().optional().describe('Comma-separated tags to filter by (OR logic)'),
98
+ page: z.number().optional().describe('Page number, 1-indexed. Default: 1. Needed to reach the global cast beyond the first page.'),
99
+ limit: z.number().optional().describe('Results per page, max 100. Default: 50'),
96
100
  project_id: projectScopeReadField
97
101
  },
98
- async ({ scope, search, collection, tags, project_id } = {}) => {
102
+ async ({ scope, search, collection, tags, page, limit, project_id } = {}) => {
99
103
  const params = new URLSearchParams();
100
104
  // Default to the user's OWN DNAs. The API defaults to "all", which pulls
101
105
  // in ~1000 global cast presets and buries the handful the user actually
@@ -106,17 +110,23 @@ function registerVisualDnaTools(server, client, options = {}) {
106
110
  if (search) params.set('search', search);
107
111
  if (collection) params.set('collection', collection);
108
112
  if (tags) params.set('tags', tags);
113
+ // Always paged. Without these the ~1000-item global cast came back whole and
114
+ // was silently cut to the display cap, so nothing past the first screen was
115
+ // reachable through this tool at all.
116
+ params.set('page', String(page && page > 0 ? Math.floor(page) : 1));
117
+ params.set('limit', String(limit && limit > 0 ? Math.min(Math.floor(limit), 100) : 50));
109
118
  if (project_id) params.set('project_id', project_id);
110
119
  const qs = params.toString();
111
120
  const result = await client.get(`/v1/visual-dna${qs ? '?' + qs : ''}`);
112
121
  const dnas = result.visual_dnas || [];
122
+ const total = result.total != null ? result.total : (result.count || dnas.length);
113
123
  // Full profiles measured 74,310 chars — the embedded analysis/description
114
124
  // blobs are large and the model only needs enough to pick an id.
115
125
  const text = compactList(dnas, {
116
126
  fields: ['id', 'name', 'type', 'folder_id', 'tags', 'thumbnail'],
117
127
  cap: 60,
118
- total: result.count || dnas.length,
119
- note: 'Narrow with `search`, `tags`, or `collection`; get_visual_dna returns one in full.',
128
+ total,
129
+ note: 'Narrow with `search`, `tags`, or `collection`, or pass `page`/`limit` for the rest; get_visual_dna returns one in full.',
120
130
  });
121
131
 
122
132
  if (ui()) {
@@ -131,8 +141,8 @@ function registerVisualDnaTools(server, client, options = {}) {
131
141
  media_type: 'image',
132
142
  use_hint: 'Use Visual DNA "{TITLE}" (id: {ID}) in my next generation for character/style consistency.'
133
143
  })),
134
- total: result.count || dnas.length,
135
- has_more: dnas.length > 24
144
+ total,
145
+ has_more: result.has_more || dnas.length > 24
136
146
  });
137
147
  }
138
148