@kolbo/mcp 1.48.0 → 1.50.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/README.md CHANGED
@@ -4,7 +4,7 @@ Use [Kolbo AI](https://kolbo.ai) as native tools in Claude Code and Claude Deskt
4
4
 
5
5
  Generate images, videos, music, speech, sound effects, multi-scene campaigns, and conversational chat — all from natural language in your coding environment. 100+ AI models behind Smart Select routing, with reusable Visual DNA profiles for character/style consistency.
6
6
 
7
- **✨ Interactive widgets (v1.30+):** in claude.ai and Claude Desktop, generations render as live Kolbo cards — real-time progress with model + settings chips, an inline result gallery / video player, and one-click **Animate · Edit · Recreate · Download** actions. Library and model searches render as browsable grids with audio preview. Text-only clients (Claude Code, Cursor) keep the classic text responses.
7
+ **✨ Interactive widgets (v1.30+):** in claude.ai, Claude Desktop, and Codex Desktop, generations render as live Kolbo cards — real-time progress with model + settings chips, an inline result gallery / video or audio player, and one-click **Animate · Edit · Recreate · Download** actions. Multi-audio generations render every track with its own player and Download button. Library and model searches render as browsable grids with audio preview. Text-only clients (Claude Code, Codex CLI, Cursor) keep the classic text responses.
8
8
 
9
9
  ## Set up — paste one prompt, or one config block (keyless, no API key)
10
10
 
@@ -113,7 +113,7 @@ Just ask your agent naturally:
113
113
 
114
114
  Without the optional skill, the config block alone already exposes every tool — you just describe what you want. With the skill installed, each of these is also routed to the right MCP tool with the right defaults — UGC mode picks 9:16 + sound-off + no-captions, marketplace mode enforces compliance (pure white bg, no text, no props), product photoshoot mode uses the right aspect for the platform (2:3 Pinterest, 16:9 hero banner, 1:1 IG feed), etc. The routing logic is shared with [Kolbo Code](https://github.com/Zoharvan12/kolbo-code), so the behavior is identical however you connect.
115
115
 
116
- ## Available Tools (86)
116
+ ## Available Tools (95)
117
117
 
118
118
  **Generation**
119
119
  | Tool | Description |
@@ -193,16 +193,18 @@ Every generation tool also accepts an optional `project_id` arg that routes the
193
193
  |------|-------------|
194
194
  | `publish_html_artifact` | Publish an HTML page, SVG, or Mermaid diagram and get a public shareable URL on `sites.kolbo.ai`. Pass `share_token` from a prior publish to update the same URL in place (old content kept in version history). |
195
195
 
196
- **Music Library** (stock / production music)
196
+ **SYNCI Music Library** (licensed production music)
197
197
  | Tool | Description |
198
198
  |------|-------------|
199
- | `search_music_library` | Search the licensed stock-music catalog by keyword + genre/mood/BPM/duration filters. Find a ready-made track (distinct from `generate_music`, which composes a new song). |
200
- | `analyze_script_for_music` | AI: turn a video/voiceover script into a music search (`query`, `mood`, `genre`, `keywords`). |
201
- | `browse_music_library` | Browse the catalog without a query (paginated). |
202
- | `get_music_library_facets` | List available genres, moods, instruments + BPM/duration ranges. |
203
- | `get_music_track_audio` | Get a track's downloadable 128/320/WAV URLs by id. |
204
- | `get_music_track_related` | Get stems + alternate versions of a track. |
205
- | `get_music_track_lyrics` | Get lyrics text, theme, and explicit flag for a track. |
199
+ | `search_music_library` | Search the licensed catalog; results contain watermarked previews only. |
200
+ | `analyze_script_for_music` | Turn a script or scene description into a music search. |
201
+ | `browse_music_library` | Browse the catalog without a query. |
202
+ | `get_music_library_facets` | List genres, moods, instruments, BPM, and duration filters. |
203
+ | `get_music_track_audio` | Get watermarked preview URLs for a track. |
204
+ | `acquire_clean_music_track` | Spend one SYNCI vendor credit and return clean MP3/WAV signed URLs. Idempotent with `request_id`. |
205
+ | `import_music_track_to_library` | Spend one vendor credit and copy a clean MP3/WAV into Kolbo's media library. |
206
+ | `get_music_track_related` | Get stems/alternate-version metadata (purchasing remains unsupported). |
207
+ | `get_music_track_lyrics` | Get lyrics metadata. |
206
208
 
207
209
  **Stock Library** (multi-source stock media: Pexels, Pixabay, Sketchfab 3D, Music)
208
210
  | Tool | Description |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolbo/mcp",
3
- "version": "1.48.0",
3
+ "version": "1.50.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": {
@@ -43,7 +43,7 @@
43
43
  "dependencies": {
44
44
  "@modelcontextprotocol/ext-apps": "^1.7.4",
45
45
  "@modelcontextprotocol/sdk": "1.29.0",
46
- "form-data": "^4.0.5",
46
+ "form-data": "^4.0.6",
47
47
  "zod": "^3.25.0"
48
48
  },
49
49
  "overrides": {
package/skill/SKILL.md CHANGED
@@ -115,7 +115,8 @@ Each `references/models/*.md` mirrors the matching skill prompt in `kolbo-api/sr
115
115
  | `upload_media` / `list_media` / `get_media` / `get_media_stats` / `favorite_media` / `unfavorite_media` / `delete_media` / `restore_media` / `permanently_delete_media` / `move_media` / `bulk_*_media` / `*_media_folder` | Media library — see `workflows/media-library.md` |
116
116
  | `create_visual_dna` / `generate_character_sheet` / `list_visual_dnas` / `get_visual_dna` / `delete_visual_dna` / `*_visual_dna_folder` (5 folder tools) | Visual DNA (+ character sheet, character folders) — see `workflows/visual-dna.md` |
117
117
  | `list_moodboards` / `get_moodboard` / `list_presets` | Style overlays |
118
- | `search_stock_media` / `get_stock_sources` / `get_stock_categories` / `get_stock_collections` / `get_stock_asset` / `analyze_script_for_stock` / `import_stock_asset` | Stock library (free, no credits) — EXISTING photos / videos / 3D / SFX / music. For stock **music** use `search_stock_media` with `mediaType: "music"` (semantic vibe query, e.g. "uplifting corporate background") → `get_stock_asset` for downloads. The older `*_music_library` tools are deprecated adapters over this — prefer the stock tools. |
118
+ | `search_stock_media` / `get_stock_sources` / `get_stock_categories` / `get_stock_collections` / `get_stock_asset` / `analyze_script_for_stock` / `import_stock_asset` | Stock library (free, no credits) — EXISTING photos / videos / 3D / SFX / music. For stock **music** use `search_stock_media` with `mediaType: "music"` (semantic vibe query, e.g. "uplifting corporate background") → `get_stock_asset` for downloads. |
119
+ | `search_music_library` / `browse_music_library` / `get_music_track_audio` / `acquire_clean_music_track` / `import_music_track_to_library` | SYNCI licensed music. Search/playback is watermarked preview-only. `acquire_clean_music_track` and `import_music_track_to_library` immediately consume one SYNCI vendor credit and return/import an unwatermarked MP3/WAV; reuse `request_id` on retries. |
119
120
  | `list_projects` / `move_session` | Projects: resolve a project NAME → the `project_id` you pass on generation/upload/doc calls; `move_session` relocates a whole session + its media when work landed in the wrong project. NOT the same as `app_builder_list_projects`. See "Projects — Where Work Lands" below. |
120
121
  | `create_project` / `update_project` / `archive_project` / `unarchive_project` / `list_sessions` | Project lifecycle + session inventory (deletion stays in-app). Create a project when the user starts new work, then pass its id on EVERY call. |
121
122
  | `add_project_context` / `list_project_context` / `delete_project_context` / `get_project_profile` / `regenerate_project_profile` | Project knowledge base (RAG): feed scripts/URLs/notes; `get_project_profile` = the living brief — read it to ground work in the project |
@@ -8,7 +8,7 @@ Load this file when the user wants AI-generated **music** — full songs, lyrics
8
8
 
9
9
  **Kolbo MCP routing:** call `generate_music`. Suno is a model option — use `list_models({ type: "music_gen" })` to see versions. Pass `instrumental` and `duration` as separate params; pass the Style/Description text as `style` and the Lyrics as `lyrics`.
10
10
 
11
- **Wants an EXISTING track, not a new song?** ("background music", "stock music", "royalty-free track") → don't generate. Use `search_stock_media` with `mediaType: "music"` (semantic vibe query "tense cinematic pulse", "uplifting corporate background") → `get_stock_asset` for download URLs. Free, no credits. The older `*_music_library` tools are deprecated adapters over the stock library prefer the stock tools.
11
+ **Wants an EXISTING track, not a new song?** ("background music", "stock music", "royalty-free track") → don't generate. Use `search_stock_media` with `mediaType: "music"` for free Kolbo/Coverr results. For SYNCI's licensed catalog use `search_music_library`; its playback URLs are watermarked previews. Final use must call `acquire_clean_music_track` or `import_music_track_to_library`, which immediately consumes one SYNCI vendor credit and returns/imports a clean MP3/WAV. Reuse `request_id` when retrying the same action.
12
12
 
13
13
  ## CRITICAL Kolbo Platform Rules
14
14
 
@@ -69,7 +69,7 @@ const BRIDGE_JS = `
69
69
  } else if (m.method === 'ui/notifications/tool-input' || m.method === 'ui/notifications/tool-input-partial') {
70
70
  // Fires while the tool is still RUNNING — lets widgets show a real
71
71
  // "preparing" state instead of a blank card until the result lands.
72
- toolInputFns.forEach(function (f) { try { f((m.params && m.params.arguments) || {}); } catch (e) {} });
72
+ toolInputFns.forEach(function (f) { try { f((m.params && m.params.arguments) || {}, m.params || {}); } catch (e) {} });
73
73
  } else if (m.method === 'ui/notifications/host-context-changed') {
74
74
  hostContext = (m.params && m.params.hostContext) || m.params || hostContext;
75
75
  themeFns.forEach(function (f) { try { f(hostContext); } catch (e) {} });
package/src/apps/index.js CHANGED
@@ -124,7 +124,19 @@ function appsEnabled(server, opts = {}) {
124
124
  if (opts.apps === true || process.env.KOLBO_MCP_APPS === '1') return true;
125
125
  try {
126
126
  const caps = server?.server?.getClientCapabilities?.();
127
- return getUiCapability(caps) !== undefined;
127
+ if (getUiCapability(caps) !== undefined) return true;
128
+
129
+ // Codex Desktop currently mounts MCP App resources from tool `_meta`, but
130
+ // its initialize handshake identifies as `codex-mcp-client` with an empty
131
+ // capabilities object. Without this compatibility path the host mounts a
132
+ // "Preparing" card while the tool takes the blocking/text fallback, so the
133
+ // completed media never reaches the iframe as structuredContent.
134
+ //
135
+ // Keep the desktop-origin check: Codex CLI uses the same client name but is
136
+ // a text surface, where returning immediately would remove the final URLs.
137
+ const info = server?.server?.getClientVersion?.();
138
+ const origin = process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE || '';
139
+ return info?.name === 'codex-mcp-client' && /codex desktop/i.test(origin);
128
140
  } catch (_) {
129
141
  return false;
130
142
  }
@@ -272,9 +284,9 @@ const TOOL_WIDGETS = {
272
284
  // media grid
273
285
  list_media: UI.mediaGrid,
274
286
  search_stock_media: UI.mediaGrid,
275
- get_stock_collections: UI.mediaGrid,
276
287
  search_music_library: UI.mediaGrid,
277
288
  browse_music_library: UI.mediaGrid,
289
+ get_stock_collections: UI.mediaGrid,
278
290
  list_presets: UI.mediaGrid,
279
291
  list_voices: UI.mediaGrid,
280
292
  list_visual_dnas: UI.mediaGrid,
package/src/apps/theme.js CHANGED
@@ -259,6 +259,24 @@ html.k-fullscreen .k-actions { flex: none; padding-top: 8px; }
259
259
  .k-audio-meta { flex: 1; min-width: 0; }
260
260
  .k-audio-title { font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
261
261
  .k-audio-sub { font-size: 11px; color: var(--text-faint); }
262
+ .k-generated-audio {
263
+ display: grid; grid-template-columns: 40px minmax(0, 1fr) auto;
264
+ align-items: center; gap: 7px 10px;
265
+ }
266
+ .k-generated-audio .k-audio-placeholder {
267
+ display: flex; align-items: center; justify-content: center; color: var(--brand);
268
+ }
269
+ .k-generated-audio .k-audio-placeholder svg { width: 19px; height: 19px; }
270
+ .k-generated-audio .k-audio-download { white-space: nowrap; }
271
+ .k-generated-audio .k-audio-player {
272
+ grid-column: 2 / -1; display: block; width: 100%; min-width: 0; height: 34px;
273
+ }
274
+ @media (max-width: 520px) {
275
+ .k-generated-audio { grid-template-columns: 36px minmax(0, 1fr); }
276
+ .k-generated-audio .k-audio-art { width: 36px; height: 36px; }
277
+ .k-generated-audio .k-audio-download { grid-column: 2; justify-self: start; }
278
+ .k-generated-audio .k-audio-player { grid-column: 1 / -1; }
279
+ }
262
280
  .k-play {
263
281
  width: 32px; height: 32px; border-radius: 50%; flex: none; border: 1px solid rgba(255,255,255,0.18);
264
282
  background: rgba(0,0,0,0.5); color: #fff; cursor: pointer;
@@ -17,6 +17,7 @@ const { widgetPage } = require('../html');
17
17
  * settings: { duration, resolution, aspect_ratio, audio, voice, mode },
18
18
  * reference_image, // thumbnail URL (optional)
19
19
  * urls, thumbnail_url, title, duration, credits_used,
20
+ * tracks: [{ title, duration, thumbnail_url, model }], // optional audio metadata by URL index
20
21
  * scenes: [{ scene_number, title, image_urls, video_urls }],
21
22
  * error,
22
23
  * open_url // "Open in Kolbo" target (optional)
@@ -53,6 +54,8 @@ const SCRIPT = `
53
54
  var state = null; // current structuredContent
54
55
  var selected = 0; // selected result index
55
56
  var pollTimer = null;
57
+ var originTool = null;
58
+ var originArgs = {};
56
59
 
57
60
  el('logo').innerHTML = KOLBO_LOGO + '<span>Kolbo</span>';
58
61
  el('action-cancel').innerHTML = ICONS.x;
@@ -122,23 +125,26 @@ function renderGenerating(sc) {
122
125
  schedulePoll(sc);
123
126
  }
124
127
 
125
- // Poll ceilings so the card never spins "Generating" forever. A generation
126
- // that genuinely stalls, or a status call that keeps failing (e.g. the record
127
- // can't be resolved), surfaces an error + Try Again instead of hanging.
128
- var MAX_POLL_MS = 12 * 60 * 1000; // hard wall-clock ceiling (covers slow 4K video)
129
- var MAX_POLL_ERRORS = 12; // ~48s of consecutive status-call failures → give up
128
+ // Poll ceilings so the card never spins forever. Losing widget-side tracking
129
+ // is not a generation failure: keep retries disabled because the paid server
130
+ // job may still complete.
131
+ var MAX_POLL_MS = 35 * 60 * 1000;
132
+ var MAX_POLL_ERRORS = 30;
130
133
  var pollStart = 0, pollErrors = 0;
131
134
 
132
135
  function schedulePoll(sc) {
133
136
  if (!pollStart) pollStart = Date.now();
134
137
  clearTimeout(pollTimer);
135
- pollTimer = setTimeout(function () { poll(sc); }, 4000);
138
+ // The call itself long-waits server-side (normally up to three minutes).
139
+ // This short pause only separates successive wait windows.
140
+ var delay = 1500;
141
+ pollTimer = setTimeout(function () { poll(sc); }, delay);
136
142
  }
137
143
  function poll(sc) {
138
144
  if (pollStart && (Date.now() - pollStart) > MAX_POLL_MS) {
139
- return renderError('This is taking longer than expected and may have stalled. Try again if it keeps happening the model may be busy. Any finished result will also appear in your Kolbo library.');
145
+ return renderTrackingIssue('This is still running longer than the tracking window. Do not retry it any completed result will appear in your Kolbo library.');
140
146
  }
141
- var args = sc.status_args || { generation_id: sc.generation_id };
147
+ var args = sc.status_args || { generation_id: sc.generation_id, wait: true };
142
148
  window.kolbo.callTool(sc.poll_tool || 'get_generation_status', args).then(function (res) {
143
149
  var st = structured(res) || {};
144
150
  var stateName = st.state || st.phase || st.status;
@@ -146,7 +152,7 @@ function poll(sc) {
146
152
  // generation state — count it toward the consecutive-error cap so a record
147
153
  // that can't be resolved errors out fast instead of polling forever.
148
154
  if ((res && res.isError) || st.success === false || (st.error && !stateName)) {
149
- if (++pollErrors >= MAX_POLL_ERRORS) return renderError(st.error || 'Could not track this generation. Please try again.');
155
+ if (++pollErrors >= MAX_POLL_ERRORS) return renderTrackingIssue(st.error || 'Tracking paused. The generation may still be running.');
150
156
  return schedulePoll(sc);
151
157
  }
152
158
  if (stateName === 'completed') {
@@ -163,9 +169,13 @@ function poll(sc) {
163
169
  renderResult(done);
164
170
  // Let the model know the outcome without it having to poll.
165
171
  try {
172
+ var completedUrls = (done.urls || []).slice();
173
+ (done.scenes || []).forEach(function (scene) {
174
+ completedUrls = completedUrls.concat(scene.image_urls || [], scene.video_urls || []);
175
+ });
166
176
  window.kolbo.updateModelContext(
167
177
  'Generation ' + (sc.generation_id || '') + ' completed (' + (sc.tool || '') + ').' +
168
- '\\nOutput URLs:\\n' + (done.urls || []).join('\\n') +
178
+ '\\nOutput URLs:\\n' + completedUrls.join('\\n') +
169
179
  (done.credits_used != null ? '\\nCredits used: ' + done.credits_used : ''));
170
180
  } catch (e) {}
171
181
  } else if (stateName === 'failed' || stateName === 'error' || stateName === 'cancelled') {
@@ -175,7 +185,7 @@ function poll(sc) {
175
185
  schedulePoll(sc);
176
186
  }
177
187
  }).catch(function () {
178
- if (++pollErrors >= MAX_POLL_ERRORS) return renderError('Lost connection while tracking this generation. Please try again.');
188
+ if (++pollErrors >= MAX_POLL_ERRORS) return renderTrackingIssue('Tracking paused after repeated connection errors. The generation may still be running.');
179
189
  schedulePoll(sc);
180
190
  });
181
191
  }
@@ -240,13 +250,35 @@ function renderVideo(sc, urls) {
240
250
 
241
251
  function renderAudio(sc, urls) {
242
252
  el('stage').innerHTML = urls.map(function (u, i) {
243
- var title = sc.title || ((TOOL_TITLES[sc.tool] || 'Audio') + (urls.length > 1 ? ' ' + (i + 1) : ''));
244
- return '<div class="k-audio-row">' +
245
- (sc.thumbnail_url ? '<img class="k-audio-art" src="' + esc(sc.thumbnail_url) + '">' : '<div class="k-audio-art"></div>') +
253
+ var track = (sc.tracks && sc.tracks[i]) || {};
254
+ var titleBase = track.title || sc.title || (TOOL_TITLES[sc.tool] || 'Audio');
255
+ var title = titleBase + (urls.length > 1 ? ' Track ' + (i + 1) : '');
256
+ var duration = track.duration != null ? track.duration : sc.duration;
257
+ var artwork = track.thumbnail_url || sc.thumbnail_url;
258
+ return '<div class="k-audio-row k-generated-audio">' +
259
+ (artwork ? '<img class="k-audio-art" src="' + esc(artwork) + '" alt="">' :
260
+ '<div class="k-audio-art k-audio-placeholder">' + ICONS.audio + '</div>') +
246
261
  '<div class="k-audio-meta"><div class="k-audio-title">' + esc(title) + '</div>' +
247
- '<div class="k-audio-sub">' + esc(sc.model || '') + (sc.duration ? ' · ' + fmtDur(sc.duration) : '') + '</div></div>' +
248
- '<audio src="' + esc(u) + '" controls style="height:32px;max-width:260px"></audio></div>';
262
+ '<div class="k-audio-sub">' + esc(track.model || sc.model || '') +
263
+ (duration ? ' · ' + fmtDur(duration) : '') + '</div></div>' +
264
+ '<button class="k-btn k-audio-download" data-audio-download="' + esc(u) +
265
+ '" aria-label="Download ' + esc(title) + '">' + ICONS.download + ' Download</button>' +
266
+ '<audio class="k-audio-player" src="' + esc(u) + '" controls preload="metadata" aria-label="Play ' +
267
+ esc(title) + '"></audio></div>';
249
268
  }).join('');
269
+ Array.prototype.forEach.call(el('stage').querySelectorAll('[data-audio-download]'), function (b) {
270
+ b.onclick = function () {
271
+ window.kolbo.openLink(downloadUrl(b.getAttribute('data-audio-download')));
272
+ };
273
+ });
274
+ var players = el('stage').querySelectorAll('.k-audio-player');
275
+ Array.prototype.forEach.call(players, function (player) {
276
+ player.addEventListener('play', function () {
277
+ Array.prototype.forEach.call(players, function (other) {
278
+ if (other !== player) other.pause();
279
+ });
280
+ });
281
+ });
250
282
  }
251
283
 
252
284
  function render3d(sc, urls) {
@@ -363,6 +395,22 @@ function renderError(msg) {
363
395
  window.kolbo.notifySize();
364
396
  }
365
397
 
398
+ function renderTrackingIssue(msg) {
399
+ clearTimeout(pollTimer);
400
+ setPhaseChip('Still working', false);
401
+ el('stage').innerHTML = '<div class="k-error">' + ICONS.clock + ' ' + esc(msg) + '</div>';
402
+ el('actions').innerHTML = '<button class="k-btn primary" id="status-btn">' + ICONS.clock + ' Check status</button>' +
403
+ '<button class="k-btn ghost" id="tracking-open">Open in Kolbo ' + ICONS.open + '</button>';
404
+ el('status-btn').onclick = function () {
405
+ window.kolbo.sendMessage('Check the existing Kolbo generation status without retrying it.' +
406
+ (state && state.generation_id ? '\\nGeneration ID: ' + state.generation_id : ''));
407
+ };
408
+ el('tracking-open').onclick = function () {
409
+ window.kolbo.openLink((state && state.open_url) || 'https://app.kolbo.ai');
410
+ };
411
+ window.kolbo.notifySize();
412
+ }
413
+
366
414
  /* ---------- fullscreen viewer ---------- */
367
415
  var isFullscreen = false;
368
416
  function toggleFullscreen() {
@@ -417,9 +465,10 @@ function renderActions(sc) {
417
465
  }
418
466
  if (sc.kind === 'video') {
419
467
  a.push('<button class="k-btn primary" id="btn-download">' + ICONS.download + ' Download</button>');
420
- } else if (hasSingleUrl) {
468
+ } else if (hasSingleUrl && sc.kind !== 'audio') {
421
469
  // Scenes (Creative Director) have no single "current" url — per-item hover
422
- // download buttons cover them instead.
470
+ // download buttons cover them instead. Audio has a visible download button
471
+ // on every track so multi-output generations never download only track 1.
423
472
  a.push('<button class="k-btn" id="btn-download">' + ICONS.download + ' Download</button>');
424
473
  }
425
474
  a.push('<button class="k-btn" id="btn-recreate">' + ICONS.retry + ' Recreate</button>');
@@ -471,6 +520,8 @@ function openPromptRow(placeholder, onSend) {
471
520
  take many seconds (model resolution, file upload, submit). Show a live
472
521
  shell immediately instead of a blank card. */
473
522
  function bootPre(toolName, args) {
523
+ if (toolName) originTool = toolName;
524
+ if (args) originArgs = args;
474
525
  if (state) return; // real data already arrived
475
526
  el('tool-title').textContent = TOOL_TITLES[toolName] || 'Generation';
476
527
  if (args && (args.prompt || args.text)) {
@@ -484,23 +535,61 @@ function bootPre(toolName, args) {
484
535
  window.kolbo.notifySize();
485
536
  }
486
537
 
538
+ function kindFromTool(tool, sc) {
539
+ if (sc && sc.scenes) return 'scenes';
540
+ if (/video|lipsync|elements|first_last/i.test(tool || '')) return 'video';
541
+ if (/music|speech|sound/i.test(tool || '')) return 'audio';
542
+ if (/3d/i.test(tool || '')) return '3d';
543
+ var firstUrl = sc && Array.isArray(sc.urls) ? String(sc.urls[0] || '').split('?')[0].toLowerCase() : '';
544
+ if (/\\.(mp4|mov|webm|mkv)$/.test(firstUrl)) return 'video';
545
+ if (/\\.(mp3|wav|m4a|aac|ogg|flac)$/.test(firstUrl)) return 'audio';
546
+ if (/\\.(glb|gltf|fbx|obj|usdz)$/.test(firstUrl)) return '3d';
547
+ return 'image';
548
+ }
549
+
550
+ // Recover a successful legacy/text result when a host mounted the iframe from
551
+ // declaration metadata but the server did not recognize its Apps capability.
552
+ function completedFromPlain(sc) {
553
+ if (!sc || (!Array.isArray(sc.urls) && !Array.isArray(sc.scenes))) return null;
554
+ return Object.assign({}, sc, {
555
+ widget: 'generation',
556
+ phase: 'completed',
557
+ tool: originTool,
558
+ kind: kindFromTool(originTool, sc),
559
+ prompt: originArgs.prompt || originArgs.text || sc.prompt_used || '',
560
+ model: sc.model || originArgs.model,
561
+ settings: {
562
+ duration: sc.duration || originArgs.duration,
563
+ resolution: originArgs.resolution,
564
+ aspect_ratio: originArgs.aspect_ratio
565
+ },
566
+ urls: sc.urls || []
567
+ });
568
+ }
569
+
487
570
  /* ---------- wire host events ---------- */
488
571
  window.kolbo.onToolResult(function (result) {
489
572
  var sc = result.structuredContent || structured(result);
490
573
  if (sc && (sc.phase || sc.widget)) return boot(sc);
574
+ var recovered = completedFromPlain(sc);
575
+ if (recovered) return boot(recovered);
491
576
  // Tool errored (or returned plain text): show it instead of a dead blank card.
492
577
  var txt = '';
493
578
  try { txt = (result.content || []).filter(function (c) { return c.type === 'text'; }).map(function (c) { return c.text; }).join(' '); } catch (e) {}
579
+ if (/timed out|timeout/i.test(txt)) {
580
+ return renderTrackingIssue((txt || 'Tracking timed out. The generation may still be running.').slice(0, 300));
581
+ }
494
582
  if (result.isError || /error|failed/i.test(txt)) {
495
583
  renderError((txt || 'The request failed.').slice(0, 300));
496
584
  }
497
585
  });
498
- window.kolbo.onToolInput(function (args) { bootPre(null, args); });
586
+ window.kolbo.onToolInput(function (args, info) { bootPre(info && info.name, args); });
499
587
  window.kolbo.ready(function (ctx) {
500
588
  var info = ctx && ctx.toolInfo;
501
589
  if (!state && info) {
590
+ originTool = (info.tool && info.tool.name) || originTool;
502
591
  if (info.result && info.result.structuredContent) return boot(info.result.structuredContent);
503
- bootPre(info.tool && info.tool.name, null);
592
+ bootPre(originTool, null);
504
593
  }
505
594
  });
506
595
  `;
@@ -36,7 +36,7 @@ const BODY = `
36
36
  const SCRIPT = `
37
37
  el('logo').innerHTML = KOLBO_LOGO + '<span>Kolbo</span>';
38
38
  el('kolbo-link').onclick = function (e) { e.preventDefault(); window.kolbo.openLink((state && state.open_url) || 'https://app.kolbo.ai'); };
39
- var state = null, pollTimer = null;
39
+ var state = null, pollTimer = null, originArgs = {};
40
40
 
41
41
  function boot(sc) {
42
42
  if (!sc) return;
@@ -83,40 +83,68 @@ function boot(sc) {
83
83
  }
84
84
 
85
85
  var pollStart = 0, pollErrors = 0;
86
- var MAX_POLL_MS = 12 * 60 * 1000, MAX_POLL_ERRORS = 12;
86
+ var MAX_POLL_MS = 35 * 60 * 1000, MAX_POLL_ERRORS = 30;
87
87
  function poll() {
88
88
  if (!pollStart) pollStart = Date.now();
89
89
  if ((Date.now() - pollStart) > MAX_POLL_MS) {
90
- return boot(Object.assign({}, state, { phase: 'failed', error: 'Transcription is taking longer than expected and may have stalledplease try again.' }));
90
+ return trackingIssue('This transcription is still running longer than the tracking window. Do not retry it any completed result will appear in your Kolbo library.');
91
91
  }
92
- window.kolbo.callTool(state.poll_tool || 'get_generation_status', { generation_id: state.generation_id })
92
+ var args = state.status_args || { generation_id: state.generation_id, wait: true };
93
+ window.kolbo.callTool(state.poll_tool || 'get_generation_status', args)
93
94
  .then(function (res) {
94
95
  var st = structured(res) || {};
95
96
  var s = st.state || st.phase;
96
97
  if ((res && res.isError) || st.success === false || (st.error && !s)) {
97
- if (++pollErrors >= MAX_POLL_ERRORS) return boot(Object.assign({}, state, { phase: 'failed', error: st.error || 'Could not track this transcription. Please try again.' }));
98
- pollTimer = setTimeout(poll, 5000); return;
98
+ if (++pollErrors >= MAX_POLL_ERRORS) return trackingIssue(st.error || 'Tracking paused. The transcription may still be running.');
99
+ pollTimer = setTimeout(poll, pollDelay()); return;
99
100
  }
100
101
  if (s === 'completed') {
101
102
  var r = st.result || st;
102
103
  boot(Object.assign({}, state, r, { phase: 'completed', credits_used: st.credits_used }));
103
104
  } else if (s === 'failed' || s === 'cancelled') {
104
105
  boot(Object.assign({}, state, { phase: 'failed', error: st.error }));
105
- } else { pollErrors = 0; pollTimer = setTimeout(poll, 5000); }
106
+ } else { pollErrors = 0; pollTimer = setTimeout(poll, pollDelay()); }
106
107
  }).catch(function () {
107
- if (++pollErrors >= MAX_POLL_ERRORS) return boot(Object.assign({}, state, { phase: 'failed', error: 'Lost connection while tracking this transcription. Please try again.' }));
108
- pollTimer = setTimeout(poll, 5000);
108
+ if (++pollErrors >= MAX_POLL_ERRORS) return trackingIssue('Tracking paused after repeated connection errors. The transcription may still be running.');
109
+ pollTimer = setTimeout(poll, pollDelay());
109
110
  });
110
111
  }
111
112
 
113
+ function pollDelay() {
114
+ // The call itself long-waits server-side; only briefly back off between
115
+ // successive wait windows.
116
+ return 1500;
117
+ }
118
+
119
+ function trackingIssue(msg) {
120
+ clearTimeout(pollTimer);
121
+ el('phase-chip').style.display = '';
122
+ el('phase-chip').textContent = 'Still working';
123
+ el('stage').classList.remove('k-empty');
124
+ el('stage').innerHTML = '<div class="k-error">' + ICONS.clock + ' ' + esc(msg) + '</div>';
125
+ el('actions').innerHTML = '<button class="k-btn primary" id="transcript-status">' + ICONS.clock + ' Check status</button>';
126
+ el('transcript-status').onclick = function () {
127
+ window.kolbo.sendMessage('Check the existing Kolbo transcription status without retrying it.' +
128
+ (state && state.generation_id ? '\\nGeneration ID: ' + state.generation_id : ''));
129
+ };
130
+ window.kolbo.notifySize();
131
+ }
132
+
112
133
  window.kolbo.onToolResult(function (result) {
113
134
  var sc = result.structuredContent || structured(result);
114
135
  // Only boot on a real widget contract (phase present). A text-JSON
115
136
  // fallback like { status: 'submitted' } has no phase and would render a
116
137
  // bogus "(empty transcript)" completed view.
117
138
  if (sc && (sc.phase || sc.widget === 'transcript')) return boot(sc);
139
+ if (sc && (typeof sc.text === 'string' || sc.srt_url || sc.txt_url)) {
140
+ return boot(Object.assign({}, sc, {
141
+ widget: 'transcript', phase: 'completed',
142
+ audio_url: originArgs.source && /^https?:\\/\\//i.test(originArgs.source) ? originArgs.source : undefined
143
+ }));
144
+ }
118
145
  var txt = '';
119
146
  try { txt = (result.content || []).filter(function (c) { return c.type === 'text'; }).map(function (c) { return c.text; }).join(' '); } catch (e) {}
147
+ if (/timed out|timeout/i.test(txt)) return trackingIssue((txt || 'Tracking timed out.').slice(0, 300));
120
148
  if (result.isError || /error|failed/i.test(txt)) {
121
149
  el('phase-chip').style.display = 'none';
122
150
  el('stage').classList.remove('k-empty');
@@ -129,6 +157,7 @@ window.kolbo.onToolResult(function (result) {
129
157
  if (card) card.style.display = 'none';
130
158
  window.kolbo.notifySize();
131
159
  });
160
+ window.kolbo.onToolInput(function (args) { originArgs = args || {}; });
132
161
  `;
133
162
 
134
163
  function transcriptWidgetHtml() {
package/src/index.js CHANGED
@@ -115,7 +115,7 @@ function createServer(opts = {}) {
115
115
  '3. Misplaced work is fixable: `move_media` / `bulk_move_media` / `move_folder_contents` move media items between projects; `move_session` moves a whole session (plus its media) to another project. If the user says a generation landed in the wrong project, move it rather than regenerating.',
116
116
  '4. If the user has not mentioned any project, omit `project_id` — the default bucket is correct in that case. Do not ask which project to use unless the user\'s intent is ambiguous.',
117
117
  '5. Written deliverables (plans, briefs, scripts, research summaries) can live in Kolbo too: author them as AI Docs with `create_doc` (project-scoped, editable in the app, shareable via `share_doc`).',
118
- '6. DIRECTOR / BATCH JOBS: generate_creative_director runs its scenes (image OR video) in parallel and only reports state="completed" once EVERY scene is terminal. Video batches can take many minutes. If the tool returns `_timed_out:true`, the batch is STILL RUNNING on the server call `get_creative_director_status` with the returned generation_id and keep checking until state="completed" to collect all scene outputs. NEVER conclude a Director run failed and fall back to plain generate_image/generate_video without first checking statusdoing so wastes the user\'s credits by paying twice. If scenes already carry image_urls/video_urls, they are done; do not regenerate.',
118
+ '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.',
119
119
  '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.',
120
120
  '8. LOCAL FILES / REFERENCE MEDIA — HOW TO HANDLE EVERY CASE: (A) User has a LOCAL file (audio, video, image, document) on their machine: if you have filesystem access (Claude Desktop / Code / IDE / any stdio MCP client) → call `upload_media` with the absolute local path OR pass the path directly to tools like `transcribe_audio` which accept local paths natively. If you have NO filesystem access (claude.ai browser/mobile) → call `media_upload_widget` IMMEDIATELY, an upload card appears, the user uploads, and a `media.kolbo.ai` CDN URL comes back — use that URL for any follow-up tool call. (B) You already have a public URL (media.kolbo.ai, any CDN, any direct link) → pass it directly to the tool. 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 a large file — use upload_media instead.',
121
121
  '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.',
@@ -214,6 +214,45 @@ async function resolveToBuffer(source, kind, opts = {}) {
214
214
  };
215
215
  }
216
216
 
217
+ // ─── Universal graceful-timeout convention ───────────────────────────────────
218
+ // pollUntilDone throws PollingTimeoutError (err.timedOut === true) when the
219
+ // CLIENT-SIDE poll window elapses — the generation is almost always still
220
+ // running server-side (or already done). Originally only
221
+ // generate_creative_director caught this and returned a non-throwing
222
+ // "_timed_out" result; every other tool let it propagate, so the MCP SDK
223
+ // wrapped it as isError:true and recovery depended entirely on the calling
224
+ // LLM reading hint text. pollOrTimedOut() is the one place that decision now
225
+ // lives — every generation/chat tool routes its pollUntilDone call through
226
+ // it instead of duplicating the try/catch. Genuine failures (state
227
+ // failed/cancelled → GenerationFailedError) are NOT caught here; they still
228
+ // throw and surface as a real tool error.
229
+ const { pollUntilDone } = require('../polling');
230
+
231
+ /**
232
+ * @returns {Promise<{result: object}|{timedOut: object}>}
233
+ * Callers do: `const poll = await pollOrTimedOut(...); if (poll.timedOut) return poll.timedOut; const result = poll.result;`
234
+ */
235
+ async function pollOrTimedOut(client, generationId, pollOpts) {
236
+ try {
237
+ return { result: await pollUntilDone(client, generationId, pollOpts) };
238
+ } catch (err) {
239
+ if (!err || !err.timedOut) throw err;
240
+ return {
241
+ timedOut: {
242
+ content: [{
243
+ type: 'text',
244
+ text: JSON.stringify({
245
+ state: 'processing',
246
+ generation_id: generationId,
247
+ _timed_out: true,
248
+ _hint: `Still running on the server after the poll window — this is NOT a failure, no credits were lost. Call get_generation_status with generation_id="${generationId}" (wait=true) to keep checking until state="completed". Do NOT re-run this tool.`
249
+ }, null, 2)
250
+ }]
251
+ }
252
+ };
253
+ }
254
+ }
255
+
217
256
  /**
218
257
  * Extract real, multiplier-adjusted credit cost from a polled getStatus
219
258
  * response. kolbo-api returns `credits_used` (final number deducted) and
@@ -363,6 +402,19 @@ function buildOpenUrl(tool, gen) {
363
402
  return url;
364
403
  }
365
404
 
405
+ /**
406
+ * Build the "Open in Kolbo" deep link for a PROJECT. Lands on the Media hub
407
+ * (all-assets view) pre-filtered to this project, where the user sees every
408
+ * generation/media item in it and can switch projects via the in-page selector.
409
+ * The media page reads `?project=<id>` on load and selects it. Returns undefined
410
+ * for a missing id or the default "API Generations" bucket (no useful landing —
411
+ * it is the catch-all, not a real workspace the user navigates to).
412
+ */
413
+ function buildProjectUrl(projectId, opts = {}) {
414
+ if (!projectId || opts.is_default) return undefined;
415
+ return `${APP_BASE_URL}/media?project=${encodeURIComponent(projectId)}`;
416
+ }
417
+
366
418
  // ─── MCP Apps generation widget helpers ──────────────────────────────────────
367
419
  // When the host renders MCP Apps (claude.ai via the remote connector, Claude
368
420
  // Desktop over stdio), generation tools return IMMEDIATELY after submit and the
@@ -380,7 +432,7 @@ const { UI, uiResult, appsEnabled, modelIcon } = require('../apps');
380
432
  * client KolboClient (for model icon lookup)
381
433
  * model, prompt, count, settings, reference_image, estimated_seconds
382
434
  * poll_tool widget-side status tool (default 'get_generation_status')
383
- * status_args args for poll_tool (default { generation_id })
435
+ * status_args args for poll_tool (default { generation_id, wait: true })
384
436
  */
385
437
  async function uiGenerating(p) {
386
438
  // No ETAs anywhere — just a spinner until the poll flips to completed.
@@ -392,7 +444,10 @@ async function uiGenerating(p) {
392
444
  tool: p.tool,
393
445
  generation_id: p.gen.generation_id,
394
446
  poll_tool: p.poll_tool || 'get_generation_status',
395
- status_args: p.status_args,
447
+ // Keep at most one long-wait status call in flight per widget. Without
448
+ // wait=true, every open card calls tools/call every few seconds, flooding
449
+ // the host's global progress/context stream and API rate limits.
450
+ status_args: p.status_args || { generation_id: p.gen.generation_id, wait: true },
396
451
  model: p.model || 'Smart Select',
397
452
  model_icon: icon,
398
453
  prompt: p.prompt,
@@ -446,11 +501,13 @@ module.exports = {
446
501
  guessFilename,
447
502
  guessContentType,
448
503
  resolveToBuffer,
504
+ pollOrTimedOut,
449
505
  creditFields,
450
506
  projectIdField,
451
507
  projectScopeReadField,
452
508
  inlineImageBlocks,
453
509
  buildOpenUrl,
510
+ buildProjectUrl,
454
511
  uiGenerating,
455
512
  uiCompleted,
456
513
  appsEnabled,
package/src/tools/chat.js CHANGED
@@ -4,8 +4,7 @@
4
4
  * new OPTIONAL args only. Full rules: ../index.js top-of-file and CLAUDE.md. */
5
5
 
6
6
  const { z } = require('zod');
7
- const { pollUntilDone } = require('../polling');
8
- const { creditFields, projectIdField } = require('./_shared');
7
+ const { pollOrTimedOut, creditFields, projectIdField } = require('./_shared');
9
8
 
10
9
  function registerChatTools(server, client) {
11
10
  // ─── chat_send_message ─────────────────────────────────────
@@ -40,10 +39,12 @@ function registerChatTools(server, client) {
40
39
  // extra time when web_search is on (may fetch + analyze multiple pages).
41
40
  const timeout = deep_think ? 600000 : (web_search ? 240000 : 120000);
42
41
 
43
- const result = await pollUntilDone(client, gen.message_id, {
42
+ const poll = await pollOrTimedOut(client, gen.message_id, {
44
43
  interval: (gen.poll_interval_hint || 2) * 1000,
45
44
  timeout
46
45
  });
46
+ if (poll.timedOut) return poll.timedOut;
47
+ const result = poll.result;
47
48
 
48
49
  // Chat status shape (from extractResult in kolbo-api sdk/controller.js):
49
50
  // { content, reasoning_content, image_urls?, video_urls?, audio_urls?, model, created_at }