@kolbo/mcp 1.62.0 → 1.64.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.62.0",
3
+ "version": "1.64.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.
@@ -293,7 +293,8 @@ function releaseSeen() {
293
293
  function schedulePoll(sc) {
294
294
  if (cancelRequested) return;
295
295
  if (!seen) { whenSeenFns.push(function () { schedulePoll(sc); }); return; }
296
- // The call itself long-waits server-side (normally up to three minutes).
296
+ // The call itself long-waits server-side, for one transport-safe window
297
+ // (~45s over the remote connector — see WAIT_WINDOW_MS in tools/generate.js).
297
298
  // This short pause only separates successive wait windows — the FIRST call
298
299
  // goes out immediately, so a card revealed by scrolling resolves at once.
299
300
  var delay = pollStart ? 1500 : 0;
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
  });
package/src/polling.js CHANGED
@@ -100,12 +100,71 @@ async function pollUntilDone(client, generationId, options = {}) {
100
100
  }
101
101
 
102
102
  // Still running: put a byte on the wire before going quiet again, so no
103
- // intermediary mistakes a 3-minute wait for a dead connection.
103
+ // intermediary mistakes a long wait for a dead connection.
104
104
  await progress.tick();
105
105
 
106
- // Wait before next poll
107
- await new Promise(resolve => setTimeout(resolve, interval));
106
+ // Wait before next poll — but never past the deadline. The check at the top
107
+ // of the loop only runs BETWEEN sleeps, so an unclamped sleep let the call
108
+ // overshoot `timeout` by up to a full interval (a 45s window with a 15s
109
+ // cadence could return at 60s). That is the difference between landing
110
+ // inside the caller's transport window and blowing straight through it.
111
+ const remaining = timeout - (Date.now() - startTime);
112
+ await new Promise(resolve => setTimeout(resolve, Math.max(0, Math.min(interval, remaining))));
108
113
  }
109
114
  }
110
115
 
