@kolbo/mcp 1.16.3 → 1.17.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 +44 -2
- package/bin/kolbo-mcp.js +1 -1
- package/package.json +2 -3
- package/src/client.js +9 -116
- package/src/index.js +2 -0
- package/src/polling.js +3 -4
- package/src/tools/_shared.js +11 -0
- package/src/tools/artifacts.js +11 -4
- package/src/tools/chat.js +142 -140
- package/src/tools/generate.js +892 -979
- package/src/tools/projects.js +36 -0
package/README.md
CHANGED
|
@@ -4,7 +4,20 @@ 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
|
-
##
|
|
7
|
+
## Recommended install: the Kolbo Claude Code plugin
|
|
8
|
+
|
|
9
|
+
The easiest way to use this MCP from Claude Code is the official **Kolbo plugin**, which bundles this server + the routing skill + a first-run API-key prompt — no `settings.json` editing required:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
claude plugin marketplace add Zoharvan12/kolbo-claude-plugin
|
|
13
|
+
claude plugin install kolbo@kolbo
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
You'll be prompted for your API key once and it's stored in your OS keychain. The plugin lives in its own tiny repo at [`kolbo-claude-plugin`](https://github.com/Zoharvan12/kolbo-claude-plugin); the routing skill it ships is auto-synced from the canonical source in [`kolbo-code`](https://github.com/Zoharvan12/kolbo-code), so the Claude Code plugin and the Kolbo Code CLI always carry the same skill.
|
|
17
|
+
|
|
18
|
+
Continue below if you'd rather wire the MCP in by hand (Claude Desktop, Cursor, custom setups).
|
|
19
|
+
|
|
20
|
+
## Manual setup
|
|
8
21
|
|
|
9
22
|
### 1. Get an API Key
|
|
10
23
|
|
|
@@ -32,18 +45,39 @@ Add to `.claude/settings.json`:
|
|
|
32
45
|
|
|
33
46
|
Just ask Claude naturally:
|
|
34
47
|
|
|
48
|
+
**Generation**
|
|
35
49
|
- *"Generate an image of a sunset over mountains"*
|
|
36
50
|
- *"Create a 5-second video of waves crashing"*
|
|
37
51
|
- *"Build a 4-scene storyboard for a coffee shop ad"*
|
|
38
52
|
- *"Remove the background from this image"*
|
|
39
53
|
- *"Make a lo-fi hip hop beat"*
|
|
40
54
|
- *"Read this out loud with a British female voice"*
|
|
55
|
+
|
|
56
|
+
**Marketing & UGC**
|
|
57
|
+
- *"Make me a UGC ad for my sneaker brand — 9:16, talking-head style"*
|
|
58
|
+
- *"TV spot for my new beverage, 15 seconds, cinematic"*
|
|
59
|
+
- *"Unboxing video for this product photo"*
|
|
60
|
+
|
|
61
|
+
**Brand & product imagery**
|
|
62
|
+
- *"Pinterest pin for my candle brand, cottagecore mood"*
|
|
63
|
+
- *"Hero banner for my landing page, wide format"*
|
|
64
|
+
- *"Lifestyle shot of my product in a kitchen"*
|
|
65
|
+
- *"4 ad creative variants for Meta and TikTok"*
|
|
66
|
+
|
|
67
|
+
**Marketplace listings**
|
|
68
|
+
- *"Generate Amazon main image + 5 secondary images for my product"*
|
|
69
|
+
- *"Full A+ content set for my Shopify listing"*
|
|
70
|
+
|
|
71
|
+
**Analysis & utility**
|
|
41
72
|
- *"Ask Claude about the latest AI news with web search on"*
|
|
42
73
|
- *"Analyze this video and tell me what prompts are shown on screen"*
|
|
43
74
|
- *"What's in this image?"*
|
|
44
75
|
- *"Create a Visual DNA profile called 'Alex' from these images"*
|
|
76
|
+
- *"Use the same brand as last time"* (loads a persisted brand kit from the workspace)
|
|
77
|
+
|
|
78
|
+
The skill that ships with the [Kolbo plugin](https://github.com/Zoharvan12/kolbo-claude-plugin) routes each of these 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. See the canonical skill at [`kolbo-code/packages/opencode/skills/kolbo/`](https://github.com/Zoharvan12/kolbo-code/tree/dev/packages/opencode/skills/kolbo).
|
|
45
79
|
|
|
46
|
-
## Available Tools (
|
|
80
|
+
## Available Tools (52)
|
|
47
81
|
|
|
48
82
|
**Generation**
|
|
49
83
|
| Tool | Description |
|
|
@@ -67,6 +101,8 @@ Every image/video/creative-director tool accepts `visual_dna_ids` and `moodboard
|
|
|
67
101
|
|
|
68
102
|
Every generation tool also accepts an optional `resolution` arg. Images use `"1K"` (~1024px) / `"2K"` (Full HD) / `"3K"` (QHD) / `"4K"` (UHD); videos use vertical-pixel tiers like `"720p"` / `"1080p"` / `"1440p"` / `"2160p"`. Values are model-dependent — call `list_models` and read the chosen model's `supported_resolutions` and `resolutionMultipliers`. Omit to use the model default.
|
|
69
103
|
|
|
104
|
+
Every generation tool also accepts an optional `project_id` arg that routes the generation into a specific project (owned or shared with edit+). Call `list_projects` to discover IDs. When omitted, generations land in the user's auto-created "API Generations" project.
|
|
105
|
+
|
|
70
106
|
**Chat & Vision**
|
|
71
107
|
| Tool | Description |
|
|
72
108
|
|------|-------------|
|
|
@@ -115,12 +151,18 @@ Every generation tool also accepts an optional `resolution` arg. Images use `"1K
|
|
|
115
151
|
| `move_folder_contents` | Move every item in a folder to a project |
|
|
116
152
|
| `get_media_stats` | Counts + storage bytes per type (optionally per project) |
|
|
117
153
|
|
|
154
|
+
**Artifacts**
|
|
155
|
+
| Tool | Description |
|
|
156
|
+
|------|-------------|
|
|
157
|
+
| `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). |
|
|
158
|
+
|
|
118
159
|
**Discovery & Account**
|
|
119
160
|
| Tool | Description |
|
|
120
161
|
|------|-------------|
|
|
121
162
|
| `list_models` | Current model catalog with costs and capabilities |
|
|
122
163
|
| `list_voices` | TTS voices (presets + cloned) |
|
|
123
164
|
| `list_presets` | Generation presets across image/video/music/text-to-video catalogs |
|
|
165
|
+
| `list_projects` | List owned + shared projects (id, name, role, is_default) — call first to resolve a project name into the `project_id` you pass to generation tools |
|
|
124
166
|
| `check_credits` | Check credit balance |
|
|
125
167
|
| `get_generation_status` | Poll a generation by ID (fallback if a tool times out) |
|
|
126
168
|
|
package/bin/kolbo-mcp.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
require('../src/index.js');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolbo/mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.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": {
|
|
@@ -27,8 +27,7 @@
|
|
|
27
27
|
"license": "MIT",
|
|
28
28
|
"repository": {
|
|
29
29
|
"type": "git",
|
|
30
|
-
"url": "https://github.com/Zoharvan12/kolbo-
|
|
31
|
-
"directory": "packages/kolbo-mcp"
|
|
30
|
+
"url": "https://github.com/Zoharvan12/kolbo-mcp"
|
|
32
31
|
},
|
|
33
32
|
"homepage": "https://docs.kolbo.ai/developer-api/claude-code-skill",
|
|
34
33
|
"author": "Kolbo AI <support@kolbo.ai>",
|
package/src/client.js
CHANGED
|
@@ -139,63 +139,6 @@ function readCliAuthKey() {
|
|
|
139
139
|
return null;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
// ---------------------------------------------------------------------------
|
|
143
|
-
// Burst control
|
|
144
|
-
// ---------------------------------------------------------------------------
|
|
145
|
-
// Caps in-flight requests so multi-step tool calls (file upload + generate +
|
|
146
|
-
// poll status) don't fan out past the kolbo-api global rate limit. The SDK
|
|
147
|
-
// tier on the server is 5000 req/min per API key; 6 in-flight × typical 2s
|
|
148
|
-
// latency keeps us at ~180/min — plenty of headroom.
|
|
149
|
-
const MAX_CONCURRENT = 6;
|
|
150
|
-
let inFlight = 0;
|
|
151
|
-
const waiting = [];
|
|
152
|
-
|
|
153
|
-
function acquireSlot() {
|
|
154
|
-
if (inFlight < MAX_CONCURRENT) {
|
|
155
|
-
inFlight++;
|
|
156
|
-
return Promise.resolve();
|
|
157
|
-
}
|
|
158
|
-
return new Promise((resolve) => waiting.push(resolve));
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function releaseSlot() {
|
|
162
|
-
if (waiting.length > 0) {
|
|
163
|
-
waiting.shift()();
|
|
164
|
-
} else {
|
|
165
|
-
inFlight = Math.max(0, inFlight - 1);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Parse retry hint from a 429 response. Honors (in order):
|
|
173
|
-
* - body.retryAfter (kolbo-api shape)
|
|
174
|
-
* - Retry-After header (RFC, seconds)
|
|
175
|
-
* - RateLimit-Reset header (seconds until reset)
|
|
176
|
-
* Falls back to 5s. Caps at 60s so we don't hang the tool call forever.
|
|
177
|
-
*/
|
|
178
|
-
function parseRetryAfter(response, body) {
|
|
179
|
-
let seconds = null;
|
|
180
|
-
if (body && typeof body.retryAfter === 'number') seconds = body.retryAfter;
|
|
181
|
-
if (seconds == null) {
|
|
182
|
-
const ra = response.headers.get('retry-after');
|
|
183
|
-
if (ra) {
|
|
184
|
-
const n = Number(ra);
|
|
185
|
-
if (Number.isFinite(n)) seconds = n;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
if (seconds == null) {
|
|
189
|
-
const reset = response.headers.get('ratelimit-reset');
|
|
190
|
-
if (reset) {
|
|
191
|
-
const n = Number(reset);
|
|
192
|
-
if (Number.isFinite(n)) seconds = n;
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
if (seconds == null || !Number.isFinite(seconds) || seconds <= 0) seconds = 5;
|
|
196
|
-
return Math.min(seconds, 60) * 1000;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
142
|
// ---------------------------------------------------------------------------
|
|
200
143
|
// HTTP client
|
|
201
144
|
// ---------------------------------------------------------------------------
|
|
@@ -246,31 +189,13 @@ class KolboClient {
|
|
|
246
189
|
}
|
|
247
190
|
|
|
248
191
|
async request(method, reqPath, body = null) {
|
|
249
|
-
await
|
|
250
|
-
try {
|
|
251
|
-
const result = await this._doRequestWithRetry(method, reqPath, body);
|
|
192
|
+
const result = await this._doRequest(method, reqPath, body);
|
|
252
193
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
}
|
|
257
|
-
return result;
|
|
258
|
-
} finally {
|
|
259
|
-
releaseSlot();
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
async _doRequestWithRetry(method, reqPath, body = null) {
|
|
264
|
-
try {
|
|
265
|
-
return await this._doRequest(method, reqPath, body);
|
|
266
|
-
} catch (err) {
|
|
267
|
-
if (err instanceof KolboApiError && err.status === 429) {
|
|
268
|
-
const delay = err.data?._kolboRetryAfterMs ?? 5000;
|
|
269
|
-
await sleep(delay);
|
|
270
|
-
return this._doRequest(method, reqPath, body);
|
|
271
|
-
}
|
|
272
|
-
throw err;
|
|
194
|
+
// On 401, try re-reading auth store and retry once
|
|
195
|
+
if (result._status === 401 && this._tryRefreshKey()) {
|
|
196
|
+
return this._doRequest(method, reqPath, body);
|
|
273
197
|
}
|
|
198
|
+
return result;
|
|
274
199
|
}
|
|
275
200
|
|
|
276
201
|
async _doRequest(method, reqPath, body = null) {
|
|
@@ -307,13 +232,6 @@ class KolboClient {
|
|
|
307
232
|
});
|
|
308
233
|
}
|
|
309
234
|
|
|
310
|
-
if (response.status === 429) {
|
|
311
|
-
throw new KolboApiError('Rate limited by Kolbo API. Retrying after backoff.', {
|
|
312
|
-
status: 429,
|
|
313
|
-
data: { ...(data || {}), _kolboRetryAfterMs: parseRetryAfter(response, data) },
|
|
314
|
-
});
|
|
315
|
-
}
|
|
316
|
-
|
|
317
235
|
if (!response.ok || data.success === false) {
|
|
318
236
|
const message = data.error || data.message || `API error: ${response.status}`;
|
|
319
237
|
const code = data.code || null;
|
|
@@ -360,29 +278,11 @@ class KolboClient {
|
|
|
360
278
|
}
|
|
361
279
|
|
|
362
280
|
async postMultipart(reqPath, formData) {
|
|
363
|
-
await
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
if (result._status === 401 && this._tryRefreshKey()) {
|
|
367
|
-
return this._doMultipartWithRetry(reqPath, formData);
|
|
368
|
-
}
|
|
369
|
-
return result;
|
|
370
|
-
} finally {
|
|
371
|
-
releaseSlot();
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
async _doMultipartWithRetry(reqPath, formData) {
|
|
376
|
-
try {
|
|
377
|
-
return await this._doMultipart(reqPath, formData);
|
|
378
|
-
} catch (err) {
|
|
379
|
-
if (err instanceof KolboApiError && err.status === 429) {
|
|
380
|
-
const delay = err.data?._kolboRetryAfterMs ?? 5000;
|
|
381
|
-
await sleep(delay);
|
|
382
|
-
return this._doMultipart(reqPath, formData);
|
|
383
|
-
}
|
|
384
|
-
throw err;
|
|
281
|
+
const result = await this._doMultipart(reqPath, formData);
|
|
282
|
+
if (result._status === 401 && this._tryRefreshKey()) {
|
|
283
|
+
return this._doMultipart(reqPath, formData);
|
|
385
284
|
}
|
|
285
|
+
return result;
|
|
386
286
|
}
|
|
387
287
|
|
|
388
288
|
async _doMultipart(reqPath, formData) {
|
|
@@ -419,13 +319,6 @@ class KolboClient {
|
|
|
419
319
|
});
|
|
420
320
|
}
|
|
421
321
|
|
|
422
|
-
if (response.status === 429) {
|
|
423
|
-
throw new KolboApiError('Rate limited by Kolbo API. Retrying after backoff.', {
|
|
424
|
-
status: 429,
|
|
425
|
-
data: { ...(data || {}), _kolboRetryAfterMs: parseRetryAfter(response, data) },
|
|
426
|
-
});
|
|
427
|
-
}
|
|
428
|
-
|
|
429
322
|
if (!response.ok || data.success === false) {
|
|
430
323
|
const message = data.error || data.message || `API error: ${response.status}`;
|
|
431
324
|
const code = data.code || null;
|
package/src/index.js
CHANGED
|
@@ -68,6 +68,7 @@ const { registerMediaTools } = require('./tools/media');
|
|
|
68
68
|
const { registerPresetTools } = require('./tools/presets');
|
|
69
69
|
const { registerAppBuilderTools } = require('./tools/app_builder');
|
|
70
70
|
const { registerArtifactTools } = require('./tools/artifacts');
|
|
71
|
+
const { registerProjectTools } = require('./tools/projects');
|
|
71
72
|
|
|
72
73
|
async function main() {
|
|
73
74
|
const client = new KolboClient();
|
|
@@ -87,6 +88,7 @@ async function main() {
|
|
|
87
88
|
registerPresetTools(server, client);
|
|
88
89
|
registerAppBuilderTools(server, client);
|
|
89
90
|
registerArtifactTools(server, client);
|
|
91
|
+
registerProjectTools(server, client);
|
|
90
92
|
|
|
91
93
|
// Start the server with stdio transport
|
|
92
94
|
const transport = new StdioServerTransport();
|
package/src/polling.js
CHANGED
|
@@ -6,10 +6,9 @@ class PollingTimeoutError extends Error {
|
|
|
6
6
|
constructor(generationId, timeoutMs) {
|
|
7
7
|
const seconds = Math.round(timeoutMs / 1000);
|
|
8
8
|
super(
|
|
9
|
-
`Generation timed out after ${seconds}s of polling. The generation
|
|
10
|
-
`
|
|
11
|
-
`
|
|
12
|
-
`Videos, deep-think chat, and large batches can legitimately take 5–15+ minutes beyond the default polling window.`
|
|
9
|
+
`Generation timed out after ${seconds}s of polling. The generation may STILL be running on the server — ` +
|
|
10
|
+
`call get_generation_status with generation_id="${generationId}" to check its current state. ` +
|
|
11
|
+
`Videos, deep-think chat, and large batches can take longer than the default polling window.`
|
|
13
12
|
);
|
|
14
13
|
this.name = 'PollingTimeoutError';
|
|
15
14
|
this.generationId = generationId;
|
package/src/tools/_shared.js
CHANGED
|
@@ -230,6 +230,16 @@ function creditFields(polledResult) {
|
|
|
230
230
|
return out;
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
+
// Shared zod schema for the optional `project_id` arg every generation tool
|
|
234
|
+
// accepts. Keep this in one place so the description never drifts across the
|
|
235
|
+
// 17 tools that use it. When omitted, the generation lands in the user's
|
|
236
|
+
// auto-created "API Generations" project. Call `list_projects` first to
|
|
237
|
+
// resolve a name → ObjectId.
|
|
238
|
+
const { z } = require('zod');
|
|
239
|
+
const projectIdField = z.string().optional().describe(
|
|
240
|
+
'Project ObjectId to drop this generation into. Call `list_projects` to discover IDs (the API has no concept of project names — only ObjectIds). Omit to use the user\'s default "API Generations" project. Requires owner / edit / full permission on the project; view-only is rejected.'
|
|
241
|
+
);
|
|
242
|
+
|
|
233
243
|
module.exports = {
|
|
234
244
|
MAX_FILE_BYTES,
|
|
235
245
|
VISUAL_DNA_MAX_BYTES,
|
|
@@ -240,4 +250,5 @@ module.exports = {
|
|
|
240
250
|
guessContentType,
|
|
241
251
|
resolveToBuffer,
|
|
242
252
|
creditFields,
|
|
253
|
+
projectIdField,
|
|
243
254
|
};
|
package/src/tools/artifacts.js
CHANGED
|
@@ -9,23 +9,29 @@ function registerArtifactTools(server, client) {
|
|
|
9
9
|
// ─── publish_html_artifact ─────────────────────────────────────
|
|
10
10
|
server.tool(
|
|
11
11
|
'publish_html_artifact',
|
|
12
|
-
'Publish an HTML page (or SVG / Mermaid diagram) to kolbo.ai and return a public shareable URL. Use this when the user explicitly asks to share, publish, or deploy a built artifact so they can send the URL to someone. The content is hosted at https://sites.kolbo.ai/<slug>; the page is served with restrictive CSP (no fetch/XHR/form-action) so it cannot exfiltrate data. Identical content uploaded twice returns the same URL (server dedup).',
|
|
12
|
+
'Publish an HTML page (or SVG / Mermaid diagram) to kolbo.ai and return a public shareable URL. Use this when the user explicitly asks to share, publish, or deploy a built artifact so they can send the URL to someone. The content is hosted at https://sites.kolbo.ai/<slug>; the page is served with restrictive CSP (no fetch/XHR/form-action) so it cannot exfiltrate data. Identical content uploaded twice returns the same URL (server dedup). To update a previously-published page in place (keeping the same URL), pass the `share_token` returned from the prior publish — the old content is preserved in version history.',
|
|
13
13
|
{
|
|
14
14
|
title: z.string().describe('Human-friendly title for the page (also used to generate the SEO slug). Keep under ~60 chars.'),
|
|
15
15
|
content: z.string().describe('The raw artifact body. For type="html" this is a full HTML document (DOCTYPE + html/head/body). For "svg" it is an <svg> document. For "mermaid" it is the Mermaid source text.'),
|
|
16
16
|
type: z.enum(['html', 'svg', 'mermaid']).optional().describe('Artifact type. Default: "html".'),
|
|
17
17
|
allow_js: z.boolean().optional().describe('Allow inline <script> execution on the published page. Default: false. Required for Tailwind JIT, Chart.js, Three.js, React-from-CDN etc.'),
|
|
18
|
+
share_token: z.string().optional().describe('Optional. Pass the `shareToken` returned from a previous publish to update that artifact in place. The public URL stays the same and the old content is moved into version history. Omit this on the first publish.'),
|
|
18
19
|
},
|
|
19
|
-
async ({ title, content, type, allow_js }) => {
|
|
20
|
+
async ({ title, content, type, allow_js, share_token }) => {
|
|
20
21
|
if (!title || !title.trim()) throw new Error('title is required');
|
|
21
22
|
if (typeof content !== 'string' || !content.length) throw new Error('content is required');
|
|
22
23
|
|
|
23
|
-
const
|
|
24
|
+
const body = {
|
|
24
25
|
title: title.trim(),
|
|
25
26
|
content,
|
|
26
27
|
type: type || 'html',
|
|
27
28
|
allowJs: allow_js === true,
|
|
28
|
-
}
|
|
29
|
+
};
|
|
30
|
+
if (typeof share_token === 'string' && share_token.trim()) {
|
|
31
|
+
body.shareToken = share_token.trim();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const result = await client.post('/artifact/quick-share', body);
|
|
29
35
|
|
|
30
36
|
const artifact = result?.data || {};
|
|
31
37
|
const slug = artifact.shareableSlug || artifact.shareToken;
|
|
@@ -49,6 +55,7 @@ function registerArtifactTools(server, client) {
|
|
|
49
55
|
shareToken: artifact.shareToken,
|
|
50
56
|
shareableSlug: slug,
|
|
51
57
|
duplicate: result?.duplicate === true,
|
|
58
|
+
updated: result?.updated === true,
|
|
52
59
|
title: artifact.title,
|
|
53
60
|
}),
|
|
54
61
|
}],
|