@viralspin/mcp 0.1.0 → 0.2.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.
Files changed (40) hide show
  1. package/BUNDLE_LOCAL_SPEC.md +133 -0
  2. package/dist/client.js +47 -9
  3. package/dist/index.js +83 -1
  4. package/dist/tools/assembly-adjust.js +102 -0
  5. package/dist/tools/carousel-video.js +190 -0
  6. package/dist/tools/engage.js +88 -0
  7. package/dist/tools/explainer-index.js +301 -0
  8. package/dist/tools/image-ads.js +156 -0
  9. package/dist/tools/kinetic-text.js +118 -0
  10. package/dist/tools/photo-picker.js +31 -0
  11. package/dist/tools/publish.js +72 -1
  12. package/dist/tools/review.js +103 -0
  13. package/dist/tools/video-assembly.js +183 -0
  14. package/engine/assemble.mjs +476 -0
  15. package/engine/gemini.mjs +164 -0
  16. package/engine/kinetic.mjs +84 -0
  17. package/engine/prompts.mjs +94 -0
  18. package/engine/remotion.config.ts +4 -0
  19. package/engine/src/Assembly.tsx +330 -0
  20. package/engine/src/KineticText.tsx +253 -0
  21. package/engine/src/Root.tsx +39 -0
  22. package/engine/src/index.ts +4 -0
  23. package/engine/tsconfig.json +15 -0
  24. package/package.json +29 -29
  25. package/src/client.ts +119 -80
  26. package/src/index.ts +151 -60
  27. package/src/tools/assembly-adjust.ts +110 -0
  28. package/src/tools/captions.ts +32 -32
  29. package/src/tools/carousel-video.ts +211 -0
  30. package/src/tools/connections.ts +27 -27
  31. package/src/tools/engage.ts +105 -0
  32. package/src/tools/explainer-index.ts +298 -0
  33. package/src/tools/image-ads.ts +192 -0
  34. package/src/tools/kinetic-text.ts +130 -0
  35. package/src/tools/photo-picker.ts +31 -0
  36. package/src/tools/posts.ts +58 -58
  37. package/src/tools/publish.ts +236 -158
  38. package/src/tools/review.ts +126 -0
  39. package/src/tools/video-assembly.ts +205 -0
  40. package/tsconfig.json +15 -15