111
- module.exports = { pollUntilDone, PollingTimeoutError, GenerationFailedError };
116
+ // ─── Blocking-wait window for the STATUS tools ──────────────────────────────
117
+ // How long get_generation_status / get_creative_director_status may block
118
+ // inside ONE tool call before handing back a non-terminal result the caller
119
+ // re-issues. This is NOT the generation's lifetime — the job keeps running
120
+ // server-side either way.
121
+ //
122
+ // It used to be a flat 180s, which over the remote HTTP connector no caller
123
+ // could ever reach: there the whole tool call has to fit inside a single
124
+ // POST /mcp response, and every hop in front of us has a shorter fuse.
125
+ //
126
+ // • MCP client request timeout — 60s (SDK DEFAULT_REQUEST_TIMEOUT_MSEC), and
127
+ // it only resets on a progress notification when the client opted into
128
+ // resetTimeoutOnProgress, whose SDK default is false. We cannot make that
129
+ // choice on the host's behalf, so this is the ceiling we must respect.
130
+ // • Cloudflare origin read — 100s. api.kolbo.ai is Cloudflare-proxied.
131
+ // • kolbo-api httpServer.timeout — 120s. Measured against the production
132
+ // settings: a SILENT stream is RST at exactly 120.0s, while a 15s write
133
+ // cadence survives 200s. So progress.tick() does defeat this hop — but no
134
+ // amount of ticking defeats a client timeout that does not reset.
135
+ //
136
+ // Net effect of the old 180s: a 185s music generation made wait=true fail with
137
+ // "the connector's server isn't responding" every single time, on a perfectly
138
+ // healthy paid generation. Returning early with state:"processing" is strictly
139
+ // better than erroring — the caller re-issues and nothing is lost.
140
+ //
141
+ // stdio hosts have no hop in between and do reset on our ticks, so they keep
142
+ // the long window. KOLBO_MCP_WAIT_MS overrides both without a release, if a
143
+ // host ever proves tighter still.
144
+ const TRANSPORT_CEILING_MS = 60000;
145
+ // Headroom for everything that happens AFTER the last poll and before the
146
+ // response is on the wire: the final status read, addDisplayNames' catalog
147
+ // lookups, JSON serialization.
148
+ const RESULT_ASSEMBLY_BUDGET_MS = 10000;
149
+ const REMOTE_WAIT_MS = 45000;
150
+ const STDIO_WAIT_MS = 180000;
151
+
152
+ /**
153
+ * @param {object} [options] tool options; `apps === true` is the remote-HTTP
154
+ * transport signal (set only by kolbo-api's connector).
155
+ */
156
+ function waitWindowMs(options = {}) {
157
+ const override = Number(process.env.KOLBO_MCP_WAIT_MS);
158
+ if (Number.isFinite(override) && override > 0) return override;
159
+ return options.apps === true ? REMOTE_WAIT_MS : STDIO_WAIT_MS;
160
+ }
161
+
162
+ module.exports = {
163
+ pollUntilDone,
164
+ PollingTimeoutError,
165
+ GenerationFailedError,
166
+ waitWindowMs,
167
+ TRANSPORT_CEILING_MS,
168
+ RESULT_ASSEMBLY_BUDGET_MS,
169
+ REMOTE_WAIT_MS,
170
+ };
@@ -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
@@ -14,7 +14,7 @@ function registerChatTools(server, client) {
14
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.',
15
15
  {
16
16
  message: z.string().describe('The user message to send'),
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. 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.'),
18
18
  session_id: z.string().optional().describe('Existing chat session ID to continue. Omit to start a new conversation.'),
19
19
  system_prompt: z.string().optional().describe('System prompt for the conversation. Only applied when creating a new session.'),
20
20
  web_search: z.boolean().optional().describe('Enable web search for this message. Default: false'),
@@ -5,8 +5,8 @@
5
5
 
6
6
  const { z } = require('zod');
7
7
  const FormData = require('form-data');
8
- const { pollUntilDone } = require('../polling');
9
- const { resolveToBuffer, pollOrTimedOut, creditFields, projectIdField, inlineImageBlocks, buildOpenUrl, uiGenerating, appsEnabled } = require('./_shared');
8
+ const { pollUntilDone, waitWindowMs } = require('../polling');
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)
@@ -115,6 +126,18 @@ function registerGenerateTools(server, client, options = {}) {
115
126
  // "submitted" response + a live ui://kolbo/generation.html widget that keeps
116
127
  // one wait=true status call in flight. Text-only hosts never take this branch.
117
128
  const ui = () => appsEnabled(server, options);
129
+
130
+ // How long the STATUS tools may block inside one tool call before handing
131
+ // back a non-terminal result the caller re-issues. Bounded by the transport,
132
+ // not by the generation — full reasoning and the measured numbers live next
133
+ // to the constants in ../polling.js.
134
+ const WAIT_WINDOW_MS = waitWindowMs(options);
135
+ const WAIT_WINDOW_S = Math.round(WAIT_WINDOW_MS / 1000);
136
+ // What to tell a caller holding a still-running generation. Never "don't call
137
+ // again" — for anything longer than the window, calling again IS the protocol.
138
+ const stillRunningHint = (idsPhrase) =>
139
+ `Still running — this is NOT a failure and no credits were lost. Each wait=true call blocks for at most ~${WAIT_WINDOW_S}s and then returns whatever the state is, so a long job (music ~3 min, video can be longer) legitimately needs SEVERAL wait=true calls in a row. Call get_generation_status again with wait=true${idsPhrase}. Do not spin with wait=false, and do not re-run the generation tool.`;
140
+
118
141
  // ─── generate_image ────────────────────────────────────────
119
142
  server.tool(
120
143
  'generate_image',
@@ -135,14 +158,15 @@ function registerGenerateTools(server, client, options = {}) {
135
158
  preset_id: z.string().optional().describe('Preset ID from list_presets type="image" to apply a saved style preset to this generation.'),
136
159
  cinematic: CINEMATIC_SCHEMA,
137
160
  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
161
+ project_id: projectIdField,
162
+ session_id: sessionIdField
139
163
  },
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 }) => {
164
+ 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
165
  if (!prompt && !(prompts && prompts.length)) throw new Error('Provide prompt or prompts');
142
166
  model = await canonicalModelId(client, model, 'text_to_img'); // lenient id resolution ("z-image" → "z-image/turbo")
143
167
  const shared = {
144
168
  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
169
+ reference_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, preset_id, cinematic, skip_color_palette, project_id, session_id
146
170
  };
147
171
 
148
172
  // Batch mode: N different prompts, one widget owning all generation ids.
@@ -180,6 +204,7 @@ function registerGenerateTools(server, client, options = {}) {
180
204
  type: 'text',
181
205
  text: JSON.stringify({
182
206
  ...creditFields(result),
207
+ session_id: gen.session_id,
183
208
  urls: result.result.urls,
184
209
  model: result.result.model,
185
210
  prompt_used: result.result.prompt_used,
@@ -207,13 +232,14 @@ function registerGenerateTools(server, client, options = {}) {
207
232
  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
233
  cinematic: CINEMATIC_SCHEMA,
209
234
  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
235
+ project_id: projectIdField,
236
+ session_id: sessionIdField
211
237
  },
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 }) => {
238
+ 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
239
  model = await canonicalModelId(client, model, 'image_editing'); // lenient id resolution ("z-image" → "z-image/turbo")
214
240
  const gen = await client.post('/v1/generate/image-edit', {
215
241
  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
242
+ visual_dna_ids, moodboard_id, enable_web_search, resolution, cinematic, skip_color_palette, project_id, session_id
217
243
  });
218
244
 
219
245
  if (ui()) return uiGenerating({
@@ -240,6 +266,7 @@ function registerGenerateTools(server, client, options = {}) {
240
266
  type: 'text',
241
267
  text: JSON.stringify({
242
268
  ...creditFields(result),
269
+ session_id: gen.session_id,
243
270
  urls: result.result.urls,
244
271
  model: result.result.model,
245
272
  prompt_used: result.result.prompt_used,
@@ -352,10 +379,10 @@ function registerGenerateTools(server, client, options = {}) {
352
379
  // blocking poll window) needs this tool to be re-checked until done.
353
380
  server.tool(
354
381
  'get_creative_director_status',
355
- 'Check the status of a Creative Director batch (from generate_creative_director) by its generation_id. Returns overall state ("processing" until EVERY scene is terminal, then "completed"/"failed") plus each scene\'s number, title, per-scene status, and image_urls/video_urls. Set wait=true to block for up to ~3 minutes instead of repeatedly calling this tool. Prefer this over the generic get_generation_status for Creative Director ids — the generic tool now returns the same scene data (it delegates here), but this one is the direct route.',
382
+ 'Check the status of a Creative Director batch (from generate_creative_director) by its generation_id. Returns overall state ("processing" until EVERY scene is terminal, then "completed"/"failed") plus each scene\'s number, title, per-scene status, and image_urls/video_urls. Set wait=true to block until the batch is terminal or the wait window closes, whichever comes first — a batch longer than one window returns state="processing" and you simply call again with wait=true. Prefer this over the generic get_generation_status for Creative Director ids — the generic tool now returns the same scene data (it delegates here), but this one is the direct route.',
356
383
  {
357
384
  generation_id: z.string().describe('The Creative Director generation_id returned by generate_creative_director.'),
358
- wait: z.boolean().optional().describe('If true, block until the batch is terminal, up to ~3 minutes. Use this instead of repeatedly checking in a loop.')
385
+ wait: z.boolean().optional().describe(`If true, block until the batch is terminal, for at most ~${WAIT_WINDOW_S}s per call. A batch that outlives one window comes back state="processing" (not an error) — call again with wait=true until it is terminal. Always prefer this over polling with wait=false.`)
359
386
  },
360
387
  async ({ generation_id, wait }) => {
361
388
  const statusUrl = `/v1/generate/creative-director/${encodeURIComponent(generation_id)}/status`;
@@ -364,7 +391,7 @@ function registerGenerateTools(server, client, options = {}) {
364
391
  try {
365
392
  status = await pollUntilDone(client, generation_id, {
366
393
  interval: 15000,
367
- timeout: 180000,
394
+ timeout: WAIT_WINDOW_MS,
368
395
  statusUrl
369
396
  });
370
397
  } catch (err) {
@@ -394,7 +421,7 @@ function registerGenerateTools(server, client, options = {}) {
394
421
  completed_scenes: completed,
395
422
  _hint: status.state === 'completed'
396
423
  ? 'All scenes terminal. Every completed scene\'s image_urls/video_urls are final.'
397
- : 'Still running. Call get_creative_director_status once with wait=true; do not poll it in a loop.'
424
+ : `Still running — not a failure. Each wait=true call blocks for at most ~${WAIT_WINDOW_S}s, and a video batch routinely outlasts several windows, so call get_creative_director_status again with wait=true and keep going until state is terminal. Scenes that already carry image_urls/video_urls are done; never re-run generate_creative_director.`
398
425
  }, null, 2) }] };
399
426
  }
400
427
  );
@@ -419,13 +446,14 @@ function registerGenerateTools(server, client, options = {}) {
419
446
  preset_id: z.string().optional().describe('Preset ID from list_presets type="video" to apply a saved motion/style preset to this generation.'),
420
447
  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
448
  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
449
+ project_id: projectIdField,
450
+ session_id: sessionIdField
423
451
  },
424
- async ({ prompt, prompts, model, aspect_ratio, duration, enhance_prompt = false, reference_images, resolution, preset_id, sound_enabled, skip_color_palette, project_id }) => {
452
+ 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
453
  if (!prompt && !(prompts && prompts.length)) throw new Error('Provide prompt or prompts');
426
454
  model = await canonicalModelId(client, model, 'text_to_video'); // lenient id resolution ("z-image" → "z-image/turbo")
427
455
  const shared = {
428
- model, aspect_ratio, duration, enhance_prompt, reference_images, resolution, preset_id, sound_enabled, skip_color_palette, project_id
456
+ model, aspect_ratio, duration, enhance_prompt, reference_images, resolution, preset_id, sound_enabled, skip_color_palette, project_id, session_id
429
457
  };
430
458
 
431
459
  // Batch mode: N different prompts, one widget owning all generation ids.
@@ -465,6 +493,7 @@ function registerGenerateTools(server, client, options = {}) {
465
493
  type: 'text',
466
494
  text: JSON.stringify({
467
495
  ...creditFields(result),
496
+ session_id: gen.session_id,
468
497
  urls: result.result.urls,
469
498
  model: result.result.model,
470
499
  duration: result.result.duration,
@@ -480,10 +509,16 @@ function registerGenerateTools(server, client, options = {}) {
480
509
  // ─── generate_video_from_image ─────────────────────────────
481
510
  server.tool(
482
511
  '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.',
512
+ '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
513
  {
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")'),
514
+ image_url: z.string().optional().describe('URL of the source image to animate. Required unless `items` is provided.'),
515
+ 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.'),
516
+ items: z.array(z.object({
517
+ image_url: z.string().describe('URL of the source image to animate for THIS clip.'),
518
+ prompt: z.string().describe('Text description of the desired MOTION for THIS clip.'),
519
+ })).max(MAX_BATCH_PROMPTS).optional().describe(
520
+ `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.`
521
+ ),
487
522
  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
523
  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
524
  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 +527,34 @@ function registerGenerateTools(server, client, options = {}) {
492
527
  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
528
  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
529
  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
530
+ project_id: projectIdField,
531
+ session_id: sessionIdField
496
532
  },
497
- async ({ image_url, prompt, model, aspect_ratio, duration, enhance_prompt = false, visual_dna_ids, resolution, sound_enabled, skip_color_palette, project_id }) => {
533
+ 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 }) => {
534
+ if (!(items && items.length) && !(image_url && prompt)) throw new Error('Provide image_url + prompt, or items');
498
535
  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
- });
536
+ const shared = {
537
+ model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution, sound_enabled, skip_color_palette, project_id, session_id
538
+ };
539
+
540
+ // Batch mode: N different stills, one widget owning all generation ids.
541
+ // Same fan-out as generate_video's prompts[], except the varying part is
542
+ // the (image_url, prompt) PAIR — submitBatch carries the prompt as the
543
+ // per-tile caption either way.
544
+ if (items && items.length) {
545
+ const batch = await submitBatch(items, (it) => client.post('/v1/generate/video/from-image', { ...shared, image_url: it.image_url, prompt: it.prompt }));
546
+ if (ui()) return uiGenerating({
547
+ tool: 'generate_video_from_image', kind: 'video', gen: batch.ok[0].gen, client, model,
548
+ count: batch.ids.length, settings: { duration, resolution, aspect_ratio },
549
+ generation_ids: batch.ids, prompts: batch.ok.map((o) => o.prompt),
550
+ failed_submissions: batch.failed,
551
+ status_args: { generation_ids: batch.ids, wait: true },
552
+ reference_image: items[0].image_url
553
+ });
554
+ return pollBatch(client, batch, { interval: (batch.ok[0].gen.poll_interval_hint || 8) * 1000, timeout: 900000 });
555
+ }
556
+
557
+ const gen = await client.post('/v1/generate/video/from-image', { ...shared, image_url, prompt });
502
558
 
503
559
  if (ui()) return uiGenerating({
504
560
  tool: 'generate_video_from_image', kind: 'video', gen, client, model, prompt,
@@ -520,11 +576,12 @@ function registerGenerateTools(server, client, options = {}) {
520
576
  type: 'text',
521
577
  text: JSON.stringify({
522
578
  ...creditFields(result),
579
+ session_id: gen.session_id,
523
580
  urls: result.result.urls,
524
581
  model: result.result.model,
525
582
  duration: result.result.duration,
526
583
  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.'
584
+ _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
585
  }, null, 2)
529
586
  }]
530
587
  };
@@ -555,15 +612,16 @@ function registerGenerateTools(server, client, options = {}) {
555
612
  use_composition_plan: z.boolean().optional().describe('Suno: enable structured composition planning (verse/chorus structure).'),
556
613
  singing_dna_id: z.string().optional().describe('Visual DNA character id whose singing voice to use (must be owned by the caller).'),
557
614
  singing_voice_id: z.string().optional().describe('Custom cloned singing-voice id (must be owned by the caller).'),
558
- project_id: projectIdField
615
+ project_id: projectIdField,
616
+ session_id: sessionIdField
559
617
  },
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 }) => {
618
+ 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
619
  model = await canonicalModelId(client, model, 'music_gen'); // lenient id resolution ("z-image" → "z-image/turbo")
562
620
  const gen = await client.post('/v1/generate/music', {
563
621
  prompt, model, style, title, instrumental, lyrics, vocal_gender, negative_tags,
564
622
  duration_seconds, enhance_prompt, preset_id,
565
623
  style_weight, weirdness, audio_weight, persona_id, use_composition_plan,
566
- singing_dna_id, singing_voice_id, project_id
624
+ singing_dna_id, singing_voice_id, project_id, session_id
567
625
  });
568
626
 
569
627
  if (ui()) return uiGenerating({
@@ -583,6 +641,7 @@ function registerGenerateTools(server, client, options = {}) {
583
641
  type: 'text',
584
642
  text: JSON.stringify({
585
643
  ...creditFields(result),
644
+ session_id: gen.session_id,
586
645
  urls: result.result.urls,
587
646
  title: result.result.title,
588
647
  duration: result.result.duration,
@@ -627,9 +686,10 @@ function registerGenerateTools(server, client, options = {}) {
627
686
  minimax_vol: z.number().optional().describe('MiniMax volume, 0–10. Default 1.'),
628
687
  minimax_intensity: z.number().optional().describe('MiniMax voice intensity.'),
629
688
  minimax_timbre: z.number().optional().describe('MiniMax voice timbre.'),
630
- project_id: projectIdField
689
+ project_id: projectIdField,
690
+ session_id: sessionIdField
631
691
  },
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 }) => {
692
+ 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
693
  model = await canonicalModelId(client, model, 'text_to_speech'); // lenient id resolution ("z-image" → "z-image/turbo")
634
694
  // Resolve the requested voice against the REAL catalog (cached) so the card
635
695
  // can show its display name + portrait instead of a raw id, and so an id
@@ -647,7 +707,7 @@ function registerGenerateTools(server, client, options = {}) {
647
707
  similarity_boost, style, use_speaker_boost,
648
708
  variance, tempo, promptBoost, seed, accentControl, voiceTitle,
649
709
  minimax_pitch, minimax_vol, minimax_intensity, minimax_timbre,
650
- project_id
710
+ project_id, session_id
651
711
  });
652
712
 
653
713
  if (ui()) return uiGenerating({
@@ -669,6 +729,7 @@ function registerGenerateTools(server, client, options = {}) {
669
729
  type: 'text',
670
730
  text: JSON.stringify({
671
731
  ...creditFields(result),
732
+ session_id: gen.session_id,
672
733
  urls: result.result.urls,
673
734
  voice: result.result.voice,
674
735
  duration: result.result.duration,
@@ -701,15 +762,16 @@ function registerGenerateTools(server, client, options = {}) {
701
762
  seed_pitch: z.number().optional().describe('FAL Seed-Audio: pitch shift in semitones.'),
702
763
  seed_reference_audio_urls: z.array(z.string()).optional().describe('FAL Seed-Audio: up to 3 reference audio URLs to condition the sound.'),
703
764
  seed_reference_image_url: z.string().optional().describe('FAL Seed-Audio: a reference image URL to condition the sound.'),
704
- project_id: projectIdField
765
+ project_id: projectIdField,
766
+ session_id: sessionIdField
705
767
  },
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 }) => {
768
+ 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
769
  model = await canonicalModelId(client, model, 'text_to_sound'); // lenient id resolution ("z-image" → "z-image/turbo")
708
770
  const gen = await client.post('/v1/generate/sound', {
709
771
  prompt, model, duration, prompt_influence,
710
772
  cfg_strength, sound_loop, sound_tempo, sound_key,
711
773
  seed_voice, seed_speed, seed_volume, seed_pitch,
712
- seed_reference_audio_urls, seed_reference_image_url, project_id
774
+ seed_reference_audio_urls, seed_reference_image_url, project_id, session_id
713
775
  });
714
776
 
715
777
  if (ui()) return uiGenerating({
@@ -729,6 +791,7 @@ function registerGenerateTools(server, client, options = {}) {
729
791
  type: 'text',
730
792
  text: JSON.stringify({
731
793
  ...creditFields(result),
794
+ session_id: gen.session_id,
732
795
  urls: result.result.urls,
733
796
  duration: result.result.duration
734
797
  }, null, 2)
@@ -766,11 +829,11 @@ function registerGenerateTools(server, client, options = {}) {
766
829
  // ─── get_generation_status ─────────────────────────────────
767
830
  server.tool(
768
831
  'get_generation_status',
769
- 'Check the status of one or more generations. Use after a generation tool returned "submitted" (widget hosts) or timed out. Tracking SEVERAL concurrent generations? Pass them ALL in generation_ids — one call returns an all_done summary. Need the final result? Set wait=true and the server blocks until every generation finishes (up to ~3 min). NEVER call this tool repeatedly in a loop one wait=true call replaces the whole loop.',
832
+ `Check the status of one or more generations. Use after a generation tool returned "submitted" (widget hosts) or timed out. Tracking SEVERAL concurrent generations? Pass them ALL in generation_ids — one call returns an all_done summary. Need the final result? Set wait=true and the server blocks until every generation finishes, for at most ~${WAIT_WINDOW_S}s per call. A job that outlives one window (music is ~3 min, video longer) comes back state="processing" that is a normal result, not an error: call again with wait=true and keep going until every id is terminal. Never poll with wait=false in a loop.`,
770
833
  {
771
834
  generation_id: z.string().optional().describe('A single generation ID to check'),
772
835
  generation_ids: z.array(z.string()).optional().describe('Multiple generation IDs to check in ONE call. Returns { all_done, pending, generations[] } — always prefer this over checking IDs one by one.'),
773
- wait: z.boolean().optional().describe('If true, block until every generation reaches a terminal state (completed/failed), up to ~3 minutes, then return the final results. Use this instead of re-calling the tool in a loop.')
836
+ wait: z.boolean().optional().describe(`If true, block until every generation reaches a terminal state (completed/failed), for at most ~${WAIT_WINDOW_S}s per call, then return whatever state they are in. Anything still processing is reported, not errored — re-issue with wait=true and only the still-pending ids. This is always better than polling with wait=false.`)
774
837
  },
775
838
  async ({ generation_id, generation_ids, wait }) => {
776
839
  const ids = (generation_ids && generation_ids.length > 0)
@@ -785,15 +848,16 @@ function registerGenerateTools(server, client, options = {}) {
785
848
  if (wait) {
786
849
  // Widgets use this long-wait path. A 15s API check cadence keeps
787
850
  // completion responsive without multiplying backend traffic for
788
- // every card left open in a host conversation.
789
- const result = await pollUntilDone(client, id, { interval: 15000, timeout: 180000 });
851
+ // every card left open in a host conversation. The window itself is
852
+ // bounded by the transport see WAIT_WINDOW_MS above.
853
+ const result = await pollUntilDone(client, id, { interval: 15000, timeout: WAIT_WINDOW_MS });
790
854
  return { generation_id: id, ...result };
791
855
  }
792
856
  const result = await client.get(`/v1/generate/${encodeURIComponent(id)}/status`);
793
857
  return { generation_id: id, ...result };
794
858
  } catch (err) {
795
859
  if (err.timedOut) {
796
- return { generation_id: id, state: 'processing', _timed_out: true, note: 'Still running after 3 min of waiting — call get_generation_status again with wait=true.' };
860
+ return { generation_id: id, state: 'processing', _timed_out: true, note: `Still running after this ~${WAIT_WINDOW_S}s wait window — call get_generation_status again with wait=true.` };
797
861
  }
798
862
  if (err.name === 'GenerationFailedError') {
799
863
  return { generation_id: id, state: 'failed', error: err.message };
@@ -807,9 +871,15 @@ function registerGenerateTools(server, client, options = {}) {
807
871
 
808
872
  const pending = results.filter(r => r.state !== 'completed' && r.state !== 'failed' && r.state !== 'cancelled');
809
873
  const doneHint = 'ALL generations are in a final state — do NOT poll again. Report the results to the user.';
810
- const pendingHint = wait
811
- ? 'Some generations are still running after the wait window. Call get_generation_status ONCE more with wait=true and the remaining generation_ids — do not spin without wait.'
812
- : 'Some generations are still processing. Do NOT re-call this tool in a loop call it ONCE with wait=true (and all pending generation_ids) to block until they finish.';
874
+ // The old wait=false hint said "call it ONCE with wait=true ... to block
875
+ // until they finish". That is the advice that broke: one wait=true call
876
+ // cannot outlast a 185s music job, and a caller that obeyed it got a
877
+ // transport error instead of a result. Say what actually works.
878
+ const pendingIds = pending.map(r => r.generation_id);
879
+ const idsPhrase = pendingIds.length > 1
880
+ ? ` and ONLY the still-pending ids: ${JSON.stringify(pendingIds)}`
881
+ : '';
882
+ const pendingHint = stillRunningHint(idsPhrase);
813
883
 
814
884
  // Single-id calls keep the original flat shape — the generation widget
815
885
  // waits on this tool with { generation_id, wait:true } and reads
@@ -917,9 +987,10 @@ function registerGenerateTools(server, client, options = {}) {
917
987
  image_url: z.string().describe('Public URL of the keyframe image'),
918
988
  timestamp_seconds: z.number().describe('Moment on the OUTPUT timeline (seconds, 0 = first frame) where this image is pinned')
919
989
  })).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
990
+ project_id: projectIdField,
991
+ session_id: sessionIdField
921
992
  },
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 }) => {
993
+ 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
994
  model = await canonicalModelId(client, model, 'elements'); // lenient id resolution ("z-image" → "z-image/turbo")
924
995
  if (!prompt) throw new Error('prompt is required');
925
996
 
@@ -943,6 +1014,7 @@ function registerGenerateTools(server, client, options = {}) {
943
1014
  if (resolution) form.append('resolution', resolution);
944
1015
  if (keyframes) form.append('keyframes', JSON.stringify(keyframes));
945
1016
  if (project_id) form.append('project_id', project_id);
1017
+ if (session_id) form.append('session_id', session_id);
946
1018
  for (const f of resolved) {
947
1019
  form.append('files', f.buffer, { filename: f.filename, contentType: f.contentType });
948
1020
  }
@@ -950,7 +1022,7 @@ function registerGenerateTools(server, client, options = {}) {
950
1022
  } else {
951
1023
  // URL-only mode: plain JSON.
952
1024
  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
1025
+ 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
1026
  });
955
1027
  }
956
1028
 
@@ -972,6 +1044,7 @@ function registerGenerateTools(server, client, options = {}) {
972
1044
  type: 'text',
973
1045
  text: JSON.stringify({
974
1046
  ...creditFields(result),
1047
+ session_id: startResponse.session_id,
975
1048
  urls: result.result?.urls || [],
976
1049
  thumbnail_url: result.result?.thumbnail_url || null,
977
1050
  duration: result.result?.duration || null,
@@ -998,9 +1071,10 @@ function registerGenerateTools(server, client, options = {}) {
998
1071
  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
1072
  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
1073
  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
1074
+ project_id: projectIdField,
1075
+ session_id: sessionIdField
1002
1076
  },
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 }) => {
1077
+ 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
1078
  model = await canonicalModelId(client, model, 'firstlastgenerations'); // lenient id resolution ("z-image" → "z-image/turbo")
1005
1079
  const urlMode = first_frame_url && last_frame_url;
1006
1080
  const fileMode = first_frame && last_frame;
@@ -1028,10 +1102,11 @@ function registerGenerateTools(server, client, options = {}) {
1028
1102
  if (visual_dna_ids) form.append('visual_dna_ids', JSON.stringify(visual_dna_ids));
1029
1103
  if (resolution) form.append('resolution', resolution);
1030
1104
  if (project_id) form.append('project_id', project_id);
1105
+ if (session_id) form.append('session_id', session_id);
1031
1106
  startResponse = await client.postMultipart('/v1/generate/first-last-frame', form);
1032
1107
  } else {
1033
1108
  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
1109
+ first_frame_url, last_frame_url, prompt, model, duration, aspect_ratio, enhance_prompt, visual_dna_ids, resolution, project_id, session_id
1035
1110
  });
1036
1111
  }
1037
1112
 
@@ -1053,6 +1128,7 @@ function registerGenerateTools(server, client, options = {}) {
1053
1128
  type: 'text',
1054
1129
  text: JSON.stringify({
1055
1130
  ...creditFields(result),
1131
+ session_id: startResponse.session_id,
1056
1132
  urls: result.result?.urls || [],
1057
1133
  thumbnail_url: result.result?.thumbnail_url || null,
1058
1134
  duration: result.result?.duration || null,
@@ -1088,9 +1164,10 @@ function registerGenerateTools(server, client, options = {}) {
1088
1164
  bounding_boxes_url: z.string().optional().describe('URL to a JSON file with per-frame boxes.'),
1089
1165
  face_image: z.string().optional().describe('Base64-encoded reference face image.')
1090
1166
  }).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
1167
+ project_id: projectIdField,
1168
+ session_id: sessionIdField
1092
1169
  },
1093
- async ({ source, audio, text_prompt, model, bounding_box_target, sync_mode, model_mode, emotion, temperature, occlusion_detection_enabled, active_speaker_detection, project_id }) => {
1170
+ 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
1171
  model = await canonicalModelId(client, model, ['lipsync-image', 'lipsync-video']); // lenient id resolution ("z-image" → "z-image/turbo")
1095
1172
  if (!source) throw new Error('source is required (URL or absolute local path to image/video)');
1096
1173
  if (!audio) throw new Error('audio is required (URL or absolute local path to audio file)');
@@ -1114,7 +1191,7 @@ function registerGenerateTools(server, client, options = {}) {
1114
1191
  temperature,
1115
1192
  occlusion_detection_enabled,
1116
1193
  active_speaker_detection,
1117
- project_id
1194
+ project_id, session_id
1118
1195
  });
1119
1196
  } else {
1120
1197
  // File mode (or mixed — resolve any local paths, pass URLs through as body fields)
@@ -1144,6 +1221,7 @@ function registerGenerateTools(server, client, options = {}) {
1144
1221
  if (occlusion_detection_enabled !== undefined) form.append('occlusion_detection_enabled', String(occlusion_detection_enabled));
1145
1222
  if (active_speaker_detection) form.append('active_speaker_detection', JSON.stringify(active_speaker_detection));
1146
1223
  if (project_id) form.append('project_id', project_id);
1224
+ if (session_id) form.append('session_id', session_id);
1147
1225
  startResponse = await client.postMultipart('/v1/generate/lipsync', form);
1148
1226
  }
1149
1227
 
@@ -1165,6 +1243,7 @@ function registerGenerateTools(server, client, options = {}) {
1165
1243
  type: 'text',
1166
1244
  text: JSON.stringify({
1167
1245
  ...creditFields(result),
1246
+ session_id: startResponse.session_id,
1168
1247
  urls: result.result?.urls || [],
1169
1248
  thumbnail_url: result.result?.thumbnail_url || null,
1170
1249
  duration: result.result?.duration || null,
@@ -1209,9 +1288,10 @@ function registerGenerateTools(server, client, options = {}) {
1209
1288
  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
1289
  }).optional(),
1211
1290
  }).optional().describe('VEED Subtitles only: style overrides. Any omitted field keeps the preset default. Best supported by Basic presets.'),
1212
- project_id: projectIdField
1291
+ project_id: projectIdField,
1292
+ session_id: sessionIdField
1213
1293
  },
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 }) => {
1294
+ 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
1295
  model = await canonicalModelId(client, model, 'video_to_video'); // lenient id resolution ("z-image" → "z-image/turbo")
1216
1296
  if (!source_video) throw new Error('source_video is required');
1217
1297
 
@@ -1221,7 +1301,7 @@ function registerGenerateTools(server, client, options = {}) {
1221
1301
  startResponse = await client.post('/v1/generate/video-from-video', {
1222
1302
  video_url: source_video, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution,
1223
1303
  reference_images, reference_videos, elements, preset, source_language, translation_language,
1224
- srt_content, srt_file_url, vocabulary, customization, project_id
1304
+ srt_content, srt_file_url, vocabulary, customization, project_id, session_id
1225
1305
  });
1226
1306
  } else {
1227
1307
  const resolved = await resolveToBuffer(source_video, 'video');
@@ -1245,6 +1325,7 @@ function registerGenerateTools(server, client, options = {}) {
1245
1325
  if (reference_videos) form.append('reference_videos', JSON.stringify(reference_videos));
1246
1326
  if (elements) form.append('elements', JSON.stringify(elements));
1247
1327
  if (project_id) form.append('project_id', project_id);
1328
+ if (session_id) form.append('session_id', session_id);
1248
1329
  startResponse = await client.postMultipart('/v1/generate/video-from-video', form);
1249
1330
  }
1250
1331
 
@@ -1267,6 +1348,7 @@ function registerGenerateTools(server, client, options = {}) {
1267
1348
  type: 'text',
1268
1349
  text: JSON.stringify({
1269
1350
  ...creditFields(result),
1351
+ session_id: startResponse.session_id,
1270
1352
  urls: result.result?.urls || [],
1271
1353
  thumbnail_url: result.result?.thumbnail_url || null,
1272
1354
  duration: result.result?.duration || null,
@@ -1291,15 +1373,16 @@ function registerGenerateTools(server, client, options = {}) {
1291
1373
  words_per_line: z.number().optional().describe('SRT: max words per subtitle line, 1–18. Default: 12.'),
1292
1374
  lines_per_subtitle: z.number().optional().describe('SRT: max lines per subtitle cue, 1–4. Default: 2.'),
1293
1375
  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
1376
+ project_id: projectIdField,
1377
+ session_id: sessionIdField
1295
1378
  },
1296
- async ({ source, language, diarize, tag_audio_events, remove_punctuation, generate_srt, words_per_line, lines_per_subtitle, stretch_captions, project_id }) => {
1379
+ async ({ source, language, diarize, tag_audio_events, remove_punctuation, generate_srt, words_per_line, lines_per_subtitle, stretch_captions, project_id, session_id }) => {
1297
1380
  if (!source) throw new Error('source is required (URL or absolute local path)');
1298
1381
 
1299
1382
  // Advanced transcription controls forwarded when provided (undefined keys are dropped by the client).
1300
1383
  const opts = {
1301
1384
  language, diarize, tag_audio_events, remove_punctuation,
1302
- generate_srt, words_per_line, lines_per_subtitle, stretch_captions, project_id
1385
+ generate_srt, words_per_line, lines_per_subtitle, stretch_captions, project_id, session_id
1303
1386
  };
1304
1387
 
1305
1388
  const isUrl = /^https?:\/\//i.test(source);
@@ -1343,6 +1426,7 @@ function registerGenerateTools(server, client, options = {}) {
1343
1426
  type: 'text',
1344
1427
  text: JSON.stringify({
1345
1428
  ...creditFields(result),
1429
+ session_id: startResponse.session_id,
1346
1430
  text: result.result?.text || '',
1347
1431
  srt_url: result.result?.srt_url || null,
1348
1432
  word_by_word_srt_url: result.result?.word_by_word_srt_url || null,
@@ -1506,13 +1590,14 @@ function registerGenerateTools(server, client, options = {}) {
1506
1590
  ai_optimize: z.boolean().optional()
1507
1591
  .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
1592
 
1509
- project_id: projectIdField
1593
+ project_id: projectIdField,
1594
+ session_id: sessionIdField
1510
1595
  },
1511
1596
  async ({
1512
1597
  image_url, operation, model, scale, aspect_ratio, skin_strength, prompt,
1513
1598
  mask_image_url, additional_images, generate_all_angles, resolution, quality, ai_optimize = false,
1514
1599
  zoom_out_percentage, expand_left, expand_right, expand_top, expand_bottom,
1515
- project_id
1600
+ project_id, session_id
1516
1601
  }) => {
1517
1602
  // No `type` argument: these are operation-routed tools (upscale / reframe /
1518
1603
  // removebg / …), each operation with its own model family — there is no single
@@ -1530,7 +1615,7 @@ function registerGenerateTools(server, client, options = {}) {
1530
1615
  image_url, operation, model, scale, aspect_ratio, skin_strength, prompt,
1531
1616
  mask_image_url, additional_images, generate_all_angles, resolution, quality, ai_optimize,
1532
1617
  zoom_out_percentage, expand_left, expand_right, expand_top, expand_bottom,
1533
- project_id
1618
+ project_id, session_id
1534
1619
  });
1535
1620
 
1536
1621
  if (ui()) return uiGenerating({
@@ -1552,6 +1637,7 @@ function registerGenerateTools(server, client, options = {}) {
1552
1637
  type: 'text',
1553
1638
  text: JSON.stringify({
1554
1639
  ...creditFields(result),
1640
+ session_id: gen.session_id,
1555
1641
  urls: result.result?.urls || [],
1556
1642
  edit_type: result.result?.edit_type || null,
1557
1643
  model: result.result?.model || null
@@ -1656,7 +1742,8 @@ function registerGenerateTools(server, client, options = {}) {
1656
1742
  start_time: z.number().optional()
1657
1743
  .describe('Start time in seconds of the segment to retake. Used with operation="retake".'),
1658
1744
 
1659
- project_id: projectIdField
1745
+ project_id: projectIdField,
1746
+ session_id: sessionIdField
1660
1747
  },
1661
1748
  async ({
1662
1749
  video_url, operation, model, aspect_ratio, scale, prompt,
@@ -1668,7 +1755,7 @@ function registerGenerateTools(server, client, options = {}) {
1668
1755
  text_prompt, context,
1669
1756
  mask_video_url, object_prompt, video_strength,
1670
1757
  start_time,
1671
- project_id
1758
+ project_id, session_id
1672
1759
  }) => {
1673
1760
  // No `type` argument: these are operation-routed tools (upscale / reframe /
1674
1761
  // removebg / …), each operation with its own model family — there is no single
@@ -1693,7 +1780,7 @@ function registerGenerateTools(server, client, options = {}) {
1693
1780
  text_prompt, context,
1694
1781
  mask_video_url, object_prompt, video_strength,
1695
1782
  start_time,
1696
- project_id
1783
+ project_id, session_id
1697
1784
  });
1698
1785
 
1699
1786
  if (ui()) return uiGenerating({
@@ -1715,6 +1802,7 @@ function registerGenerateTools(server, client, options = {}) {
1715
1802
  type: 'text',
1716
1803
  text: JSON.stringify({
1717
1804
  ...creditFields(result),
1805
+ session_id: gen.session_id,
1718
1806
  urls: result.result?.urls || [],
1719
1807
  download_url: result.result?.download_url || null,
1720
1808
  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);
@@ -325,8 +325,14 @@ function registerModelTools(server, client, options = {}) {
325
325
  const flat = String(s).replace(/\s+/g, ' ').trim();
326
326
  return flat.length > 130 ? flat.slice(0, 127).trimEnd() + '…' : flat;
327
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`);
328
334
  const formatModel = m =>
329
- `${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) : ''}`;
330
336
 
331
337
  const sections = [];
332
338
 
@@ -341,7 +347,10 @@ function registerModelTools(server, client, options = {}) {
341
347
  }
342
348
  const text = `Kolbo model catalog — ${result.count} models total.\n\n`
343
349
  + `${sections.join('\n\n')}\n\n`
344
- + '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 '
345
354
  + 'category (with per-model resolutions, durations, aspect ratios and reference-image '
346
355
  + 'caps), re-call with `type`:\n'
347
356
  + ' text_to_img · image_editing · text_to_video · img_to_video · video_to_video ·\n'
@@ -354,7 +363,7 @@ function registerModelTools(server, client, options = {}) {
354
363
  }
355
364
 
356
365
  if (withSummary.length > 0) {
357
- 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')}`);
358
367
  }
359
368
  if (withoutSummary.length > 0) {
360
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(),
@@ -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).'),