@hubfluencer/mcp 0.5.0 → 0.6.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/src/index.ts CHANGED
@@ -16,44 +16,165 @@
16
16
  * → render → wait_for_completion → download_result
17
17
  */
18
18
 
19
- import { writeFile } from "node:fs/promises";
19
+ import { randomUUID } from "node:crypto";
20
+ import { mkdir, writeFile } from "node:fs/promises";
21
+ import { dirname } from "node:path";
20
22
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
21
23
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
22
24
  import { z } from "zod";
25
+ import {
26
+ buildPlanBody,
27
+ type CreateCampaignArgs,
28
+ pickBrandProfileFields,
29
+ pickProductProfileFields,
30
+ runCreateCampaign,
31
+ summarizePack,
32
+ summarizePackItem,
33
+ summarizePackListEntry,
34
+ validatePlanSource,
35
+ validateVariationsBatch,
36
+ } from "./campaign.js";
23
37
  import {
24
38
  clientFromEnv,
25
39
  type HubfluencerClient,
26
40
  type HubfluencerError,
27
41
  } from "./client.js";
28
42
  import {
43
+ addSegmentKey,
29
44
  asRecord,
30
45
  assertSafeFetchUrl,
46
+ decideTrackingPlan,
47
+ editorAdAutopilotKey,
48
+ editorAdCreateKey,
49
+ formatApiErrorMessage,
31
50
  idemKey,
32
51
  inferKind,
33
52
  type Kind,
53
+ makeVideoAutopilotKey,
54
+ makeVideoEditorCreateKey,
34
55
  type NormalizedStatus,
35
56
  normalizeStatus,
36
57
  resolveSavePath,
58
+ sliderCreateKey,
59
+ startStateDiscriminator,
60
+ type TrackingSnapshot,
61
+ validateKind,
62
+ validateLanguage,
63
+ validateProductPrompt,
37
64
  } from "./core.js";
38
65
  import { runLogin } from "./login.js";
39
66
  import {
67
+ createCampaignOutput,
68
+ createDraftOutput,
69
+ createEditorOutput,
70
+ downloadResultOutput,
71
+ getCampaignPackOutput,
72
+ getCreditsOutput,
73
+ getRecommendationsOutput,
74
+ getSeriesDashboardOutput,
75
+ getSliderOutput,
76
+ getStatusOutput,
77
+ listProjectsOutput,
78
+ listScheduledPostsOutput,
79
+ makeVideoOutput,
80
+ waitForCompletionOutput,
81
+ } from "./output-schemas.js";
82
+ import {
83
+ assetSubscriptionErrorText,
84
+ buildPerformanceBody,
85
+ buildReviewLinkBody,
86
+ type CreateReviewLinkResult,
87
+ PERFORMANCE_PLATFORMS,
88
+ PERFORMANCE_SOURCES,
89
+ pickCreativeAttributeFields,
90
+ REVIEW_LINK_MAX_EXPIRES_IN_HOURS,
91
+ REVIEW_LINK_MIN_EXPIRES_IN_HOURS,
92
+ type RecordPerformanceArgs,
93
+ reviewWebOrigin,
94
+ shapeCreateReviewLink,
95
+ summarizeAssetQuota,
96
+ summarizeCatalogAsset,
97
+ summarizePerformance,
98
+ summarizeReviewLink,
99
+ validatePerformanceMetrics,
100
+ validateReviewOutput,
101
+ } from "./perf-review-assets.js";
102
+ import {
103
+ buildScheduledPostBody,
104
+ calendarScopeErrorText,
105
+ MAX_PENDING_SCHEDULED_POSTS,
106
+ pickSeriesFields,
107
+ SCHEDULED_POST_PLATFORMS,
108
+ SCHEDULED_POST_STATUSES,
109
+ summarizeDashboard,
110
+ summarizeEpisode,
111
+ summarizeScheduledPost,
112
+ summarizeSeries,
113
+ validateScheduledPostCopy,
114
+ } from "./series-calendar.js";
115
+ import {
116
+ CATALOG_ASSET_EXTS,
40
117
  IMAGE_EXTS,
118
+ resolveVideoReadPath,
119
+ uploadCatalogAsset,
41
120
  uploadImageFile,
42
121
  uploadShortPoster,
43
122
  uploadVideoFile,
44
123
  VIDEO_EXTS,
45
124
  } from "./uploads.js";
125
+ import { USER_AGENT, VERSION } from "./version.js";
46
126
 
47
127
  async function fetchStatus(
48
128
  client: HubfluencerClient,
49
129
  kind: Kind,
50
130
  slug: string,
131
+ deadline?: number,
51
132
  ): Promise<NormalizedStatus> {
52
- const path = kind === "short" ? `/shorts/${slug}` : `/editor/${slug}`;
53
- const res = await client.get<{ data: unknown }>(path);
133
+ const path =
134
+ kind === "short"
135
+ ? `/shorts/${slug}`
136
+ : kind === "tracking"
137
+ ? `/tracking/${slug}`
138
+ : kind === "slider"
139
+ ? `/sliders/${slug}`
140
+ : `/editor/${slug}`;
141
+ // On the poll path the caller passes the loop deadline so this GET's retry
142
+ // sequence can't overrun the wait budget (see client.request `deadline`).
143
+ const res = await client.get<{ data: unknown }>(path, undefined, deadline);
54
144
  return normalizeStatus(kind, slug, asRecord(res).data);
55
145
  }
56
146
 
147
+ function isRecompositeInProgressConflict(e: unknown): boolean {
148
+ const err = e as Partial<HubfluencerError>;
149
+ if (err?.status !== 409) return false;
150
+ const texts: string[] = [];
151
+ if (typeof err.code === "string") texts.push(err.code);
152
+ if (typeof err.message === "string") texts.push(err.message);
153
+ const body = err.body;
154
+ if (body && typeof body === "object") {
155
+ const record = body as Record<string, unknown>;
156
+ if (typeof record.error === "string") texts.push(record.error);
157
+ if (typeof record.message === "string") texts.push(record.message);
158
+ const nested = record.error;
159
+ if (nested && typeof nested === "object") {
160
+ const nestedRecord = nested as Record<string, unknown>;
161
+ if (typeof nestedRecord.code === "string") texts.push(nestedRecord.code);
162
+ if (typeof nestedRecord.message === "string")
163
+ texts.push(nestedRecord.message);
164
+ }
165
+ }
166
+ return texts.some((text) => {
167
+ const normalized = text.toLowerCase();
168
+ return (
169
+ normalized === "recomposite_in_progress" ||
170
+ /\bre-?composit(?:e|ing|ion)\b.*\balready\b.*\b(progress|running)\b/.test(
171
+ normalized,
172
+ ) ||
173
+ /\bcarousel\b.*\bre-?render\b.*\balready\b.*\bprogress\b/.test(normalized)
174
+ );
175
+ });
176
+ }
177
+
57
178
  // The video_url comes back in API JSON — allow loopback http only when the API
58
179
  // base itself is local (dev), mirroring uploads.ts and the base-URL exception.
59
180
  const ALLOW_LOOPBACK_FETCH =
@@ -72,7 +193,10 @@ async function downloadTo(
72
193
  // Bound the download: a presigned URL could in theory hang or point at
73
194
  // something huge, so cap both time and size instead of buffering blindly.
74
195
  const MAX_BYTES = 1024 * 1024 * 1024; // 1 GB — generous for a short ad
75
- const resp = await fetch(videoUrl, { signal: AbortSignal.timeout(120_000) });
196
+ const resp = await fetch(videoUrl, {
197
+ headers: { "user-agent": USER_AGENT },
198
+ signal: AbortSignal.timeout(120_000),
199
+ });
76
200
  if (!resp.ok)
77
201
  throw new Error(`Failed to download video (HTTP ${resp.status}).`);
78
202
  // Fast path: trust a present content-length to reject an oversize body before
@@ -106,6 +230,10 @@ async function downloadTo(
106
230
  await reader.cancel().catch(() => {});
107
231
  }
108
232
  const buf = Buffer.concat(chunks, total);
233
+ // resolveSavePath accepts a nested target (e.g. "clips/out.mp4"), so create
234
+ // the parent chain before writing — otherwise a full spend + download lands on
235
+ // an ENOENT at the final writeFile. Recursive mkdir is a no-op when it exists.
236
+ await mkdir(dirname(target), { recursive: true });
109
237
  await writeFile(target, buf);
110
238
  return { saved_to: target, bytes: buf.length };
111
239
  }