@@ -0,0 +1,105 @@
1
+ import { get, post, del } from "../client.js";
2
+
3
+ interface KeywordLink {
4
+ id: string;
5
+ keyword: string;
6
+ link: string;
7
+ platform: string | null;
8
+ media_id: string | null;
9
+ match_type: "exact" | "contains";
10
+ is_active: boolean;
11
+ reply_to_comment: boolean;
12
+ created_at: string;
13
+ updated_at: string;
14
+ }
15
+
16
+ function describe(k: KeywordLink): string {
17
+ const scope = k.media_id ? `post ${k.media_id}` : "all posts";
18
+ const platform = k.platform ?? "any platform";
19
+ const reply = k.reply_to_comment ? ", + comment reply" : ", DM only";
20
+ const status = k.is_active ? "" : " (paused)";
21
+ return `"${k.keyword}" → ${k.link} [${platform}, ${scope}, ${k.match_type}${reply}]${status}`;
22
+ }
23
+
24
+ export const setKeywordLinkTool = {
25
+ name: "set_keyword_link",
26
+ description:
27
+ "Map a keyword to a resource/destination link for DM automation. When a follower comments/DMs the keyword, " +
28
+ "the link is sent back (delivery activates once Instagram messaging access is approved). " +
29
+ "Re-running with the same keyword + scope updates the link instead of creating a duplicate. " +
30
+ "Omit media_id for a global rule (fires on any post); set media_id for a single post.",
31
+ inputSchema: {
32
+ type: "object",
33
+ properties: {
34
+ keyword: { type: "string", description: "The trigger word, e.g. 'guide' or 'link'. Case-insensitive." },
35
+ link: { type: "string", description: "The destination URL to send (e.g. a resource-hub article)." },
36
+ platform: {
37
+ type: "string",
38
+ enum: ["instagram", "tiktok", "youtube"],
39
+ description: "Platform this rule applies to. Omit for any connected platform. Only Instagram delivers today.",
40
+ },
41
+ media_id: {
42
+ type: "string",
43
+ description: "Optional. A specific post/media id to scope the rule to. Omit for a global rule across all posts.",
44
+ },
45
+ match_type: {
46
+ type: "string",
47
+ enum: ["exact", "contains"],
48
+ description: "'contains' (default) fires if the comment includes the keyword; 'exact' requires a full match.",
49
+ },
50
+ reply_to_comment: {
51
+ type: "boolean",
52
+ description: "Also post a public reply to the comment (e.g. 'Check your DMs', wording varies). Defaults to true.",
53
+ },
54
+ },
55
+ required: ["keyword", "link"],
56
+ },
57
+
58
+ async execute(args: { keyword: string; link: string; platform?: string; media_id?: string; match_type?: string; reply_to_comment?: boolean }) {
59
+ const created = await post<KeywordLink>("/v1/engage/keyword-links", {
60
+ keyword: args.keyword,
61
+ link: args.link,
62
+ platform: args.platform ?? null,
63
+ media_id: args.media_id ?? null,
64
+ match_type: args.match_type ?? "contains",
65
+ reply_to_comment: args.reply_to_comment ?? true,
66
+ });
67
+ return `Saved: ${describe(created)}`;
68
+ },
69
+ };
70
+
71
+ export const listKeywordLinksTool = {
72
+ name: "list_keyword_links",
73
+ description: "List your keyword→link DM-automation rules.",
74
+ inputSchema: { type: "object", properties: {}, required: [] },
75
+
76
+ async execute(_args: Record<string, unknown>) {
77
+ const rules = await get<KeywordLink[]>("/v1/engage/keyword-links");
78
+ if (!rules.length) {
79
+ return "No keyword→link rules yet. Use set_keyword_link to add one (e.g. map 'guide' to your resource link).";
80
+ }
81
+ return rules.map((r) => `• ${describe(r)}`).join("\n");
82
+ },
83
+ };
84
+
85
+ export const deleteKeywordLinkTool = {
86
+ name: "delete_keyword_link",
87
+ description: "Delete a keyword→link rule by its keyword. If multiple rules share the keyword, all of them are removed.",
88
+ inputSchema: {
89
+ type: "object",
90
+ properties: {
91
+ keyword: { type: "string", description: "The keyword whose rule(s) should be deleted." },
92
+ },
93
+ required: ["keyword"],
94
+ },
95
+
96
+ async execute(args: { keyword: string }) {
97
+ const rules = await get<KeywordLink[]>("/v1/engage/keyword-links");
98
+ const matches = rules.filter((r) => r.keyword.toLowerCase() === args.keyword.trim().toLowerCase());
99
+ if (!matches.length) return `No rule found for keyword "${args.keyword}".`;
100
+ for (const m of matches) {
101
+ await del(`/v1/engage/keyword-links/${m.id}`);
102
+ }
103
+ return `Deleted ${matches.length} rule(s) for "${args.keyword}".`;
104
+ },
105
+ };
@@ -0,0 +1,298 @@
1
+ import fs from "node:fs";
2
+ import https from "node:https";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+
6
+ const TL_BASE = "https://api.twelvelabs.io/v1.3";
7
+ const VIRALSPIN_HOME = path.join(os.homedir(), ".viralspin");
8
+ const CACHE_FILE = path.join(VIRALSPIN_HOME, "explainer_cache.json");
9
+
10
+ // ── Cache helpers ──────────────────────────────────────────────────────────
11
+
12
+ interface CacheEntry {
13
+ video_id: string;
14
+ index_id: string;
15
+ }
16
+
17
+ interface Cache {
18
+ indexes: Record<string, string>; // folderPath → index_id
19
+ videos: Record<string, CacheEntry>; // "filePath|size" → { video_id, index_id }
20
+ }
21
+
22
+ function readCache(): Cache {
23
+ try {
24
+ if (fs.existsSync(CACHE_FILE)) return JSON.parse(fs.readFileSync(CACHE_FILE, "utf8"));
25
+ } catch { /* ignore */ }
26
+ return { indexes: {}, videos: {} };
27
+ }
28
+
29
+ function writeCache(c: Cache) {
30
+ fs.mkdirSync(VIRALSPIN_HOME, { recursive: true });
31
+ fs.writeFileSync(CACHE_FILE, JSON.stringify(c, null, 2));
32
+ }
33
+
34
+ function cacheKey(filePath: string): string {
35
+ try {
36
+ const stat = fs.statSync(filePath);
37
+ return `${filePath}|${stat.size}`;
38
+ } catch {
39
+ return filePath;
40
+ }
41
+ }
42
+
43
+ // ── Twelve Labs REST helpers ───────────────────────────────────────────────
44
+
45
+ function tlFetch(method: string, urlPath: string, body?: unknown, apiKey?: string): Promise<any> {
46
+ return new Promise((resolve, reject) => {
47
+ const key = apiKey || process.env.TWELVE_LABS_API_KEY || "";
48
+ const bodyBuf = body ? Buffer.from(JSON.stringify(body)) : undefined;
49
+ const req = https.request(`${TL_BASE}${urlPath}`, {
50
+ method,
51
+ headers: {
52
+ "x-api-key": key,
53
+ "Content-Type": "application/json",
54
+ ...(bodyBuf ? { "Content-Length": bodyBuf.length } : {}),
55
+ },
56
+ }, (res) => {
57
+ const chunks: Buffer[] = [];
58
+ res.on("data", (c) => chunks.push(c));
59
+ res.on("end", () => {
60
+ const text = Buffer.concat(chunks).toString();
61
+ try { resolve(JSON.parse(text)); }
62
+ catch { reject(new Error(`TL parse error: ${text.slice(0, 200)}`)); }
63
+ });
64
+ });
65
+ req.on("error", reject);
66
+ if (bodyBuf) req.write(bodyBuf);
67
+ req.end();
68
+ });
69
+ }
70
+
71
+ async function tlUploadFile(filePath: string, indexId: string, apiKey: string): Promise<string> {
72
+ // Multipart upload using form-data
73
+ const { default: FormData } = await import("form-data" as any);
74
+ const form = new FormData();
75
+ form.append("index_id", indexId);
76
+ form.append("language", "en");
77
+ form.append("video_file", fs.createReadStream(filePath), { filename: path.basename(filePath) });
78
+
79
+ return new Promise((resolve, reject) => {
80
+ const headers = { ...form.getHeaders(), "x-api-key": apiKey };
81
+ const req = https.request(`${TL_BASE}/tasks`, { method: "POST", headers }, (res) => {
82
+ const chunks: Buffer[] = [];
83
+ res.on("data", (c) => chunks.push(c));
84
+ res.on("end", () => {
85
+ try {
86
+ const d = JSON.parse(Buffer.concat(chunks).toString());
87
+ resolve(d._id || d.id);
88
+ } catch (e) {
89
+ reject(new Error(`TL upload parse error: ${Buffer.concat(chunks).toString().slice(0, 200)}`));
90
+ }
91
+ });
92
+ });
93
+ req.on("error", reject);
94
+ form.pipe(req);
95
+ });
96
+ }
97
+
98
+ async function waitForTask(taskId: string, apiKey: string): Promise<string> {
99
+ const deadline = Date.now() + 30 * 60 * 1000; // 30-min max
100
+ while (Date.now() < deadline) {
101
+ await new Promise((r) => setTimeout(r, 8000));
102
+ const d = await tlFetch("GET", `/tasks/${taskId}`, undefined, apiKey);
103
+ if (d.status === "ready") return d.video_id;
104
+ if (d.status === "failed") throw new Error(`Twelve Labs indexing failed for task ${taskId}: ${d.error || ""}`);
105
+ }
106
+ throw new Error(`Twelve Labs indexing timed out for task ${taskId}`);
107
+ }
108
+
109
+ async function getOrCreateIndex(folderPath: string, indexName: string, apiKey: string): Promise<string> {
110
+ const cache = readCache();
111
+ if (cache.indexes[folderPath]) return cache.indexes[folderPath];
112
+
113
+ // Create a new index
114
+ const d = await tlFetch("POST", "/indexes", {
115
+ name: indexName,
116
+ engines: [{ name: "marengo2.6", options: ["visual", "conversation", "text_in_video"] }],
117
+ }, apiKey);
118
+ const indexId: string = d._id || d.id;
119
+ cache.indexes[folderPath] = indexId;
120
+ writeCache(cache);
121
+ return indexId;
122
+ }
123
+
124
+ // ── Folder walking ─────────────────────────────────────────────────────────
125
+
126
+ const VIDEO_EXTS = new Set([".mp4", ".mkv", ".mov", ".avi", ".wmv", ".webm"]);
127
+
128
+ function walkFolder(folder: string): string[] {
129
+ const results: string[] = [];
130
+ try {
131
+ for (const entry of fs.readdirSync(folder, { withFileTypes: true })) {
132
+ const full = path.join(folder, entry.name);
133
+ if (entry.isDirectory()) results.push(...walkFolder(full));
134
+ else if (VIDEO_EXTS.has(path.extname(entry.name).toLowerCase())) results.push(full);
135
+ }
136
+ } catch { /* ignore permission errors */ }
137
+ return results;
138
+ }
139
+
140
+ // ── Tool definition ────────────────────────────────────────────────────────
141
+
142
+ interface IndexArgs {
143
+ sources_folder: string;
144
+ index_name?: string;
145
+ }
146
+
147
+ export const indexExplainerSourcesTool = {
148
+ name: "index_explainer_sources",
149
+ description:
150
+ "Index a local folder of screen recordings and OBS videos with Twelve Labs so they can be searched semantically " +
151
+ "inside create_video_assembly. Call this once per folder — indexing is cached, so re-running skips already-indexed files. " +
152
+ "Returns an index_id to pass as explainer_index_id in create_video_assembly. " +
153
+ "Requires TWELVE_LABS_API_KEY in the MCP environment.",
154
+ inputSchema: {
155
+ type: "object",
156
+ properties: {
157
+ sources_folder: {
158
+ type: "string",
159
+ description: "Local folder containing your screen recordings / OBS videos (e.g. C:/obs-recordings).",
160
+ },
161
+ index_name: {
162
+ type: "string",
163
+ description: "Optional name for the Twelve Labs index (defaults to the folder's base name).",
164
+ },
165
+ },
166
+ required: ["sources_folder"],
167
+ },
168
+
169
+ async execute(args: IndexArgs): Promise<string> {
170
+ const apiKey = process.env.TWELVE_LABS_API_KEY;
171
+ if (!apiKey) {
172
+ return "Error: TWELVE_LABS_API_KEY is not set in the MCP environment. Add it to your ~/.claude.json MCP config under env.TWELVE_LABS_API_KEY.";
173
+ }
174
+
175
+ const folder = args.sources_folder;
176
+ if (!fs.existsSync(folder)) {
177
+ return `Error: folder not found: ${folder}`;
178
+ }
179
+
180
+ const indexName = args.index_name || path.basename(folder) || "viralspin-explainer";
181
+ process.stderr.write(`[explainer-index] Getting/creating Twelve Labs index for ${folder}…\n`);
182
+ const indexId = await getOrCreateIndex(folder, indexName, apiKey);
183
+ process.stderr.write(`[explainer-index] index_id=${indexId}\n`);
184
+
185
+ const videos = walkFolder(folder);
186
+ if (videos.length === 0) {
187
+ return `No video files found in ${folder}. Supported formats: ${[...VIDEO_EXTS].join(", ")}`;
188
+ }
189
+
190
+ const cache = readCache();
191
+ let indexed = 0;
192
+ let skipped = 0;
193
+ const errors: string[] = [];
194
+
195
+ for (const vpath of videos) {
196
+ const key = cacheKey(vpath);
197
+ if (cache.videos[key]) {
198
+ skipped++;
199
+ process.stderr.write(`[explainer-index] skip (cached): ${path.basename(vpath)}\n`);
200
+ continue;
201
+ }
202
+ process.stderr.write(`[explainer-index] uploading: ${path.basename(vpath)}…\n`);
203
+ try {
204
+ const taskId = await tlUploadFile(vpath, indexId, apiKey);
205
+ process.stderr.write(`[explainer-index] waiting for task ${taskId}…\n`);
206
+ const videoId = await waitForTask(taskId, apiKey);
207
+ cache.videos[key] = { video_id: videoId, index_id: indexId };
208
+ writeCache(cache);
209
+ indexed++;
210
+ process.stderr.write(`[explainer-index] indexed: ${path.basename(vpath)} → video_id=${videoId}\n`);
211
+ } catch (e: any) {
212
+ errors.push(`${path.basename(vpath)}: ${e?.message || e}`);
213
+ process.stderr.write(`[explainer-index] ERROR: ${e?.message || e}\n`);
214
+ }
215
+ }
216
+
217
+ const parts = [
218
+ `Index ready: ${indexId}`,
219
+ ` Newly indexed: ${indexed} video${indexed !== 1 ? "s" : ""}`,
220
+ ` Already cached: ${skipped}`,
221
+ ` Total in folder: ${videos.length}`,
222
+ "",
223
+ `Pass this to create_video_assembly as: explainer_index_id: "${indexId}"`,
224
+ ];
225
+ if (errors.length) {
226
+ parts.push("", `Errors (${errors.length}):`, ...errors.map((e) => ` • ${e}`));
227
+ }
228
+ return parts.join("\n");
229
+ },
230
+ };
231
+
232
+ // ── Clip retrieval (used by assemble.mjs via env forwarding) ───────────────
233
+ // Exported so assemble.mjs can also call this logic if needed in future.
234
+
235
+ export async function searchAndExtract(
236
+ searchQuery: string,
237
+ indexId: string,
238
+ outDir: string,
239
+ clipIndex: number,
240
+ ): Promise<{ clip_path: string; confidence: number; start_sec: number } | null> {
241
+ const apiKey = process.env.TWELVE_LABS_API_KEY;
242
+ if (!apiKey) return null;
243
+
244
+ try {
245
+ const result = await tlFetch("POST", "/search", {
246
+ query_text: searchQuery,
247
+ index_id: indexId,
248
+ search_options: ["visual", "conversation", "text_in_video"],
249
+ page_limit: 1,
250
+ }, apiKey);
251
+
252
+ const hit = result?.data?.[0];
253
+ if (!hit) return null;
254
+
255
+ const videoId: string = hit.video_id;
256
+ const startSec: number = hit.start || 0;
257
+ const endSec: number = hit.end || startSec + 15;
258
+ const confidence: number = hit.score || 0;
259
+
260
+ // Look up local path from cache
261
+ const cache = readCache();
262
+ let localPath: string | null = null;
263
+ for (const entry of Object.values(cache.videos)) {
264
+ if (entry.video_id === videoId && entry.index_id === indexId) {
265
+ // Find the key (which is "filePath|size")
266
+ for (const [key, val] of Object.entries(cache.videos)) {
267
+ if (val.video_id === videoId) {
268
+ localPath = key.split("|")[0];
269
+ break;
270
+ }
271
+ }
272
+ break;
273
+ }
274
+ }
275
+
276
+ if (!localPath || !fs.existsSync(localPath)) return null;
277
+
278
+ const outPath = path.join(outDir, `explainer_clip_${clipIndex}.mp4`);
279
+ const duration = Math.min(endSec - startSec, 30);
280
+ const { spawn } = await import("child_process");
281
+
282
+ await new Promise<void>((resolve, reject) => {
283
+ const ff = spawn("ffmpeg", [
284
+ "-y", "-ss", String(startSec), "-t", String(duration),
285
+ "-i", localPath!,
286
+ "-c:v", "libx264", "-crf", "26", "-c:a", "aac", "-movflags", "+faststart",
287
+ outPath,
288
+ ]);
289
+ ff.on("close", (code) => code === 0 ? resolve() : reject(new Error(`ffmpeg exit ${code}`)));
290
+ ff.on("error", reject);
291
+ });
292
+
293
+ return { clip_path: outPath, confidence, start_sec: startSec };
294
+ } catch (e: any) {
295
+ process.stderr.write(`[explainer-index] searchAndExtract error: ${e?.message || e}\n`);
296
+ return null;
297
+ }
298
+ }
@@ -0,0 +1,192 @@
1
+ import { get, post, pyPost, pyGet, uploadFiles } from "../client.js";
2
+
3
+ const APP_BASE = process.env.VIRALSPIN_APP_URL || "https://app.viralspin.ai";
4
+
5
+ interface PrepareAssetArgs {
6
+ image_url: string;
7
+ }
8
+
9
+ interface AssembleImageAdArgs {
10
+ product_image_path: string;
11
+ prompt: string;
12
+ pin?: string;
13
+ }
14
+
15
+ interface PublishToMetaArgs {
16
+ media_url: string;
17
+ platforms: ("instagram" | "facebook")[];
18
+ caption: string;
19
+ }
20
+
21
+ export const prepareProductAssetTool = {
22
+ name: "prepare_product_asset",
23
+ description:
24
+ "Download and verify a product image from a local path or public URL, upload it to ViralSpin " +
25
+ "cloud storage, and return a stable cloud URL ready for Higgsfield image ad generation.",
26
+ inputSchema: {
27
+ type: "object" as const,
28
+ required: ["image_url"],
29
+ properties: {
30
+ image_url: {
31
+ type: "string",
32
+ description:
33
+ "Local file path (e.g. C:/photos/product.jpg) or public URL of the product image.",
34
+ },
35
+ },
36
+ },
37
+ async execute({ image_url }: PrepareAssetArgs): Promise<string> {
38
+ if (image_url.startsWith("http://") || image_url.startsWith("https://")) {
39
+ return `✅ Asset ready (already a URL):\n\n ${image_url}\n\nPass this to assemble_image_ad.`;
40
+ }
41
+ const { urls } = await uploadFiles([image_url]);
42
+ return [
43
+ `✅ Product asset uploaded:`,
44
+ ``,
45
+ ` ${urls[0]}`,
46
+ ``,
47
+ `Pass this URL to assemble_image_ad.`,
48
+ ].join("\n");
49
+ },
50
+ };
51
+
52
+ export const assembleImageAdTool = {
53
+ name: "assemble_image_ad",
54
+ description:
55
+ "Generate image ad variants from a product photo using Higgsfield AI. " +
56
+ "Uploads the product image, generates styled variants with your prompt, then creates a " +
57
+ "client review link showing all variants in a carousel. " +
58
+ "Requires Higgsfield credentials saved in ViralSpin Settings.",
59
+ inputSchema: {
60
+ type: "object" as const,
61
+ required: ["product_image_path", "prompt"],
62
+ properties: {
63
+ product_image_path: {
64
+ type: "string",
65
+ description: "Local file path (e.g. C:/photos/product.jpg) or public URL of the product image.",
66
+ },
67
+ prompt: {
68
+ type: "string",
69
+ description:
70
+ "Higgsfield styling prompt describing the desired ad aesthetic. " +
71
+ "Example: 'luxury product shot on white marble, soft studio lighting, premium brand aesthetic'",
72
+ },
73
+ pin: {
74
+ type: "string",
75
+ description: "Optional 4-digit PIN to protect the client review link.",
76
+ },
77
+ },
78
+ },
79
+ async execute({ product_image_path, prompt, pin }: AssembleImageAdArgs): Promise<string> {
80
+ // Upload local files; pass through URLs unchanged
81
+ let imageUrl = product_image_path;
82
+ if (!product_image_path.startsWith("http://") && !product_image_path.startsWith("https://")) {
83
+ const { urls } = await uploadFiles([product_image_path]);
84
+ imageUrl = urls[0];
85
+ }
86
+
87
+ // Start async generation job — Higgsfield lives on Python backend
88
+ const { job_id } = await pyPost<{ job_id: string }>("/v1/higgsfield/generate", {
89
+ product_image_url: imageUrl,
90
+ prompt,
91
+ });
92
+
93
+ // Poll until done (~3 min timeout)
94
+ let variants: string[] = [];
95
+ for (let attempt = 0; attempt < 40; attempt++) {
96
+ await new Promise((r) => setTimeout(r, 5000));
97
+ const status = await pyGet<{
98
+ status: string;
99
+ variant_urls?: string[];
100
+ error?: string;
101
+ }>(`/v1/higgsfield/status/${job_id}`);
102
+
103
+ if (status.status === "complete") {
104
+ variants = status.variant_urls ?? [];
105
+ break;
106
+ }
107
+ if (status.status === "failed") {
108
+ throw new Error(`Higgsfield generation failed: ${status.error ?? "unknown error"}`);
109
+ }
110
+ }
111
+
112
+ if (!variants.length) {
113
+ throw new Error("Generation timed out after ~3 minutes — try again or check Higgsfield credentials");
114
+ }
115
+
116
+ // Create a review session with image variants displayed as a carousel
117
+ const { token } = await post<{ token: string; review_url: string }>("/v1/review-sessions", {
118
+ output_path: variants[0],
119
+ metadata: {
120
+ media_type: "image",
121
+ variants,
122
+ caption: prompt,
123
+ platform_targets: ["instagram", "facebook"],
124
+ },
125
+ pin: pin ?? null,
126
+ expires_hours: 168,
127
+ });
128
+
129
+ const reviewUrl = `${APP_BASE}/review/${token}`;
130
+ return [
131
+ `✅ ${variants.length} image ad variant${variants.length > 1 ? "s" : ""} generated!`,
132
+ ``,
133
+ `Review link: ${reviewUrl}`,
134
+ ``,
135
+ `Send this to your client — they'll see all ${variants.length} variants as a carousel`,
136
+ `and can Approve or Request Changes.`,
137
+ pin ? `PIN: ${pin}` : ``,
138
+ ``,
139
+ `To publish an approved variant:`,
140
+ ` publish_to_meta with media_url="${variants[0]}" and your caption.`,
141
+ ]
142
+ .filter((l) => l !== undefined)
143
+ .join("\n");
144
+ },
145
+ };
146
+
147
+ export const publishToMetaTool = {
148
+ name: "publish_to_meta",
149
+ description:
150
+ "Publish an image (or video) to Instagram and/or Facebook. " +
151
+ "Use after a client approves an image ad variant from assemble_image_ad.",
152
+ inputSchema: {
153
+ type: "object" as const,
154
+ required: ["media_url", "platforms", "caption"],
155
+ properties: {
156
+ media_url: {
157
+ type: "string",
158
+ description: "Public URL of the approved image or video to publish.",
159
+ },
160
+ platforms: {
161
+ type: "array",
162
+ items: { type: "string", enum: ["instagram", "facebook"] },
163
+ description: "Target platforms: 'instagram', 'facebook', or both.",
164
+ },
165
+ caption: {
166
+ type: "string",
167
+ description: "Post caption (include hashtags here if desired).",
168
+ },
169
+ },
170
+ },
171
+ async execute({ media_url, platforms, caption }: PublishToMetaArgs): Promise<string> {
172
+ const result = await post<{
173
+ status?: string;
174
+ results?: Record<string, { success: boolean; error?: string }>;
175
+ }>("/v1/publish/schedule", {
176
+ video_url: media_url,
177
+ platforms,
178
+ caption,
179
+ publish_now: true,
180
+ });
181
+
182
+ const lines: string[] = ["📸 Published to Meta:"];
183
+ const results = result.results ?? {};
184
+ for (const [platform, res] of Object.entries(results)) {
185
+ lines.push(` ${res.success ? "✅" : "❌"} ${platform}${res.error ? `: ${res.error}` : ""}`);
186
+ }
187
+ if (!Object.keys(results).length) {
188
+ lines.push(" (no platform results returned — check your connected accounts in Settings)");
189
+ }
190
+ return lines.join("\n");
191
+ },
192
+ };