@kolbo/mcp 1.88.3 → 1.89.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.
@@ -8,8 +8,8 @@
8
8
  * or non-boolean entries whenever the registered tool surface changes.
9
9
  */
10
10
 
11
- const READ_ONLY = [
12
- 'list_fonts', 'get_font', 'get_font_upload_status',
11
+ const READ_ONLY = [
12
+ 'list_fonts', 'get_font', 'get_font_upload_status',
13
13
  'get_creative_director_status', 'get_generation_status', 'list_models',
14
14
  'check_credits', 'show_plans', 'get_session_usage', 'list_voices',
15
15
  'chat_list_conversations', 'chat_get_messages',
@@ -21,7 +21,7 @@ const READ_ONLY = [
21
21
  'list_projects', 'get_project', 'list_sessions', 'list_project_context', 'get_project_profile',
22
22
  'list_project_assets',
23
23
  'list_session_generations',
24
- 'list_agents', 'list_docs', 'get_doc',
24
+ 'list_agents', 'list_skills', 'list_docs', 'get_doc',
25
25
  'get_review_storage_usage', 'list_review_assets', 'get_review_asset',
26
26
  'list_review_collections', 'list_review_comments', 'list_review_share_links',
27
27
  'search_music_library', 'analyze_script_for_music', 'browse_music_library',
@@ -36,8 +36,9 @@ const OPEN_WORLD_READ_ONLY = [
36
36
  'blender_get_command_status',
37
37
  ];
38
38
 
39
- const PRIVATE_WRITE = [
40
- 'upload_font', 'rename_font', 'create_font_upload_ticket', 'font_upload_widget',
39
+ const PRIVATE_WRITE = [
40
+ 'upload_font', 'rename_font', 'create_font_upload_ticket', 'font_upload_widget',
41
+ 'create_video_editor_session',
41
42
  'media_upload_widget', 'create_upload_ticket', 'upload_media',
42
43
  'favorite_media', 'unfavorite_media',
43
44
  'create_media_folder', 'update_media_folder',
@@ -56,7 +57,7 @@ const PRIVATE_WRITE = [
56
57
  'create_project', 'duplicate_project', 'update_project',
57
58
  'archive_project', 'unarchive_project', 'add_project_context',
58
59
  'link_project_asset', 'unlink_project_asset',
59
- 'create_agent',
60
+ 'create_agent', 'create_skill',
60
61
  'create_doc',
61
62
  'create_review_asset', 'update_review_asset', 'add_review_version',
62
63
  'set_review_status', 'create_review_collection', 'update_review_collection',
@@ -65,8 +66,9 @@ const PRIVATE_WRITE = [
65
66
  'import_stock_asset',
66
67
  ];
67
68
 
68
- const DESTRUCTIVE_WRITE = [
69
- 'delete_font',
69
+ const DESTRUCTIVE_WRITE = [
70
+ 'delete_font',
71
+ 'export_video_editor_session',
70
72
  // These actions spend credits, enqueue irreversible work, or cancel it.
71
73
  'generate_image', 'generate_image_edit', 'generate_creative_director',
72
74
  'generate_video', 'generate_video_from_image', 'generate_music',
@@ -89,7 +91,7 @@ const DESTRUCTIVE_WRITE = [
89
91
  'delete_session',
90
92
  'delete_project_context', 'regenerate_project_profile',
91
93
  'update_project_asset',
92
- 'update_agent', 'delete_agent', 'update_doc', 'delete_doc',
94
+ 'update_agent', 'delete_agent', 'update_skill', 'delete_skill', 'update_doc', 'delete_doc',
93
95
  'delete_review_asset', 'delete_review_collection',
94
96
  'edit_review_comment', 'delete_review_comment',
95
97
  ];
@@ -990,7 +990,11 @@ async function uiCompleted(p, textPayload, extraContent) {
990
990
  // `settings` wiped the resolution / aspect / DNA chips off the finished
991
991
  // card. Every reader already does `sc.settings || {}`.
992
992
  ...(settings ? { settings } : {}),
993
- visual_dnas: await resolveVisualDnas(p.client, (settings || {}).visual_dna_ids),
993
+ ...(Array.isArray(p.visual_dnas)
994
+ ? { visual_dnas: p.visual_dnas }
995
+ : Array.isArray(settings?.visual_dna_ids)
996
+ ? { visual_dnas: await resolveVisualDnas(p.client, settings.visual_dna_ids) }
997
+ : {}),
994
998
  moodboards: await resolveMoodboards(p.client, moodboardIds(settings)),
995
999
  ...mediaRefs(p),
996
1000
  urls: preferOwnedUrls(p.urls),
@@ -1058,11 +1062,12 @@ const MAX_TEXT_CHARS = 20000;
1058
1062
  * @param {object} opts
1059
1063
  * @param {string[]} opts.fields keys to keep per row, in order (others dropped)
1060
1064
  * @param {number} [opts.cap] max rows to include (default 50)
1061
- * @param {number} [opts.total] true total, so the model knows more exist
1065
+ * @param {number} [opts.total] true total, so the model knows more exist
1066
+ * @param {number} [opts.maxChars] text budget; Infinity for bounded server pages that must remain complete
1062
1067
  * @param {object} [opts.extra] extra top-level keys to merge in
1063
1068
  * @param {string} [opts.note] guidance on how to fetch the rest
1064
1069
  */
1065
- function compactList(items, { fields, cap = 50, total, extra, note } = {}) {
1070
+ function compactList(items, { fields, cap = 50, total, extra, note, maxChars = MAX_TEXT_CHARS } = {}) {
1066
1071
  const rows = Array.isArray(items) ? items : [];
1067
1072
  const kept = rows.slice(0, cap).map((row) => {
1068
1073
  if (!row || typeof row !== 'object') return row;
@@ -1085,11 +1090,11 @@ function compactList(items, { fields, cap = 50, total, extra, note } = {}) {
1085
1090
  }
1086
1091
 
1087
1092
  let text = JSON.stringify(payload);
1088
- if (text.length > MAX_TEXT_CHARS) {
1093
+ if (text.length > maxChars) {
1089
1094
  // Still too big even trimmed (very long descriptions). Halve until it fits
1090
1095
  // rather than returning something the host will truncate at a random byte.
1091
1096
  let n = kept.length;
1092
- while (n > 1 && text.length > MAX_TEXT_CHARS) {
1097
+ while (n > 1 && text.length > maxChars) {
1093
1098
  n = Math.floor(n / 2);
1094
1099
  payload.items = kept.slice(0, n);
1095
1100
  payload.count = n;
@@ -6,15 +6,39 @@
6
6
  const { z } = require('zod');
7
7
  const { listResult } = require('../apps');
8
8
 
9
+ /**
10
+ * Skills rename (2026-09-09). The product surface is called Skills; the original tools
11
+ * were called *_agent. Because a published tool name can never be withdrawn (commandment
12
+ * above), every tool is registered TWICE from one definition:
13
+ *
14
+ * list_skill s/create_skill/update_skill/delete_skill — current vocabulary
15
+ * list_agents/create_agent/update_agent/delete_agent — original, still supported
16
+ *
17
+ * Same handler, same behaviour. Only the tool name, the id ARG name (`skill_id` vs the
18
+ * original `agent_id`) and the prose differ — the legacy arg name must keep working
19
+ * exactly as published, so it is not renamed, only shadowed by the new one.
20
+ *
21
+ * The server route is `/v1/skills`, which the backend also serves at `/v1/agents`.
22
+ */
9
23
  function registerAgentTools(server, client) {
10
- // ─── list_agents ───────────────────────────────────────────
24
+ registerSkillCrud(server, client, { noun: 'skill', idArg: 'skill_id', suffix: 'skill', plural: 'skills' });
25
+ registerSkillCrud(server, client, { noun: 'agent', idArg: 'agent_id', suffix: 'agent', plural: 'agents' });
26
+ }
27
+
28
+ function registerSkillCrud(server, client, { noun, idArg, suffix, plural }) {
29
+ const legacyNote = noun === 'agent'
30
+ ? ' NOTE: "agent" is the original name for this feature; the product now calls them Skills. New integrations should prefer list_skills/create_skill/update_skill/delete_skill — these keep working unchanged.'
31
+ : '';
32
+ const idDesc = `${noun[0].toUpperCase() + noun.slice(1)} id (from list_${plural}).`;
33
+
34
+ // ─── list_skills / list_agents ─────────────────────────────
11
35
  server.tool(
12
- 'list_agents',
13
- 'List the user\'s custom chat agents (personal + any global/org preset agents visible to them). A custom agent is a reusable, named persona for the chat tool — its `description` is the system instruction the model adopts. Use this to resolve an agent NAME the user mentioned into its id, or to show what agents exist. Returns id, name, description, emoji, is_global. Personal agents (is_global:false) are editable/deletable; global presets are not.',
36
+ `list_${plural}`,
37
+ `List the user's custom ${plural} (personal + any platform/org preset ${plural} visible to them). A ${noun} is a reusable, named persona for the chat tool — its \`description\` is the system instruction the model adopts. Use this to resolve a ${noun} NAME the user mentioned into its id, or to show what ${plural} exist. Returns id, name, description, emoji, is_global. Personal ${plural} (is_global:false) are editable/deletable; platform presets are not.${legacyNote}`,
14
38
  { search: z.string().optional().describe('Optional case-insensitive name filter.') },
15
39
  async ({ search }) => {
16
40
  const qs = search ? `?search=${encodeURIComponent(search)}` : '';
17
- const result = await client.get(`/v1/agents${qs}`);
41
+ const result = await client.get(`/v1/skills${qs}`);
18
42
  const agents = result.agents || [];
19
43
  const text = JSON.stringify({
20
44
  agents,
@@ -23,26 +47,26 @@ function registerAgentTools(server, client) {
23
47
 
24
48
  return listResult(text, {
25
49
  widget: 'list',
26
- title: 'Your Agents',
50
+ title: noun === 'skill' ? 'Your Skills' : 'Your Agents',
27
51
  items: agents.map(a => ({
28
52
  id: a.id,
29
53
  title: (a.emoji ? a.emoji + ' ' : '') + a.name,
30
54
  subtitle: a.description,
31
55
  badge: a.is_global ? 'preset' : null,
32
- use_hint: 'Use my "{TITLE}" agent (agent_id: {ID}) for this conversation.'
56
+ use_hint: `Use my "{TITLE}" ${noun} (${idArg}: {ID}) for this conversation.`
33
57
  })),
34
58
  total: agents.length
35
59
  });
36
60
  }
37
61
  );
38
62
 
39
- // ─── create_agent ──────────────────────────────────────────
63
+ // ─── create_skill / create_agent ───────────────────────────
40
64
  server.tool(
41
- 'create_agent',
42
- 'Create a reusable custom chat agent (a named persona for the chat tool). The `description` IS the agent\'s system instruction — write it as the persona + behavior you want ("You are a senior creative director. Turn any brief into a structured shot list…"). Use when the user wants a persistent, reusable assistant ("make me a creative-director agent", "set up a support-triage bot"). For a ONE-OFF persona on a single conversation, pass `system_prompt` to chat_send_message instead — no need to create an agent. Plan limits apply (server rejects when the custom-agent cap is reached).',
65
+ `create_${suffix}`,
66
+ `Create a reusable custom ${noun} (a named persona for the chat tool). The \`description\` IS the ${noun}'s system instruction — write it as the persona + behavior you want ("You are a senior creative director. Turn any brief into a structured shot list…"). Use when the user wants a persistent, reusable assistant ("make me a creative-director ${noun}", "set up a support-triage bot"). For a ONE-OFF persona on a single conversation, pass \`system_prompt\` to chat_send_message instead — no need to create a ${noun}. Plan limits apply (server rejects when the ${noun} cap is reached).${legacyNote}`,
43
67
  {
44
- name: z.string().optional().describe('Agent name. If omitted, a name is generated from the description.'),
45
- description: z.string().describe('The agent persona + instructions (max 2000 chars). This becomes the system prompt the model adopts in every conversation that uses the agent.'),
68
+ name: z.string().optional().describe(`${noun[0].toUpperCase() + noun.slice(1)} name. If omitted, a name is generated from the description.`),
69
+ description: z.string().describe(`The ${noun} persona + instructions (max 2000 chars). This becomes the system prompt the model adopts in every conversation that uses the ${noun}.`),
46
70
  emoji: z.string().optional().describe('Optional emoji avatar (auto-picked if omitted).'),
47
71
  thumbnail: z.string().optional().describe('Optional thumbnail image URL.')
48
72
  },
@@ -51,40 +75,42 @@ function registerAgentTools(server, client) {
51
75
  if (name !== undefined) body.name = name;
52
76
  if (emoji !== undefined) body.emoji = emoji;
53
77
  if (thumbnail !== undefined) body.thumbnail = thumbnail;
54
- const result = await client.post('/v1/agents', body);
55
- return { content: [{ type: 'text', text: JSON.stringify({ agent: result.agent, _hint: 'Reuse this agent by selecting it in the chat tool. Its description is the system instruction applied to every conversation using it.' }, null, 2) }] };
78
+ const result = await client.post('/v1/skills', body);
79
+ return { content: [{ type: 'text', text: JSON.stringify({ agent: result.agent, _hint: `Reuse this ${noun} by selecting it in the chat tool. Its description is the system instruction applied to every conversation using it.` }, null, 2) }] };
56
80
  }
57
81
  );
58
82
 
59
- // ─── update_agent ──────────────────────────────────────────
83
+ // ─── update_skill / update_agent ───────────────────────────
60
84
  server.tool(
61
- 'update_agent',
62
- 'Edit a custom chat agent in place: name, description (persona/instructions), or emoji/thumbnail. NEVER delete and recreate an agent to change its persona — conversations already reference this agent_id. Only personal agents you own can be edited — global preset agents are protected. Resolve the agent id with list_agents first.',
85
+ `update_${suffix}`,
86
+ `Edit a custom ${noun} in place: name, description (persona/instructions), or emoji/thumbnail. NEVER delete and recreate a ${noun} to change its persona — conversations already reference this id. Only personal ${plural} you own can be edited — platform presets are protected. Resolve the id with list_${plural} first.${legacyNote}`,
63
87
  {
64
- agent_id: z.string().describe('Agent id (from list_agents).'),
88
+ [idArg]: z.string().describe(idDesc),
65
89
  name: z.string().optional().describe('New name.'),
66
90
  description: z.string().optional().describe('New persona/instructions (replaces the old description; max 2000 chars).'),
67
91
  emoji: z.string().optional().describe('New emoji avatar.'),
68
92
  thumbnail: z.string().optional().describe('New thumbnail image URL.')
69
93
  },
70
- async ({ agent_id, name, description, emoji, thumbnail }) => {
94
+ async (args) => {
95
+ const id = args[idArg];
96
+ const { name, description, emoji, thumbnail } = args;
71
97
  const body = {};
72
98
  if (name !== undefined) body.name = name;
73
99
  if (description !== undefined) body.description = description;
74
100
  if (emoji !== undefined) body.emoji = emoji;
75
101
  if (thumbnail !== undefined) body.thumbnail = thumbnail;
76
- const result = await client.put(`/v1/agents/${encodeURIComponent(agent_id)}`, body);
102
+ const result = await client.put(`/v1/skills/${encodeURIComponent(id)}`, body);
77
103
  return { content: [{ type: 'text', text: JSON.stringify({ agent: result.agent }, null, 2) }] };
78
104
  }
79
105
  );
80
106
 
81
- // ─── delete_agent ──────────────────────────────────────────
107
+ // ─── delete_skill / delete_agent ───────────────────────────
82
108
  server.tool(
83
- 'delete_agent',
84
- 'Delete a custom chat agent you own. Global preset agents cannot be deleted. This removes the agent config only — it does not touch any conversations that used it.',
85
- { agent_id: z.string().describe('Agent id (from list_agents).') },
86
- async ({ agent_id }) => {
87
- const result = await client.delete(`/v1/agents/${encodeURIComponent(agent_id)}`);
109
+ `delete_${suffix}`,
110
+ `Delete a custom ${noun} you own. Platform preset ${plural} cannot be deleted. This removes the ${noun} config only — it does not touch any conversations that used it.${legacyNote}`,
111
+ { [idArg]: z.string().describe(idDesc) },
112
+ async (args) => {
113
+ const result = await client.delete(`/v1/skills/${encodeURIComponent(args[idArg])}`);
88
114
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
89
115
  }
90
116
  );
package/src/tools/chat.js CHANGED
@@ -20,11 +20,12 @@ function registerChatTools(server, client) {
20
20
  web_search: z.boolean().optional().describe('Enable web search for this message. Default: false'),
21
21
  deep_think: z.boolean().optional().describe('Enable deep think (extended reasoning). Default: false'),
22
22
  thinking_level: z.string().optional().describe('Thinking effort ID from list_models type="text" thinkingLevels. The server uses the model catalog default when omitted or invalid. Separate from legacy deep_think; safeguards take precedence.'),
23
+ routing_mode: z.enum(['fast', 'balanced', 'smart']).optional().describe('Auto routing mode. Use fast, balanced, or smart when model is Auto; omitted uses the server default (balanced).'),
23
24
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt. Default: false — only pass true if the user explicitly asks to enhance/improve the prompt.'),
24
25
  media_urls: z.array(z.string()).optional().describe('Public URLs of images, videos, or audio files to analyze. The model auto-routes to a vision-capable model when media is present. For a local file, get a URL first via the LOCAL FILE route in this tool\'s description.'),
25
26
  project_id: projectIdField
26
27
  },
27
- async ({ message, model, session_id, system_prompt, web_search, deep_think, thinking_level, enhance_prompt = false, media_urls, project_id }) => {
28
+ async ({ message, model, session_id, system_prompt, web_search, deep_think, thinking_level, routing_mode, enhance_prompt = false, media_urls, project_id }) => {
28
29
  // Every generate_* tool resolves its model this way; chat was the one
29
30
  // `model` arg that went straight to the API, which has no fuzzy matching.
30
31
  // So the display names list_models hands back ("Claude Fable 5") came
@@ -40,6 +41,7 @@ function registerChatTools(server, client) {
40
41
  web_search,
41
42
  deep_think,
42
43
  ...(thinking_level !== undefined ? { thinking_level } : {}),
44
+ ...(routing_mode !== undefined ? { routing_mode } : {}),
43
45
  enhance_prompt,
44
46
  media_urls,
45
47
  project_id
@@ -0,0 +1,22 @@
1
+ 'use strict';
2
+ const { z } = require('zod');
3
+
4
+ function registerEditorTools(server, client) {
5
+ server.tool('create_video_editor_session',
6
+ 'Create an editable timeline from existing Kolbo-hosted images/video, audio layers and text. No new media generation. Requires project_id with edit access. Returns session_id and editor_url. Preserve the returned ID; creation is not a polling operation.',
7
+ {
8
+ project_id: z.string().min(1), name: z.string().min(1).max(120),
9
+ format: z.enum(['16:9', '9:16', '1:1', '4:5', '21:9']).optional(),
10
+ clips: z.array(z.object({ url: z.string().url(), name: z.string().optional(), duration_ms: z.number().finite().min(0).max(1800000).optional(), trim_start_ms: z.number().finite().min(0).max(1800000).optional(), trim_end_ms: z.number().finite().min(0).max(1800000).optional(), volume: z.number().finite().min(0).max(2).optional(), muted: z.boolean().optional() })).min(1).max(60),
11
+ audio: z.array(z.object({ url: z.string().url(), name: z.string().optional(), start_ms: z.number().finite().min(0).max(1800000).optional(), duration_ms: z.number().finite().min(0).max(1800000).optional(), volume: z.number().finite().min(0).max(2).optional(), fade_in_ms: z.number().finite().min(0).max(1800000).optional(), fade_out_ms: z.number().finite().min(0).max(1800000).optional() })).max(16).optional(),
12
+ texts: z.array(z.object({ content: z.string().min(1).max(2000), start_ms: z.number().finite().min(0).max(1800000).optional(), duration_ms: z.number().finite().min(0).max(1800000).optional(), font_size: z.number().finite().min(8).max(512).optional(), color: z.string().optional(), vertical: z.enum(['top', 'middle', 'bottom']).optional() })).max(120).optional(),
13
+ },
14
+ async args => ({ content: [{ type: 'text', text: JSON.stringify(await client.post('/v1/editor/sessions', args)) }] })
15
+ );
16
+ server.tool('export_video_editor_session',
17
+ 'Export an owned Video Editor timeline to MP4. Identical snapshots reuse the same job. If pending, call again with the returned job_id; do not recreate the timeline. retry_failed explicitly retries a terminal failed/cancelled snapshot and cannot be combined with job_id. This exports media to the library, not a public website.',
18
+ { session_id: z.string().min(1), quality: z.enum(['480p', '720p', '1080p']).optional(), job_id: z.string().min(1).max(100).optional(), retry_failed: z.boolean().optional() },
19
+ async args => ({ content: [{ type: 'text', text: JSON.stringify(await client.post('/v1/editor/exports', args)) }] })
20
+ );
21
+ }
22
+ module.exports = { registerEditorTools };
@@ -182,6 +182,8 @@ const imageSettings = (a = {}) => ({
182
182
  resolution: a.resolution,
183
183
  aspect_ratio: a.aspect_ratio,
184
184
  quality: a.quality,
185
+ background: a.background,
186
+ output_format: a.output_format,
185
187
  ...refSettings(a),
186
188
  });
187
189
 
@@ -269,20 +271,25 @@ function registerGenerateTools(server, client, options = {}) {
269
271
  moodboard_id: z.string().optional().describe('Moodboard ID (from list_moodboards / get_moodboard) whose master_prompt and style_guide should be applied to this generation.'),
270
272
  enable_web_search: z.boolean().optional().describe('Enable web-search grounding for the prompt (useful for current events, brand references, real-world accuracy). Default: false'),
271
273
  resolution: z.string().optional().describe('Image resolution tier: "1K" (~1024px), "2K" (Full HD), "3K" (QHD), or "4K" (UHD). Model-dependent — call list_models and read supported_resolutions on the chosen model. Read resolution_multipliers on the same model to predict credit cost. Omit to use the model default.'),
272
- quality: z.string().optional().describe('Quality tier for models that support it (e.g. "low", "medium", "high", "auto"). Check list_models → supported_qualities on the chosen model. "auto" is normalised to "medium" on gpt-image-2. Omit to use the model default.'),
274
+ quality: z.string().optional().describe('Quality tier for models that support it (e.g. "low", "medium", "high", "xhigh", "max", "auto"). Check list_models → supported_qualities on the chosen model. "auto" is normalised to "medium" on gpt-image-2. Omit to use the model default.'),
275
+ background: z.enum(['auto', 'opaque', 'transparent']).optional().describe('Output background. Before passing "transparent", call list_models for the chosen image model and require supports_transparent_background=true. Use PNG or WebP (PNG is selected by default when omitted). Kolbo forwards the setting and appends the exact phrase "no background" once to the effective prompt; prompt wording alone is not sufficient.'),
276
+ output_format: z.enum(['png', 'jpeg', 'webp']).optional().describe('Output file format on supported OpenAI image models. Default: png.'),
277
+ output_compression: z.number().int().min(0).max(100).optional().describe('JPEG/WebP output compression quality (0-100); not applicable to PNG.'),
278
+ moderation: z.enum(['auto', 'low']).optional().describe('Moderation setting on supported OpenAI image models. Default: auto.'),
279
+ mask_image_url: z.string().url().optional().describe('Optional public PNG mask URL for supported OpenAI edits with source/reference images. Transparent regions indicate areas to edit.'),
273
280
  preset_id: z.string().optional().describe('Exact preset ID from list_presets type="image". If the user requests any image preset, resolve it with list_presets and pass it here; do not omit it.'),
274
281
  cinematic: CINEMATIC_SCHEMA,
275
282
  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.'),
276
283
  project_id: projectIdField,
277
284
  session_id: sessionIdField
278
285
  },
279
- 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, font_ids }) => {
286
+ async ({ prompt, prompts, model, aspect_ratio, enhance_prompt = false, num_images, reference_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, background, output_format, output_compression, moderation, mask_image_url, preset_id, cinematic, skip_color_palette, project_id, session_id, font_ids }) => {
280
287
  if (!prompt && !(prompts && prompts.length)) throw new Error('Provide prompt or prompts');
281
288
  model = await canonicalModelId(client, model, 'text_to_img'); // lenient id resolution ("z-image" → "z-image/turbo")
282
289
  aspect_ratio = await resolveCatalogAspectRatio(client, model, aspect_ratio, 'text_to_img');
283
290
  const shared = {
284
291
  model, aspect_ratio, enhance_prompt, font_ids,
285
- reference_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, preset_id, cinematic, skip_color_palette, project_id, session_id
292
+ reference_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, background, output_format, output_compression, moderation, mask_image_url, preset_id, cinematic, skip_color_palette, project_id, session_id
286
293
  };
287
294
 
288
295
  // Batch mode: N different prompts, one widget owning all generation ids.
@@ -355,20 +362,25 @@ function registerGenerateTools(server, client, options = {}) {
355
362
  moodboard_id: z.string().optional().describe('Moodboard ID whose master_prompt and style_guide should be applied.'),
356
363
  enable_web_search: z.boolean().optional().describe('Enable web-search grounding. Default: false'),
357
364
  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.'),
358
- quality: z.string().optional().describe('Quality tier for edit models that support it (e.g. "low", "medium", "high", "auto"). Check list_models → supported_qualities on the chosen model. "auto" is normalised to "medium" on gpt-image-2. Omit to use the model default.'),
365
+ quality: z.string().optional().describe('Quality tier for edit models that support it (e.g. "low", "medium", "high", "xhigh", "max", "auto"). Check list_models → supported_qualities on the chosen model. "auto" is normalised to "medium" on gpt-image-2. Omit to use the model default.'),
366
+ background: z.enum(['auto', 'opaque', 'transparent']).optional().describe('Output background. Before passing "transparent", call list_models for the chosen image-edit model and require supports_transparent_background=true. Use PNG or WebP (PNG is selected by default when omitted). Kolbo forwards the setting and appends the exact phrase "no background" once to the effective prompt; prompt wording alone is not sufficient.'),
367
+ output_format: z.enum(['png', 'jpeg', 'webp']).optional().describe('Output file format on supported OpenAI image models. Default: png.'),
368
+ output_compression: z.number().int().min(0).max(100).optional().describe('JPEG/WebP output compression quality (0-100); not applicable to PNG.'),
369
+ moderation: z.enum(['auto', 'low']).optional().describe('Moderation setting on supported OpenAI image models. Default: auto.'),
370
+ mask_image_url: z.string().url().optional().describe('Optional public PNG mask URL for supported OpenAI edits with source/reference images. Transparent regions indicate areas to edit.'),
359
371
  preset_id: z.string().optional().describe('Exact preset ID from list_presets type="image_edit" to apply an image-editing preset. If the user requests a preset, resolve and pass it; do not silently omit it.'),
360
372
  cinematic: CINEMATIC_SCHEMA,
361
373
  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.'),
362
374
  project_id: projectIdField,
363
375
  session_id: sessionIdField
364
376
  },
365
- async ({ prompt, prompts, model, source_images, reference_images, aspect_ratio, enhance_prompt = false, num_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, preset_id, cinematic, skip_color_palette, project_id, session_id, font_ids }) => {
377
+ async ({ prompt, prompts, model, source_images, reference_images, aspect_ratio, enhance_prompt = false, num_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, background, output_format, output_compression, moderation, mask_image_url, preset_id, cinematic, skip_color_palette, project_id, session_id, font_ids }) => {
366
378
  if (!prompt && !(prompts && prompts.length)) throw new Error('Provide prompt or prompts');
367
379
  model = await canonicalModelId(client, model, 'image_editing'); // lenient id resolution ("z-image" → "z-image/turbo")
368
380
  aspect_ratio = await resolveCatalogAspectRatio(client, model, aspect_ratio, 'image_editing');
369
381
  const shared = {
370
382
  model, source_images, reference_images, aspect_ratio, enhance_prompt, font_ids,
371
- visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, preset_id, cinematic, skip_color_palette, project_id, session_id
383
+ visual_dna_ids, moodboard_id, enable_web_search, resolution, quality, background, output_format, output_compression, moderation, mask_image_url, preset_id, cinematic, skip_color_palette, project_id, session_id
372
384
  };
373
385
  const settings = imageSettings(shared);
374
386
 
@@ -9,10 +9,8 @@ const { resolveToBuffer, DEFAULT_MAX_FILE_MB, compactList } = require('./_shared
9
9
  const { ownedUrl } = require('./owned-url');
10
10
  const { UI, uiResult, listResult } = require('../apps');
11
11
 
12
- // How many tiles the media grid renders. A rendering limit only — the text
13
- // payload always carries the full page, and `total` reports the real library
14
- // count, so a capped grid can never be mistaken for "that's everything".
15
- const GRID_CAP = 24;
12
+ // Server pagination bounds the page. Never slice it again: both text readers
13
+ // and the widget advance by the server's page size and would skip hidden rows.
16
14
 
17
15
  async function mintUploadTicket(client) {
18
16
  const ticket = await client.post('/v1/media/upload-ticket', {});
@@ -272,7 +270,8 @@ function registerMediaTools(server, client, options = {}) {
272
270
  // locates an item; get_media returns one in full.
273
271
  const text = compactList(media, {
274
272
  fields: ['id', 'filename', 'media_type', 'url', 'thumbnail_url', 'size', 'project_id', 'created_at'],
275
- cap: 50,
273
+ cap: media.length,
274
+ maxChars: Infinity,
276
275
  total: pagination ? (pagination.total_items != null ? pagination.total_items : pagination.total) : media.length,
277
276
  extra: pagination ? { pagination } : undefined,
278
277
  note: 'Narrow with `type`, `category`, `project_id`, `folder_id`, or `search`; get_media returns one item in full.',
@@ -289,7 +288,7 @@ function registerMediaTools(server, client, options = {}) {
289
288
  const totalItems = pagination
290
289
  ? (pagination.total_items != null ? pagination.total_items : pagination.total)
291
290
  : null;
292
- const items = media.slice(0, GRID_CAP).map((m) => ({
291
+ const items = media.map((m) => ({
293
292
  id: m.id,
294
293
  title: m.filename,
295
294
  subtitle: m.media_type + (m.size ? ' · ' + Math.round(m.size / 1024) + 'KB' : ''),
@@ -302,8 +301,9 @@ function registerMediaTools(server, client, options = {}) {
302
301
  widget: 'media-grid',
303
302
  title: 'Media Library',
304
303
  items,
305
- total: totalItems != null ? totalItems : media.length,
306
- shown: Math.min(media.length, GRID_CAP),
304
+ total: totalItems != null && totalItems >= 0 ? totalItems : null,
305
+ ...(typeof pagination?.has_next === 'boolean' ? { has_next: pagination.has_next } : {}),
306
+ shown: media.length,
307
307
  // Everything "Load more" needs to fetch page N+1 ITSELF. The button used
308
308
  // to send a chat message asking the model to run the next page, on the
309
309
  // belief that a widget cannot invoke a tool — it can
@@ -311,8 +311,8 @@ function registerMediaTools(server, client, options = {}) {
311
311
  // with). Worse, the payload carried no page and no filters, so the model
312
312
  // could not reconstruct the query either and typically re-ran page 1.
313
313
  page_tool: 'list_media',
314
- page: page || 1,
315
- page_size: page_size || 50,
314
+ page: pagination?.page || page || 1,
315
+ page_size: pagination?.page_size || page_size || 50,
316
316
  query: { project_id, folder_id, type, category, source_type, sort, search }
317
317
  });
318
318
  }
@@ -49,7 +49,9 @@ function modelChips(m) {
49
49
  const ds = [...m.supported_durations].sort((a, b) => a - b);
50
50
  chips.push(ds.length > 1 ? `${ds[0]}-${ds[ds.length - 1]}s` : `${ds[0]}s`);
51
51
  }
52
- if (m.supports_visual_dna) chips.push('DNA');
52
+ if (m.supports_custom_fonts) chips.unshift('Fonts');
53
+ if (m.supports_transparent_background) chips.unshift('No BG');
54
+ if (m.supports_visual_dna) chips.push('DNA');
53
55
  if (m.new_model || m.newModel) chips.push('NEW');
54
56
  return chips.slice(0, 3);
55
57
  }
@@ -101,7 +103,9 @@ function buildCatalogStructured(models, type, compact) {
101
103
 
102
104
  // One row per model — every identifier, nothing else. ~90 bytes/model, so the
103
105
  // whole 400+ model catalog fits in a payload an agent can actually read.
104
- const identifierRow = (m) => ({
106
+ const identifierRow = (m) => ({
107
+ supports_custom_fonts: m.supports_custom_fonts === true,
108
+ supports_transparent_background: m.supports_transparent_background === true,
105
109
  identifier: m.identifier,
106
110
  name: m.name,
107
111
  types: m.types,
@@ -190,8 +194,9 @@ function registerModelTools(server, client, options = {}) {
190
194
  // "this model rejects DNA" (cap = 0) from "I don't know" (field
191
195
  // missing). Now an explicit `max_dna: 0 (DNA not supported)` says the
192
196
  // model says no, and absence means the API doesn't expose the field.
193
- const formatSpecs = m => {
194
- const parts = [];
197
+ const formatSpecs = m => {
198
+ const parts = [];
199
+ parts.push('supports_custom_fonts: ' + (m.supports_custom_fonts === true));
195
200
  if (m.haveThinking && Array.isArray(m.thinkingLevels) && m.thinkingLevels.length) {
196
201
  parts.push(`thinking_level: ${m.thinkingLevels.map(level => level.id).join('/')} (default ${m.thinkingDefault})`);
197
202
  }
@@ -205,7 +210,8 @@ function registerModelTools(server, client, options = {}) {
205
210
  const isLipsyncVideo = types.includes('lipsync-video');
206
211
  const isLipsyncImage = types.includes('lipsync-image');
207
212
  const isImageEdit = types.includes('image_editing');
208
- const isImage = types.includes('text_to_img') || isImageEdit;
213
+ const isImage = types.includes('text_to_img') || isImageEdit;
214
+ if (isImage) parts.push('supports_transparent_background: ' + (m.supports_transparent_background === true));
209
215
 
210
216
  if (Array.isArray(m.supported_resolutions) && m.supported_resolutions.length) {
211
217
  const mult = m.resolution_multipliers || {};
@@ -177,7 +177,7 @@ function registerVisualDnaTools(server, client, options = {}) {
177
177
  // ─── get_visual_dna ────────────────────────────────────────
178
178
  server.tool(
179
179
  'get_visual_dna',
180
- 'Fetch a single Visual DNA profile by ID. Returns the full profile including system_prompt and all reference images. To fetch a teammate\'s Visual DNA that lives in a shared project, pass project_id (you need edit+ on it).',
180
+ 'Fetch a single Visual DNA profile by ID. Returns the full stored description, character attributes, and all reference images. Internal extraction system prompts are not exposed. To fetch a teammate\'s Visual DNA that lives in a shared project, pass project_id (you need edit+ on it).',
181
181
  {
182
182
  visual_dna_id: z.string().describe('The Visual DNA profile ID'),
183
183
  project_id: projectScopeReadField