@@ -158,19 +286,15 @@ function fail(message: string) {
158
286
  function errMessage(e: unknown): string {
159
287
  const he = e as HubfluencerError;
160
288
  if (he && typeof he.status === "number") {
161
- const base = `API error (HTTP ${he.status}${he.code ? `, ${he.code}` : ""}): ${he.message}`;
162
- // Surface the structured credit fields the server attaches to 402s so the
163
- // agent can report exactly how short it is instead of a bare sentence.
164
- const body = he.body as Record<string, unknown> | undefined;
165
- if (
166
- body &&
167
- (body.required_credits != null || body.available_credits != null)
168
- ) {
169
- const req = body.required_credits;
170
- const have = body.available_credits;
171
- return `${base} (needs ${req ?? "?"} credits, have ${have ?? "?"}).`;
172
- }
173
- return base;
289
+ // Delegate the formatting (incl. the 402 credit-shortfall parsing) to the
290
+ // pure, unit-tested helper in core.ts index.ts can't be imported from a
291
+ // test (it boots a live stdio server at module load).
292
+ return formatApiErrorMessage({
293
+ status: he.status,
294
+ code: he.code,
295
+ message: he.message,
296
+ body: he.body,
297
+ });
174
298
  }
175
299
  return e instanceof Error ? e.message : String(e);
176
300
  }
@@ -315,7 +439,9 @@ function tool<A>(
315
439
 
316
440
  const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
317
441
 
318
- const kindSchema = z.enum(["short", "editor"]).describe("Project kind");
442
+ const kindSchema = z
443
+ .enum(["short", "editor", "tracking", "slider"])
444
+ .describe("Project kind");
319
445
 
320
446
  // Creative-style value lists shared by SHORTS and EDITOR (single source of
321
447
  // truth, mirroring the API's VideoFactory.creative_formats/0 +
@@ -345,7 +471,45 @@ const WRITE = {
345
471
  openWorldHint: true,
346
472
  };
347
473
 
348
- const server = new McpServer({ name: "hubfluencer", version: "0.5.0" });
474
+ // Server-level instructions surfaced to the client during the MCP handshake — a
475
+ // single place for the cross-tool conventions an agent needs before its first
476
+ // call (the per-tool `description`s carry the specifics). Kept concise: the two
477
+ // altitudes (one-shot asset vs. the full delegate-my-content-operation loop),
478
+ // money, how waiting/resuming works, and the local-file sandbox.
479
+ const SERVER_INSTRUCTIONS = `Hubfluencer turns a prompt — or a whole product page — into finished, post-ready vertical videos and image carousels, and can run the surrounding content operation (plan → draft → review → schedule → measure → repeat).
480
+
481
+ MAKE ONE ASSET (simplest)
482
+ - make_video({ prompt }) creates, generates, and returns a finished MP4 — it picks a single-clip short or a multi-scene editor ad from the prompt (override with kind:"short"|"editor"). create_short / create_editor_ad are the per-product one-shots; create_slider makes an image carousel; create_tracking_video burns a CV overlay onto a local clip.
483
+ - Granular (full control): create a draft, then drive the pipeline step by step (create_editor_draft → generate_scenario → set_scene_count → set_segment_prompt → generate_segment(s) → generate_voice + generate_music → render). Poll with get_status / wait_for_completion; fetch with download_result (a slider has no MP4 — read its slides with get_slider).
484
+
485
+ DELEGATE THE CONTENT OPERATION (the full loop — steps 1-3 are $0)
486
+ 1. ONBOARD a product: extract_product_page(url) pulls facts + imagery (SSRF-hardened, 0cr); import_product_image saves its picture/logo. Persist identity once with create_product_profile + create_brand_profile so every later draft inherits it instead of re-describing the brand.
487
+ 2. PLAN ($0, free quota — not credits): plan_campaign proposes angles/formats + a per-item credit estimate; generate_hook_variations writes hook options. create_campaign(url | product_profile_id) does 1-2 end-to-end and returns a pack of editable DRAFTS.
488
+ 3. PACK & MATERIALIZE ($0): create_campaign_pack / add_pack_variations assemble items; materialize_pack_item turns each into a 0-credit draft (editor_ad/short → a video project, carousel → a slider). list_campaign_packs lists packs; get_campaign_pack({ id }) shows every item's draft slug + credit estimate.
489
+ 4. GENERATE per draft (CREDITS — a separate, explicit step): run start_autopilot/generate_short/generate_slider on each draft slug. Nothing above this line spends video credits.
490
+ 5. REVIEW: create_review_link mints a no-login page where a human approves/comments (list_review_links / revoke_review_link manage them). Gate spend or posting on approval.
491
+ 6. CALENDAR (reminder-only): schedule_post / list_scheduled_posts / reschedule_post / cancel_scheduled_post stage a calendar that pushes REMINDERS to the user — it does NOT auto-publish (posting to TikTok/IG stays a human step in the app). After the user posts the presigned export, call mark_scheduled_posted.
492
+ 7. MEASURE → DOUBLE DOWN: record_performance logs views/engagement (tag creatives via set_item_attributes); get_recommendations gives a CAUTIOUS, non-causal read of your own best hook/format (present its confidence + note as-is, never upgrade them); make_more_like_winner spins fresh hooks from a proven item — feed a winner back into add_pack_variations → step 4.
493
+ 8. RECURRING: create_series + plan_series_episodes → materialize_episode drafts each episode; get_series_dashboard shows upcoming/drafted/published lanes; mark_episode_posted closes a recurring show's loop.
494
+
495
+ CREDITS (spent server-side; each tool prices before charging — see its description)
496
+ - Free (0 credits): every draft create/edit, reorder, all slider re-composites, and the whole onboard/plan/pack/materialize path (steps 1-3). get_credits shows the balance.
497
+ - Charged: short render 15; editor AI scene 5 (4 in a batch of ≥3); voice 3; music 5; editor render 0 (auto-charges ungenerated scenes); slider 1/slide; tracking render 1 (refunded on failure). make_video/create_* report the estimate first — pass dry_run to preview, max_credits to cap.
498
+ - AI ASSISTS (all generate_*/enhance/suggest/plan/hook tools) draw a FREE daily quota (20/day), NOT credits. On 429, set auto_unlock:true to spend 1 credit for +10 assists (retried once), or write the copy yourself and use the set_* tools.
499
+
500
+ WAITING & RESUMING
501
+ - Renders run async and can take minutes. wait_for_completion polls a budget capped at 280s; if it returns terminal=false / timed_out, just CALL IT AGAIN with the same slug. make_video returns the slug + a resume hint on timeout.
502
+ - Creates and charged actions are idempotency-keyed: a transport retry is safe; a genuine re-run after a terminal FAILURE restarts rather than replaying the dead attempt.
503
+
504
+ LOCAL FILES (sandboxed)
505
+ - Reads (upload_video / product / logo / closing / poster) are confined to HUBFLUENCER_INPUT_DIR (default: cwd); writes (save_path on wait_for_completion / download_result) are confined to HUBFLUENCER_OUTPUT_DIR (default: cwd), .mp4 only. Paths outside the base are refused.
506
+
507
+ AUTH: set HUBFLUENCER_API_TOKEN (Settings → Access tokens) or run \`npx -y @hubfluencer/mcp login\`. HUBFLUENCER_BASE_URL overrides the API host; HUBFLUENCER_CREDENTIALS overrides the device-link credentials path.`;
508
+
509
+ const server = new McpServer(
510
+ { name: "hubfluencer", version: VERSION },
511
+ { instructions: SERVER_INSTRUCTIONS },
512
+ );
349
513
 
350
514
  // The SDK's `registerTool` is generic over the Zod input shape; with this many
351
515
  // tools its conditional types hit TS2589 ("excessively deep"). Inputs are still
@@ -370,12 +534,12 @@ async function pollToTerminal(
370
534
  intervalMs: number,
371
535
  ): Promise<NormalizedStatus> {
372
536
  const deadline = Date.now() + budgetMs;
373
- let status = await fetchStatus(client, kind, slug);
537
+ let status = await fetchStatus(client, kind, slug, deadline);
374
538
  let n = 0;
375
539
  while (!status.terminal && Date.now() + intervalMs <= deadline) {
376
540
  await reportProgress(extra, ++n, `stage: ${status.stage}`);
377
541
  await sleep(intervalMs);
378
- status = await fetchStatus(client, kind, slug);
542
+ status = await fetchStatus(client, kind, slug, deadline);
379
543
  }
380
544
  return status;
381
545
  }
@@ -403,7 +567,12 @@ async function pollSegmentToTerminal(
403
567
  const deadline = Date.now() + budgetMs;
404
568
  const sid = String(segmentId);
405
569
  const read = async (): Promise<{ status: string; error: string | null }> => {
406
- const res = await client.get<{ data: unknown }>(`/editor/${slug}`);
570
+ // Bound each poll GET's retries by the loop deadline (see fetchStatus).
571
+ const res = await client.get<{ data: unknown }>(
572
+ `/editor/${slug}`,
573
+ undefined,
574
+ deadline,
575
+ );
407
576
  const data = asRecord(asRecord(res).data);
408
577
  const segments = Array.isArray(data.segments)
409
578
  ? (data.segments as Record<string, unknown>[])
@@ -467,10 +636,27 @@ registerTool(
467
636
  .describe(
468
637
  "'short' (fast, 1 clip), 'editor' (multi-scene), or 'auto' (default — inferred)",
469
638
  ),
639
+ product_subject: z
640
+ .string()
641
+ .max(300)
642
+ .optional()
643
+ .describe(
644
+ 'EDITOR only: the concrete product / main subject the video must be about (e.g. "Bordeaux red ' +
645
+ 'wine, dark green bottle"). Hard-grounds the AI so it cannot invent an unrelated product. ' +
646
+ "Recommended for ads.",
647
+ ),
648
+ project_intent: z
649
+ .enum(["social_ad", "creative_story"])
650
+ .optional()
651
+ .describe(
652
+ "EDITOR only: social_ad for promotional content (product focus + CTA) or creative_story (default).",
653
+ ),
470
654
  language: z
471
655
  .string()
472
656
  .optional()
473
- .describe('Language code, e.g. "en" (default)'),
657
+ .describe(
658
+ 'Language code, e.g. "en" (default), "fr". Drives scenario, narration, voice, and captions.',
659
+ ),
474
660
  aspect: z
475
661
  .string()
476
662
  .optional()
@@ -603,6 +789,7 @@ registerTool(
603
789
  "Spend cap: refuse to start (no charge) if the priced estimate exceeds this. Returns the estimate.",
604
790
  ),
605
791
  },
792
+ outputSchema: makeVideoOutput,
606
793
  annotations: { title: "Make a video", ...WRITE, idempotentHint: false },
607
794
  },
608
795
  tool(
@@ -610,6 +797,8 @@ registerTool(
610
797
  args: {
611
798
  prompt: string;
612
799
  kind?: string;
800
+ product_subject?: string;
801
+ project_intent?: string;
613
802
  language?: string;
614
803
  aspect?: string;
615
804
  voice_id?: string;
@@ -638,9 +827,17 @@ registerTool(
638
827
  client,
639
828
  extra,
640
829
  ) => {
641
- if (!args.prompt || args.prompt.trim().length < 10) {
642
- return fail("prompt must be at least 10 characters.");
830
+ const promptLen = args.prompt?.trim().length ?? 0;
831
+ if (promptLen < 10 || promptLen > 5000) {
832
+ return fail("prompt must be 10–5000 characters.");
643
833
  }
834
+ const langErr = validateLanguage(args.language);
835
+ if (langErr) return fail(langErr);
836
+ // kind is a flat z.string() (TS2589), so validate it in code the same way
837
+ // slide_count is — reject anything other than the documented values instead
838
+ // of silently inferring a wrong-product spend (see validateKind).
839
+ const kindErr = validateKind(args.kind);
840
+ if (kindErr) return fail(kindErr);
644
841
  const requestedKind =
645
842
  args.kind === "short" || args.kind === "editor" ? args.kind : undefined;
646
843
  const kind: Kind = requestedKind ?? inferKind(args.prompt);
@@ -650,6 +847,25 @@ registerTool(
650
847
  );
651
848
  const budgetMs = waitSeconds * 1000;
652
849
 
850
+ // star_rating / text_beats are flat here (see the schema note on TS2589)
851
+ // but create_short bounds them with Zod (0..5; ≤8 beats, each ≤120). Apply
852
+ // the SAME bounds in code so a one-shot short can't slip past make_video an
853
+ // out-of-range value that create_short would have rejected.
854
+ if (
855
+ args.star_rating !== undefined &&
856
+ (args.star_rating < 0 || args.star_rating > 5)
857
+ ) {
858
+ return fail("star_rating must be between 0 and 5.");
859
+ }
860
+ if (args.text_beats !== undefined) {
861
+ if (args.text_beats.length > 8) {
862
+ return fail("text_beats must have at most 8 entries.");
863
+ }
864
+ if (args.text_beats.some((b) => b.length > 120)) {
865
+ return fail("each text_beats entry must be at most 120 characters.");
866
+ }
867
+ }
868
+
653
869
  // Validate the save_path up front so we fail before spending credits.
654
870
  if (args.save_path) resolveSavePath(args.save_path);
655
871
 
@@ -718,22 +934,18 @@ registerTool(
718
934
  {
719
935
  language: args.language ?? "en",
720
936
  product_prompt: args.prompt,
937
+ product_subject: args.product_subject,
938
+ project_intent: args.project_intent,
721
939
  export_aspect_ratio: args.aspect,
722
940
  voice_id: args.voice_id,
723
941
  creative_format: args.creative_format,
724
942
  visual_language: args.visual_language,
725
943
  theme: args.theme,
726
944
  },
727
- idemKey(
728
- "make-editor",
729
- args.prompt,
730
- args.language ?? "en",
731
- args.aspect ?? "",
732
- args.voice_id ?? "",
733
- args.creative_format ?? "",
734
- args.visual_language ?? "",
735
- args.theme ?? "",
736
- ),
945
+ // Fold every create-affecting param into the key so two calls that
946
+ // differ only by style/theme/voice/aspect get distinct drafts (shared
947
+ // with the golden-pin test via core.ts).
948
+ makeVideoEditorCreateKey(args),
737
949
  );
738
950
  slug = created.data.slug;
739
951
  }
@@ -795,18 +1007,49 @@ registerTool(
795
1007
  });
796
1008
  }
797
1009
 
798
- // 4) Affordable and authorized — start the charging pipeline.
1010
+ // 4) Affordable and authorized — start the charging pipeline. Read the
1011
+ // project's current state first and fold an anti-replay discriminator
1012
+ // into the start key: on a re-run AFTER a terminal failure the server's
1013
+ // 24h idempotency cache would otherwise replay the failed run's cached
1014
+ // 202 and never restart (the exact trap generate_short warns about).
1015
+ // startStateDiscriminator returns a STABLE "start" for a fresh/in-flight
1016
+ // project (so a transport double-send still dedupes — and the server's
1017
+ // already-running / 409 gate independently blocks a double charge) but a
1018
+ // per-attempt signature once terminally failed, so a genuine re-run mints
1019
+ // a fresh key and re-enqueues. Best-effort: if the read fails we fall
1020
+ // back to the stable "start" (the server gate remains the real guard).
1021
+ let startState = "start";
1022
+ try {
1023
+ const cur = await client.get<{ data: unknown }>(
1024
+ kind === "short" ? `/shorts/${slug}` : `/editor/${slug}`,
1025
+ );
1026
+ startState = startStateDiscriminator(kind, asRecord(cur).data);
1027
+ } catch {
1028
+ // status unreadable — keep the stable token (the server gate still holds)
1029
+ }
1030
+
799
1031
  if (kind === "short") {
800
1032
  await client.post(
801
1033
  `/shorts/${slug}/generate`,
802
1034
  undefined,
803
- `gen-short:${slug}`,
1035
+ // gen-short:<slug> + the failure-state discriminator: a fresh/in-flight
1036
+ // short reuses "gen-short:<slug>:start" (transport-retry dedupe), a
1037
+ // re-run after a failed render gets a fresh key and re-renders.
1038
+ `gen-short:${slug}:${startState}`,
804
1039
  );
805
1040
  } else {
806
1041
  await client.post(
807
1042
  `/editor/${slug}/autopilot`,
808
1043
  undefined,
809
- `autopilot:${slug}`,
1044
+ // Fold the create-affecting params AND the failure-state discriminator
1045
+ // into the start key so it tracks the inputs + current state, not just
1046
+ // the slug. This key is only compared against a retry of *this* tool on
1047
+ // *this* slug; the other autopilot entry points use a different field
1048
+ // set/order on purpose (the server idempotency cache is scoped to
1049
+ // path+key, and the slug — already in the path — is unique per draft),
1050
+ // so the shapes are not meant to be interchangeable across tools.
1051
+ // Composition lives in core.ts, shared with the golden-pin test.
1052
+ makeVideoAutopilotKey(slug, args, startState),
810
1053
  );
811
1054
  }
812
1055
 
@@ -851,7 +1094,13 @@ registerTool(
851
1094
  note: status.ready
852
1095
  ? "Done. Presigned ~24h URL — download promptly."
853
1096
  : status.terminal
854
- ? `Terminal (${status.stage}). ${status.error ?? ""}`.trim()
1097
+ ? // A terminal-but-not-ready make_video is a failed/cancelled run.
1098
+ // Point at a restart: re-running make_video now genuinely restarts
1099
+ // (the start key folds the failure state, so it no longer replays the
1100
+ // dead 202), or resume the pipeline directly. Fix the cause (often
1101
+ // credits) first.
1102
+ `Terminal (${status.stage}). ${status.error ?? ""}`.trim() +
1103
+ ` To restart, fix the cause (often credits) then re-run make_video, or resume with ${resume}.`
855
1104
  : `Still rendering — call wait_for_completion(${resumeArgs}).`,
856
1105
  },
857
1106
  links,
@@ -869,6 +1118,7 @@ registerTool(
869
1118
  description:
870
1119
  "Returns the authenticated account's credit balance. A short costs 15 credits.",
871
1120
  inputSchema: {},
1121
+ outputSchema: getCreditsOutput,
872
1122
  annotations: { title: "Get credits", ...RO },
873
1123
  },
874
1124
  tool(async (_args, client) => {
@@ -926,6 +1176,7 @@ registerTool(
926
1176
  .optional()
927
1177
  .describe("Max items per kind (default 25, capped at 100)"),
928
1178
  },
1179
+ outputSchema: listProjectsOutput,
929
1180
  annotations: { title: "List projects", ...RO },
930
1181
  },
931
1182
  tool(
@@ -1104,6 +1355,7 @@ registerTool(
1104
1355
  "Optional caption beats shown sequentially instead of the static subheadline.",
1105
1356
  ),
1106
1357
  },
1358
+ outputSchema: createDraftOutput,
1107
1359
  annotations: { title: "Create short", ...WRITE, idempotentHint: true },
1108
1360
  },
1109
1361
  tool(
@@ -1200,6 +1452,372 @@ registerTool(
1200
1452
  ),
1201
1453
  );
1202
1454
 
1455
+ interface TrackingData {
1456
+ slug: string;
1457
+ status: string;
1458
+ source: { status: string } | null;
1459
+ result: { id?: number; status: string; video_url: string | null } | null;
1460
+ }
1461
+
1462
+ async function getTracking(
1463
+ client: HubfluencerClient,
1464
+ slug: string,
1465
+ deadline?: number,
1466
+ ): Promise<TrackingData> {
1467
+ // On a poll loop the caller passes the loop deadline so this GET's retry
1468
+ // sequence stays inside the wait budget (see client.request `deadline`).
1469
+ const res = await client.get<{ data: TrackingData }>(
1470
+ `/tracking/${slug}`,
1471
+ undefined,
1472
+ deadline,
1473
+ );
1474
+ return res.data;
1475
+ }
1476
+
1477
+ // Poll until the uploaded source clip finishes server-side processing.
1478
+ //
1479
+ // Returns "ready" when the source is usable, "failed" when the server-side
1480
+ // processing FAILED (the API now surfaces failed sources — see the latest_source
1481
+ // contract), and "timeout" when the budget elapsed while it was still in flight.
1482
+ // Short-circuiting on "failed" matters: a failed source is not progressing, so
1483
+ // spinning the full budget and then telling the agent it is "still processing"
1484
+ // would be both slow and wrong.
1485
+ async function waitForTrackingSource(
1486
+ client: HubfluencerClient,
1487
+ slug: string,
1488
+ extra: ToolExtra,
1489
+ budgetMs = 120_000,
1490
+ intervalMs = 3_000,
1491
+ ): Promise<"ready" | "failed" | "timeout"> {
1492
+ const deadline = Date.now() + budgetMs;
1493
+ let n = 0;
1494
+ while (Date.now() + intervalMs <= deadline) {
1495
+ const t = await getTracking(client, slug, deadline);
1496
+ if (t.source?.status === "ready") return "ready";
1497
+ if (t.source?.status === "failed") return "failed";
1498
+ await reportProgress(extra, ++n, "processing source clip");
1499
+ await sleep(intervalMs);
1500
+ }
1501
+ return "timeout";
1502
+ }
1503
+
1504
+ // Poll the render until the result reaches a terminal state. The budget is
1505
+ // generous (default 15 min) because the production Replicate Cog can cold-start a
1506
+ // GPU before the burn-in even begins, and worst-case 60s/1080p sources may spend
1507
+ // several additional minutes in Mask R-CNN + CPU x264 encode. A short budget
1508
+ // would routinely time the agent out mid-render even on a job that ultimately succeeds.
1509
+ async function pollTrackingComplete(
1510
+ client: HubfluencerClient,
1511
+ slug: string,
1512
+ extra: ToolExtra,
1513
+ budgetMs = 900_000,
1514
+ intervalMs = 4_000,
1515
+ ): Promise<TrackingData> {
1516
+ const deadline = Date.now() + budgetMs;
1517
+ let n = 0;
1518
+ let t = await getTracking(client, slug, deadline);
1519
+ while (
1520
+ t.result?.status !== "completed" &&
1521
+ t.result?.status !== "failed" &&
1522
+ Date.now() + intervalMs <= deadline
1523
+ ) {
1524
+ await reportProgress(extra, ++n, "applying tracking overlay");
1525
+ await sleep(intervalMs);
1526
+ t = await getTracking(client, slug, deadline);
1527
+ }
1528
+ return t;
1529
+ }
1530
+
1531
+ registerTool(
1532
+ "create_tracking_video",
1533
+ {
1534
+ title: "Create a tracking-overlay video",
1535
+ description:
1536
+ "One-shot: upload a local video and burn the computer-vision 'object tracking' overlay on top " +
1537
+ "(white convex-hull polygons hugging each moving subject, HUD corner brackets, a centroid crosshair, " +
1538
+ "and per-subject telemetry), then download the result. Costs 1 credit on render. The source clip must " +
1539
+ "be ≤60s and ≤1080p. This creates the project, uploads the clip, waits for it to be ready, renders, " +
1540
+ "polls to completion, and (when save_path is given) downloads the finished MP4. " +
1541
+ "RESUMING: if the call returns terminal=false (slow upload or a render still cold-starting on the GPU), " +
1542
+ "call this tool AGAIN with the returned slug to resume — it reuses the in-flight upload (it only (re)uploads " +
1543
+ "when no source exists yet or the prior upload failed) and never re-charges (a render in flight is reported, " +
1544
+ "not duplicated). Resuming a slug whose render already COMPLETED just returns the finished video: a " +
1545
+ "completed project is FINAL. The source clip is deleted on successful completion, so you cannot re-render it " +
1546
+ "with a different overlay: for a variant (different classes/color) start a NEW project (omit slug, pass " +
1547
+ "video_path). Re-uploading even the identical clip charges a fresh 1-credit render (the dedup keys on the new " +
1548
+ "upload). Once the project " +
1549
+ "exists you can also poll/download it with get_status / download_result / wait_for_completion using " +
1550
+ 'kind:"tracking" and that slug.',
1551
+ inputSchema: {
1552
+ video_path: z
1553
+ .string()
1554
+ .optional()
1555
+ .describe(
1556
+ "Local path to the source video (mp4/mov/webm/mkv), inside HUBFLUENCER_INPUT_DIR or cwd. " +
1557
+ "Required on a fresh call; optional when resuming with slug (the clip is already uploaded).",
1558
+ ),
1559
+ slug: z
1560
+ .string()
1561
+ .optional()
1562
+ .describe(
1563
+ "Resume an existing tracking project (from a prior timeout/partial-failure payload): skips create " +
1564
+ "and reuses the in-flight upload (only re-uploads when no source exists yet or the prior upload failed). " +
1565
+ "Resuming a project whose render already COMPLETED just returns the finished video: a completed project " +
1566
+ "is final (its source clip was deleted on completion), so you cannot re-render it with a different overlay. " +
1567
+ "For a variant, start a fresh project (omit slug, pass video_path). Pass this back to continue an in-flight " +
1568
+ "project rather than forking a new one; omit it to start a fresh project.",
1569
+ ),
1570
+ classes: z
1571
+ .array(z.number().int().min(0).max(79))
1572
+ .optional()
1573
+ .describe(
1574
+ "COCO-80 class ids (0-79) to track (e.g. [0]=person, [16]=dog). Omit to track all subjects.",
1575
+ ),
1576
+ color: z
1577
+ .array(z.number().int().min(0).max(255))
1578
+ .min(3)
1579
+ .max(4)
1580
+ .optional()
1581
+ .describe(
1582
+ "Optional overlay color as RGB [r,g,b] or RGBA [r,g,b,a] (each 0–255). Omit for the default white overlay.",
1583
+ ),
1584
+ save_path: z
1585
+ .string()
1586
+ .optional()
1587
+ .describe(
1588
+ "Optional .mp4 path to download the finished video to (inside HUBFLUENCER_OUTPUT_DIR or cwd).",
1589
+ ),
1590
+ },
1591
+ annotations: {
1592
+ title: "Create tracking video",
1593
+ ...WRITE,
1594
+ idempotentHint: false,
1595
+ },
1596
+ },
1597
+ tool(
1598
+ async (
1599
+ args: {
1600
+ video_path?: string;
1601
+ slug?: string;
1602
+ classes?: number[];
1603
+ color?: number[];
1604
+ save_path?: string;
1605
+ },
1606
+ client,
1607
+ extra,
1608
+ ) => {
1609
+ // Validate the save_path up front so we fail before spending a credit.
1610
+ if (args.save_path) resolveSavePath(args.save_path);
1611
+
1612
+ // 1. Resolve the project: resume an existing slug, or create a fresh one
1613
+ // (0 credits). On a resume we first read the current project so the
1614
+ // orchestration plan can reason about its source/result state.
1615
+ const existing: TrackingData | null = args.slug
1616
+ ? await getTracking(client, args.slug)
1617
+ : null;
1618
+
1619
+ // Compute the whole orchestration plan — pure, side-effect-free (see
1620
+ // decideTrackingPlan in core.ts, which is where this branching is unit
1621
+ // tested). It yields the create/resume idempotency anchor, whether the
1622
+ // source is already live (do-NOT-reupload), whether to (re)submit a render,
1623
+ // and the safety-critical render key. On a FRESH call the anchor folds in a
1624
+ // per-invocation random nonce so two runs over the SAME local clip path
1625
+ // within the 24h idempotency window fork distinct projects instead of the
1626
+ // second replaying the first's cached 201 (then appending an upload+render
1627
+ // to the stale project). Mirrors start_autopilot's
1628
+ // idemKey("autopilot", slug, randomUUID()). The render key is derived from
1629
+ // the anchor PLUS a per-attempt/config discriminator, never the anchor
1630
+ // verbatim, or a retry-after-failure would replay the cached 202 forever
1631
+ // and never re-enqueue the worker.
1632
+ const plan = decideTrackingPlan(
1633
+ existing as TrackingSnapshot | null,
1634
+ args,
1635
+ randomUUID(),
1636
+ );
1637
+
1638
+ let slug: string;
1639
+ if (args.slug) {
1640
+ slug = args.slug;
1641
+ } else {
1642
+ if (!args.video_path) {
1643
+ return fail("video_path is required to start a new tracking video.");
1644
+ }
1645
+ // Validate the read path up front (ext allowlist, input-dir
1646
+ // confinement, exists/non-empty/size cap) BEFORE creating the project,
1647
+ // so a present-but-invalid path fails fast instead of orphaning an
1648
+ // empty draft. uploadVideoFile re-resolves the same path below.
1649
+ await resolveVideoReadPath(args.video_path);
1650
+ const created = await client.post<{ data: { slug: string } }>(
1651
+ "/tracking",
1652
+ {},
1653
+ plan.idemBase,
1654
+ );
1655
+ slug = created.data.slug;
1656
+ }
1657
+
1658
+ // Resume of an already-FINISHED render: the project is final. On a
1659
+ // successful completion the API deletes the source upload (it has served
1660
+ // its purpose — the burned-in output is the only artifact the user keeps;
1661
+ // tracking.ex delete_source_after_completion), so a completed resume carries
1662
+ // source:null. We must therefore return the finished video HERE, before the
1663
+ // source gate below — otherwise hasLiveSource is false and that gate wrongly
1664
+ // reports "source not uploaded" on a paid, completed project. A different
1665
+ // overlay is NOT a resume: combination_hash keys on the source's s3_key,
1666
+ // which is gone, so any re-upload (even of the identical clip) gets a fresh
1667
+ // key, misses the dedup, and charges a new render — so the honest guidance
1668
+ // is "start a new tracking project", never "resume with new classes/color".
1669
+ if (existing?.result?.status === "completed") {
1670
+ const videoUrl = existing.result.video_url ?? null;
1671
+ let saved_to: string | undefined;
1672
+ if (videoUrl && args.save_path) {
1673
+ saved_to = (await downloadTo(videoUrl, args.save_path)).saved_to;
1674
+ }
1675
+ const variantNote = plan.configSupplied
1676
+ ? " A different overlay needs a NEW tracking project (omit slug, pass video_path): the finished project's source clip was deleted on completion, so re-running can't re-render it, and re-uploading even the same clip charges a fresh 1-credit render."
1677
+ : "";
1678
+ return ok(
1679
+ {
1680
+ slug,
1681
+ kind: "tracking",
1682
+ status: "completed",
1683
+ terminal: true,
1684
+ video_url: videoUrl,
1685
+ ...(saved_to ? { saved_to } : {}),
1686
+ note: `Already completed — the project is final. Presigned ~24h URL, download promptly.${variantNote}`,
1687
+ },
1688
+ videoUrl ? [mp4Link(videoUrl, slug)] : [],
1689
+ );
1690
+ }
1691
+
1692
+ // 2. Upload the source clip — reuses the editor upload routes, which the
1693
+ // tracking factory is authorised for (single-PUT or resumable multipart).
1694
+ // Only (re)upload when there is NO usable source row: a fresh project has
1695
+ // none, and a prior upload that ended `failed` is dead. Crucially, do NOT
1696
+ // re-upload for pending/processing — that is exactly the state during the
1697
+ // waitForTrackingSource timeout window the resume note points the agent at,
1698
+ // and a second uploadVideoFile would fork a duplicate EditorUpload (orphan
1699
+ // the in-flight one, double-consume the per-user live-upload ceiling, and
1700
+ // let the render pick the wrong/latest source). EditorUpload statuses are
1701
+ // pending|processing|ready|failed; only `failed` is unusable, the rest are
1702
+ // progressing toward ready, so we just wait on them below.
1703
+ // The source is "live" (do NOT re-upload) when it exists and is not failed.
1704
+ // The API now surfaces pending/processing sources too, so an in-flight upload
1705
+ // is observed here and routed to wait-not-reupload rather than forking a
1706
+ // duplicate EditorUpload (which would orphan the in-flight one, double-consume
1707
+ // the per-user live-upload ceiling, and let the render pick the wrong source).
1708
+ if (!plan.hasLiveSource) {
1709
+ if (!args.video_path) {
1710
+ return fail(
1711
+ `Source for ${slug} is not uploaded yet (or the prior upload failed) and no ` +
1712
+ "video_path was given to (re)upload. Pass video_path to upload, or wait and " +
1713
+ "resume with the slug if an upload is already in flight.",
1714
+ );
1715
+ }
1716
+ await uploadVideoFile(client, slug, args.video_path, {
1717
+ onProgress: (completed, total, message) =>
1718
+ reportProgress(extra, completed, message, total),
1719
+ });
1720
+ }
1721
+
1722
+ // 3. Wait for the upload to finish processing before charging a render.
1723
+ // Three outcomes: ready (proceed), failed (the server-side processing
1724
+ // failed — re-running with the slug alone would just re-observe the same
1725
+ // dead source and loop, so tell the agent to re-run WITH video_path to
1726
+ // re-upload), or timeout (still in flight — resume on the slug to keep
1727
+ // waiting; no re-upload, no re-charge).
1728
+ const sourceState = await waitForTrackingSource(client, slug, extra);
1729
+ if (sourceState === "failed") {
1730
+ return ok({
1731
+ slug,
1732
+ kind: "tracking",
1733
+ status: "failed",
1734
+ terminal: true,
1735
+ note: `Source upload failed for ${slug} — re-run create_tracking_video with slug:"${slug}" and video_path to re-upload the clip.`,
1736
+ });
1737
+ }
1738
+ if (sourceState === "timeout") {
1739
+ return ok({
1740
+ slug,
1741
+ kind: "tracking",
1742
+ status: "processing",
1743
+ terminal: false,
1744
+ note: `Source still processing — call create_tracking_video again with slug:"${slug}" to resume (no re-upload, no re-charge).`,
1745
+ });
1746
+ }
1747
+
1748
+ // 4. Render (1 credit). Decide whether to (re)submit the render POST:
1749
+ // - no result yet, or the last one failed+refunded → render.
1750
+ // - a result is in flight (pending/processing) → never re-render
1751
+ // mid-flight (a poll-timeout resume); the server's 409 guard backs
1752
+ // this up anyway. Fall through to polling.
1753
+ // - a `completed` result already returned above (the source is deleted on
1754
+ // completion, so a completed project is final; a different overlay must
1755
+ // be a brand-new tracking project, never a resume with new config).
1756
+ // A completed result already short-circuited above, so reaching here means
1757
+ // the result is null / failed / in-flight — plan.shouldRender is true only
1758
+ // for "no result yet" or "failed+refunded" (a re-render of a completed
1759
+ // project is impossible now the source is deleted), and an in-flight render
1760
+ // is left to the poll below (the server's 409 guard backs this up).
1761
+ if (plan.shouldRender) {
1762
+ const body: Record<string, unknown> = {};
1763
+ if (args.classes !== undefined) body.classes = args.classes;
1764
+ if (args.color !== undefined) body.color = args.color;
1765
+
1766
+ // plan.renderKey is the anti-replay-of-a-dead-202 key (derived in
1767
+ // decideTrackingPlan, unit-tested in core.test.ts): a transport retry of
1768
+ // THIS attempt replays the cached 202, but a retry AFTER a fail+refund
1769
+ // mints a fresh key (the per-attempt discriminator diverges), so the
1770
+ // worker re-enqueues instead of replaying the dead 202 forever.
1771
+ try {
1772
+ await client.post(`/tracking/${slug}/render`, body, plan.renderKey);
1773
+ } catch (e) {
1774
+ // A 409 (tracking_render_in_progress) means a render is already
1775
+ // active — a race with another caller (e.g. the front-end) or a
1776
+ // resume where `existing.result` was momentarily null at poll time.
1777
+ // The render is fine; fall through to polling instead of surfacing a
1778
+ // hard error. Mirrors generate_short's 409 handling.
1779
+ if ((e as { status?: number }).status !== 409) throw e;
1780
+ }
1781
+ }
1782
+
1783
+ // 5. Poll to completion and (optionally) download.
1784
+ const final = await pollTrackingComplete(client, slug, extra);
1785
+ const status = final.result?.status ?? final.status;
1786
+ const videoUrl = final.result?.video_url ?? null;
1787
+ const terminal = status === "completed" || status === "failed";
1788
+ let saved_to: string | undefined;
1789
+ if (videoUrl && args.save_path) {
1790
+ saved_to = (await downloadTo(videoUrl, args.save_path)).saved_to;
1791
+ }
1792
+
1793
+ // Don't overstate success: a finished poll only means the result reached a
1794
+ // terminal state OR the budget ran out. A still-running render may yet
1795
+ // fail+refund server-side, so point the agent back at the slug either way.
1796
+ const note =
1797
+ status === "completed"
1798
+ ? "Done. Presigned ~24h URL — download promptly."
1799
+ : status === "failed"
1800
+ ? "Render failed — the 1 credit was refunded. Retry with the same slug."
1801
+ : `Still rendering — resume with create_tracking_video({ slug: "${slug}"${
1802
+ args.save_path ? `, save_path: "${args.save_path}"` : ""
1803
+ } }), or poll wait_for_completion({ slug: "${slug}", kind: "tracking" }). It may still fail+refund.`;
1804
+
1805
+ return ok(
1806
+ {
1807
+ slug,
1808
+ kind: "tracking",
1809
+ status,
1810
+ terminal,
1811
+ video_url: videoUrl,
1812
+ ...(saved_to ? { saved_to } : {}),
1813
+ note,
1814
+ },
1815
+ videoUrl ? [mp4Link(videoUrl, slug)] : [],
1816
+ );
1817
+ },
1818
+ ),
1819
+ );
1820
+
1203
1821
  registerTool(
1204
1822
  "generate_short",
1205
1823
  {
@@ -1209,7 +1827,10 @@ registerTool(
1209
1827
  "duplicate while a render is in flight is reported as in-progress (no double charge), and a failed " +
1210
1828
  "short can be re-generated. Then poll with get_status or wait_for_completion (kind=short).",
1211
1829
  inputSchema: { slug: z.string().describe("Short slug from create_short") },
1212
- annotations: { title: "Generate short", ...WRITE, idempotentHint: true },
1830
+ // Not idempotent: a repeat after a terminal (failed) render re-charges 15
1831
+ // credits (the whole point of allowing a re-generation), so a blind retry is
1832
+ // NOT free — only an in-flight duplicate dedupes (server 409). See below.
1833
+ annotations: { title: "Generate short", ...WRITE },
1213
1834
  },
1214
1835
  tool(async (args: { slug: string }, client) => {
1215
1836
  // No stable idempotency key (see generate_slider): a per-slug key would
@@ -1329,6 +1950,7 @@ registerTool(
1329
1950
  "Vertical placement of the on-image copy across all slides. Omit for the template's natural placement.",
1330
1951
  ),
1331
1952
  },
1953
+ outputSchema: createDraftOutput,
1332
1954
  annotations: { title: "Create slider", ...WRITE, idempotentHint: true },
1333
1955
  },
1334
1956
  tool(
@@ -1368,12 +1990,7 @@ registerTool(
1368
1990
  const res = await client.post<{ data: { slug: string } }>(
1369
1991
  "/sliders",
1370
1992
  body,
1371
- idemKey(
1372
- "create-slider",
1373
- args.prompt,
1374
- args.mode ?? "",
1375
- args.template ?? "",
1376
- ),
1993
+ sliderCreateKey(args),
1377
1994
  );
1378
1995
  return ok({
1379
1996
  slug: res.data.slug,
@@ -1396,7 +2013,10 @@ registerTool(
1396
2013
  inputSchema: {
1397
2014
  slug: z.string().describe("Slider slug from create_slider"),
1398
2015
  },
1399
- annotations: { title: "Generate slider", ...WRITE, idempotentHint: true },
2016
+ // Not idempotent: a repeat after a terminal render re-charges 1 credit/slide
2017
+ // (a failed carousel can be re-generated), so a blind retry is not free —
2018
+ // only an in-flight duplicate dedupes (server 409). See below.
2019
+ annotations: { title: "Generate slider", ...WRITE },
1400
2020
  },
1401
2021
  tool(async (args: { slug: string }, client) => {
1402
2022
  // No stable idempotency key on purpose: a per-slug key would replay the
@@ -1433,6 +2053,7 @@ registerTool(
1433
2053
  "completed | failed. completed:true means every slide image_url is ready to save. Once completed you " +
1434
2054
  "can restyle_slider (new template/accent) or edit_slider_slide (one slide's text) for free.",
1435
2055
  inputSchema: { slug: z.string().describe("Slider slug") },
2056
+ outputSchema: getSliderOutput,
1436
2057
  annotations: { title: "Get slider", ...RO },
1437
2058
  },
1438
2059
  tool(async (args: { slug: string }, client) => {
@@ -1541,8 +2162,10 @@ registerTool(
1541
2162
  next: "poll get_slider until status is 'completed' (free re-composite, 0 credits)",
1542
2163
  });
1543
2164
  } catch (e) {
1544
- // 409 = carousel not completed yet, or a re-composite already running.
1545
- if ((e as { status?: number }).status === 409) {
2165
+ // Only the active free re-composite race is idempotent from the MCP
2166
+ // client's perspective. A 409 saying the slider must be completed is an
2167
+ // actionable user error and must surface as one.
2168
+ if (isRecompositeInProgressConflict(e)) {
1546
2169
  return ok({ slug: args.slug, kind: "slider", status: "processing" });
1547
2170
  }
1548
2171
  throw e;
@@ -1615,8 +2238,10 @@ registerTool(
1615
2238
  next: "poll get_slider until the slide's status is 'completed' (free re-composite, 0 credits)",
1616
2239
  });
1617
2240
  } catch (e) {
1618
- // 409 = carousel not completed yet, or a re-composite already running.
1619
- if ((e as { status?: number }).status === 409) {
2241
+ // Only the active free re-composite race is idempotent from the MCP
2242
+ // client's perspective. A 409 saying the slider must be completed is an
2243
+ // actionable user error and must surface as one.
2244
+ if (isRecompositeInProgressConflict(e)) {
1620
2245
  return ok({
1621
2246
  slug: args.slug,
1622
2247
  kind: "slider",
@@ -1650,10 +2275,29 @@ registerTool(
1650
2275
  .string()
1651
2276
  .min(10)
1652
2277
  .describe("Brief for the ad (min 10 chars)"),
2278
+ product_subject: z
2279
+ .string()
2280
+ .max(300)
2281
+ .optional()
2282
+ .describe(
2283
+ 'The concrete product or main subject the video must be about (e.g. "Bordeaux red wine, dark green ' +
2284
+ 'bottle with a gold label"). Distinct from the freeform brief: hard-grounds the AI scenario + narration ' +
2285
+ "so it cannot invent an unrelated product. Strongly recommended for ads.",
2286
+ ),
2287
+ project_intent: z
2288
+ .enum(["social_ad", "creative_story"])
2289
+ .optional()
2290
+ .describe(
2291
+ "social_ad for promotional/ad content (product focus + CTA), creative_story for brand narratives " +
2292
+ "(default).",
2293
+ ),
1653
2294
  language: z
1654
2295
  .string()
1655
2296
  .optional()
1656
- .describe('Language code, e.g. "en" (default)'),
2297
+ .describe(
2298
+ 'Language code, e.g. "en" (default), "fr", "es". Drives the WHOLE video: scenario, narration script, ' +
2299
+ "narration voice, and captions. Ask the user / infer from their locale.",
2300
+ ),
1657
2301
  creative_format: z
1658
2302
  .enum(CREATIVE_FORMATS)
1659
2303
  .optional()
@@ -1681,42 +2325,64 @@ registerTool(
1681
2325
  .optional()
1682
2326
  .describe('Aspect ratio (default "9:16")'),
1683
2327
  },
2328
+ outputSchema: createEditorOutput,
1684
2329
  annotations: { title: "Create editor ad", ...WRITE, idempotentHint: true },
1685
2330
  },
1686
- tool(async (args: Record<string, unknown>, client) => {
1687
- const created = await client.post<{ data: { slug: string } }>(
1688
- "/editor",
1689
- {
1690
- language: args.language ?? "en",
1691
- product_prompt: args.product_prompt,
1692
- creative_format: args.creative_format,
1693
- visual_language: args.visual_language,
1694
- theme: args.theme,
1695
- voice_id: args.voice_id,
1696
- export_aspect_ratio: args.export_aspect_ratio,
2331
+ tool(
2332
+ async (
2333
+ args: {
2334
+ product_prompt: string;
2335
+ product_subject?: string;
2336
+ project_intent?: string;
2337
+ language?: string;
2338
+ creative_format?: string;
2339
+ visual_language?: string;
2340
+ theme?: string;
2341
+ voice_id?: string;
2342
+ export_aspect_ratio?: string;
1697
2343
  },
1698
- // Fold every create-affecting param into the key so two calls that
1699
- // differ only by style/theme/voice/aspect get distinct drafts.
1700
- idemKey(
1701
- "create-editor",
1702
- String(args.product_prompt ?? ""),
1703
- String(args.language ?? "en"),
1704
- String(args.creative_format ?? ""),
1705
- String(args.visual_language ?? ""),
1706
- String(args.theme ?? ""),
1707
- String(args.voice_id ?? ""),
1708
- String(args.export_aspect_ratio ?? ""),
1709
- ),
1710
- );
1711
- const slug = created.data.slug;
1712
- const started = await client.post<{ data: unknown }>(
1713
- `/editor/${slug}/autopilot`,
1714
- undefined,
1715
- `autopilot:${slug}`,
1716
- );
1717
- const status = normalizeStatus("editor", slug, asRecord(started).data);
1718
- return ok({ slug, kind: "editor", status, next: "wait_for_completion" });
1719
- }),
2344
+ client,
2345
+ ) => {
2346
+ const langErr = validateLanguage(args.language);
2347
+ if (langErr) return fail(langErr);
2348
+ const promptErr = validateProductPrompt(args.product_prompt);
2349
+ if (promptErr) return fail(promptErr);
2350
+
2351
+ const created = await client.post<{ data: { slug: string } }>(
2352
+ "/editor",
2353
+ {
2354
+ language: args.language ?? "en",
2355
+ product_prompt: args.product_prompt,
2356
+ product_subject: args.product_subject,
2357
+ project_intent: args.project_intent,
2358
+ creative_format: args.creative_format,
2359
+ visual_language: args.visual_language,
2360
+ theme: args.theme,
2361
+ voice_id: args.voice_id,
2362
+ export_aspect_ratio: args.export_aspect_ratio,
2363
+ },
2364
+ // Fold every create-affecting param into the key so two calls that
2365
+ // differ only by style/theme/voice/aspect get distinct drafts.
2366
+ // Composition lives in core.ts, shared with the golden-pin test.
2367
+ editorAdCreateKey(args),
2368
+ );
2369
+ const slug = created.data.slug;
2370
+ const started = await client.post<{ data: unknown }>(
2371
+ `/editor/${slug}/autopilot`,
2372
+ undefined,
2373
+ // Fold the create-affecting params into the start key so it tracks
2374
+ // inputs, not just the slug. Self-consistent per-tool only: this key
2375
+ // is compared against retries of *this* tool on *this* slug. The
2376
+ // field set/order differs from make_video / start_autopilot on
2377
+ // purpose (server cache is path+key scoped, slug is unique per
2378
+ // draft), so the keys are not interchangeable across tools.
2379
+ // Composition lives in core.ts, shared with the golden-pin test.
2380
+ editorAdAutopilotKey(slug, args),
2381
+ );
2382
+ const status = normalizeStatus("editor", slug, asRecord(started).data);
2383
+ return ok({ slug, kind: "editor", status, next: "wait_for_completion" });
2384
+ },
2385
+ ),
1720
2386
  );
1721
2387
 
1722
2388
  registerTool(
@@ -1726,31 +2392,83 @@ registerTool(
1726
2392
  description:
1727
2393
  "Starts server-orchestrated autopilot (scenario → segments → narration → voice → music → render) on an " +
1728
2394
  "EXISTING editor project/draft. Spends credits — preview the estimate first with the autopilot/cost endpoint " +
1729
- "or make_video({ dry_run:true }). Idempotent per slug (safe to retry). Use this to run a draft created by " +
1730
- "make_video dry_run, or to resume after topping up / raising your spend cap. Then poll with " +
2395
+ "or make_video({ dry_run:true }). Each call is a genuine start attempt (like tapping Launch in the app): " +
2396
+ "if a run is already in progress for this slug the server returns already_running it never double-starts " +
2397
+ "or double-charges. Once finished, failed, or cancelled, calling again relaunches it (resume after a top-up, raising your spend cap, or a mid-pipeline failure). Then poll with " +
1731
2398
  'wait_for_completion({ slug, kind: "editor" }).',
1732
- inputSchema: { slug: z.string().describe("Editor project slug") },
1733
- annotations: { title: "Start autopilot", ...WRITE, idempotentHint: true },
2399
+ inputSchema: {
2400
+ slug: z.string().describe("Editor project slug"),
2401
+ language: z
2402
+ .string()
2403
+ .optional()
2404
+ .describe(
2405
+ 'Optional language override applied before launch (ISO 639-1, e.g. "fr"). Drives scenario, ' +
2406
+ "narration, voice, and captions.",
2407
+ ),
2408
+ product_subject: z
2409
+ .string()
2410
+ .max(300)
2411
+ .optional()
2412
+ .describe(
2413
+ "Optional locked product / main subject applied before launch so the AI cannot invent an " +
2414
+ "unrelated product.",
2415
+ ),
2416
+ product_prompt: z
2417
+ .string()
2418
+ .optional()
2419
+ .describe("Optional freeform brief to set/replace before launch."),
2420
+ },
2421
+ annotations: { title: "Start autopilot", ...WRITE, idempotentHint: false },
1734
2422
  },
1735
- tool(async (args: { slug: string }, client) => {
1736
- const started = await client.post<{ data: unknown }>(
1737
- `/editor/${args.slug}/autopilot`,
1738
- undefined,
1739
- `autopilot:${args.slug}`,
1740
- );
1741
- const status = normalizeStatus("editor", args.slug, asRecord(started).data);
1742
- return ok({
1743
- slug: args.slug,
1744
- kind: "editor",
1745
- status,
1746
- next: "wait_for_completion",
1747
- });
1748
- }),
1749
- );
2423
+ tool(
2424
+ async (
2425
+ args: {
2426
+ slug: string;
2427
+ language?: string;
2428
+ product_subject?: string;
2429
+ product_prompt?: string;
2430
+ },
2431
+ client,
2432
+ ) => {
2433
+ const langErr = validateLanguage(args.language);
2434
+ if (langErr) return fail(langErr);
2435
+ const promptErr = validateProductPrompt(args.product_prompt);
2436
+ if (promptErr) return fail(promptErr);
1750
2437
 
1751
- // ── AI assists (free daily quota) ───────────────────────────────────────────
2438
+ const slug = args.slug;
2439
+ const overrides: Record<string, unknown> = {};
2440
+ if (args.language !== undefined) overrides.language = args.language;
2441
+ if (args.product_subject !== undefined)
2442
+ overrides.product_subject = args.product_subject;
2443
+ if (args.product_prompt !== undefined)
2444
+ overrides.product_prompt = args.product_prompt;
1752
2445
 
1753
- registerTool(
2446
+ const started = await client.post<{ data: unknown }>(
2447
+ `/editor/${slug}/autopilot`,
2448
+ Object.keys(overrides).length > 0 ? overrides : undefined,
2449
+ // Each call is a genuine new start attempt (matching the in-app Launch
2450
+ // button). A per-invocation nonce makes the idempotency key unique so the
2451
+ // server's 24h idempotency cache can't replay a PRIOR start's response on
2452
+ // a deliberate relaunch (resume after a failure / top-up). Transport-level
2453
+ // retries of THIS call reuse this one computed key, so an accidental
2454
+ // double-send is still deduped; a genuine double-start is independently
2455
+ // prevented server-side by the already-running gate.
2456
+ idemKey("autopilot", slug, randomUUID()),
2457
+ );
2458
+ const status = normalizeStatus("editor", slug, asRecord(started).data);
2459
+ return ok({
2460
+ slug,
2461
+ kind: "editor",
2462
+ status,
2463
+ next: "wait_for_completion",
2464
+ });
2465
+ },
2466
+ ),
2467
+ );
2468
+
2469
+ // ── AI assists (free daily quota) ───────────────────────────────────────────
2470
+
2471
+ registerTool(
1754
2472
  "get_ai_assists",
1755
2473
  {
1756
2474
  title: "Get AI assist quota",
@@ -1818,12 +2536,22 @@ registerTool(
1818
2536
  .max(5000)
1819
2537
  .optional()
1820
2538
  .describe("Brief for the ad — 10–5000 chars, or omit entirely"),
2539
+ product_subject: z
2540
+ .string()
2541
+ .max(300)
2542
+ .optional()
2543
+ .describe(
2544
+ "The concrete product / main subject the video must be about. Hard-grounds the AI so it cannot " +
2545
+ "invent an unrelated product. Recommended for ads.",
2546
+ ),
1821
2547
  language: z
1822
2548
  .string()
1823
2549
  .min(2)
1824
2550
  .max(10)
1825
2551
  .optional()
1826
- .describe('Language code, e.g. "en" (default)'),
2552
+ .describe(
2553
+ 'Language code, e.g. "en" (default). Drives scenario, narration, voice, and captions.',
2554
+ ),
1827
2555
  creative_format: z
1828
2556
  .enum(CREATIVE_FORMATS)
1829
2557
  .optional()
@@ -1855,8 +2583,12 @@ registerTool(
1855
2583
  project_intent: z
1856
2584
  .enum(["social_ad", "creative_story"])
1857
2585
  .optional()
1858
- .describe("Project intent (optional)"),
2586
+ .describe(
2587
+ "social_ad for promotional/ad content (product focus + CTA), creative_story for brand " +
2588
+ "narratives (default).",
2589
+ ),
1859
2590
  },
2591
+ outputSchema: createEditorOutput,
1860
2592
  annotations: {
1861
2593
  title: "Create editor draft",
1862
2594
  ...WRITE,
@@ -1867,6 +2599,7 @@ registerTool(
1867
2599
  async (
1868
2600
  args: {
1869
2601
  product_prompt?: string;
2602
+ product_subject?: string;
1870
2603
  language?: string;
1871
2604
  creative_format?: string;
1872
2605
  visual_language?: string;
@@ -1880,6 +2613,7 @@ registerTool(
1880
2613
  const prompt = args.product_prompt?.trim();
1881
2614
  const body: Record<string, unknown> = {
1882
2615
  language: args.language ?? "en",
2616
+ product_subject: args.product_subject,
1883
2617
  creative_format: args.creative_format,
1884
2618
  visual_language: args.visual_language,
1885
2619
  theme: args.theme,
@@ -1891,13 +2625,20 @@ registerTool(
1891
2625
  const created = await client.post<{ data: { slug: string } }>(
1892
2626
  "/editor",
1893
2627
  body,
2628
+ // Fold every create-affecting param into the key so two calls that
2629
+ // differ only by intent/style/theme/voice/aspect get distinct
2630
+ // drafts; a transport retry of an identical call still reuses one.
1894
2631
  idemKey(
1895
2632
  "create-editor-draft",
1896
2633
  prompt ?? "",
2634
+ args.product_subject ?? "",
2635
+ args.project_intent ?? "",
1897
2636
  args.language ?? "en",
1898
2637
  args.creative_format ?? "",
1899
2638
  args.visual_language ?? "",
1900
2639
  args.theme ?? "",
2640
+ args.voice_id ?? "",
2641
+ args.export_aspect_ratio ?? "",
1901
2642
  ),
1902
2643
  );
1903
2644
  return ok({
@@ -2039,7 +2780,7 @@ registerTool(
2039
2780
  title: "Set the number of scenes",
2040
2781
  description:
2041
2782
  "Adjusts the editor to exactly `count` scenes (1–20) by adding pending segments to grow, or deleting " +
2042
- "only TRAILING un-generated (pending) segments to shrink. Never deletes completed/processing segments — " +
2783
+ "only TRAILING un-generated (pending or failed) segments to shrink. Never deletes completed/processing segments — " +
2043
2784
  "if a shrink would require that, it stops and reports. Free (no credits, no assist). Each AI-generated " +
2044
2785
  "scene is a fixed 8 seconds, so `count` scenes ≈ count×8s of AI footage (uploaded clips keep their own length).",
2045
2786
  inputSchema: {
@@ -2063,15 +2804,22 @@ registerTool(
2063
2804
  const target = args.count;
2064
2805
 
2065
2806
  if (target > current) {
2807
+ // Salt the add-segment idempotency keys with ONE fresh nonce per call so a
2808
+ // grow→shrink→grow on this draft inside the server's 24h dedupe window
2809
+ // doesn't replay an earlier round's cached 201s (which would add nothing).
2810
+ // Safe: the tool re-derives `current` from live state every call, so a
2811
+ // whole-tool retry with the same target still adds nothing (target ≤ current).
2812
+ const growNonce = randomUUID();
2066
2813
  for (let i = current; i < target; i++) {
2067
2814
  await client.post(
2068
2815
  `/editor/${args.slug}/segments`,
2069
2816
  {},
2070
- idemKey("add-segment", args.slug, String(i)),
2817
+ addSegmentKey(args.slug, i, growNonce),
2071
2818
  );
2072
2819
  }
2073
2820
  } else if (target < current) {
2074
- // Delete only trailing pending segments (never completed/processing).
2821
+ // Delete only trailing un-generated segments pending/draft/unset OR a
2822
+ // previously-failed scene (never completed/processing).
2075
2823
  const isPending = (s: Record<string, unknown>) => {
2076
2824
  const st = (s.status as string) ?? (s.generation_status as string);
2077
2825
  return (
@@ -2088,8 +2836,8 @@ registerTool(
2088
2836
  }
2089
2837
  if (current - removable > target) {
2090
2838
  return fail(
2091
- `Cannot shrink to ${target} scenes: only ${removable} trailing pending segment(s) are safe to ` +
2092
- `delete (the rest are completed/processing). Currently ${current} scenes.`,
2839
+ `Cannot shrink to ${target} scenes: only ${removable} trailing un-generated (pending/failed) segment(s) are safe ` +
2840
+ `to delete (the rest are completed/processing). Currently ${current} scenes.`,
2093
2841
  );
2094
2842
  }
2095
2843
  for (let i = segments.length - 1; i >= target; i--) {
@@ -2188,7 +2936,9 @@ registerTool(
2188
2936
  "the next (preserves visual continuity). Each AI-generated scene is a fixed 8 seconds, so N scenes ≈ N×8s of footage. Costs 5 credits per segment generated. Stops and reports what " +
2189
2937
  "completed if a scene errors on submit (e.g. 402 credits_insufficient) or fails while rendering; if a " +
2190
2938
  "scene is still rendering past the budget it returns timed_out so you can re-run to continue (completed " +
2191
- "scenes are skipped). Tip: to generate everything at lowest cost, skip this and just call render it auto-generates any ungenerated scenes at the batch rate (4 credits/scene for ≥3); use this tool when you want to review or stop per scene. May take several minutes.",
2939
+ "scenes are skipped). The whole call is capped at ~280s: on a project with many scenes it processes as " +
2940
+ "many as fit, returns timed_out, and you re-run to continue (it picks up the remaining pending scenes). " +
2941
+ "Tip: to generate everything at lowest cost, skip this and just call render — it auto-generates any ungenerated scenes at the batch rate (4 credits/scene for ≥3); use this tool when you want to review or stop per scene. May take several minutes.",
2192
2942
  inputSchema: { slug: z.string().describe("Editor project slug") },
2193
2943
  annotations: {
2194
2944
  title: "Generate all segments",
@@ -2212,12 +2962,34 @@ registerTool(
2212
2962
  );
2213
2963
  });
2214
2964
 
2965
+ // Overall wall-clock budget: cap the whole call at the same 280s ceiling the
2966
+ // other wait loops use (under a typical 300s client timeout). Without it a
2967
+ // project with many pending scenes could run unbounded (~8 min/scene × 20 ≈
2968
+ // hours) in a single call. When the budget runs out mid-batch we stop and
2969
+ // return the standard resume contract — call again and it naturally continues
2970
+ // with whatever scenes are still pending (completed ones are skipped).
2971
+ const overallDeadline = Date.now() + 280_000;
2972
+ // Each scene's own poll budget stays generous (a Veo scene can take minutes)
2973
+ // but is clamped to the time left overall so one scene can't overrun 280s.
2974
+ const PER_SEGMENT_MAX_MS = 6 * 60 * 1000;
2975
+
2215
2976
  const generated: Array<string | number> = [];
2216
2977
  let n = 0;
2217
2978
  for (const seg of pending) {
2218
2979
  const id = seg.id;
2219
2980
  if (id === undefined) continue;
2220
2981
  const sid = id as string | number;
2982
+ // Stop before submitting another scene once the overall budget is spent, so
2983
+ // the call returns instead of charging + waiting past the deadline.
2984
+ const remainingMs = overallDeadline - Date.now();
2985
+ if (remainingMs <= 0) {
2986
+ return ok({
2987
+ slug: args.slug,
2988
+ generated,
2989
+ timed_out: true,
2990
+ note: "Wall-clock budget reached — stopped before the next scene. Re-run generate_all_segments to continue (it retries failed/pending scenes and skips completed ones).",
2991
+ });
2992
+ }
2221
2993
  await reportProgress(
2222
2994
  extra,
2223
2995
  ++n,
@@ -2242,12 +3014,13 @@ registerTool(
2242
3014
  // Gate on THIS segment finishing before starting the next, so the next
2243
3015
  // scene can build on this one's last frame (visual continuity). Poll the
2244
3016
  // segment itself — the project-level status never goes terminal mid-batch.
3017
+ // Clamp its budget to the time left overall so the whole call honours 280s.
2245
3018
  const seg_result = await pollSegmentToTerminal(
2246
3019
  client,
2247
3020
  args.slug,
2248
3021
  sid,
2249
3022
  extra,
2250
- 6 * 60 * 1000,
3023
+ Math.min(PER_SEGMENT_MAX_MS, remainingMs),
2251
3024
  15000,
2252
3025
  );
2253
3026
  if (seg_result.status === "failed") {
@@ -2707,6 +3480,8 @@ registerTool(
2707
3480
  args.file_path,
2708
3481
  {
2709
3482
  fitMode: args.fit_mode,
3483
+ onProgress: (completed, total, message) =>
3484
+ reportProgress(extra, completed, message, total),
2710
3485
  },
2711
3486
  );
2712
3487
  await reportProgress(
@@ -3140,9 +3915,12 @@ registerTool(
3140
3915
  {
3141
3916
  title: "Get generation status",
3142
3917
  description:
3143
- "Returns a normalized status {stage, terminal, ready, video_url, error} for a short or editor " +
3144
- "project. ready:true means the post-ready MP4 is at video_url.",
3918
+ "Returns a normalized status {stage, terminal, ready, video_url, error} for a short, editor, " +
3919
+ "tracking, or slider project. ready:true means the post-ready MP4 is at video_url — except for a " +
3920
+ "slider (a carousel of stills, not a video): its video_url is always null, ready:true means every " +
3921
+ "slide is composited, and you read the per-slide image URLs with get_slider.",
3145
3922
  inputSchema: { slug: z.string(), kind: kindSchema },
3923
+ outputSchema: getStatusOutput,
3146
3924
  annotations: { title: "Get status", ...RO },
3147
3925
  },
3148
3926
  tool(async (args: { slug: string; kind: Kind }, client) => {
@@ -3158,7 +3936,9 @@ registerTool(
3158
3936
  description:
3159
3937
  "Polls status (emitting progress) until terminal (ready/failed) or the wait budget is exhausted. " +
3160
3938
  "Generation can take several minutes; if it returns terminal=false, call again to keep waiting. " +
3161
- "Pass save_path to download the finished MP4 when it's ready (e.g. to resume a make_video that timed out mid-render).",
3939
+ "Pass save_path to download the finished MP4 when it's ready (e.g. to resume a make_video that timed out mid-render). " +
3940
+ 'Works for kind:"slider" too, but a carousel has no MP4 — it just goes terminal when composited; read the slide ' +
3941
+ "images with get_slider (save_path is ignored for sliders).",
3162
3942
  inputSchema: {
3163
3943
  slug: z.string(),
3164
3944
  kind: kindSchema,
@@ -3183,7 +3963,14 @@ registerTool(
3183
3963
  "Optional .mp4 path to download to when ready (confined to HUBFLUENCER_OUTPUT_DIR or cwd)",
3184
3964
  ),
3185
3965
  },
3186
- annotations: { title: "Wait for completion", ...RO },
3966
+ outputSchema: waitForCompletionOutput,
3967
+ // Not readOnly: when save_path is set this writes the finished MP4 to disk,
3968
+ // so a client that auto-approves readOnly tools must not treat it as one.
3969
+ annotations: {
3970
+ title: "Wait for completion",
3971
+ ...WRITE,
3972
+ idempotentHint: true,
3973
+ },
3187
3974
  },
3188
3975
  tool(
3189
3976
  async (
@@ -3232,7 +4019,8 @@ registerTool(
3232
4019
  title: "Get / download the finished video",
3233
4020
  description:
3234
4021
  "Returns the post-ready MP4 URL for a completed project (presigned, ~24h TTL — use promptly). " +
3235
- "If save_path is given (confined to HUBFLUENCER_OUTPUT_DIR or cwd), the file is downloaded there.",
4022
+ "If save_path is given (confined to HUBFLUENCER_OUTPUT_DIR or cwd), the file is downloaded there. " +
4023
+ "Not for sliders — a carousel has no MP4; use get_slider for its slide images.",
3236
4024
  inputSchema: {
3237
4025
  slug: z.string(),
3238
4026
  kind: kindSchema,
@@ -3241,10 +4029,21 @@ registerTool(
3241
4029
  .optional()
3242
4030
  .describe("Optional .mp4 path inside the output base"),
3243
4031
  },
3244
- annotations: { title: "Download result", ...RO },
4032
+ // Not readOnly: with save_path it writes the MP4 to disk. Reading the URL is
4033
+ // side-effect-free but the tool as a whole can write, so it isn't readOnly.
4034
+ outputSchema: downloadResultOutput,
4035
+ annotations: { title: "Download result", ...WRITE, idempotentHint: true },
3245
4036
  },
3246
4037
  tool(
3247
4038
  async (args: { slug: string; kind: Kind; save_path?: string }, client) => {
4039
+ // A slider has no MP4 deliverable — steer the agent to get_slider instead
4040
+ // of the misleading "not ready" path (a completed slider IS ready, but its
4041
+ // video_url is null by design).
4042
+ if (args.kind === "slider") {
4043
+ return fail(
4044
+ "A slider (carousel) has no MP4 to download. Use get_slider to read the per-slide image URLs.",
4045
+ );
4046
+ }
3248
4047
  const status = await fetchStatus(client, args.kind, args.slug);
3249
4048
  if (!status.ready || !status.video_url) {
3250
4049
  return fail(
@@ -3270,6 +4069,2111 @@ registerTool(
3270
4069
  ),
3271
4070
  );
3272
4071
 
4072
+ // ── Campaign engine: product sources ─────────────────────────────────────────
4073
+ //
4074
+ // The "delegate my social content" surface. An agent ingests a product page,
4075
+ // persists a durable product/brand identity, plans a campaign, and creates a
4076
+ // pack full of editable DRAFTS — all at $0 (the planning tools meter the FREE
4077
+ // daily AI-assist quota; materialize creates 0-credit drafts). Generation/render
4078
+ // stays a SEPARATE, explicit, PAID step (autopilot/generate per draft).
4079
+ //
4080
+ // GROUND TRUTH: none of the campaign controllers mount the Idempotency plug, so
4081
+ // these tools send NO Idempotency-Key and their idempotentHint reflects the
4082
+ // server's real behavior (creates fork on retry; materialize is idempotent via a
4083
+ // server-side row lock; extract is stateless).
4084
+
4085
+ registerTool(
4086
+ "extract_product_page",
4087
+ {
4088
+ title: "Extract product facts from a URL",
4089
+ description:
4090
+ "Fetches a public product page SERVER-SIDE (SSRF-guarded, HTML-only, no redirects to internal ranges) " +
4091
+ "and returns parsed product facts: name, description, benefits, price, brand, plus candidate product " +
4092
+ "image URLs and a site/brand logo URL (URLs only — images are NOT downloaded here). Stateless: writes " +
4093
+ "nothing and spends 0 credits. Scope: video:generate. Rate limit: 30/min. " +
4094
+ "Next: create_product_profile from these facts, then import_product_image to pull the primary image + logo " +
4095
+ "into the profile — or just call create_campaign to do all of that end to end.",
4096
+ inputSchema: {
4097
+ url: z
4098
+ .string()
4099
+ .max(2048)
4100
+ .describe("Absolute http(s) URL of a product page (≤2048 chars)"),
4101
+ },
4102
+ annotations: {
4103
+ title: "Extract product page",
4104
+ ...WRITE,
4105
+ // Stateless (no DB write, no credit) → replaying the same URL is safe.
4106
+ idempotentHint: true,
4107
+ },
4108
+ },
4109
+ tool(async (args: { url: string }, client) => {
4110
+ const url = args.url?.trim();
4111
+ if (!url) return fail("url is required.");
4112
+ const res = await client.post<{ data: unknown }>(
4113
+ "/product-sources/extract",
4114
+ { url },
4115
+ );
4116
+ return ok(asRecord(res).data ?? res);
4117
+ }),
4118
+ );
4119
+
4120
+ registerTool(
4121
+ "import_product_image",
4122
+ {
4123
+ title: "Import a product image URL into a profile",
4124
+ description:
4125
+ "Fetches an image at a public URL SERVER-SIDE (SSRF-guarded, JPEG/PNG only — verified by magic bytes, not " +
4126
+ "just the Content-Type — size-capped) and stores it as a ready catalog asset, linking it to a product " +
4127
+ "profile. link_as 'primary_image' (default) sets the profile's product image; 'logo' sets its logo. " +
4128
+ "Spends 0 credits. Scope: video:generate. Rate limit: 30/min. Use the image/logo URLs from " +
4129
+ "extract_product_page. 404 if the product profile isn't yours; 422 if the URL isn't a safe JPG/PNG.",
4130
+ inputSchema: {
4131
+ product_profile_id: z
4132
+ .number()
4133
+ .int()
4134
+ .describe("Id of one of YOUR product profiles to link the image to"),
4135
+ url: z
4136
+ .string()
4137
+ .max(2048)
4138
+ .describe("Absolute http(s) URL of a JPG/PNG image (≤2048 chars)"),
4139
+ link_as: z
4140
+ .enum(["primary_image", "logo"])
4141
+ .optional()
4142
+ .describe(
4143
+ "Which profile field to set: 'primary_image' (default) or 'logo'",
4144
+ ),
4145
+ },
4146
+ annotations: {
4147
+ title: "Import product image",
4148
+ ...WRITE,
4149
+ // Each call stores a NEW catalog asset (no idempotency plug) → not idempotent.
4150
+ idempotentHint: false,
4151
+ },
4152
+ },
4153
+ tool(
4154
+ async (
4155
+ args: {
4156
+ product_profile_id: number;
4157
+ url: string;
4158
+ link_as?: "primary_image" | "logo";
4159
+ },
4160
+ client,
4161
+ ) => {
4162
+ const url = args.url?.trim();
4163
+ if (!url) return fail("url is required.");
4164
+ // The controller keys the image URL off `image_url` (distinct from
4165
+ // extract's `url`); map the agent-facing `url` onto it.
4166
+ const body: Record<string, unknown> = {
4167
+ image_url: url,
4168
+ product_profile_id: args.product_profile_id,
4169
+ };
4170
+ if (args.link_as !== undefined) body.link_as = args.link_as;
4171
+ const res = await client.post<{ data: unknown }>(
4172
+ "/product-sources/import-image",
4173
+ body,
4174
+ );
4175
+ return ok(asRecord(res).data ?? res);
4176
+ },
4177
+ ),
4178
+ );
4179
+
4180
+ // ── Campaign engine: product profiles ────────────────────────────────────────
4181
+
4182
+ registerTool(
4183
+ "list_product_profiles",
4184
+ {
4185
+ title: "List your product profiles",
4186
+ description:
4187
+ "Lists your saved product fact sheets (name, benefits, offer, audience, CTA, compliance constraints, " +
4188
+ "imported image/logo keys), newest first. Read-only, 0 credits. Scope: video:read. Pass an id to plan_campaign / " +
4189
+ "generate_hook_variations / create_campaign_pack so a campaign is planned from a saved source of truth.",
4190
+ inputSchema: {},
4191
+ annotations: { title: "List product profiles", ...RO },
4192
+ },
4193
+ tool(async (_args, client) => ok(await client.get("/product-profiles"))),
4194
+ );
4195
+
4196
+ registerTool(
4197
+ "create_product_profile",
4198
+ {
4199
+ title: "Create a product profile",
4200
+ description:
4201
+ "Saves a reusable product fact sheet. Only `name` is required; the rest (description, benefits[], offer, " +
4202
+ "proof_points[], audience, cta, banned_claims[], brand_tone) ground the AI when planning/generating. " +
4203
+ "0 credits. Scope: video:generate. Tip: seed it from extract_product_page's facts. To attach imagery, call " +
4204
+ "import_product_image afterwards (sets primary_image/logo).",
4205
+ inputSchema: {
4206
+ name: z.string().min(1).max(300).describe("Product name (required)"),
4207
+ description: z
4208
+ .string()
4209
+ .max(4000)
4210
+ .optional()
4211
+ .describe("What the product is / does"),
4212
+ benefits: z
4213
+ .array(z.string())
4214
+ .optional()
4215
+ .describe("Key benefit statements"),
4216
+ offer: z
4217
+ .string()
4218
+ .max(500)
4219
+ .optional()
4220
+ .describe("Headline offer / price / promotion"),
4221
+ proof_points: z
4222
+ .array(z.string())
4223
+ .optional()
4224
+ .describe("Ratings, review counts, other proof points"),
4225
+ audience: z.string().max(500).optional().describe("Target audience"),
4226
+ cta: z.string().max(300).optional().describe("Preferred call to action"),
4227
+ banned_claims: z
4228
+ .array(z.string())
4229
+ .optional()
4230
+ .describe("Claims the product must never make"),
4231
+ brand_tone: z
4232
+ .string()
4233
+ .max(500)
4234
+ .optional()
4235
+ .describe("Tone of voice for generated copy"),
4236
+ source_url: z
4237
+ .string()
4238
+ .max(2048)
4239
+ .optional()
4240
+ .describe("Product page URL the facts came from"),
4241
+ },
4242
+ annotations: {
4243
+ title: "Create product profile",
4244
+ ...WRITE,
4245
+ // No idempotency plug → a retry forks a duplicate profile.
4246
+ idempotentHint: false,
4247
+ },
4248
+ },
4249
+ tool(async (args: Record<string, unknown>, client) => {
4250
+ const res = await client.post<{ data: unknown }>(
4251
+ "/product-profiles",
4252
+ pickProductProfileFields(args),
4253
+ );
4254
+ return ok(asRecord(res).data ?? res);
4255
+ }),
4256
+ );
4257
+
4258
+ registerTool(
4259
+ "update_product_profile",
4260
+ {
4261
+ title: "Update a product profile",
4262
+ description:
4263
+ "Patches a saved product profile — only the fields you pass change. 0 credits. Scope: video:generate. " +
4264
+ "404 for an unknown/foreign id.",
4265
+ inputSchema: {
4266
+ id: z.number().int().describe("Product profile id"),
4267
+ name: z.string().min(1).max(300).optional().describe("Product name"),
4268
+ description: z.string().max(4000).optional(),
4269
+ benefits: z.array(z.string()).optional(),
4270
+ offer: z.string().max(500).optional(),
4271
+ proof_points: z.array(z.string()).optional(),
4272
+ audience: z.string().max(500).optional(),
4273
+ cta: z.string().max(300).optional(),
4274
+ banned_claims: z.array(z.string()).optional(),
4275
+ brand_tone: z.string().max(500).optional(),
4276
+ source_url: z.string().max(2048).optional(),
4277
+ },
4278
+ annotations: {
4279
+ title: "Update product profile",
4280
+ ...WRITE,
4281
+ // PATCH by id → replaying is naturally idempotent (same fields → same state).
4282
+ idempotentHint: true,
4283
+ },
4284
+ },
4285
+ tool(async (args: { id: number } & Record<string, unknown>, client) => {
4286
+ const res = await client.patch<{ data: unknown }>(
4287
+ `/product-profiles/${args.id}`,
4288
+ pickProductProfileFields(args),
4289
+ );
4290
+ return ok(asRecord(res).data ?? res);
4291
+ }),
4292
+ );
4293
+
4294
+ // ── Campaign engine: brand profiles ──────────────────────────────────────────
4295
+
4296
+ registerTool(
4297
+ "list_brand_profiles",
4298
+ {
4299
+ title: "List your brand profiles",
4300
+ description:
4301
+ "Lists your saved brand identities (name, logo key, colors, font, tone words, default CTA/audience, " +
4302
+ "preferred visual_language/creative_format, and linked assets), newest first. Read-only, 0 credits. " +
4303
+ "Scope: video:read. Pass an id as brand_profile_id to generate_hook_variations / create_campaign_pack / " +
4304
+ "create_campaign to steer tone/format and inherit the logo into materialized drafts.",
4305
+ inputSchema: {},
4306
+ annotations: { title: "List brand profiles", ...RO },
4307
+ },
4308
+ tool(async (_args, client) => ok(await client.get("/brand-profiles"))),
4309
+ );
4310
+
4311
+ registerTool(
4312
+ "create_brand_profile",
4313
+ {
4314
+ title: "Create a brand profile",
4315
+ description:
4316
+ "Saves a reusable brand identity. Only `name` is required. primary_color/secondary_color are hex " +
4317
+ "(e.g. #09EFBE); preferred_visual_language and preferred_creative_format take the same values as the " +
4318
+ "editor/short tools; tone_words[]/default_cta/default_audience/banned_claims[] steer generated copy. " +
4319
+ "0 credits. Scope: video:generate. Attach a logo/product asset from your catalog with " +
4320
+ "update_brand_profile (attach_catalog_asset_id).",
4321
+ inputSchema: {
4322
+ name: z.string().min(1).max(300).describe("Brand name (required)"),
4323
+ logo_s3_key: z
4324
+ .string()
4325
+ .optional()
4326
+ .describe("S3 key of the brand logo (from an import/upload)"),
4327
+ primary_color: z.string().optional().describe("Hex color, e.g. #ffffff"),
4328
+ secondary_color: z
4329
+ .string()
4330
+ .optional()
4331
+ .describe("Hex color, e.g. #ffffff"),
4332
+ font_family: z
4333
+ .string()
4334
+ .optional()
4335
+ .describe("Brand typography (a supported Shorts overlay font)"),
4336
+ tone_words: z
4337
+ .array(z.string())
4338
+ .optional()
4339
+ .describe("Tone descriptors, e.g. ['bold','warm']"),
4340
+ default_cta: z.string().max(300).optional(),
4341
+ default_audience: z.string().max(500).optional(),
4342
+ banned_claims: z.array(z.string()).optional(),
4343
+ preferred_visual_language: z
4344
+ .enum(VISUAL_LANGUAGES)
4345
+ .optional()
4346
+ .describe("Default visual language for generated videos"),
4347
+ preferred_creative_format: z
4348
+ .enum(CREATIVE_FORMATS)
4349
+ .optional()
4350
+ .describe("Default creative format for generated videos"),
4351
+ },
4352
+ annotations: {
4353
+ title: "Create brand profile",
4354
+ ...WRITE,
4355
+ idempotentHint: false,
4356
+ },
4357
+ },
4358
+ tool(async (args: Record<string, unknown>, client) => {
4359
+ const res = await client.post<{ data: unknown }>(
4360
+ "/brand-profiles",
4361
+ pickBrandProfileFields(args),
4362
+ );
4363
+ return ok(asRecord(res).data ?? res);
4364
+ }),
4365
+ );
4366
+
4367
+ registerTool(
4368
+ "update_brand_profile",
4369
+ {
4370
+ title: "Update a brand profile (+ attach/detach assets)",
4371
+ description:
4372
+ "Patches a saved brand profile — only the fields you pass change. 0 credits. Scope: video:generate. " +
4373
+ "You can ALSO manage its catalog-asset links in the same call: attach_catalog_asset_id links one of your " +
4374
+ "catalog assets (role required: 'logo' | 'product_image' | 'other'); detach_asset_id removes an existing " +
4375
+ "link by its LINK id (from the profile's assets[].id, not the catalog_asset_id). 404 for an unknown/foreign " +
4376
+ "profile; 422 if the catalog asset isn't yours. Returns the updated profile with its assets.",
4377
+ inputSchema: {
4378
+ id: z.number().int().describe("Brand profile id"),
4379
+ name: z.string().min(1).max(300).optional(),
4380
+ logo_s3_key: z.string().optional(),
4381
+ primary_color: z.string().optional().describe("Hex color, e.g. #ffffff"),
4382
+ secondary_color: z.string().optional().describe("Hex color"),
4383
+ font_family: z.string().optional(),
4384
+ tone_words: z.array(z.string()).optional(),
4385
+ default_cta: z.string().max(300).optional(),
4386
+ default_audience: z.string().max(500).optional(),
4387
+ banned_claims: z.array(z.string()).optional(),
4388
+ preferred_visual_language: z.enum(VISUAL_LANGUAGES).optional(),
4389
+ preferred_creative_format: z.enum(CREATIVE_FORMATS).optional(),
4390
+ attach_catalog_asset_id: z
4391
+ .number()
4392
+ .int()
4393
+ .optional()
4394
+ .describe(
4395
+ "Link this catalog asset id to the profile (requires asset_role)",
4396
+ ),
4397
+ asset_role: z
4398
+ .enum(["logo", "product_image", "other"])
4399
+ .optional()
4400
+ .describe("Role for attach_catalog_asset_id (required when attaching)"),
4401
+ detach_asset_id: z
4402
+ .number()
4403
+ .int()
4404
+ .optional()
4405
+ .describe("Remove this asset LINK id (assets[].id) from the profile"),
4406
+ },
4407
+ annotations: {
4408
+ title: "Update brand profile",
4409
+ ...WRITE,
4410
+ // The PATCH itself is idempotent, but attach/detach sub-calls create/delete
4411
+ // links (not idempotency-keyed), so a blind retry isn't a clean no-op.
4412
+ idempotentHint: false,
4413
+ },
4414
+ },
4415
+ tool(
4416
+ async (
4417
+ args: {
4418
+ id: number;
4419
+ attach_catalog_asset_id?: number;
4420
+ asset_role?: "logo" | "product_image" | "other";
4421
+ detach_asset_id?: number;
4422
+ } & Record<string, unknown>,
4423
+ client,
4424
+ ) => {
4425
+ if (
4426
+ args.attach_catalog_asset_id !== undefined &&
4427
+ args.asset_role === undefined
4428
+ ) {
4429
+ return fail(
4430
+ "asset_role is required when attach_catalog_asset_id is set ('logo' | 'product_image' | 'other').",
4431
+ );
4432
+ }
4433
+ // 1) Patch the scalar brand fields when any were supplied.
4434
+ const fields = pickBrandProfileFields(args);
4435
+ if (Object.keys(fields).length > 0) {
4436
+ await client.patch(`/brand-profiles/${args.id}`, fields);
4437
+ }
4438
+ // 2) Attach a catalog asset (separate endpoint keyed on catalog_asset_id).
4439
+ if (args.attach_catalog_asset_id !== undefined) {
4440
+ await client.post(`/brand-profiles/${args.id}/assets`, {
4441
+ catalog_asset_id: args.attach_catalog_asset_id,
4442
+ role: args.asset_role,
4443
+ });
4444
+ }
4445
+ // 3) Detach an asset LINK by its id (204 No Content).
4446
+ if (args.detach_asset_id !== undefined) {
4447
+ await client.del(
4448
+ `/brand-profiles/${args.id}/assets/${args.detach_asset_id}`,
4449
+ );
4450
+ }
4451
+ // Return the fresh profile with its (updated) assets.
4452
+ const res = await client.get<{ data: unknown }>(
4453
+ `/brand-profiles/${args.id}`,
4454
+ );
4455
+ return ok(asRecord(res).data ?? res);
4456
+ },
4457
+ ),
4458
+ );
4459
+
4460
+ // ── Campaign engine: planning (free AI-assist quota) ──────────────────────────
4461
+
4462
+ registerTool(
4463
+ "plan_campaign",
4464
+ {
4465
+ title: "Plan a campaign from product facts",
4466
+ description:
4467
+ "Turns product facts (a saved product_profile_id OR an inline product with a name) into a campaign PLAN: " +
4468
+ "ad hooks, recommended output formats, a one-line positioning statement, and a transparent credit ESTIMATE " +
4469
+ "for the proposed paid outputs. Generates no media and persists nothing. Spends $0 — it consumes the FREE " +
4470
+ "daily AI-assist quota (NOT credits). Scope: video:generate. Rate limit: 30/min. On 429 the quota is " +
4471
+ "exhausted: set auto_unlock:true to spend 1 credit for +10 assists (retried once) or call unlock_ai_assists. " +
4472
+ "Next: generate_hook_variations for draftable variations, then create_campaign_pack.",
4473
+ inputSchema: {
4474
+ product_profile_id: z
4475
+ .number()
4476
+ .int()
4477
+ .optional()
4478
+ .describe("Id of a saved product profile (OR pass product)"),
4479
+ product: z
4480
+ .object({
4481
+ name: z.string(),
4482
+ description: z.string().optional(),
4483
+ benefits: z.array(z.string()).optional(),
4484
+ offer: z.string().optional(),
4485
+ proof_points: z.array(z.string()).optional(),
4486
+ audience: z.string().optional(),
4487
+ cta: z.string().optional(),
4488
+ banned_claims: z.array(z.string()).optional(),
4489
+ })
4490
+ .optional()
4491
+ .describe(
4492
+ "Inline product facts (name required) if you have no profile",
4493
+ ),
4494
+ goal: z
4495
+ .string()
4496
+ .optional()
4497
+ .describe("Campaign goal, e.g. awareness or conversion"),
4498
+ platform: z
4499
+ .string()
4500
+ .optional()
4501
+ .describe("Target platform, e.g. tiktok, instagram, reels, shorts"),
4502
+ language: z
4503
+ .string()
4504
+ .optional()
4505
+ .describe('Viewer-facing language code, e.g. "en" (default)'),
4506
+ auto_unlock: z
4507
+ .boolean()
4508
+ .optional()
4509
+ .describe(
4510
+ "On 429, spend 1 credit to unlock +10 assists and retry once (default false)",
4511
+ ),
4512
+ },
4513
+ annotations: {
4514
+ title: "Plan campaign",
4515
+ ...WRITE,
4516
+ // Consumes the free quota (a side effect) → not idempotent.
4517
+ idempotentHint: false,
4518
+ },
4519
+ },
4520
+ tool(
4521
+ async (
4522
+ args: {
4523
+ product_profile_id?: number;
4524
+ product?: Record<string, unknown>;
4525
+ goal?: string;
4526
+ platform?: string;
4527
+ language?: string;
4528
+ auto_unlock?: boolean;
4529
+ },
4530
+ client,
4531
+ ) => {
4532
+ const srcErr = validatePlanSource(
4533
+ args.product_profile_id,
4534
+ args.product as { name?: unknown } | undefined,
4535
+ );
4536
+ if (srcErr) return fail(srcErr);
4537
+ const body = buildPlanBody(args);
4538
+ const res = await withAssist(client, args.auto_unlock ?? false, () =>
4539
+ client.post<{ data: unknown }>("/campaign-packs/plan", body),
4540
+ );
4541
+ return ok(asRecord(res).data ?? res);
4542
+ },
4543
+ ),
4544
+ );
4545
+
4546
+ registerTool(
4547
+ "generate_hook_variations",
4548
+ {
4549
+ title: "Generate a hook variations board",
4550
+ description:
4551
+ "Turns product facts (a saved product_profile_id OR an inline product with a name) and an OPTIONAL " +
4552
+ "brand_profile_id into 3-6 distinct hook variations — each with a hook, angle, suggested output format " +
4553
+ "(editor_ad/short/carousel), a draft script, a ready-to-post caption, hashtags, and a per-variation credit " +
4554
+ "ESTIMATE. Generates no media and persists nothing. Spends $0 — it consumes the FREE daily AI-assist quota " +
4555
+ "(NOT credits). Scope: video:generate. Rate limit: 30/min. On 429 set auto_unlock:true (1 credit → +10 " +
4556
+ "assists, retried once) or call unlock_ai_assists. Next: create_campaign_pack, then add_pack_variations to " +
4557
+ "persist + materialize the ones you pick as DRAFTS.",
4558
+ inputSchema: {
4559
+ product_profile_id: z
4560
+ .number()
4561
+ .int()
4562
+ .optional()
4563
+ .describe("Id of a saved product profile (OR pass product)"),
4564
+ product: z
4565
+ .object({
4566
+ name: z.string(),
4567
+ description: z.string().optional(),
4568
+ benefits: z.array(z.string()).optional(),
4569
+ offer: z.string().optional(),
4570
+ proof_points: z.array(z.string()).optional(),
4571
+ audience: z.string().optional(),
4572
+ cta: z.string().optional(),
4573
+ banned_claims: z.array(z.string()).optional(),
4574
+ })
4575
+ .optional()
4576
+ .describe(
4577
+ "Inline product facts (name required) if you have no profile",
4578
+ ),
4579
+ brand_profile_id: z
4580
+ .number()
4581
+ .int()
4582
+ .optional()
4583
+ .describe(
4584
+ "Optional saved brand profile to steer tone/format/banned claims",
4585
+ ),
4586
+ count: z
4587
+ .number()
4588
+ .int()
4589
+ .optional()
4590
+ .describe("Desired number of variations, clamped to 3-6 (default 4)"),
4591
+ goal: z.string().optional().describe("Campaign goal"),
4592
+ platform: z
4593
+ .string()
4594
+ .optional()
4595
+ .describe("Target platform, e.g. tiktok, instagram"),
4596
+ language: z
4597
+ .string()
4598
+ .optional()
4599
+ .describe('Viewer-facing language code, e.g. "en" (default)'),
4600
+ auto_unlock: z
4601
+ .boolean()
4602
+ .optional()
4603
+ .describe(
4604
+ "On 429, spend 1 credit to unlock +10 assists and retry once (default false)",
4605
+ ),
4606
+ },
4607
+ annotations: {
4608
+ title: "Generate hook variations",
4609
+ ...WRITE,
4610
+ idempotentHint: false,
4611
+ },
4612
+ },
4613
+ tool(
4614
+ async (
4615
+ args: {
4616
+ product_profile_id?: number;
4617
+ product?: Record<string, unknown>;
4618
+ brand_profile_id?: number;
4619
+ count?: number;
4620
+ goal?: string;
4621
+ platform?: string;
4622
+ language?: string;
4623
+ auto_unlock?: boolean;
4624
+ },
4625
+ client,
4626
+ ) => {
4627
+ const srcErr = validatePlanSource(
4628
+ args.product_profile_id,
4629
+ args.product as { name?: unknown } | undefined,
4630
+ );
4631
+ if (srcErr) return fail(srcErr);
4632
+ const body = buildPlanBody(args);
4633
+ const res = await withAssist(client, args.auto_unlock ?? false, () =>
4634
+ client.post<{ data: unknown }>("/campaign-packs/hook-variations", body),
4635
+ );
4636
+ return ok(asRecord(res).data ?? res);
4637
+ },
4638
+ ),
4639
+ );
4640
+
4641
+ // ── Campaign engine: packs (persist / materialize) ───────────────────────────
4642
+
4643
+ registerTool(
4644
+ "create_campaign_pack",
4645
+ {
4646
+ title: "Persist a campaign pack",
4647
+ description:
4648
+ "Stores a campaign pack (the planning container) plus any inline TEXT items (hook/caption copy). editor_ad/" +
4649
+ "short/carousel items are NOT created here — persist them via add_pack_variations (which also materializes " +
4650
+ "them into drafts). Spends 0 credits. Scope: video:generate. Link a product_profile_id and/or brand_profile_id " +
4651
+ "so materialized drafts inherit the product image + brand logo. Returns the pack id + items. Next: " +
4652
+ "add_pack_variations to fill it with draftable outputs.",
4653
+ inputSchema: {
4654
+ title: z
4655
+ .string()
4656
+ .max(300)
4657
+ .optional()
4658
+ .describe("Human-friendly pack name"),
4659
+ product_profile_id: z
4660
+ .number()
4661
+ .int()
4662
+ .optional()
4663
+ .describe(
4664
+ "Saved product profile to ground the pack (422 if not yours)",
4665
+ ),
4666
+ brand_profile_id: z
4667
+ .number()
4668
+ .int()
4669
+ .optional()
4670
+ .describe(
4671
+ "Saved brand profile applied to the pack + materialized drafts",
4672
+ ),
4673
+ brief: z
4674
+ .string()
4675
+ .max(4000)
4676
+ .optional()
4677
+ .describe("Freeform brief for the campaign"),
4678
+ source_type: z
4679
+ .enum(["url", "image", "brief", "asset"])
4680
+ .optional()
4681
+ .describe("Where the pack originated"),
4682
+ source_url: z
4683
+ .string()
4684
+ .max(2048)
4685
+ .optional()
4686
+ .describe("Origin URL when source_type is 'url'"),
4687
+ language: z
4688
+ .string()
4689
+ .optional()
4690
+ .describe(
4691
+ "Language stored on the pack + used for materialized draft copy",
4692
+ ),
4693
+ items: z
4694
+ .array(
4695
+ z.object({
4696
+ kind: z
4697
+ .enum(["hook", "caption"])
4698
+ .describe("Only hook/caption text items persist here"),
4699
+ hook: z.string().optional(),
4700
+ caption: z.string().optional(),
4701
+ hashtags: z.array(z.string()).optional(),
4702
+ }),
4703
+ )
4704
+ .optional()
4705
+ .describe("Optional inline hook/caption copy items"),
4706
+ },
4707
+ annotations: {
4708
+ title: "Create campaign pack",
4709
+ ...WRITE,
4710
+ // No idempotency plug → a retry forks a duplicate pack.
4711
+ idempotentHint: false,
4712
+ },
4713
+ },
4714
+ tool(
4715
+ async (
4716
+ args: {
4717
+ title?: string;
4718
+ product_profile_id?: number;
4719
+ brand_profile_id?: number;
4720
+ brief?: string;
4721
+ source_type?: string;
4722
+ source_url?: string;
4723
+ language?: string;
4724
+ items?: Array<Record<string, unknown>>;
4725
+ },
4726
+ client,
4727
+ ) => {
4728
+ const body: Record<string, unknown> = {};
4729
+ if (args.title !== undefined) body.title = args.title;
4730
+ if (args.product_profile_id !== undefined)
4731
+ body.product_profile_id = args.product_profile_id;
4732
+ if (args.brand_profile_id !== undefined)
4733
+ body.brand_profile_id = args.brand_profile_id;
4734
+ if (args.brief !== undefined) body.brief = args.brief;
4735
+ if (args.source_type !== undefined) body.source_type = args.source_type;
4736
+ if (args.source_url !== undefined) body.source_url = args.source_url;
4737
+ if (args.language !== undefined) body.language = args.language;
4738
+ if (args.items !== undefined) body.items = args.items;
4739
+ const res = await client.post<{ data: unknown }>("/campaign-packs", body);
4740
+ return ok(summarizePack(asRecord(res).data));
4741
+ },
4742
+ ),
4743
+ );
4744
+
4745
+ registerTool(
4746
+ "list_campaign_packs",
4747
+ {
4748
+ title: "List your campaign packs",
4749
+ description:
4750
+ "Lists your campaign packs (newest first) at the PACK level only: id, title, status, source, and linked " +
4751
+ "product/brand profile. It does NOT include per-item detail — the list endpoint doesn't load items, so " +
4752
+ "per-item kind/status/draft slug/deep-link route/credit estimate come from get_campaign_pack({ id }). " +
4753
+ "Read-only, 0 credits. Scope: video:read.",
4754
+ inputSchema: {},
4755
+ annotations: { title: "List campaign packs", ...RO },
4756
+ },
4757
+ tool(async (_args, client) => {
4758
+ const res = await client.get<{ data: unknown }>("/campaign-packs");
4759
+ const data = asRecord(res).data;
4760
+ // The list endpoint does NOT preload items (they'd serialize as []); emit
4761
+ // pack-level fields only and point the agent at get_campaign_pack for items.
4762
+ const packs = Array.isArray(data) ? data.map(summarizePackListEntry) : [];
4763
+ return ok({ packs });
4764
+ }),
4765
+ );
4766
+
4767
+ registerTool(
4768
+ "get_campaign_pack",
4769
+ {
4770
+ title: "Get one campaign pack with its items",
4771
+ description:
4772
+ "Returns a campaign pack and its items. Per item you get: kind, status (planned → draft → generating → " +
4773
+ "completed | failed), the materialized DRAFT slug + deep-link route (editor_ad/short → the video project, " +
4774
+ "carousel → the slider), the per-item credit ESTIMATE, and — once an item has been generated — its " +
4775
+ "preview_url/video_result_id. Read-only, 0 credits. Scope: video:read. Use the draft slugs to generate each " +
4776
+ "output EXPLICITLY (a separate, PAID step): start_autopilot/generate for videos, generate_slider for carousels.",
4777
+ inputSchema: {
4778
+ id: z.number().int().describe("Campaign pack id"),
4779
+ },
4780
+ outputSchema: getCampaignPackOutput,
4781
+ annotations: { title: "Get campaign pack", ...RO },
4782
+ },
4783
+ tool(async (args: { id: number }, client) => {
4784
+ const res = await client.get<{ data: unknown }>(
4785
+ `/campaign-packs/${args.id}`,
4786
+ );
4787
+ return ok(summarizePack(asRecord(res).data));
4788
+ }),
4789
+ );
4790
+
4791
+ registerTool(
4792
+ "add_pack_variations",
4793
+ {
4794
+ title: "Add + materialize hook variations to a pack",
4795
+ description:
4796
+ "Persists a batch of selected hook variations (from generate_hook_variations) as items under a pack and, by " +
4797
+ "default (materialize:true), turns each into an editable DRAFT (a video project or image slider). Spends 0 " +
4798
+ "credits — nothing is generated/rendered (that's a separate PAID step). Scope: video:generate. Caps: at most " +
4799
+ "12 variations per call; rate-limited to 15/min. Per-variation BEST-EFFORT: a variation that can't be " +
4800
+ "materialized (e.g. you hit a draft limit) stays a 'planned' item with a recorded reason while the rest still " +
4801
+ "materialize — the response summary lists created/materialized/planned + skipped_reasons. Pass the variation " +
4802
+ "objects verbatim from the board (each needs at least suggested_format). Next: get_campaign_pack to review, " +
4803
+ "then generate each draft explicitly.",
4804
+ inputSchema: {
4805
+ pack_id: z
4806
+ .number()
4807
+ .int()
4808
+ .describe("Campaign pack id (from create_campaign_pack)"),
4809
+ variations: z
4810
+ .array(
4811
+ z.object({
4812
+ suggested_format: z
4813
+ .enum(["editor_ad", "short", "carousel"])
4814
+ .describe("Output kind for this variation"),
4815
+ hook: z.string().optional(),
4816
+ angle: z.string().optional(),
4817
+ draft_script: z.string().optional(),
4818
+ caption: z.string().optional(),
4819
+ hashtags: z.array(z.string()).optional(),
4820
+ credit_estimate: z.record(z.unknown()).optional(),
4821
+ }),
4822
+ )
4823
+ .describe("1-12 variations to persist (echo them from the board)"),
4824
+ materialize: z
4825
+ .boolean()
4826
+ .optional()
4827
+ .describe(
4828
+ "Materialize each into a draft (default true); false keeps them planned",
4829
+ ),
4830
+ },
4831
+ annotations: {
4832
+ title: "Add pack variations",
4833
+ ...WRITE,
4834
+ // Each call creates NEW items (no idempotency plug) → not idempotent.
4835
+ idempotentHint: false,
4836
+ },
4837
+ },
4838
+ tool(
4839
+ async (
4840
+ args: {
4841
+ pack_id: number;
4842
+ variations: Array<Record<string, unknown>>;
4843
+ materialize?: boolean;
4844
+ },
4845
+ client,
4846
+ ) => {
4847
+ const err = validateVariationsBatch(args.variations);
4848
+ if (err) return fail(err);
4849
+ const body: Record<string, unknown> = { variations: args.variations };
4850
+ if (args.materialize !== undefined) body.materialize = args.materialize;
4851
+ const res = await client.post<{ data: unknown }>(
4852
+ `/campaign-packs/${args.pack_id}/variations`,
4853
+ body,
4854
+ );
4855
+ const data = asRecord(asRecord(res).data);
4856
+ const items = Array.isArray(data.items)
4857
+ ? data.items.map(summarizePackItem)
4858
+ : [];
4859
+ return ok({
4860
+ campaign_pack_id: data.campaign_pack_id ?? args.pack_id,
4861
+ items,
4862
+ summary: data.summary ?? null,
4863
+ next: "get_campaign_pack to review the drafts; generation/render is a separate, explicit, PAID step per draft.",
4864
+ });
4865
+ },
4866
+ ),
4867
+ );
4868
+
4869
+ registerTool(
4870
+ "materialize_pack_item",
4871
+ {
4872
+ title: "Materialize one planned pack item into a draft",
4873
+ description:
4874
+ "Turns a single PLANNED editor_ad/short/carousel item into an editable DRAFT (a video project or image " +
4875
+ "slider) and links it back to the item. Spends 0 credits — generation/render is a separate PAID step. " +
4876
+ "Scope: video:generate. Rate limit: 60/min. Safe to retry: NO duplicate draft is ever created (a concurrent " +
4877
+ "race loser converges on the same draft under a row lock). A SEQUENTIAL re-call of an already-materialized " +
4878
+ "item returns 422 ('already materialized') — that 422 means the draft already exists; read it via " +
4879
+ "get_campaign_pack. 422 also for hook/caption items or when you hit a draft limit. Prefer add_pack_variations " +
4880
+ "to materialize a batch at once; use this to (re)try one item.",
4881
+ inputSchema: {
4882
+ pack_id: z.number().int().describe("Campaign pack id"),
4883
+ item_id: z
4884
+ .number()
4885
+ .int()
4886
+ .describe("Campaign pack item id (from get_campaign_pack)"),
4887
+ },
4888
+ annotations: {
4889
+ title: "Materialize pack item",
4890
+ ...WRITE,
4891
+ // idempotentHint in the "no duplicate is ever created" sense: the server
4892
+ // locks the item row and re-asserts the output ref is still null, so a
4893
+ // concurrent race loser converges on the SAME draft. A sequential replay is
4894
+ // rejected 422 by the pre-lock ensure_materializable (already materialized) —
4895
+ // it never forks a second draft.
4896
+ idempotentHint: true,
4897
+ },
4898
+ },
4899
+ tool(async (args: { pack_id: number; item_id: number }, client) => {
4900
+ const res = await client.post<{ data: unknown }>(
4901
+ `/campaign-packs/${args.pack_id}/items/${args.item_id}/materialize`,
4902
+ );
4903
+ return ok(summarizePackItem(asRecord(res).data));
4904
+ }),
4905
+ );
4906
+
4907
+ // ── Campaign engine: high-level orchestrator ─────────────────────────────────
4908
+
4909
+ registerTool(
4910
+ "create_campaign",
4911
+ {
4912
+ title: "Create a whole campaign from a product (one shot)",
4913
+ description:
4914
+ "The high-level path (like make_video, but for a content CAMPAIGN): give a product URL (or a saved " +
4915
+ "product_profile_id) and get back a persisted campaign pack full of editable DRAFTS. It runs the whole " +
4916
+ "pipeline — extract the page (if a url) → save a product profile → best-effort import the primary image + " +
4917
+ "logo → plan → generate hook variations → create the pack → add the variations as materialized DRAFTS. " +
4918
+ "Spends $0 end to end: it only ever touches the FREE daily AI-assist quota (plan + hook variations) and " +
4919
+ "creates 0-credit drafts. It NEVER starts autopilot/generation/render — that stays a SEPARATE, explicit, " +
4920
+ "PAID step you trigger per draft afterwards. Scope: video:generate. On a partial failure it returns " +
4921
+ "everything achieved so far (profile id, pack id, per-item status) plus precise resume guidance naming the " +
4922
+ "granular tool to continue with. The result lists each draft's slug + kind + per-item credit estimate.",
4923
+ inputSchema: {
4924
+ url: z
4925
+ .string()
4926
+ .max(2048)
4927
+ .optional()
4928
+ .describe(
4929
+ "Product page URL to ingest (required unless product_profile_id is given)",
4930
+ ),
4931
+ product_profile_id: z
4932
+ .number()
4933
+ .int()
4934
+ .optional()
4935
+ .describe("Use a saved product profile instead of extracting a URL"),
4936
+ brand_profile_id: z
4937
+ .number()
4938
+ .int()
4939
+ .optional()
4940
+ .describe(
4941
+ "Optional saved brand profile — steers hooks and gives drafts your logo/brand",
4942
+ ),
4943
+ item_count: z
4944
+ .number()
4945
+ .int()
4946
+ .optional()
4947
+ .describe("How many variations to aim for, clamped to 3-6 (default 6)"),
4948
+ formats: z
4949
+ .array(z.enum(["editor_ad", "short", "carousel"]))
4950
+ .optional()
4951
+ .describe(
4952
+ "Restrict to these output formats (default: whatever the board recommends)",
4953
+ ),
4954
+ language: z
4955
+ .string()
4956
+ .optional()
4957
+ .describe('Viewer-facing language code, e.g. "en" (default)'),
4958
+ goal: z.string().optional().describe("Campaign goal, e.g. conversion"),
4959
+ platform: z
4960
+ .string()
4961
+ .optional()
4962
+ .describe("Target platform, e.g. tiktok, instagram"),
4963
+ },
4964
+ annotations: {
4965
+ title: "Create campaign",
4966
+ ...WRITE,
4967
+ // Multi-step create pipeline (profiles, pack, items — none idempotency-keyed);
4968
+ // a blind re-run forks new rows. Resume with the granular tools instead.
4969
+ idempotentHint: false,
4970
+ },
4971
+ outputSchema: createCampaignOutput,
4972
+ },
4973
+ tool(async (args: CreateCampaignArgs, client) => {
4974
+ if (!args.url && args.product_profile_id === undefined) {
4975
+ return fail("Provide either url or product_profile_id.");
4976
+ }
4977
+ const result = await runCreateCampaign(client, args, (msg) =>
4978
+ console.error(`[create_campaign] ${msg}`),
4979
+ );
4980
+ return ok(result);
4981
+ }),
4982
+ );
4983
+
4984
+ // ── Series: recurring-content shows ───────────────────────────────────────────
4985
+
4986
+ registerTool(
4987
+ "create_series",
4988
+ {
4989
+ title: "Create a recurring-content series",
4990
+ description:
4991
+ "Creates a reusable recurring-content SHOW template (e.g. 'Myth vs Fact', 'Founder Tip') bound to an " +
4992
+ "optional brand + product profile — the durable format the episode planner draws upcoming ideas from. " +
4993
+ "name and template are required. Spends 0 credits. Scope: video:generate. A supplied " +
4994
+ "brand_profile_id/product_profile_id must be one of your own (422 invalid_brand_profile/" +
4995
+ "invalid_product_profile otherwise). Next: plan_series_episodes to draft idea episodes.",
4996
+ inputSchema: {
4997
+ name: z
4998
+ .string()
4999
+ .max(300)
5000
+ .describe("Show name, e.g. 'Myth vs Fact' (required)"),
5001
+ template: z
5002
+ .enum([
5003
+ "founder_tip",
5004
+ "myth_vs_fact",
5005
+ "before_after",
5006
+ "customer_question",
5007
+ "build_in_public",
5008
+ "product_demo_of_the_week",
5009
+ "mistake_to_avoid",
5010
+ ])
5011
+ .describe(
5012
+ "The recurring format/template (required). One of: founder_tip, myth_vs_fact, " +
5013
+ "before_after, customer_question, build_in_public, product_demo_of_the_week, mistake_to_avoid",
5014
+ ),
5015
+ brand_profile_id: z
5016
+ .number()
5017
+ .int()
5018
+ .optional()
5019
+ .describe(
5020
+ "Optional saved brand profile the series (and its drafts) inherit",
5021
+ ),
5022
+ product_profile_id: z
5023
+ .number()
5024
+ .int()
5025
+ .optional()
5026
+ .describe("Optional saved product profile grounding episode ideas"),
5027
+ cadence: z
5028
+ .enum(["weekly", "biweekly", "monthly"])
5029
+ .optional()
5030
+ .describe(
5031
+ "Posting cadence: weekly, biweekly, or monthly (steers the ideas)",
5032
+ ),
5033
+ tone: z
5034
+ .string()
5035
+ .optional()
5036
+ .describe("Editorial tone, e.g. playful, expert"),
5037
+ default_format: z
5038
+ .enum(["editor_ad", "short", "carousel"])
5039
+ .optional()
5040
+ .describe("Default output format for materialized episodes"),
5041
+ status: z
5042
+ .enum(["active", "paused", "archived"])
5043
+ .optional()
5044
+ .describe("Series status (default active)"),
5045
+ },
5046
+ annotations: {
5047
+ title: "Create series",
5048
+ ...WRITE,
5049
+ // No idempotency plug on the series controller → a retry forks a duplicate.
5050
+ idempotentHint: false,
5051
+ },
5052
+ },
5053
+ tool(
5054
+ async (
5055
+ args: {
5056
+ name: string;
5057
+ template: string;
5058
+ brand_profile_id?: number;
5059
+ product_profile_id?: number;
5060
+ cadence?: string;
5061
+ tone?: string;
5062
+ default_format?: string;
5063
+ status?: string;
5064
+ },
5065
+ client,
5066
+ ) => {
5067
+ const res = await client.post<{ data: unknown }>(
5068
+ "/series",
5069
+ pickSeriesFields(args),
5070
+ );
5071
+ return ok(summarizeSeries(asRecord(res).data));
5072
+ },
5073
+ ),
5074
+ );
5075
+
5076
+ registerTool(
5077
+ "list_series",
5078
+ {
5079
+ title: "List your series",
5080
+ description:
5081
+ "Lists your recurring-content series (newest first), each summarized with its status, template, cadence, " +
5082
+ "tone, default format, and linked brand/product profile + rolling campaign pack ids. Read-only, 0 credits. " +
5083
+ "Scope: video:read. Use get_series_dashboard for one series' episodes grouped by lane.",
5084
+ inputSchema: {},
5085
+ annotations: { title: "List series", ...RO },
5086
+ },
5087
+ tool(async (_args, client) => {
5088
+ const res = await client.get<{ data: unknown }>("/series");
5089
+ const data = asRecord(res).data;
5090
+ const series = Array.isArray(data) ? data.map(summarizeSeries) : [];
5091
+ return ok({ series });
5092
+ }),
5093
+ );
5094
+
5095
+ registerTool(
5096
+ "update_series",
5097
+ {
5098
+ title: "Update a series",
5099
+ description:
5100
+ "Patches one of your series; only the fields you pass change. Toggle status among active/paused/archived " +
5101
+ "(a paused series stops surfacing for new ideas). A changed brand_profile_id/product_profile_id is " +
5102
+ "re-verified against your own profiles (422 otherwise). Spends 0 credits. Scope: video:generate. " +
5103
+ "404 for an unknown/foreign series.",
5104
+ inputSchema: {
5105
+ id: z.number().int().describe("Series id"),
5106
+ name: z.string().max(300).optional().describe("Show name"),
5107
+ template: z
5108
+ .enum([
5109
+ "founder_tip",
5110
+ "myth_vs_fact",
5111
+ "before_after",
5112
+ "customer_question",
5113
+ "build_in_public",
5114
+ "product_demo_of_the_week",
5115
+ "mistake_to_avoid",
5116
+ ])
5117
+ .optional()
5118
+ .describe(
5119
+ "Recurring format/template. One of: founder_tip, myth_vs_fact, before_after, " +
5120
+ "customer_question, build_in_public, product_demo_of_the_week, mistake_to_avoid",
5121
+ ),
5122
+ brand_profile_id: z
5123
+ .number()
5124
+ .int()
5125
+ .optional()
5126
+ .describe("Saved brand profile to bind (422 if not yours)"),
5127
+ product_profile_id: z
5128
+ .number()
5129
+ .int()
5130
+ .optional()
5131
+ .describe("Saved product profile to bind (422 if not yours)"),
5132
+ cadence: z
5133
+ .enum(["weekly", "biweekly", "monthly"])
5134
+ .optional()
5135
+ .describe("Posting cadence: weekly, biweekly, or monthly"),
5136
+ tone: z.string().optional().describe("Editorial tone"),
5137
+ default_format: z
5138
+ .enum(["editor_ad", "short", "carousel"])
5139
+ .optional()
5140
+ .describe("Default output format for materialized episodes"),
5141
+ status: z
5142
+ .enum(["active", "paused", "archived"])
5143
+ .optional()
5144
+ .describe("Series status"),
5145
+ },
5146
+ annotations: { title: "Update series", ...WRITE, idempotentHint: false },
5147
+ },
5148
+ tool(
5149
+ async (
5150
+ args: {
5151
+ id: number;
5152
+ name?: string;
5153
+ template?: string;
5154
+ brand_profile_id?: number;
5155
+ product_profile_id?: number;
5156
+ cadence?: string;
5157
+ tone?: string;
5158
+ default_format?: string;
5159
+ status?: string;
5160
+ },
5161
+ client,
5162
+ ) => {
5163
+ const res = await client.patch<{ data: unknown }>(
5164
+ `/series/${args.id}`,
5165
+ pickSeriesFields(args),
5166
+ );
5167
+ return ok(summarizeSeries(asRecord(res).data));
5168
+ },
5169
+ ),
5170
+ );
5171
+
5172
+ registerTool(
5173
+ "plan_series_episodes",
5174
+ {
5175
+ title: "Plan upcoming episode ideas for a series",
5176
+ description:
5177
+ "Drafts 3-5 distinct upcoming EPISODE ideas (status 'idea') for one of your series, grounded on its " +
5178
+ "product profile (or name) and steered by its brand/tone/cadence, and persists them as episodes. " +
5179
+ "Generates no media and spends $0 — it consumes the FREE daily AI-assist quota (NOT credits). " +
5180
+ "Scope: video:generate. Rate limit: 30/min. On 429 the quota is exhausted: set auto_unlock:true to spend " +
5181
+ "1 credit for +10 assists (retried once) or call unlock_ai_assists. 404 for an unknown/foreign series. " +
5182
+ "Next: materialize_episode to turn a chosen idea into a 0-credit draft.",
5183
+ inputSchema: {
5184
+ series_id: z.number().int().describe("Series id"),
5185
+ count: z
5186
+ .number()
5187
+ .int()
5188
+ .optional()
5189
+ .describe("Desired number of ideas, clamped to 3-5 (default 4)"),
5190
+ language: z
5191
+ .string()
5192
+ .optional()
5193
+ .describe('Viewer-facing language code, e.g. "en" (default)'),
5194
+ auto_unlock: z
5195
+ .boolean()
5196
+ .optional()
5197
+ .describe(
5198
+ "On 429, spend 1 credit to unlock +10 assists and retry once (default false)",
5199
+ ),
5200
+ },
5201
+ annotations: {
5202
+ title: "Plan series episodes",
5203
+ ...WRITE,
5204
+ // Consumes the free quota + persists idea episodes (a side effect) → not idempotent.
5205
+ idempotentHint: false,
5206
+ },
5207
+ },
5208
+ tool(
5209
+ async (
5210
+ args: {
5211
+ series_id: number;
5212
+ count?: number;
5213
+ language?: string;
5214
+ auto_unlock?: boolean;
5215
+ },
5216
+ client,
5217
+ ) => {
5218
+ const body: Record<string, unknown> = {};
5219
+ if (args.count !== undefined) body.count = args.count;
5220
+ if (args.language !== undefined && args.language.trim() !== "")
5221
+ body.language = args.language;
5222
+ const res = await withAssist(client, args.auto_unlock ?? false, () =>
5223
+ client.post<{ data: unknown }>(
5224
+ `/series/${args.series_id}/episodes/plan`,
5225
+ body,
5226
+ ),
5227
+ );
5228
+ const data = asRecord(asRecord(res).data);
5229
+ const episodes = Array.isArray(data.episodes)
5230
+ ? data.episodes.map(summarizeEpisode)
5231
+ : [];
5232
+ return ok({
5233
+ series_id: args.series_id,
5234
+ episodes,
5235
+ ai_assist_quota: data.ai_assist_quota ?? null,
5236
+ next: "materialize_episode({ series_id, episode_id }) to turn an idea into a 0-credit draft.",
5237
+ });
5238
+ },
5239
+ ),
5240
+ );
5241
+
5242
+ registerTool(
5243
+ "list_series_episodes",
5244
+ {
5245
+ title: "List a series' episodes",
5246
+ description:
5247
+ "Lists a series' episodes (position ascending), each summarized with its status (idea → drafted → " +
5248
+ "published), hook/brief/caption, suggested format, and — once materialized — the draft slug + deep-link " +
5249
+ "route (editor_ad/short → the video project, carousel → the slider). Optionally filter by status. " +
5250
+ "Read-only, 0 credits. Scope: video:read. 404 for an unknown/foreign series.",
5251
+ inputSchema: {
5252
+ series_id: z.number().int().describe("Series id"),
5253
+ status: z
5254
+ .enum(["idea", "drafted", "published", "archived"])
5255
+ .optional()
5256
+ .describe("Filter to one status"),
5257
+ },
5258
+ annotations: { title: "List series episodes", ...RO },
5259
+ },
5260
+ tool(async (args: { series_id: number; status?: string }, client) => {
5261
+ const res = await client.get<{ data: unknown }>(
5262
+ `/series/${args.series_id}/episodes`,
5263
+ args.status ? { status: args.status } : undefined,
5264
+ );
5265
+ const data = asRecord(asRecord(res).data);
5266
+ const episodes = Array.isArray(data.episodes)
5267
+ ? data.episodes.map(summarizeEpisode)
5268
+ : [];
5269
+ return ok({ series_id: args.series_id, episodes });
5270
+ }),
5271
+ );
5272
+
5273
+ registerTool(
5274
+ "materialize_episode",
5275
+ {
5276
+ title: "Materialize an episode idea into a draft",
5277
+ description:
5278
+ "Turns an 'idea' episode into an editable 0-credit DRAFT (a video project or image slider) grouped under " +
5279
+ "the series' rolling campaign pack and inheriting the series' brand. Spends 0 credits — generation/render " +
5280
+ "is a SEPARATE, explicit, PAID step. Scope: video:generate. Rate limit: 60/min. Safe to retry: NO duplicate " +
5281
+ "draft is ever created (a concurrent race loser converges on the same draft under a row lock). A SEQUENTIAL " +
5282
+ "re-call of an already-materialized episode returns 422 ('already materialized') — that 422 means the draft " +
5283
+ "already exists; find it via list_series_episodes / get_series_dashboard. 422 also for a non-idea episode or " +
5284
+ "when you hit a draft limit. Returns the drafted episode with its draft slug + kind; generate it explicitly " +
5285
+ "next (start_autopilot/generate for videos, generate_slider for carousels).",
5286
+ inputSchema: {
5287
+ series_id: z.number().int().describe("Series id"),
5288
+ episode_id: z
5289
+ .number()
5290
+ .int()
5291
+ .describe(
5292
+ "Episode id (from plan_series_episodes / list_series_episodes)",
5293
+ ),
5294
+ },
5295
+ annotations: {
5296
+ title: "Materialize episode",
5297
+ ...WRITE,
5298
+ // idempotentHint in the "no duplicate is ever created" sense: the server
5299
+ // takes FOR UPDATE locks and re-checks materializability, so a concurrent
5300
+ // race loser converges on the SAME draft. A sequential replay is rejected
5301
+ // 422 by the pre-lock ensure_materializable (already materialized) — it
5302
+ // never forks a second draft.
5303
+ idempotentHint: true,
5304
+ },
5305
+ },
5306
+ tool(async (args: { series_id: number; episode_id: number }, client) => {
5307
+ const res = await client.post<{ data: unknown }>(
5308
+ `/series/${args.series_id}/episodes/${args.episode_id}/materialize`,
5309
+ );
5310
+ const episode = summarizeEpisode(asRecord(res).data);
5311
+ return ok({
5312
+ ...episode,
5313
+ next: episode.draft_slug
5314
+ ? "Generate this draft EXPLICITLY (a separate, PAID step): editor_ad/short → start_autopilot({ slug }); carousel → generate_slider({ slug })."
5315
+ : "Draft created — review it with list_series_episodes.",
5316
+ });
5317
+ }),
5318
+ );
5319
+
5320
+ registerTool(
5321
+ "get_series_dashboard",
5322
+ {
5323
+ title: "Get a series dashboard",
5324
+ description:
5325
+ "Returns a read-only overview of one series: the series plus its episodes grouped into lanes — upcoming " +
5326
+ "(ideas), drafted, and published — with per-item performance notes for episodes linked to a campaign-pack " +
5327
+ "item. Each drafted/published episode carries its draft slug + deep-link route. Read-only, 0 credits. " +
5328
+ "Scope: video:read. 404 for an unknown/foreign series.",
5329
+ inputSchema: {
5330
+ series_id: z.number().int().describe("Series id"),
5331
+ },
5332
+ outputSchema: getSeriesDashboardOutput,
5333
+ annotations: { title: "Get series dashboard", ...RO },
5334
+ },
5335
+ tool(async (args: { series_id: number }, client) => {
5336
+ const res = await client.get<{ data: unknown }>(
5337
+ `/series/${args.series_id}/dashboard`,
5338
+ );
5339
+ return ok(summarizeDashboard(asRecord(res).data));
5340
+ }),
5341
+ );
5342
+
5343
+ registerTool(
5344
+ "mark_episode_posted",
5345
+ {
5346
+ title: "Mark a drafted episode as posted",
5347
+ description:
5348
+ "Records that you posted a DRAFTED episode MANUALLY (there is no platform posting API): flips the episode " +
5349
+ "'drafted' → 'published' (labelled 'Posted' in the app) and stamps posted_at, making the dashboard's " +
5350
+ "published lane reachable. Spends 0 credits. Scope: video:generate. Atomic — only a drafted episode " +
5351
+ "transitions; an idea or archived episode returns 422 invalid_status (materialize it into a draft first). " +
5352
+ "IDEMPOTENT on replay: re-marking an already-published episode returns 200 with the original posted_at " +
5353
+ "untouched. 404 for an unknown/foreign series or episode.",
5354
+ inputSchema: {
5355
+ series_id: z.number().int().describe("Series id"),
5356
+ episode_id: z
5357
+ .number()
5358
+ .int()
5359
+ .describe("Episode id (must be a drafted episode)"),
5360
+ },
5361
+ annotations: {
5362
+ title: "Mark episode posted",
5363
+ ...WRITE,
5364
+ // The server transition is a CAS whose replay it defines as a no-op 200
5365
+ // (already-published keeps the original posted_at), so re-marking is safe.
5366
+ idempotentHint: true,
5367
+ },
5368
+ },
5369
+ tool(async (args: { series_id: number; episode_id: number }, client) => {
5370
+ const res = await client.post<{ data: unknown }>(
5371
+ `/series/${args.series_id}/episodes/${args.episode_id}/mark-posted`,
5372
+ );
5373
+ return ok(summarizeEpisode(asRecord(res).data));
5374
+ }),
5375
+ );
5376
+
5377
+ // ── Content calendar: reminder-only scheduling ────────────────────────────────
5378
+ //
5379
+ // REMINDER-FIRST BY DESIGN: these tools schedule a completed video as a manual-
5380
+ // posting REMINDER (a push to the USER at the due time, then mark_scheduled_posted
5381
+ // to record the manual post). They NEVER accept or send a social-account id, so
5382
+ // every post is reminder-only and the pipeline WriteScope floor authorizes the
5383
+ // write with video:generate — publishing stays human. Attaching an account
5384
+ // (auto-publish) is app-only (account:admin), so a 403 insufficient_scope from
5385
+ // the write tools means the TARGETED post already carries an account, or the token
5386
+ // predates the reminder-calendar scope split — calendarWrite() spells that out.
5387
+
5388
+ /**
5389
+ * Wraps a calendar WRITE (schedule/reschedule/cancel) so an
5390
+ * `insufficient_scope` 403 is re-surfaced with the reminder-only explanation
5391
+ * (the generic errMessage would print the bare server sentence). Any other
5392
+ * failure flows through tool()'s normal errMessage path unchanged.
5393
+ */
5394
+ function calendarWrite<A>(
5395
+ fn: (
5396
+ args: A,
5397
+ client: HubfluencerClient,
5398
+ extra: ToolExtra,
5399
+ ) => Promise<ReturnType<typeof ok>>,
5400
+ ) {
5401
+ return tool<A>(async (args, client, extra) => {
5402
+ try {
5403
+ return await fn(args, client, extra);
5404
+ } catch (e) {
5405
+ const he = e as HubfluencerError;
5406
+ if (he && he.status === 403) {
5407
+ return fail(calendarScopeErrorText(he.status, he.code, errMessage(e)));
5408
+ }
5409
+ throw e;
5410
+ }
5411
+ });
5412
+ }
5413
+
5414
+ registerTool(
5415
+ "schedule_post",
5416
+ {
5417
+ title: "Schedule a posting reminder for a finished video",
5418
+ description:
5419
+ "Schedules one of your own COMPLETED video results for a FUTURE UTC instant as a manual-posting REMINDER: " +
5420
+ "a push notification fires to YOU at the scheduled time and you confirm the manual post with " +
5421
+ "mark_scheduled_posted. This tool is reminder-only — it never attaches a social account, so publishing " +
5422
+ "stays in your hands (auto-publish is app-only). Spends 0 credits. Scope: video:generate. Get the " +
5423
+ "video_result_id from a completed project's result (get_status/download_result) or list_scheduled_posts. " +
5424
+ `platform is your posting INTENT (${SCHEDULED_POST_PLATFORMS.join("/")}); "other" for anywhere else. ` +
5425
+ `Caption ≤${5000} chars; ≤${30} hashtags (each ≤${100}). At most ${MAX_PENDING_SCHEDULED_POSTS} pending ` +
5426
+ "reminders per account (422 scheduled_post_limit_reached beyond that — cancel or mark some posted first). " +
5427
+ "422 for a non-completed/foreign video result or a non-future instant.",
5428
+ inputSchema: {
5429
+ video_result_id: z
5430
+ .number()
5431
+ .int()
5432
+ .describe(
5433
+ "Id of one of your own COMPLETED video results (the render to post)",
5434
+ ),
5435
+ platform: z
5436
+ .enum(SCHEDULED_POST_PLATFORMS)
5437
+ .describe('Where you intend to post: tiktok, instagram, or "other"'),
5438
+ scheduled_at: z
5439
+ .string()
5440
+ .describe(
5441
+ "The UTC instant to be reminded, ISO-8601 (must be in the future)",
5442
+ ),
5443
+ caption: z
5444
+ .string()
5445
+ .optional()
5446
+ .describe(
5447
+ "Optional post caption to stage with the reminder (≤5000 chars)",
5448
+ ),
5449
+ hashtags: z
5450
+ .array(z.string())
5451
+ .optional()
5452
+ .describe("Optional hashtags to stage (≤30, each ≤100 chars)"),
5453
+ timezone: z
5454
+ .string()
5455
+ .optional()
5456
+ .describe("Your chosen timezone (display only), e.g. Europe/Paris"),
5457
+ campaign_pack_item_id: z
5458
+ .number()
5459
+ .int()
5460
+ .optional()
5461
+ .describe(
5462
+ "Optional id of one of your own campaign pack items to group under",
5463
+ ),
5464
+ },
5465
+ annotations: {
5466
+ title: "Schedule post reminder",
5467
+ ...WRITE,
5468
+ // No idempotency plug on the scheduled-post controller → a retry forks a
5469
+ // duplicate reminder.
5470
+ idempotentHint: false,
5471
+ },
5472
+ },
5473
+ calendarWrite(
5474
+ async (
5475
+ args: {
5476
+ video_result_id: number;
5477
+ platform: string;
5478
+ scheduled_at: string;
5479
+ caption?: string;
5480
+ hashtags?: string[];
5481
+ timezone?: string;
5482
+ campaign_pack_item_id?: number;
5483
+ },
5484
+ client,
5485
+ ) => {
5486
+ const copyErr = validateScheduledPostCopy(args);
5487
+ if (copyErr) return fail(copyErr);
5488
+ const res = await client.post<{ data: unknown }>(
5489
+ "/scheduled-posts",
5490
+ buildScheduledPostBody(args),
5491
+ );
5492
+ return ok(summarizeScheduledPost(asRecord(res).data));
5493
+ },
5494
+ ),
5495
+ );
5496
+
5497
+ registerTool(
5498
+ "list_scheduled_posts",
5499
+ {
5500
+ title: "List your scheduled posts (calendar)",
5501
+ description:
5502
+ "Lists your scheduled posts (calendar) ordered by scheduled_at ascending, each summarized with its " +
5503
+ "platform, status, the staged caption/hashtags, the presigned export video_url (post it manually), and the " +
5504
+ "owning video's kind. Optionally narrow the window with from/to (ISO-8601 UTC) and/or filter by status " +
5505
+ `(${SCHEDULED_POST_STATUSES.join("/")}). Read-only, 0 credits. Scope: video:read. Account bindings are ` +
5506
+ "intentionally omitted (this surface is reminder-only).",
5507
+ inputSchema: {
5508
+ from: z
5509
+ .string()
5510
+ .optional()
5511
+ .describe("Lower bound (inclusive) on scheduled_at, ISO-8601 UTC"),
5512
+ to: z
5513
+ .string()
5514
+ .optional()
5515
+ .describe("Upper bound (inclusive) on scheduled_at, ISO-8601 UTC"),
5516
+ status: z
5517
+ .enum(SCHEDULED_POST_STATUSES)
5518
+ .optional()
5519
+ .describe("Filter to one status"),
5520
+ },
5521
+ outputSchema: listScheduledPostsOutput,
5522
+ annotations: { title: "List scheduled posts", ...RO },
5523
+ },
5524
+ tool(
5525
+ async (args: { from?: string; to?: string; status?: string }, client) => {
5526
+ const query: Record<string, string> = {};
5527
+ if (args.from !== undefined) query.from = args.from;
5528
+ if (args.to !== undefined) query.to = args.to;
5529
+ if (args.status !== undefined) query.status = args.status;
5530
+ const res = await client.get<{ data: unknown }>(
5531
+ "/scheduled-posts",
5532
+ Object.keys(query).length > 0 ? query : undefined,
5533
+ );
5534
+ const data = asRecord(res).data;
5535
+ const posts = Array.isArray(data) ? data.map(summarizeScheduledPost) : [];
5536
+ return ok({ posts });
5537
+ },
5538
+ ),
5539
+ );
5540
+
5541
+ registerTool(
5542
+ "reschedule_post",
5543
+ {
5544
+ title: "Reschedule a scheduled post",
5545
+ description:
5546
+ "Moves a still-'scheduled' or 'reminded' post to a new FUTURE instant (a reminded post is re-armed back to " +
5547
+ "'scheduled' — 'remind me later'). Spends 0 credits. Scope: video:generate. 422 not_reschedulable for a " +
5548
+ "post already posted/publishing/published/failed/canceled, or scheduled_in_past for a non-future/ malformed " +
5549
+ "instant. 404 for unknown/foreign ids. A 403 insufficient_scope means the targeted post carries a social " +
5550
+ "account (app-only) — reminder-only posts are reschedulable with video:generate.",
5551
+ inputSchema: {
5552
+ id: z.number().int().describe("Scheduled post id"),
5553
+ scheduled_at: z
5554
+ .string()
5555
+ .describe("The new UTC instant, ISO-8601 (must be in the future)"),
5556
+ },
5557
+ annotations: { title: "Reschedule post", ...WRITE, idempotentHint: false },
5558
+ },
5559
+ calendarWrite(async (args: { id: number; scheduled_at: string }, client) => {
5560
+ const res = await client.patch<{ data: unknown }>(
5561
+ `/scheduled-posts/${args.id}`,
5562
+ { scheduled_at: args.scheduled_at },
5563
+ );
5564
+ return ok(summarizeScheduledPost(asRecord(res).data));
5565
+ }),
5566
+ );
5567
+
5568
+ registerTool(
5569
+ "cancel_scheduled_post",
5570
+ {
5571
+ title: "Cancel a scheduled post",
5572
+ description:
5573
+ "Cancels a 'scheduled', 'reminded', or 'failed' post (status → 'canceled'); it will no longer fire. Spends " +
5574
+ "0 credits. Scope: video:generate. 422 not_cancelable for a post already posted/publishing/published/" +
5575
+ "canceled. 404 for unknown/foreign ids. A 403 insufficient_scope means the targeted post carries a social " +
5576
+ "account (app-only) — reminder-only posts are cancelable with video:generate.",
5577
+ inputSchema: {
5578
+ id: z.number().int().describe("Scheduled post id"),
5579
+ },
5580
+ annotations: {
5581
+ title: "Cancel scheduled post",
5582
+ readOnlyHint: false,
5583
+ // Cancellation is a state-destroying transition on the reminder.
5584
+ destructiveHint: true,
5585
+ openWorldHint: true,
5586
+ idempotentHint: false,
5587
+ },
5588
+ },
5589
+ calendarWrite(async (args: { id: number }, client) => {
5590
+ const res = await client.del<{ data: unknown }>(
5591
+ `/scheduled-posts/${args.id}`,
5592
+ );
5593
+ return ok(summarizeScheduledPost(asRecord(res).data));
5594
+ }),
5595
+ );
5596
+
5597
+ registerTool(
5598
+ "mark_scheduled_posted",
5599
+ {
5600
+ title: "Mark a scheduled post as posted (manual posting)",
5601
+ description:
5602
+ "Records that you posted this content MANUALLY: transitions a 'scheduled' or 'reminded' post to 'posted' " +
5603
+ "and stamps posted_at. Spends 0 credits. Scope: video:generate (closing the manual-posting loop is allowed " +
5604
+ "for agent tokens). Atomic — a post in any other status (including an already-posted one, so a SECOND call) " +
5605
+ "returns 422 not_markable; this is NOT a replayable no-op. 404 for unknown/foreign ids. Touches no platform API.",
5606
+ inputSchema: {
5607
+ id: z
5608
+ .number()
5609
+ .int()
5610
+ .describe("Scheduled post id (a scheduled or reminded post)"),
5611
+ },
5612
+ annotations: {
5613
+ title: "Mark scheduled post posted",
5614
+ ...WRITE,
5615
+ // The CAS returns 422 not_markable on a second call (not a no-op), so a
5616
+ // blind retry after success is NOT idempotent.
5617
+ idempotentHint: false,
5618
+ },
5619
+ },
5620
+ tool(async (args: { id: number }, client) => {
5621
+ const res = await client.post<{ data: unknown }>(
5622
+ `/scheduled-posts/${args.id}/mark-posted`,
5623
+ );
5624
+ return ok(summarizeScheduledPost(asRecord(res).data));
5625
+ }),
5626
+ );
5627
+
5628
+ // ── Performance loop: metrics, attributes, cautious recommendation ────────────
5629
+ //
5630
+ // Attach lightweight performance snapshots + creative-attribute tags to a
5631
+ // campaign-pack ITEM (keyed by the globally-unique item id), then read a CAUTIOUS
5632
+ // aggregate recommendation over your own items. Everything here is user-scoped
5633
+ // (a foreign/unknown item is a generic 404) and spends NO video credits; only
5634
+ // make_more_like_winner consumes the free daily AI-assist quota. get_status ids
5635
+ // come from a materialized pack item (get_campaign_pack surfaces each item id).
5636
+
5637
+ registerTool(
5638
+ "record_performance",
5639
+ {
5640
+ title: "Record a performance snapshot for a campaign-pack item",
5641
+ description:
5642
+ "Attaches one performance snapshot (platform + engagement/conversion counts + optional revenue/watch " +
5643
+ "time) to one of your own campaign-pack items (item_id from get_campaign_pack). Many snapshots per item " +
5644
+ "are allowed — captured_at (defaults to now if omitted) distinguishes them, so you can log a trend over " +
5645
+ "time. Spends 0 credits. Scope: video:generate. Rate limit: 30/min. 404 for a foreign/unknown item; 422 " +
5646
+ "for invalid metrics. NOT idempotency-keyed — a transport retry records a DUPLICATE snapshot, so don't " +
5647
+ "blindly retry a success.",
5648
+ inputSchema: {
5649
+ item_id: z
5650
+ .number()
5651
+ .int()
5652
+ .describe("Campaign-pack item id (from get_campaign_pack)"),
5653
+ platform: z
5654
+ .enum(PERFORMANCE_PLATFORMS)
5655
+ .describe('Where it ran: tiktok, instagram, or "other"'),
5656
+ views: z.number().int().optional().describe("View count (≥0)"),
5657
+ likes: z.number().int().optional().describe("Like count (≥0)"),
5658
+ comments: z.number().int().optional().describe("Comment count (≥0)"),
5659
+ shares: z.number().int().optional().describe("Share count (≥0)"),
5660
+ clicks: z.number().int().optional().describe("Click count (≥0)"),
5661
+ conversions: z
5662
+ .number()
5663
+ .int()
5664
+ .optional()
5665
+ .describe("Conversion count (≥0)"),
5666
+ revenue: z
5667
+ .number()
5668
+ .optional()
5669
+ .describe("Attributed revenue (≥0, ≤~1 trillion)"),
5670
+ watch_time_seconds: z
5671
+ .number()
5672
+ .int()
5673
+ .optional()
5674
+ .describe("Total watch time in seconds (≥0)"),
5675
+ source: z
5676
+ .enum(PERFORMANCE_SOURCES)
5677
+ .optional()
5678
+ .describe("How the metrics were gathered (default manual)"),
5679
+ captured_at: z
5680
+ .string()
5681
+ .optional()
5682
+ .describe("When measured, ISO-8601 UTC (defaults to now; not future)"),
5683
+ metadata: z
5684
+ .record(z.string(), z.unknown())
5685
+ .optional()
5686
+ .describe("Optional provenance blob (≤16 KiB JSON)"),
5687
+ },
5688
+ annotations: {
5689
+ title: "Record performance",
5690
+ ...WRITE,
5691
+ // Append-only (many snapshots per item, no idempotency plug) → a retry
5692
+ // forks a duplicate snapshot.
5693
+ idempotentHint: false,
5694
+ },
5695
+ },
5696
+ tool(async (args: RecordPerformanceArgs & { item_id: number }, client) => {
5697
+ const err = validatePerformanceMetrics(args);
5698
+ if (err) return fail(err);
5699
+ const res = await client.post<{ data: unknown }>(
5700
+ `/campaign-pack-items/${args.item_id}/performance`,
5701
+ buildPerformanceBody(args),
5702
+ );
5703
+ return ok({
5704
+ ...summarizePerformance(asRecord(res).data),
5705
+ next: "set_item_attributes to tag this item's creative, then get_recommendations once a few items have both.",
5706
+ });
5707
+ }),
5708
+ );
5709
+
5710
+ registerTool(
5711
+ "list_performance",
5712
+ {
5713
+ title: "List a campaign-pack item's performance snapshots",
5714
+ description:
5715
+ "Returns one of your own campaign-pack items' performance snapshots (newest first) plus a small summary " +
5716
+ "(latest snapshot + count). Read-only, 0 credits. Scope: video:read. 404 for a foreign/unknown item.",
5717
+ inputSchema: {
5718
+ item_id: z.number().int().describe("Campaign-pack item id"),
5719
+ },
5720
+ annotations: { title: "List performance", ...RO },
5721
+ },
5722
+ tool(async (args: { item_id: number }, client) => {
5723
+ const res = await client.get<{ data: unknown }>(
5724
+ `/campaign-pack-items/${args.item_id}/performance`,
5725
+ );
5726
+ const data = asRecord(asRecord(res).data);
5727
+ const performance = Array.isArray(data.performance)
5728
+ ? data.performance.map(summarizePerformance)
5729
+ : [];
5730
+ return ok({
5731
+ item_id: args.item_id,
5732
+ performance,
5733
+ summary: data.summary ?? null,
5734
+ });
5735
+ }),
5736
+ );
5737
+
5738
+ registerTool(
5739
+ "set_item_attributes",
5740
+ {
5741
+ title: "Set the creative attributes for a campaign-pack item",
5742
+ description:
5743
+ "Inserts-or-updates the SINGLE creative-attributes row (hook_type, creative_format, visual_language, " +
5744
+ "product_category, cta, length, caption_style) for one of your own campaign-pack items — the tags the " +
5745
+ "recommendation loop correlates with performance. Calling it again UPDATES the same row (atomic upsert). " +
5746
+ "Spends 0 credits. Scope: video:generate. 404 for a foreign/unknown item; 422 for invalid attributes.",
5747
+ inputSchema: {
5748
+ item_id: z.number().int().describe("Campaign-pack item id"),
5749
+ hook_type: z
5750
+ .string()
5751
+ .optional()
5752
+ .describe('The hook style, e.g. "question", "bold_claim"'),
5753
+ creative_format: z
5754
+ .string()
5755
+ .optional()
5756
+ .describe('Narrative format, e.g. "problem_solution"'),
5757
+ visual_language: z
5758
+ .string()
5759
+ .optional()
5760
+ .describe('Production treatment, e.g. "ugc_realism"'),
5761
+ product_category: z.string().optional().describe("Product category tag"),
5762
+ cta: z.string().optional().describe("Call-to-action used"),
5763
+ length: z.string().optional().describe('Length bucket, e.g. "short"'),
5764
+ caption_style: z.string().optional().describe("Caption style tag"),
5765
+ metadata: z
5766
+ .record(z.string(), z.unknown())
5767
+ .optional()
5768
+ .describe("Optional extra classifiers"),
5769
+ },
5770
+ annotations: {
5771
+ title: "Set item attributes",
5772
+ ...WRITE,
5773
+ // A single upserted row: a repeat identical call converges to the same
5774
+ // row, so re-applying is safe.
5775
+ idempotentHint: true,
5776
+ },
5777
+ },
5778
+ tool(async (args: { item_id: number } & Record<string, unknown>, client) => {
5779
+ const res = await client.put<{ data: unknown }>(
5780
+ `/campaign-pack-items/${args.item_id}/attributes`,
5781
+ pickCreativeAttributeFields(args),
5782
+ );
5783
+ return ok(asRecord(res).data);
5784
+ }),
5785
+ );
5786
+
5787
+ registerTool(
5788
+ "get_item_attributes",
5789
+ {
5790
+ title: "Fetch the creative attributes for a campaign-pack item",
5791
+ description:
5792
+ "Returns the single creative-attributes row for one of your own campaign-pack items, or null if none has " +
5793
+ "been set. Read-only, 0 credits. Scope: video:read. 404 for a foreign/unknown item.",
5794
+ inputSchema: {
5795
+ item_id: z.number().int().describe("Campaign-pack item id"),
5796
+ },
5797
+ annotations: { title: "Get item attributes", ...RO },
5798
+ },
5799
+ tool(async (args: { item_id: number }, client) => {
5800
+ const res = await client.get<{ data: unknown }>(
5801
+ `/campaign-pack-items/${args.item_id}/attributes`,
5802
+ );
5803
+ return ok(asRecord(res).data ?? null);
5804
+ }),
5805
+ );
5806
+
5807
+ registerTool(
5808
+ "get_recommendations",
5809
+ {
5810
+ title: "Cautious creative recommendation from your own performance",
5811
+ description:
5812
+ "Aggregates your own campaign-pack items that have BOTH creative attributes AND at least one performance " +
5813
+ "snapshot into a CAUTIOUS recommendation: the top-performing hook_type / creative_format / visual_language " +
5814
+ "by average views, your best items, and an HONEST confidence level. Confidence is a function of sample size " +
5815
+ 'and is returned as "none" (no data yet), "low", or "moderate" — it is NEVER overstated, and the result ' +
5816
+ "NEVER claims causation, ROAS, or attribution: these are correlations in YOUR OWN data, a hint to test, not " +
5817
+ "proof. Present the server's `note` and `confidence` as-is; do not upgrade them. Read-only; NO LLM; NO " +
5818
+ "video credits; NO quota. Scope: video:read.",
5819
+ inputSchema: {},
5820
+ outputSchema: getRecommendationsOutput,
5821
+ annotations: { title: "Get recommendations", ...RO },
5822
+ },
5823
+ tool(async (_args, client) => {
5824
+ const res = await client.get<{ data: unknown }>(
5825
+ "/performance/recommendations",
5826
+ );
5827
+ // Pass the server aggregate through UNCHANGED — the caution semantics
5828
+ // (confidence tier + non-causal note) are the server's to state, not ours.
5829
+ return ok(asRecord(res).data);
5830
+ }),
5831
+ );
5832
+
5833
+ registerTool(
5834
+ "make_more_like_winner",
5835
+ {
5836
+ title: "Make more hook variations like a winning item",
5837
+ description:
5838
+ "Generates 3 follow-up hook variations from one of your own campaign-pack items, threading that item's " +
5839
+ "winning angle/format/hook into the generator while keeping its brand + product profile — the way to " +
5840
+ "double down on a proven creative. Generates no media, persists nothing, and spends NO video credits: it " +
5841
+ "consumes the FREE daily AI-assist quota instead (429 when exhausted; set auto_unlock:true to spend 1 credit " +
5842
+ "for +10 assists, retried once, or call unlock_ai_assists). Scope: video:generate. Rate limit: 30/min. 404 " +
5843
+ "for a foreign/unknown item; 422 when the item has no usable product context. These are new IDEAS — feed a " +
5844
+ "chosen one into add_pack_variations / create_campaign_pack to turn it into a draft.",
5845
+ inputSchema: {
5846
+ item_id: z
5847
+ .number()
5848
+ .int()
5849
+ .describe("Campaign-pack item id of the winning creative"),
5850
+ auto_unlock: z
5851
+ .boolean()
5852
+ .optional()
5853
+ .describe(
5854
+ "On 429, spend 1 credit to unlock +10 assists and retry once (default false)",
5855
+ ),
5856
+ },
5857
+ annotations: {
5858
+ title: "Make more like winner",
5859
+ ...WRITE,
5860
+ // Consumes the free quota (a side effect); no idempotency plug.
5861
+ idempotentHint: false,
5862
+ },
5863
+ },
5864
+ tool(async (args: { item_id: number; auto_unlock?: boolean }, client) => {
5865
+ const res = await withAssist(client, args.auto_unlock ?? false, () =>
5866
+ client.post<{ data: unknown }>(
5867
+ `/campaign-pack-items/${args.item_id}/make-more`,
5868
+ ),
5869
+ );
5870
+ const data = asRecord(asRecord(res).data);
5871
+ return ok({
5872
+ item_id: args.item_id,
5873
+ variations: Array.isArray(data.variations) ? data.variations : [],
5874
+ ai_assist_quota: data.ai_assist_quota ?? null,
5875
+ next: "Feed a chosen variation into add_pack_variations({ pack_id, variations, materialize: true }) to draft it.",
5876
+ });
5877
+ }),
5878
+ );
5879
+
5880
+ // ── Review links: no-login approval loop ──────────────────────────────────────
5881
+ //
5882
+ // Mint an expiring, hashed-token review link for ONE completed output so a
5883
+ // reviewer can open /review/<token> with no account. The plaintext token is
5884
+ // returned ONCE at creation and is NOT retrievable again (only its hash is
5885
+ // stored). All user-scoped; spends 0 credits; writes need video:generate.
5886
+
5887
+ registerTool(
5888
+ "create_review_link",
5889
+ {
5890
+ title: "Create a no-login review link for one completed output",
5891
+ description:
5892
+ "Mints an expiring, hashed-token review link for EXACTLY ONE of your own COMPLETED outputs — a video " +
5893
+ "result (video_result_id) OR a carousel/slider (slider_id), never both — optionally grouped under one of " +
5894
+ "your campaign packs. The reviewer opens the returned URL with NO account. Spends 0 credits. Scope: " +
5895
+ "video:generate. IMPORTANT: the plaintext token + review URL are returned ONCE and CANNOT be retrieved " +
5896
+ "again (only the hash is stored) — surface them to the user immediately. expires_in_hours defaults to 168 " +
5897
+ `(7 days) and is clamped to ${REVIEW_LINK_MIN_EXPIRES_IN_HOURS}..${REVIEW_LINK_MAX_EXPIRES_IN_HOURS}. ` +
5898
+ "422 for a not-completed/foreign output or a foreign campaign pack.",
5899
+ inputSchema: {
5900
+ video_result_id: z
5901
+ .number()
5902
+ .int()
5903
+ .optional()
5904
+ .describe(
5905
+ "Id of one of your own COMPLETED video results (mutually exclusive with slider_id)",
5906
+ ),
5907
+ slider_id: z
5908
+ .number()
5909
+ .int()
5910
+ .optional()
5911
+ .describe(
5912
+ "Id of one of your own COMPLETED sliders/carousels (mutually exclusive with video_result_id)",
5913
+ ),
5914
+ campaign_pack_id: z
5915
+ .number()
5916
+ .int()
5917
+ .optional()
5918
+ .describe(
5919
+ "Optional id of one of your own campaign packs to group under",
5920
+ ),
5921
+ expires_in_hours: z
5922
+ .number()
5923
+ .int()
5924
+ .optional()
5925
+ .describe(
5926
+ `How long the link stays valid; clamped ${REVIEW_LINK_MIN_EXPIRES_IN_HOURS}..${REVIEW_LINK_MAX_EXPIRES_IN_HOURS} (default 168 = 7 days)`,
5927
+ ),
5928
+ download_enabled: z
5929
+ .boolean()
5930
+ .optional()
5931
+ .describe(
5932
+ "Whether the reviewer may download the output (default false)",
5933
+ ),
5934
+ version_label: z
5935
+ .string()
5936
+ .optional()
5937
+ .describe("Optional human label for this review version (≤120 chars)"),
5938
+ },
5939
+ annotations: {
5940
+ title: "Create review link",
5941
+ ...WRITE,
5942
+ // Mints a fresh token every call (no idempotency plug) → a retry forks a
5943
+ // second link with a NEW token.
5944
+ idempotentHint: false,
5945
+ },
5946
+ },
5947
+ tool(
5948
+ async (
5949
+ args: {
5950
+ video_result_id?: number;
5951
+ slider_id?: number;
5952
+ campaign_pack_id?: number;
5953
+ expires_in_hours?: number;
5954
+ download_enabled?: boolean;
5955
+ version_label?: string;
5956
+ },
5957
+ client,
5958
+ ) => {
5959
+ const err = validateReviewOutput(args.video_result_id, args.slider_id);
5960
+ if (err) return fail(err);
5961
+ const res = await client.post<{ data: unknown }>(
5962
+ "/review-links",
5963
+ buildReviewLinkBody(args),
5964
+ );
5965
+ // The reviewer page (/review/<token>) is a FRONT web-app route, not an API
5966
+ // endpoint, so build the absolute URL on the web-app origin (HUBFLUENCER_WEB_URL,
5967
+ // default https://app.hubfluencer.com) — NOT client.origin (the API base).
5968
+ const result: CreateReviewLinkResult = shapeCreateReviewLink(
5969
+ asRecord(res).data,
5970
+ reviewWebOrigin(),
5971
+ );
5972
+ return ok(result);
5973
+ },
5974
+ ),
5975
+ );
5976
+
5977
+ registerTool(
5978
+ "list_review_links",
5979
+ {
5980
+ title: "List your review links",
5981
+ description:
5982
+ "Lists your review links, newest first, each summarized with its status (pending → viewed → approved / " +
5983
+ "changes_requested / revoked), the output it points at, expiry, and its visible comment_count — so you can " +
5984
+ "see review + approval activity at a glance. NEVER returns the token (only shown once at creation). " +
5985
+ "Read-only, 0 credits. Scope: video:read.",
5986
+ inputSchema: {},
5987
+ annotations: { title: "List review links", ...RO },
5988
+ },
5989
+ tool(async (_args, client) => {
5990
+ const res = await client.get<{ data: unknown }>("/review-links");
5991
+ const data = asRecord(res).data;
5992
+ const links = Array.isArray(data) ? data.map(summarizeReviewLink) : [];
5993
+ return ok({ review_links: links });
5994
+ }),
5995
+ );
5996
+
5997
+ registerTool(
5998
+ "revoke_review_link",
5999
+ {
6000
+ title: "Revoke a review link",
6001
+ description:
6002
+ "Revokes one of your own review links (status → 'revoked'); the no-login URL immediately stops working. " +
6003
+ "Returns the revoked link. Spends 0 credits. Scope: video:generate. 404 for an unknown/foreign id.",
6004
+ inputSchema: {
6005
+ id: z.number().int().describe("Review link id (from list_review_links)"),
6006
+ },
6007
+ annotations: {
6008
+ title: "Revoke review link",
6009
+ readOnlyHint: false,
6010
+ // Disabling the public URL is a state-destroying transition on the link.
6011
+ destructiveHint: true,
6012
+ openWorldHint: true,
6013
+ idempotentHint: false,
6014
+ },
6015
+ },
6016
+ tool(async (args: { id: number }, client) => {
6017
+ const res = await client.del<{ data: unknown }>(`/review-links/${args.id}`);
6018
+ return ok(summarizeReviewLink(asRecord(res).data));
6019
+ }),
6020
+ );
6021
+
6022
+ // ── Asset catalog: cross-project reusable media ───────────────────────────────
6023
+ //
6024
+ // The reusable user asset library (images + videos). Uploading here lets an asset
6025
+ // be reused across projects (via the app's from-asset flows). SUBSCRIPTION-GATED:
6026
+ // these routes sit behind RequireSubscription, which returns 402
6027
+ // `subscription_required` when the user has no active subscription —
6028
+ // assetCatalog() re-surfaces that as a clear "needs an active subscription".
6029
+
6030
+ /**
6031
+ * Wraps an asset-catalog tool so a `subscription_required` 402 (the
6032
+ * RequireSubscription plug) is re-surfaced with a clear "subscribe first"
6033
+ * explanation (the generic errMessage would print the bare server sentence). Any
6034
+ * other failure flows through tool()'s normal errMessage path unchanged.
6035
+ */
6036
+ function assetCatalog<A>(
6037
+ fn: (
6038
+ args: A,
6039
+ client: HubfluencerClient,
6040
+ extra: ToolExtra,
6041
+ ) => Promise<ReturnType<typeof ok>>,
6042
+ ) {
6043
+ return tool<A>(async (args, client, extra) => {
6044
+ try {
6045
+ return await fn(args, client, extra);
6046
+ } catch (e) {
6047
+ const he = e as HubfluencerError;
6048
+ if (he && he.status === 402) {
6049
+ return fail(
6050
+ assetSubscriptionErrorText(he.status, he.code, errMessage(e)),
6051
+ );
6052
+ }
6053
+ throw e;
6054
+ }
6055
+ });
6056
+ }
6057
+
6058
+ registerTool(
6059
+ "list_assets",
6060
+ {
6061
+ title: "List your reusable asset catalog",
6062
+ description:
6063
+ "Lists your reusable asset catalog (images + videos), newest first, each with its media type, mime, size, " +
6064
+ "description, status, and a presigned asset_url — plus your storage quota. Optionally page with limit/offset " +
6065
+ "(offset = page * limit). Read-only, 0 credits. Scope: video:read. Requires an ACTIVE SUBSCRIPTION (402 " +
6066
+ "subscription_required otherwise).",
6067
+ inputSchema: {
6068
+ limit: z
6069
+ .number()
6070
+ .int()
6071
+ .optional()
6072
+ .describe("Max assets to return (a positive integer)"),
6073
+ offset: z
6074
+ .number()
6075
+ .int()
6076
+ .optional()
6077
+ .describe("How many to skip (offset = page * limit)"),
6078
+ },
6079
+ annotations: { title: "List assets", ...RO },
6080
+ },
6081
+ assetCatalog(async (args: { limit?: number; offset?: number }, client) => {
6082
+ const query: Record<string, number> = {};
6083
+ if (args.limit !== undefined) query.limit = args.limit;
6084
+ if (args.offset !== undefined) query.offset = args.offset;
6085
+ const res = await client.get<{ data: unknown; meta?: unknown }>(
6086
+ "/assets",
6087
+ Object.keys(query).length > 0 ? query : undefined,
6088
+ );
6089
+ const data = asRecord(res).data;
6090
+ const assets = Array.isArray(data) ? data.map(summarizeCatalogAsset) : [];
6091
+ const quota = asRecord(asRecord(res).meta).quota;
6092
+ return ok({
6093
+ assets,
6094
+ quota: quota ? summarizeAssetQuota(quota) : null,
6095
+ });
6096
+ }),
6097
+ );
6098
+
6099
+ registerTool(
6100
+ "get_asset_quota",
6101
+ {
6102
+ title: "Get your asset-catalog storage quota",
6103
+ description:
6104
+ "Returns your asset-catalog storage quota (limit / used / pending / reserved / remaining bytes). Read-only, " +
6105
+ "0 credits. Scope: video:read. Requires an ACTIVE SUBSCRIPTION (402 subscription_required otherwise).",
6106
+ inputSchema: {},
6107
+ annotations: { title: "Get asset quota", ...RO },
6108
+ },
6109
+ assetCatalog(async (_args, client) => {
6110
+ const res = await client.get<{ data: unknown }>("/assets/quota");
6111
+ return ok(summarizeAssetQuota(asRecord(res).data));
6112
+ }),
6113
+ );
6114
+
6115
+ registerTool(
6116
+ "upload_asset",
6117
+ {
6118
+ title: "Upload a local file to your reusable asset catalog",
6119
+ description:
6120
+ "Uploads a local IMAGE or VIDEO into your reusable asset catalog so it can be reused across projects. " +
6121
+ `Allowed types: ${CATALOG_ASSET_EXTS.map((e) => `.${e}`).join(", ")} — images ≤20 MiB, videos ≤500 MB. ` +
6122
+ "Runs presign → PUT → confirm; the confirm HEADs the object and validates size/type, so a mismatch 422s. " +
6123
+ "For a VIDEO you MUST pass duration_seconds (≤60; the server rejects a video with no/over-long duration). " +
6124
+ "Spends 0 credits. Scope: video:generate. Rate limit: 30/min. Requires an ACTIVE SUBSCRIPTION (402 " +
6125
+ "subscription_required otherwise). The local path is confined to HUBFLUENCER_INPUT_DIR (default: cwd). " +
6126
+ "Returns the confirmed asset id + media type + status.",
6127
+ inputSchema: {
6128
+ file_path: z
6129
+ .string()
6130
+ .describe(
6131
+ "Path to a local image/video, inside HUBFLUENCER_INPUT_DIR (default: cwd)",
6132
+ ),
6133
+ description: z
6134
+ .string()
6135
+ .optional()
6136
+ .describe("Optional description stored with the asset"),
6137
+ duration_seconds: z
6138
+ .number()
6139
+ .optional()
6140
+ .describe("Video duration in seconds (REQUIRED for a video, ≤60)"),
6141
+ width: z.number().int().optional().describe("Optional pixel width"),
6142
+ height: z.number().int().optional().describe("Optional pixel height"),
6143
+ },
6144
+ annotations: {
6145
+ title: "Upload asset",
6146
+ ...WRITE,
6147
+ // Each upload presigns a fresh asset row (no idempotency plug) → a retry
6148
+ // uploads a duplicate.
6149
+ idempotentHint: false,
6150
+ },
6151
+ },
6152
+ assetCatalog(
6153
+ async (
6154
+ args: {
6155
+ file_path: string;
6156
+ description?: string;
6157
+ duration_seconds?: number;
6158
+ width?: number;
6159
+ height?: number;
6160
+ },
6161
+ client,
6162
+ ) => {
6163
+ const asset = await uploadCatalogAsset(client, args.file_path, {
6164
+ description: args.description,
6165
+ durationSeconds: args.duration_seconds,
6166
+ width: args.width,
6167
+ height: args.height,
6168
+ });
6169
+ return ok({
6170
+ ...asset,
6171
+ next: "list_assets to see it; reuse it across projects from the app's asset picker.",
6172
+ });
6173
+ },
6174
+ ),
6175
+ );
6176
+
3273
6177
  // ── Boot ──────────────────────────────────────────────────────────────────────
3274
6178
 
3275
6179
  async function main() {