@hubfluencer/mcp 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -64,7 +64,9 @@ claude mcp add hubfluencer --env HUBFLUENCER_API_TOKEN=YOUR_TOKEN -- npx -y @hub
64
64
  | Tool | What it does |
65
65
  |---|---|
66
66
  | **`make_video`** | **One shot: prompt → finished MP4** (create → **price** → start → poll → download). Use this by default. Prices the job and checks your balance before charging; pass `dry_run:true` to preview the cost without spending, or `max_credits` to cap it. `kind:"auto"` picks a multi-scene editor ad for ad/promo/story briefs, a short for simple ones (reported as `kind_inferred`). |
67
- | `create_short` / `generate_short` | Create a short draft (0 credits) / render it (15 credits) |
67
+ | `create_short` / `generate_short` / `generate_short_text` | Create a short draft (0 credits) / generate editable overlay copy (1 AI assist) / render it (15 credits) |
68
+ | `create_slider` / `generate_slider` / `get_slider` | Create an image carousel draft (0 credits) / render it (1 credit per slide, 3–10) / read the per-slide image URLs + caption + hashtags. One prompt → N still slides + ready-to-post text. |
69
+ | `restyle_slider` / `edit_slider_slide` | Re-composite a **completed** carousel for **free** (0 credits): swap the template/accent on every slide, or rewrite one slide's headline/body/kicker. Reuses the AI backgrounds; poll `get_slider` until `completed`. |
68
70
  | `create_editor_ad` | Create an editor project **and** run autopilot end-to-end |
69
71
  | `start_autopilot` | Run autopilot on an **existing** editor draft (e.g. resume a `make_video` `dry_run`, or after topping up) |
70
72
  | `get_status` / `wait_for_completion` | Normalized `{stage, terminal, ready, video_url, error}`; block-poll until terminal (bounded) |
@@ -89,7 +91,7 @@ claude mcp add hubfluencer --env HUBFLUENCER_API_TOKEN=YOUR_TOKEN -- npx -y @hub
89
91
  | Tool | What it does |
90
92
  |---|---|
91
93
  | `get_ai_assists` / `unlock_ai_assists` | Check the daily quota / spend 1 credit for +10 |
92
- | `enhance_prompt` / `suggest_next_scene` / `suggest_music_prompt` | AI helpers (1 assist each) |
94
+ | `generate_short_text` / `enhance_prompt` / `suggest_next_scene` / `suggest_music_prompt` | AI helpers (1 assist each) |
93
95
 
94
96
  ## Typical flow
95
97
 
@@ -100,9 +102,16 @@ It prices the job and only charges if it's affordable (and within `max_credits`
100
102
  If it returns `terminal:false`, the render is still going — call `wait_for_completion` with the returned slug.
101
103
 
102
104
  **Granular (control/recovery):**
103
- `create_short` → `generate_short` → `wait_for_completion {kind:"short"}` → `download_result`, or
105
+ `create_short` → optional `generate_short_text` → `generate_short` → `wait_for_completion {kind:"short"}` → `download_result`, or
104
106
  `create_editor_ad` → `wait_for_completion {kind:"editor"}` → `download_result`.
105
107
 
108
+ **Image carousel:**
109
+ `create_slider({ prompt: "5 tips for…", mode: "ad_driven", slide_count: 5 })` → `generate_slider` →
110
+ poll `get_slider` until `completed:true`, then download each `slides[].image_url` and post them with the
111
+ returned `caption` + `hashtags`. To tweak the look afterward without re-paying, `restyle_slider`
112
+ (new template/accent, re-renders every slide) or `edit_slider_slide` (rewrite one slide's text) — both
113
+ free; poll `get_slider` again until `completed`.
114
+
106
115
  > Result URLs are presigned and expire (~24h). Download promptly. Publishing to
107
116
  > TikTok/Instagram requires a human-linked social account and is out of scope —
108
117
  > return the MP4 + a suggested caption instead.
package/dist/index.js CHANGED
@@ -4,25 +4,43 @@ var __getProtoOf = Object.getPrototypeOf;
4
4
  var __defProp = Object.defineProperty;
5
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
6
  var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ function __accessProp(key) {
8
+ return this[key];
9
+ }
10
+ var __toESMCache_node;
11
+ var __toESMCache_esm;
7
12
  var __toESM = (mod, isNodeMode, target) => {
13
+ var canCache = mod != null && typeof mod === "object";
14
+ if (canCache) {
15
+ var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
16
+ var cached = cache.get(mod);
17
+ if (cached)
18
+ return cached;
19
+ }
8
20
  target = mod != null ? __create(__getProtoOf(mod)) : {};
9
21
  const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
10
22
  for (let key of __getOwnPropNames(mod))
11
23
  if (!__hasOwnProp.call(to, key))
12
24
  __defProp(to, key, {
13
- get: () => mod[key],
25
+ get: __accessProp.bind(mod, key),
14
26
  enumerable: true
15
27
  });
28
+ if (canCache)
29
+ cache.set(mod, to);
16
30
  return to;
17
31
  };
18
32
  var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
33
+ var __returnValue = (v) => v;
34
+ function __exportSetter(name, newValue) {
35
+ this[name] = __returnValue.bind(null, newValue);
36
+ }
19
37
  var __export = (target, all) => {
20
38
  for (var name in all)
21
39
  __defProp(target, name, {
22
40
  get: all[name],
23
41
  enumerable: true,
24
42
  configurable: true,
25
- set: (newValue) => all[name] = () => newValue
43
+ set: __exportSetter.bind(all, name)
26
44
  });
27
45
  };
28
46
 
@@ -6285,7 +6303,7 @@ var require_formats = __commonJS((exports) => {
6285
6303
  }
6286
6304
  var TIME = /^(\d\d):(\d\d):(\d\d(?:\.\d+)?)(z|([+-])(\d\d)(?::?(\d\d))?)?$/i;
6287
6305
  function getTime(strictTimeZone) {
6288
- return function time(str) {
6306
+ return function time3(str) {
6289
6307
  const matches = TIME.exec(str);
6290
6308
  if (!matches)
6291
6309
  return false;
@@ -19409,7 +19427,7 @@ function finalize(ctx, schema) {
19409
19427
  result.$schema = "http://json-schema.org/draft-07/schema#";
19410
19428
  } else if (ctx.target === "draft-04") {
19411
19429
  result.$schema = "http://json-schema.org/draft-04/schema#";
19412
- } else if (ctx.target === "openapi-3.0") {} else {}
19430
+ } else if (ctx.target === "openapi-3.0") {}
19413
19431
  if (ctx.external?.uri) {
19414
19432
  const id = ctx.external.registry.get(schema)?.id;
19415
19433
  if (!id)
@@ -19657,7 +19675,7 @@ var literalProcessor = (schema, ctx, json, _params) => {
19657
19675
  if (val === undefined) {
19658
19676
  if (ctx.unrepresentable === "throw") {
19659
19677
  throw new Error("Literal `undefined` cannot be represented in JSON Schema");
19660
- } else {}
19678
+ }
19661
19679
  } else if (typeof val === "bigint") {
19662
19680
  if (ctx.unrepresentable === "throw") {
19663
19681
  throw new Error("BigInt literals cannot be represented in JSON Schema");
@@ -30017,7 +30035,7 @@ async function pollSegmentToTerminal(client, slug, segmentId, extra, budgetMs, i
30017
30035
  }
30018
30036
  registerTool("make_video", {
30019
30037
  title: "Make a video from a prompt (one shot)",
30020
- description: "The simplest path: give a prompt, get a finished MP4. Creates the project (free), PRICES it against " + "your live credit balance, then — only if it's affordable and within max_credits — starts generation, " + "polls to completion (emitting progress), and (if save_path is given) downloads the result. " + "Spends credits (15 for a short; a multi-scene editor ad ~28). Pass dry_run:true to preview the cost " + "WITHOUT charging (returns {estimated_credits, available_credits, slug}); pass max_credits to cap the " + "spend. kind defaults to 'auto' — a multi-scene editor ad for ad/promo/story briefs, a single-clip short " + "for simple/short ones; the chosen kind is reported back as kind_inferred. If it returns terminal=false " + "the render is still running — call wait_for_completion with the returned slug to finish. " + "BE PROACTIVE WITH BRANDING: pass headline (the on-screen TITLE) and subheadline (secondary title) plus " + "music_vibe/theme so a one-shot short isn't bare — these apply to SHORTS (music_vibe/headline/subheadline " + "are ignored for editor; theme applies to both). For richer branding — a product image, brand logo, or " + "closing card — drive the granular path instead: create_short + set_short_product/set_short_poster, or " + "create_editor_draft + set_product/set_logo/set_closing_image, then start_autopilot/generate_short.",
30038
+ description: "The simplest path: give a prompt, get a finished MP4. Creates the project (free), PRICES it against " + "your live credit balance, then — only if it's affordable and within max_credits — starts generation, " + "polls to completion (emitting progress), and (if save_path is given) downloads the result. " + "Spends credits (15 for a short; a multi-scene editor ad ~28). Pass dry_run:true to preview the cost " + "WITHOUT charging (returns {estimated_credits, available_credits, slug}); pass max_credits to cap the " + "spend. kind defaults to 'auto' — a multi-scene editor ad for ad/promo/story briefs, a single-clip short " + "for simple/short ones; the chosen kind is reported back as kind_inferred. If it returns terminal=false " + "the render is still running — call wait_for_completion with the returned slug to finish. " + "BE PROACTIVE WITH BRANDING: pass headline (the on-screen TITLE) and subheadline (secondary title) plus " + "music_vibe/visual_language so a one-shot short isn't bare — these apply to SHORTS (music_vibe/headline/subheadline " + "are ignored for editor; theme applies to editor and is legacy-only for shorts). For richer branding — a product image, brand logo, or " + "closing card — drive the granular path instead: create_short + set_short_product/set_short_poster, or " + "create_editor_draft + set_product/set_logo/set_closing_image, then start_autopilot/generate_short.",
30021
30039
  inputSchema: {
30022
30040
  prompt: exports_external.string().describe("What the ad/video should be about (min 10 chars)"),
30023
30041
  kind: exports_external.string().optional().describe("'short' (fast, 1 clip), 'editor' (multi-scene), or 'auto' (default — inferred)"),
@@ -30026,8 +30044,43 @@ registerTool("make_video", {
30026
30044
  voice_id: exports_external.string().optional().describe("Preferred narration voice id for editor ads (see list_voices); shorts have no voiceover"),
30027
30045
  headline: exports_external.string().optional().describe("SHORTS only: the on-screen TITLE overlay (≤160). Set this so the short isn't bare."),
30028
30046
  subheadline: exports_external.string().optional().describe("SHORTS only: the secondary title / supporting line (≤200)"),
30047
+ text_beats: exports_external.array(exports_external.string()).optional().describe("SHORTS only: caption beats shown sequentially instead of a static subheadline (≤8, each ≤120 chars)."),
30048
+ headline_color: exports_external.string().optional().describe("SHORTS only: headline hex color, e.g. #ffffff"),
30049
+ subheadline_color: exports_external.string().optional().describe("SHORTS only: subheadline hex color, e.g. #ffffff"),
30050
+ accent_color: exports_external.string().optional().describe("SHORTS only: 6-digit brand accent hex color, e.g. #09EFBE"),
30051
+ offer_text: exports_external.string().optional().describe("SHORTS only: optional offer chip, e.g. -40% (≤16 chars)"),
30052
+ cta_text: exports_external.string().optional().describe("SHORTS only: optional CTA pill, e.g. Shop now (≤24 chars)"),
30053
+ badge_text: exports_external.string().optional().describe("SHORTS only: optional badge stamp, e.g. BEST SELLER (≤24 chars)"),
30054
+ star_rating: exports_external.number().optional().describe("SHORTS only: optional 0..5 star rating under the subheadline"),
30055
+ text_position: exports_external.enum(["top", "center", "bottom"]).optional().describe("SHORTS only: title overlay position (default bottom)"),
30056
+ text_animation: exports_external.enum([
30057
+ "reveal",
30058
+ "typewriter",
30059
+ "fade_in",
30060
+ "pop",
30061
+ "bounce",
30062
+ "word_stagger",
30063
+ "word_spotlight"
30064
+ ]).optional().describe("SHORTS only: title overlay animation (default fade_in)"),
30065
+ font_family: exports_external.string().optional().describe("SHORTS only: overlay font family, e.g. ShortFontSpaceGrotesk"),
30029
30066
  music_vibe: exports_external.string().optional().describe("SHORTS only: music mood — Upbeat (default), Cinematic, Minimal, Luxury, Playful, Jazz"),
30030
- theme: exports_external.string().optional().describe("Visual theme (both kinds): none (literal — no imposed style), realistic (default), cinematic, " + "anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, gaming, retro_80s, " + "minimalist, cyberpunk"),
30067
+ creative_format: exports_external.enum([
30068
+ "problem_solution",
30069
+ "mistake_fix",
30070
+ "myth_vs_reality",
30071
+ "before_after",
30072
+ "proof_demo",
30073
+ "product_reveal"
30074
+ ]).optional().describe("SHORTS only: segment structure. Omit for Auto/generic."),
30075
+ visual_language: exports_external.enum([
30076
+ "kinetic_creator",
30077
+ "premium_editorial",
30078
+ "cinematic_product",
30079
+ "ugc_realism",
30080
+ "startup_explainer",
30081
+ "luxury_minimal"
30082
+ ]).optional().describe("SHORTS only: visual style direction and render look. Default app choice is kinetic_creator."),
30083
+ theme: exports_external.string().optional().describe("Visual theme: editor style, and legacy shorts fallback only when visual_language is unset. " + "none (literal — no imposed style), realistic (default), cinematic, " + "anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, gaming, retro_80s, " + "minimalist, cyberpunk"),
30031
30084
  save_path: exports_external.string().optional().describe("Optional .mp4 path to download to (confined to HUBFLUENCER_OUTPUT_DIR or cwd)"),
30032
30085
  max_wait_seconds: exports_external.number().optional().describe("Block budget seconds (default 240, capped 10–280)"),
30033
30086
  dry_run: exports_external.boolean().optional().describe("Preview only: create a free draft, price it, and STOP before spending credits. " + "Returns {estimated_credits, available_credits, slug}. Resume with generate_short / start_autopilot."),
@@ -30051,9 +30104,22 @@ registerTool("make_video", {
30051
30104
  language: args.language,
30052
30105
  headline: args.headline,
30053
30106
  subheadline: args.subheadline,
30107
+ text_beats: args.text_beats,
30108
+ headline_color: args.headline_color,
30109
+ subheadline_color: args.subheadline_color,
30110
+ accent_color: args.accent_color,
30111
+ offer_text: args.offer_text,
30112
+ cta_text: args.cta_text,
30113
+ badge_text: args.badge_text,
30114
+ star_rating: args.star_rating,
30115
+ short_text_position: args.text_position,
30116
+ short_text_animation: args.text_animation,
30117
+ short_font_family: args.font_family,
30054
30118
  music_vibe: args.music_vibe,
30119
+ creative_format: args.creative_format,
30120
+ visual_language: args.visual_language,
30055
30121
  theme: args.theme
30056
- }, idemKey("make-short", args.prompt, args.language ?? "", args.headline ?? "", args.subheadline ?? "", args.music_vibe ?? "", args.theme ?? ""));
30122
+ }, idemKey("make-short", args.prompt, args.language ?? "", args.headline ?? "", args.subheadline ?? "", JSON.stringify(args.text_beats ?? []), args.headline_color ?? "", args.subheadline_color ?? "", args.accent_color ?? "", args.offer_text ?? "", args.cta_text ?? "", args.badge_text ?? "", String(args.star_rating ?? ""), args.text_position ?? "", args.text_animation ?? "", args.font_family ?? "", args.music_vibe ?? "", args.creative_format ?? "", args.visual_language ?? "", args.theme ?? ""));
30057
30123
  slug = created.data.slug;
30058
30124
  } else {
30059
30125
  const created = await client.post("/editor", {
@@ -30171,18 +30237,50 @@ registerTool("list_projects", {
30171
30237
  }));
30172
30238
  registerTool("create_short", {
30173
30239
  title: "Create a short (draft)",
30174
- description: "Creates a short draft from a product prompt (min 10 chars). A short is a 12s vertical (two 6s AI " + "segments + an on-screen title overlay + music; 14s when an end-card poster is set). Returns the slug, " + "costs 0 credits. Follow with generate_short to render. " + "BE PROACTIVE: don't ship a bare clip — set a headline (the on-screen TITLE) and subheadline (secondary " + "title), and pick a music_vibe and theme that fit the brand. To brand it further, attach a product image " + "(set_short_product) or an end-card poster (set_short_poster) before generate_short. (Shorts have no logo " + "overlay — that's an editor-only feature; use create_editor_ad for logo branding.)",
30240
+ description: "Creates a short draft from a product prompt (min 10 chars). A short is a 12s vertical (two 6s AI " + "segments + an on-screen title overlay + music; 14s when an end-card poster is set). Returns the slug, " + "costs 0 credits. Follow with generate_short to render. " + "BE PROACTIVE: don't ship a bare clip — set a headline (the on-screen TITLE) and subheadline (secondary " + "title), and pick a music_vibe plus visual_language that fit the brand. To brand it further, attach a product image " + "(set_short_product) or an end-card poster (set_short_poster) before generate_short. (Shorts have no logo " + "overlay — that's an editor-only feature; use create_editor_ad for logo branding.)",
30175
30241
  inputSchema: {
30176
30242
  product_prompt: exports_external.string().min(10).describe("What the short should be about"),
30177
30243
  language: exports_external.string().optional().describe('Language code, e.g. "en" (default)'),
30178
30244
  headline: exports_external.string().max(160).optional().describe("The on-screen TITLE composited over the short (poster text overlay). ≤160 chars. Set this."),
30179
30245
  subheadline: exports_external.string().max(200).optional().describe("The SECONDARY title / supporting line under the headline. ≤200 chars."),
30180
- theme: exports_external.string().optional().describe("Visual theme (default realistic). One of: none (no imposed style — follows the prompt literally), " + "realistic, cinematic, anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, " + "gaming, retro_80s, minimalist, cyberpunk."),
30246
+ creative_format: exports_external.enum([
30247
+ "problem_solution",
30248
+ "mistake_fix",
30249
+ "myth_vs_reality",
30250
+ "before_after",
30251
+ "proof_demo",
30252
+ "product_reveal"
30253
+ ]).optional().describe("Optional structure: problem_solution, mistake_fix, myth_vs_reality, before_after, proof_demo, product_reveal. Omit for Auto."),
30254
+ visual_language: exports_external.enum([
30255
+ "kinetic_creator",
30256
+ "premium_editorial",
30257
+ "cinematic_product",
30258
+ "ugc_realism",
30259
+ "startup_explainer",
30260
+ "luxury_minimal"
30261
+ ]).optional().describe("Visual language for Veo direction and render styling. Good default: kinetic_creator."),
30262
+ theme: exports_external.string().optional().describe("Deprecated for shorts: legacy visual theme used only when visual_language is unset."),
30181
30263
  music_vibe: exports_external.string().optional().describe("Background-music mood. Recognized: Upbeat (default), Cinematic, Minimal, Luxury, Playful, Jazz."),
30182
30264
  music_instruments: exports_external.array(exports_external.string()).optional().describe('Optional instrument hints, e.g. ["piano", "strings"]'),
30183
30265
  text_position: exports_external.enum(["top", "center", "bottom"]).optional().describe("Where the title overlay sits (default bottom)"),
30184
- text_animation: exports_external.enum(["reveal", "typewriter", "fade_in", "pop", "bounce"]).optional().describe("Title overlay animation (default fade_in)"),
30185
- font_family: exports_external.string().optional().describe("Overlay font (default ShortFontSpaceGrotesk). One of: ShortFontSpaceGrotesk, ShortFontMontserrat, " + "ShortFontTheBold, ShortFontImpact, ShortFontLato, ShortFontProximaNova, ShortFontAnton, " + "ShortFontBebasNeue, ShortFontOswald, ShortFontArchivoBlack, ShortFontPoppins, ShortFontInter, " + "ShortFontTikTokSans, ShortFontBangers.")
30266
+ text_animation: exports_external.enum([
30267
+ "reveal",
30268
+ "typewriter",
30269
+ "fade_in",
30270
+ "pop",
30271
+ "bounce",
30272
+ "word_stagger",
30273
+ "word_spotlight"
30274
+ ]).optional().describe("Title overlay animation (default fade_in)"),
30275
+ font_family: exports_external.string().optional().describe("Overlay font (default ShortFontSpaceGrotesk). One of: ShortFontSpaceGrotesk, ShortFontMontserrat, " + "ShortFontTheBold, ShortFontImpact, ShortFontLato, ShortFontAnton, " + "ShortFontBebasNeue, ShortFontOswald, ShortFontArchivoBlack, ShortFontPoppins, ShortFontInter, " + "ShortFontTikTokSans, ShortFontBangers, ShortFontDMSerif, ShortFontPermanentMarker."),
30276
+ headline_color: exports_external.string().optional().describe("Headline hex color, e.g. #ffffff"),
30277
+ subheadline_color: exports_external.string().optional().describe("Subheadline hex color, e.g. #ffffff"),
30278
+ accent_color: exports_external.string().optional().describe("6-digit brand accent hex color, e.g. #09EFBE"),
30279
+ offer_text: exports_external.string().max(16).optional().describe('Optional offer chip, e.g. "-40%" or "2 FOR 1"'),
30280
+ cta_text: exports_external.string().max(24).optional().describe('Optional CTA pill, e.g. "Shop now"'),
30281
+ badge_text: exports_external.string().max(24).optional().describe('Optional badge stamp, e.g. "BEST SELLER"'),
30282
+ star_rating: exports_external.number().min(0).max(5).optional().describe("Optional 0..5 star rating under the subheadline"),
30283
+ text_beats: exports_external.array(exports_external.string().max(120)).max(8).optional().describe("Optional caption beats shown sequentially instead of the static subheadline.")
30186
30284
  },
30187
30285
  annotations: { title: "Create short", ...WRITE, idempotentHint: true }
30188
30286
  }, tool(async (args, client) => {
@@ -30195,6 +30293,10 @@ registerTool("create_short", {
30195
30293
  body.headline = args.headline;
30196
30294
  if (args.subheadline !== undefined)
30197
30295
  body.subheadline = args.subheadline;
30296
+ if (args.creative_format !== undefined)
30297
+ body.creative_format = args.creative_format;
30298
+ if (args.visual_language !== undefined)
30299
+ body.visual_language = args.visual_language;
30198
30300
  if (args.theme !== undefined)
30199
30301
  body.theme = args.theme;
30200
30302
  if (args.music_vibe !== undefined)
@@ -30207,7 +30309,23 @@ registerTool("create_short", {
30207
30309
  body.short_text_animation = args.text_animation;
30208
30310
  if (args.font_family !== undefined)
30209
30311
  body.short_font_family = args.font_family;
30210
- const res = await client.post("/shorts", body, idemKey("create-short", args.product_prompt, args.language ?? "", args.headline ?? "", args.subheadline ?? ""));
30312
+ if (args.headline_color !== undefined)
30313
+ body.headline_color = args.headline_color;
30314
+ if (args.subheadline_color !== undefined)
30315
+ body.subheadline_color = args.subheadline_color;
30316
+ if (args.accent_color !== undefined)
30317
+ body.accent_color = args.accent_color;
30318
+ if (args.offer_text !== undefined)
30319
+ body.offer_text = args.offer_text;
30320
+ if (args.cta_text !== undefined)
30321
+ body.cta_text = args.cta_text;
30322
+ if (args.badge_text !== undefined)
30323
+ body.badge_text = args.badge_text;
30324
+ if (args.star_rating !== undefined)
30325
+ body.star_rating = args.star_rating;
30326
+ if (args.text_beats !== undefined)
30327
+ body.text_beats = args.text_beats;
30328
+ const res = await client.post("/shorts", body, idemKey("create-short", args.product_prompt, args.language ?? "", args.headline ?? "", args.subheadline ?? "", args.creative_format ?? "", args.visual_language ?? "", args.theme ?? "", args.music_vibe ?? "", JSON.stringify(args.music_instruments ?? []), args.text_position ?? "", args.text_animation ?? "", args.font_family ?? "", args.headline_color ?? "", args.subheadline_color ?? "", args.accent_color ?? "", args.offer_text ?? "", args.cta_text ?? "", args.badge_text ?? "", String(args.star_rating ?? ""), JSON.stringify(args.text_beats ?? [])));
30211
30329
  return ok({
30212
30330
  slug: res.data.slug,
30213
30331
  kind: "short",
@@ -30216,13 +30334,229 @@ registerTool("create_short", {
30216
30334
  }));
30217
30335
  registerTool("generate_short", {
30218
30336
  title: "Generate (render) a short",
30219
- description: "Deducts 15 credits and starts the render pipeline for an existing short. Idempotent per slug " + "(safe to retry). Then poll with get_status or wait_for_completion (kind=short).",
30337
+ description: "Deducts 15 credits and starts the render pipeline for an existing short. Safe to call again: a " + "duplicate while a render is in flight is reported as in-progress (no double charge), and a failed " + "short can be re-generated. Then poll with get_status or wait_for_completion (kind=short).",
30220
30338
  inputSchema: { slug: exports_external.string().describe("Short slug from create_short") },
30221
30339
  annotations: { title: "Generate short", ...WRITE, idempotentHint: true }
30222
30340
  }, tool(async (args, client) => {
30223
- const res = await client.post(`/shorts/${args.slug}/generate`, undefined, `gen-short:${args.slug}`);
30224
- const status = normalizeStatus("short", args.slug, asRecord(res).data);
30225
- return ok(status);
30341
+ try {
30342
+ const res = await client.post(`/shorts/${args.slug}/generate`);
30343
+ const status = normalizeStatus("short", args.slug, asRecord(res).data);
30344
+ return ok(status);
30345
+ } catch (e) {
30346
+ if (e.status === 409) {
30347
+ return ok({
30348
+ kind: "short",
30349
+ slug: args.slug,
30350
+ stage: "processing",
30351
+ terminal: false,
30352
+ ready: false,
30353
+ video_url: null,
30354
+ error: null
30355
+ });
30356
+ }
30357
+ throw e;
30358
+ }
30359
+ }));
30360
+ registerTool("generate_short_text", {
30361
+ title: "Generate short overlay text (AI assist)",
30362
+ description: "Generates editable headline, subheadline, and caption beats for an existing short draft. " + "CONSUMES 1 AI ASSIST (free daily quota) and spends no video credits. The server reads the saved " + "short fields, so update/create the draft with the current product prompt, creative_format, visual_language, " + "language, and seed copy first. On 429 set auto_unlock:true (1 credit -> +10 assists, retried once) or write the text yourself.",
30363
+ inputSchema: {
30364
+ slug: exports_external.string().describe("Short slug from create_short"),
30365
+ auto_unlock: exports_external.boolean().optional().describe("On 429, spend 1 credit to unlock +10 assists and retry once (default false)")
30366
+ },
30367
+ annotations: {
30368
+ title: "Generate short text",
30369
+ ...WRITE,
30370
+ idempotentHint: false
30371
+ }
30372
+ }, tool(async (args, client) => {
30373
+ const res = await withAssist(client, args.auto_unlock ?? false, () => client.post(`/shorts/${args.slug}/text/generate`));
30374
+ return ok(asRecord(res).data ?? res);
30375
+ }));
30376
+ registerTool("create_slider", {
30377
+ title: "Create an image slider / carousel (draft)",
30378
+ description: "Creates a carousel draft from a prompt (min 10 chars). One prompt produces N still slides (an AI " + "background + a composited headline/body + optional logo) PLUS a ready-to-post caption and hashtags. " + "Returns the slug, costs 0 credits. Follow with generate_slider to render, then get_slider to read the " + "slide image URLs + caption. mode 'creative' tells a story; 'ad_driven' lists product facts/benefits.",
30379
+ inputSchema: {
30380
+ prompt: exports_external.string().min(10).describe("What the carousel is about"),
30381
+ mode: exports_external.enum(["creative", "ad_driven"]).optional().describe("'creative' (storytelling) or 'ad_driven' (facts about the product). Default creative."),
30382
+ template: exports_external.enum([
30383
+ "boldStatement",
30384
+ "editorialStory",
30385
+ "scrapbook",
30386
+ "featureGrid",
30387
+ "offerCard",
30388
+ "comparison"
30389
+ ]).optional().describe("Composition preset. boldStatement/editorialStory/scrapbook are creative; " + "featureGrid/offerCard/comparison are ad-driven. Defaults to the mode default."),
30390
+ slide_count: exports_external.number().optional().describe("Number of slides, integer 3–10 (default 5)"),
30391
+ aspect_ratio: exports_external.enum(["4:5", "1:1", "9:16"]).optional().describe("Canvas ratio (default 4:5, the highest-reach carousel ratio)"),
30392
+ accent_color: exports_external.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe("Brand accent: must start with #, exactly 6 hex digits, e.g. #09EFBE"),
30393
+ text_position: exports_external.enum(["top", "middle", "bottom"]).optional().describe("Vertical placement of the on-image copy across all slides. Omit for the template's natural placement.")
30394
+ },
30395
+ annotations: { title: "Create slider", ...WRITE, idempotentHint: true }
30396
+ }, tool(async (args, client) => {
30397
+ if (args.slide_count !== undefined) {
30398
+ if (!Number.isInteger(args.slide_count) || args.slide_count < 3 || args.slide_count > 10) {
30399
+ return fail("slide_count must be an integer between 3 and 10");
30400
+ }
30401
+ }
30402
+ const body = { prompt: args.prompt };
30403
+ if (args.mode !== undefined)
30404
+ body.mode = args.mode;
30405
+ if (args.template !== undefined)
30406
+ body.template = args.template;
30407
+ if (args.slide_count !== undefined)
30408
+ body.slide_count = args.slide_count;
30409
+ if (args.aspect_ratio !== undefined)
30410
+ body.aspect_ratio = args.aspect_ratio;
30411
+ if (args.accent_color !== undefined)
30412
+ body.accent_color = args.accent_color;
30413
+ if (args.text_position !== undefined)
30414
+ body.text_position = args.text_position;
30415
+ const res = await client.post("/sliders", body, idemKey("create-slider", args.prompt, args.mode ?? "", args.template ?? ""));
30416
+ return ok({
30417
+ slug: res.data.slug,
30418
+ kind: "slider",
30419
+ next: "generate_slider, then get_slider to read the slide URLs + caption"
30420
+ });
30421
+ }));
30422
+ registerTool("generate_slider", {
30423
+ title: "Generate (render) a carousel",
30424
+ description: "Deducts 1 credit per slide (3–10 slides) and starts the pipeline (copy → AI backgrounds → composite) for an existing " + "carousel. Safe to call again: a duplicate while a render is already in flight is reported as still " + "processing (no double charge), and a failed carousel can be re-generated. Then poll with get_slider " + "until status is 'completed' or 'failed'.",
30425
+ inputSchema: {
30426
+ slug: exports_external.string().describe("Slider slug from create_slider")
30427
+ },
30428
+ annotations: { title: "Generate slider", ...WRITE, idempotentHint: true }
30429
+ }, tool(async (args, client) => {
30430
+ try {
30431
+ const res = await client.post(`/sliders/${args.slug}/generate`);
30432
+ const data = asRecord(asRecord(res).data);
30433
+ return ok({
30434
+ slug: args.slug,
30435
+ kind: "slider",
30436
+ status: data.status ?? "processing"
30437
+ });
30438
+ } catch (e) {
30439
+ if (e.status === 409) {
30440
+ return ok({ slug: args.slug, kind: "slider", status: "processing" });
30441
+ }
30442
+ throw e;
30443
+ }
30444
+ }));
30445
+ registerTool("get_slider", {
30446
+ title: "Get carousel status + deliverable",
30447
+ description: "Returns the carousel's status and, when completed, the per-slide image URLs (download these), the " + "per-slide headline/body/kicker, the caption, and the hashtags. status flows draft → processing → " + "completed | failed. completed:true means every slide image_url is ready to save. Once completed you " + "can restyle_slider (new template/accent) or edit_slider_slide (one slide's text) for free.",
30448
+ inputSchema: { slug: exports_external.string().describe("Slider slug") },
30449
+ annotations: { title: "Get slider", ...RO }
30450
+ }, tool(async (args, client) => {
30451
+ const res = await client.get(`/sliders/${args.slug}`);
30452
+ const data = asRecord(asRecord(res).data);
30453
+ const slides = Array.isArray(data.slides) ? data.slides : [];
30454
+ const status = data.status ?? "unknown";
30455
+ return ok({
30456
+ slug: args.slug,
30457
+ kind: "slider",
30458
+ status,
30459
+ stage: status,
30460
+ terminal: status === "completed" || status === "failed",
30461
+ completed: status === "completed",
30462
+ error: data.error_message ?? null,
30463
+ caption: data.caption ?? null,
30464
+ hashtags: Array.isArray(data.hashtags) ? data.hashtags : [],
30465
+ slides: slides.map((s) => ({
30466
+ position: s.position,
30467
+ image_url: s.image_url ?? null,
30468
+ headline: s.headline ?? null,
30469
+ body: s.body ?? null,
30470
+ kicker: s.kicker ?? null,
30471
+ status: s.status ?? null
30472
+ }))
30473
+ });
30474
+ }));
30475
+ registerTool("restyle_slider", {
30476
+ title: "Restyle a carousel (free re-composite of every slide)",
30477
+ description: "Re-composites ALL slides of a completed carousel under a new template, accent color and/or text " + "position — for FREE (0 credits). Reuses the already-generated AI backgrounds, so no new images are paid for. The " + "carousel must already be 'completed' (generate it first); a draft/processing carousel returns a " + "409 conflict. Runs async: it marks every slide processing, so poll get_slider until status is back " + "to 'completed' (or 'failed'). Omit a field to leave it unchanged.",
30478
+ inputSchema: {
30479
+ slug: exports_external.string().describe("Slider slug from create_slider"),
30480
+ template: exports_external.enum([
30481
+ "boldStatement",
30482
+ "editorialStory",
30483
+ "scrapbook",
30484
+ "featureGrid",
30485
+ "offerCard",
30486
+ "comparison"
30487
+ ]).optional().describe("New composition preset. boldStatement/editorialStory/scrapbook are creative; " + "featureGrid/offerCard/comparison are ad-driven."),
30488
+ accent_color: exports_external.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe("New brand accent: must start with #, exactly 6 hex digits, e.g. #09EFBE"),
30489
+ text_position: exports_external.enum(["top", "middle", "bottom"]).optional().describe("New vertical placement of the on-image copy across all slides.")
30490
+ },
30491
+ annotations: { title: "Restyle slider", ...WRITE, idempotentHint: false }
30492
+ }, tool(async (args, client) => {
30493
+ const body = {};
30494
+ if (args.template !== undefined)
30495
+ body.template = args.template;
30496
+ if (args.accent_color !== undefined)
30497
+ body.accent_color = args.accent_color;
30498
+ if (args.text_position !== undefined)
30499
+ body.text_position = args.text_position;
30500
+ try {
30501
+ const res = await client.post(`/sliders/${args.slug}/restyle`, body);
30502
+ const data = asRecord(asRecord(res).data);
30503
+ return ok({
30504
+ slug: args.slug,
30505
+ kind: "slider",
30506
+ status: data.status ?? "processing",
30507
+ next: "poll get_slider until status is 'completed' (free re-composite, 0 credits)"
30508
+ });
30509
+ } catch (e) {
30510
+ if (e.status === 409) {
30511
+ return ok({ slug: args.slug, kind: "slider", status: "processing" });
30512
+ }
30513
+ throw e;
30514
+ }
30515
+ }));
30516
+ registerTool("edit_slider_slide", {
30517
+ title: "Edit one carousel slide's on-image text (free re-composite)",
30518
+ description: "Rewrites the on-image copy of a SINGLE slide (by position) and re-composites just that slide — for " + "FREE (0 credits). Reuses the slide's existing AI background, so no new image is paid for. The " + "carousel must already be 'completed' (a draft/processing carousel returns a 409 conflict). Runs " + "async: the slide goes back to processing, so poll get_slider until its status is 'completed'. Pass " + "only the fields you want to change. kicker = the small eyebrow/label line above the headline.",
30519
+ inputSchema: {
30520
+ slug: exports_external.string().describe("Slider slug from create_slider"),
30521
+ position: exports_external.number().int().min(1).describe("1-based slide position (from get_slider)"),
30522
+ headline: exports_external.string().max(120).optional().describe("Slide headline, ≤120 chars"),
30523
+ body: exports_external.string().max(600).optional().describe("Slide body, ≤600 chars"),
30524
+ kicker: exports_external.string().max(40).optional().describe("Small eyebrow/label line above the headline, ≤40 chars")
30525
+ },
30526
+ annotations: {
30527
+ title: "Edit slider slide",
30528
+ ...WRITE,
30529
+ idempotentHint: false
30530
+ }
30531
+ }, tool(async (args, client) => {
30532
+ const body = {};
30533
+ if (args.headline !== undefined)
30534
+ body.headline = args.headline;
30535
+ if (args.body !== undefined)
30536
+ body.body = args.body;
30537
+ if (args.kicker !== undefined)
30538
+ body.kicker = args.kicker;
30539
+ try {
30540
+ const res = await client.patch(`/sliders/${args.slug}/slides/${args.position}`, body);
30541
+ const data = asRecord(asRecord(res).data);
30542
+ return ok({
30543
+ slug: args.slug,
30544
+ kind: "slider",
30545
+ position: args.position,
30546
+ status: data.status ?? "processing",
30547
+ next: "poll get_slider until the slide's status is 'completed' (free re-composite, 0 credits)"
30548
+ });
30549
+ } catch (e) {
30550
+ if (e.status === 409) {
30551
+ return ok({
30552
+ slug: args.slug,
30553
+ kind: "slider",
30554
+ position: args.position,
30555
+ status: "processing"
30556
+ });
30557
+ }
30558
+ throw e;
30559
+ }
30226
30560
  }));
30227
30561
  registerTool("create_editor_ad", {
30228
30562
  title: "Create a multi-scene editor ad (autopilot)",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hubfluencer/mcp",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Model Context Protocol server for Hubfluencer — let AI agents generate post-ready shorts and editor ads.",
5
5
  "license": "MIT",
6
6
  "author": "Monocursive <contact@monocursive.com>",
package/src/index.ts CHANGED
@@ -427,8 +427,8 @@ registerTool(
427
427
  "for simple/short ones; the chosen kind is reported back as kind_inferred. If it returns terminal=false " +
428
428
  "the render is still running — call wait_for_completion with the returned slug to finish. " +
429
429
  "BE PROACTIVE WITH BRANDING: pass headline (the on-screen TITLE) and subheadline (secondary title) plus " +
430
- "music_vibe/theme so a one-shot short isn't bare — these apply to SHORTS (music_vibe/headline/subheadline " +
431
- "are ignored for editor; theme applies to both). For richer branding — a product image, brand logo, or " +
430
+ "music_vibe/visual_language so a one-shot short isn't bare — these apply to SHORTS (music_vibe/headline/subheadline " +
431
+ "are ignored for editor; theme applies to editor and is legacy-only for shorts). For richer branding — a product image, brand logo, or " +
432
432
  "closing card — drive the granular path instead: create_short + set_short_product/set_short_poster, or " +
433
433
  "create_editor_draft + set_product/set_logo/set_closing_image, then start_autopilot/generate_short.",
434
434
  // Schema kept intentionally flat (no .min/.max/.int chains) — the SDK's
@@ -468,17 +468,102 @@ registerTool(
468
468
  .string()
469
469
  .optional()
470
470
  .describe("SHORTS only: the secondary title / supporting line (≤200)"),
471
+ text_beats: z
472
+ .array(z.string())
473
+ .optional()
474
+ .describe(
475
+ "SHORTS only: caption beats shown sequentially instead of a static subheadline (≤8, each ≤120 chars).",
476
+ ),
477
+ headline_color: z
478
+ .string()
479
+ .optional()
480
+ .describe("SHORTS only: headline hex color, e.g. #ffffff"),
481
+ subheadline_color: z
482
+ .string()
483
+ .optional()
484
+ .describe("SHORTS only: subheadline hex color, e.g. #ffffff"),
485
+ accent_color: z
486
+ .string()
487
+ .optional()
488
+ .describe("SHORTS only: 6-digit brand accent hex color, e.g. #09EFBE"),
489
+ offer_text: z
490
+ .string()
491
+ .optional()
492
+ .describe("SHORTS only: optional offer chip, e.g. -40% (≤16 chars)"),
493
+ cta_text: z
494
+ .string()
495
+ .optional()
496
+ .describe("SHORTS only: optional CTA pill, e.g. Shop now (≤24 chars)"),
497
+ badge_text: z
498
+ .string()
499
+ .optional()
500
+ .describe(
501
+ "SHORTS only: optional badge stamp, e.g. BEST SELLER (≤24 chars)",
502
+ ),
503
+ star_rating: z
504
+ .number()
505
+ .optional()
506
+ .describe(
507
+ "SHORTS only: optional 0..5 star rating under the subheadline",
508
+ ),
509
+ text_position: z
510
+ .enum(["top", "center", "bottom"])
511
+ .optional()
512
+ .describe("SHORTS only: title overlay position (default bottom)"),
513
+ text_animation: z
514
+ .enum([
515
+ "reveal",
516
+ "typewriter",
517
+ "fade_in",
518
+ "pop",
519
+ "bounce",
520
+ "word_stagger",
521
+ "word_spotlight",
522
+ ])
523
+ .optional()
524
+ .describe("SHORTS only: title overlay animation (default fade_in)"),
525
+ font_family: z
526
+ .string()
527
+ .optional()
528
+ .describe(
529
+ "SHORTS only: overlay font family, e.g. ShortFontSpaceGrotesk",
530
+ ),
471
531
  music_vibe: z
472
532
  .string()
473
533
  .optional()
474
534
  .describe(
475
535
  "SHORTS only: music mood — Upbeat (default), Cinematic, Minimal, Luxury, Playful, Jazz",
476
536
  ),
537
+ creative_format: z
538
+ .enum([
539
+ "problem_solution",
540
+ "mistake_fix",
541
+ "myth_vs_reality",
542
+ "before_after",
543
+ "proof_demo",
544
+ "product_reveal",
545
+ ])
546
+ .optional()
547
+ .describe("SHORTS only: segment structure. Omit for Auto/generic."),
548
+ visual_language: z
549
+ .enum([
550
+ "kinetic_creator",
551
+ "premium_editorial",
552
+ "cinematic_product",
553
+ "ugc_realism",
554
+ "startup_explainer",
555
+ "luxury_minimal",
556
+ ])
557
+ .optional()
558
+ .describe(
559
+ "SHORTS only: visual style direction and render look. Default app choice is kinetic_creator.",
560
+ ),
477
561
  theme: z
478
562
  .string()
479
563
  .optional()
480
564
  .describe(
481
- "Visual theme (both kinds): none (literal no imposed style), realistic (default), cinematic, " +
565
+ "Visual theme: editor style, and legacy shorts fallback only when visual_language is unset. " +
566
+ "none (literal — no imposed style), realistic (default), cinematic, " +
482
567
  "anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, gaming, retro_80s, " +
483
568
  "minimalist, cyberpunk",
484
569
  ),
@@ -518,7 +603,20 @@ registerTool(
518
603
  voice_id?: string;
519
604
  headline?: string;
520
605
  subheadline?: string;
606
+ text_beats?: string[];
607
+ headline_color?: string;
608
+ subheadline_color?: string;
609
+ accent_color?: string;
610
+ offer_text?: string;
611
+ cta_text?: string;
612
+ badge_text?: string;
613
+ star_rating?: number;
614
+ text_position?: string;
615
+ text_animation?: string;
616
+ font_family?: string;
521
617
  music_vibe?: string;
618
+ creative_format?: string;
619
+ visual_language?: string;
522
620
  theme?: string;
523
621
  save_path?: string;
524
622
  max_wait_seconds?: number;
@@ -559,7 +657,20 @@ registerTool(
559
657
  language: args.language,
560
658
  headline: args.headline,
561
659
  subheadline: args.subheadline,
660
+ text_beats: args.text_beats,
661
+ headline_color: args.headline_color,
662
+ subheadline_color: args.subheadline_color,
663
+ accent_color: args.accent_color,
664
+ offer_text: args.offer_text,
665
+ cta_text: args.cta_text,
666
+ badge_text: args.badge_text,
667
+ star_rating: args.star_rating,
668
+ short_text_position: args.text_position,
669
+ short_text_animation: args.text_animation,
670
+ short_font_family: args.font_family,
562
671
  music_vibe: args.music_vibe,
672
+ creative_format: args.creative_format,
673
+ visual_language: args.visual_language,
563
674
  theme: args.theme,
564
675
  },
565
676
  // Fold every create-affecting param into the key: two calls that
@@ -571,7 +682,20 @@ registerTool(
571
682
  args.language ?? "",
572
683
  args.headline ?? "",
573
684
  args.subheadline ?? "",
685
+ JSON.stringify(args.text_beats ?? []),
686
+ args.headline_color ?? "",
687
+ args.subheadline_color ?? "",
688
+ args.accent_color ?? "",
689
+ args.offer_text ?? "",
690
+ args.cta_text ?? "",
691
+ args.badge_text ?? "",
692
+ String(args.star_rating ?? ""),
693
+ args.text_position ?? "",
694
+ args.text_animation ?? "",
695
+ args.font_family ?? "",
574
696
  args.music_vibe ?? "",
697
+ args.creative_format ?? "",
698
+ args.visual_language ?? "",
575
699
  args.theme ?? "",
576
700
  ),
577
701
  );
@@ -844,7 +968,7 @@ registerTool(
844
968
  "segments + an on-screen title overlay + music; 14s when an end-card poster is set). Returns the slug, " +
845
969
  "costs 0 credits. Follow with generate_short to render. " +
846
970
  "BE PROACTIVE: don't ship a bare clip — set a headline (the on-screen TITLE) and subheadline (secondary " +
847
- "title), and pick a music_vibe and theme that fit the brand. To brand it further, attach a product image " +
971
+ "title), and pick a music_vibe plus visual_language that fit the brand. To brand it further, attach a product image " +
848
972
  "(set_short_product) or an end-card poster (set_short_poster) before generate_short. (Shorts have no logo " +
849
973
  "overlay — that's an editor-only feature; use create_editor_ad for logo branding.)",
850
974
  inputSchema: {
@@ -870,13 +994,37 @@ registerTool(
870
994
  .describe(
871
995
  "The SECONDARY title / supporting line under the headline. ≤200 chars.",
872
996
  ),
997
+ creative_format: z
998
+ .enum([
999
+ "problem_solution",
1000
+ "mistake_fix",
1001
+ "myth_vs_reality",
1002
+ "before_after",
1003
+ "proof_demo",
1004
+ "product_reveal",
1005
+ ])
1006
+ .optional()
1007
+ .describe(
1008
+ "Optional structure: problem_solution, mistake_fix, myth_vs_reality, before_after, proof_demo, product_reveal. Omit for Auto.",
1009
+ ),
1010
+ visual_language: z
1011
+ .enum([
1012
+ "kinetic_creator",
1013
+ "premium_editorial",
1014
+ "cinematic_product",
1015
+ "ugc_realism",
1016
+ "startup_explainer",
1017
+ "luxury_minimal",
1018
+ ])
1019
+ .optional()
1020
+ .describe(
1021
+ "Visual language for Veo direction and render styling. Good default: kinetic_creator.",
1022
+ ),
873
1023
  theme: z
874
1024
  .string()
875
1025
  .optional()
876
1026
  .describe(
877
- "Visual theme (default realistic). One of: none (no imposed style follows the prompt literally), " +
878
- "realistic, cinematic, anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, " +
879
- "gaming, retro_80s, minimalist, cyberpunk.",
1027
+ "Deprecated for shorts: legacy visual theme used only when visual_language is unset.",
880
1028
  ),
881
1029
  music_vibe: z
882
1030
  .string()
@@ -893,7 +1041,15 @@ registerTool(
893
1041
  .optional()
894
1042
  .describe("Where the title overlay sits (default bottom)"),
895
1043
  text_animation: z
896
- .enum(["reveal", "typewriter", "fade_in", "pop", "bounce"])
1044
+ .enum([
1045
+ "reveal",
1046
+ "typewriter",
1047
+ "fade_in",
1048
+ "pop",
1049
+ "bounce",
1050
+ "word_stagger",
1051
+ "word_spotlight",
1052
+ ])
897
1053
  .optional()
898
1054
  .describe("Title overlay animation (default fade_in)"),
899
1055
  font_family: z
@@ -901,9 +1057,49 @@ registerTool(
901
1057
  .optional()
902
1058
  .describe(
903
1059
  "Overlay font (default ShortFontSpaceGrotesk). One of: ShortFontSpaceGrotesk, ShortFontMontserrat, " +
904
- "ShortFontTheBold, ShortFontImpact, ShortFontLato, ShortFontProximaNova, ShortFontAnton, " +
1060
+ "ShortFontTheBold, ShortFontImpact, ShortFontLato, ShortFontAnton, " +
905
1061
  "ShortFontBebasNeue, ShortFontOswald, ShortFontArchivoBlack, ShortFontPoppins, ShortFontInter, " +
906
- "ShortFontTikTokSans, ShortFontBangers.",
1062
+ "ShortFontTikTokSans, ShortFontBangers, ShortFontDMSerif, ShortFontPermanentMarker.",
1063
+ ),
1064
+ headline_color: z
1065
+ .string()
1066
+ .optional()
1067
+ .describe("Headline hex color, e.g. #ffffff"),
1068
+ subheadline_color: z
1069
+ .string()
1070
+ .optional()
1071
+ .describe("Subheadline hex color, e.g. #ffffff"),
1072
+ accent_color: z
1073
+ .string()
1074
+ .optional()
1075
+ .describe("6-digit brand accent hex color, e.g. #09EFBE"),
1076
+ offer_text: z
1077
+ .string()
1078
+ .max(16)
1079
+ .optional()
1080
+ .describe('Optional offer chip, e.g. "-40%" or "2 FOR 1"'),
1081
+ cta_text: z
1082
+ .string()
1083
+ .max(24)
1084
+ .optional()
1085
+ .describe('Optional CTA pill, e.g. "Shop now"'),
1086
+ badge_text: z
1087
+ .string()
1088
+ .max(24)
1089
+ .optional()
1090
+ .describe('Optional badge stamp, e.g. "BEST SELLER"'),
1091
+ star_rating: z
1092
+ .number()
1093
+ .min(0)
1094
+ .max(5)
1095
+ .optional()
1096
+ .describe("Optional 0..5 star rating under the subheadline"),
1097
+ text_beats: z
1098
+ .array(z.string().max(120))
1099
+ .max(8)
1100
+ .optional()
1101
+ .describe(
1102
+ "Optional caption beats shown sequentially instead of the static subheadline.",
907
1103
  ),
908
1104
  },
909
1105
  annotations: { title: "Create short", ...WRITE, idempotentHint: true },
@@ -915,12 +1111,22 @@ registerTool(
915
1111
  language?: string;
916
1112
  headline?: string;
917
1113
  subheadline?: string;
1114
+ creative_format?: string;
1115
+ visual_language?: string;
918
1116
  theme?: string;
919
1117
  music_vibe?: string;
920
1118
  music_instruments?: string[];
921
1119
  text_position?: string;
922
1120
  text_animation?: string;
923
1121
  font_family?: string;
1122
+ headline_color?: string;
1123
+ subheadline_color?: string;
1124
+ accent_color?: string;
1125
+ offer_text?: string;
1126
+ cta_text?: string;
1127
+ badge_text?: string;
1128
+ star_rating?: number;
1129
+ text_beats?: string[];
924
1130
  },
925
1131
  client,
926
1132
  ) => {
@@ -931,6 +1137,10 @@ registerTool(
931
1137
  if (args.language !== undefined) body.language = args.language;
932
1138
  if (args.headline !== undefined) body.headline = args.headline;
933
1139
  if (args.subheadline !== undefined) body.subheadline = args.subheadline;
1140
+ if (args.creative_format !== undefined)
1141
+ body.creative_format = args.creative_format;
1142
+ if (args.visual_language !== undefined)
1143
+ body.visual_language = args.visual_language;
934
1144
  if (args.theme !== undefined) body.theme = args.theme;
935
1145
  if (args.music_vibe !== undefined) body.music_vibe = args.music_vibe;
936
1146
  if (args.music_instruments !== undefined)
@@ -941,6 +1151,17 @@ registerTool(
941
1151
  body.short_text_animation = args.text_animation;
942
1152
  if (args.font_family !== undefined)
943
1153
  body.short_font_family = args.font_family;
1154
+ if (args.headline_color !== undefined)
1155
+ body.headline_color = args.headline_color;
1156
+ if (args.subheadline_color !== undefined)
1157
+ body.subheadline_color = args.subheadline_color;
1158
+ if (args.accent_color !== undefined)
1159
+ body.accent_color = args.accent_color;
1160
+ if (args.offer_text !== undefined) body.offer_text = args.offer_text;
1161
+ if (args.cta_text !== undefined) body.cta_text = args.cta_text;
1162
+ if (args.badge_text !== undefined) body.badge_text = args.badge_text;
1163
+ if (args.star_rating !== undefined) body.star_rating = args.star_rating;
1164
+ if (args.text_beats !== undefined) body.text_beats = args.text_beats;
944
1165
  const res = await client.post<{ data: { slug: string } }>(
945
1166
  "/shorts",
946
1167
  body,
@@ -950,6 +1171,22 @@ registerTool(
950
1171
  args.language ?? "",
951
1172
  args.headline ?? "",
952
1173
  args.subheadline ?? "",
1174
+ args.creative_format ?? "",
1175
+ args.visual_language ?? "",
1176
+ args.theme ?? "",
1177
+ args.music_vibe ?? "",
1178
+ JSON.stringify(args.music_instruments ?? []),
1179
+ args.text_position ?? "",
1180
+ args.text_animation ?? "",
1181
+ args.font_family ?? "",
1182
+ args.headline_color ?? "",
1183
+ args.subheadline_color ?? "",
1184
+ args.accent_color ?? "",
1185
+ args.offer_text ?? "",
1186
+ args.cta_text ?? "",
1187
+ args.badge_text ?? "",
1188
+ String(args.star_rating ?? ""),
1189
+ JSON.stringify(args.text_beats ?? []),
953
1190
  ),
954
1191
  );
955
1192
  return ok({
@@ -966,22 +1203,431 @@ registerTool(
966
1203
  {
967
1204
  title: "Generate (render) a short",
968
1205
  description:
969
- "Deducts 15 credits and starts the render pipeline for an existing short. Idempotent per slug " +
970
- "(safe to retry). Then poll with get_status or wait_for_completion (kind=short).",
1206
+ "Deducts 15 credits and starts the render pipeline for an existing short. Safe to call again: a " +
1207
+ "duplicate while a render is in flight is reported as in-progress (no double charge), and a failed " +
1208
+ "short can be re-generated. Then poll with get_status or wait_for_completion (kind=short).",
971
1209
  inputSchema: { slug: z.string().describe("Short slug from create_short") },
972
1210
  annotations: { title: "Generate short", ...WRITE, idempotentHint: true },
973
1211
  },
974
1212
  tool(async (args: { slug: string }, client) => {
975
- const res = await client.post<{ data: unknown }>(
976
- `/shorts/${args.slug}/generate`,
977
- undefined,
978
- `gen-short:${args.slug}`,
1213
+ // No stable idempotency key (see generate_slider): a per-slug key would
1214
+ // replay the first response for 24h and block an intentional re-generation
1215
+ // of a failed short. Double-charge is prevented server-side — the batch
1216
+ // claim's conditional update rolls back the losing transaction's credit
1217
+ // deduction — and a 409 means a render is already running.
1218
+ try {
1219
+ const res = await client.post<{ data: unknown }>(
1220
+ `/shorts/${args.slug}/generate`,
1221
+ );
1222
+ const status = normalizeStatus("short", args.slug, asRecord(res).data);
1223
+ return ok(status);
1224
+ } catch (e) {
1225
+ if ((e as { status?: number }).status === 409) {
1226
+ return ok({
1227
+ kind: "short",
1228
+ slug: args.slug,
1229
+ stage: "processing",
1230
+ terminal: false,
1231
+ ready: false,
1232
+ video_url: null,
1233
+ error: null,
1234
+ });
1235
+ }
1236
+ throw e;
1237
+ }
1238
+ }),
1239
+ );
1240
+
1241
+ registerTool(
1242
+ "generate_short_text",
1243
+ {
1244
+ title: "Generate short overlay text (AI assist)",
1245
+ description:
1246
+ "Generates editable headline, subheadline, and caption beats for an existing short draft. " +
1247
+ "CONSUMES 1 AI ASSIST (free daily quota) and spends no video credits. The server reads the saved " +
1248
+ "short fields, so update/create the draft with the current product prompt, creative_format, visual_language, " +
1249
+ "language, and seed copy first. On 429 set auto_unlock:true (1 credit -> +10 assists, retried once) or write the text yourself.",
1250
+ inputSchema: {
1251
+ slug: z.string().describe("Short slug from create_short"),
1252
+ auto_unlock: z
1253
+ .boolean()
1254
+ .optional()
1255
+ .describe(
1256
+ "On 429, spend 1 credit to unlock +10 assists and retry once (default false)",
1257
+ ),
1258
+ },
1259
+ annotations: {
1260
+ title: "Generate short text",
1261
+ ...WRITE,
1262
+ idempotentHint: false,
1263
+ },
1264
+ },
1265
+ tool(async (args: { slug: string; auto_unlock?: boolean }, client) => {
1266
+ const res = await withAssist(client, args.auto_unlock ?? false, () =>
1267
+ client.post<{ data: unknown }>(`/shorts/${args.slug}/text/generate`),
979
1268
  );
980
- const status = normalizeStatus("short", args.slug, asRecord(res).data);
981
- return ok(status);
1269
+ return ok(asRecord(res).data ?? res);
1270
+ }),
1271
+ );
1272
+
1273
+ // ── Sliders (image carousels) ────────────────────────────────────────────────
1274
+
1275
+ registerTool(
1276
+ "create_slider",
1277
+ {
1278
+ title: "Create an image slider / carousel (draft)",
1279
+ description:
1280
+ "Creates a carousel draft from a prompt (min 10 chars). One prompt produces N still slides (an AI " +
1281
+ "background + a composited headline/body + optional logo) PLUS a ready-to-post caption and hashtags. " +
1282
+ "Returns the slug, costs 0 credits. Follow with generate_slider to render, then get_slider to read the " +
1283
+ "slide image URLs + caption. mode 'creative' tells a story; 'ad_driven' lists product facts/benefits.",
1284
+ inputSchema: {
1285
+ prompt: z.string().min(10).describe("What the carousel is about"),
1286
+ mode: z
1287
+ .enum(["creative", "ad_driven"])
1288
+ .optional()
1289
+ .describe(
1290
+ "'creative' (storytelling) or 'ad_driven' (facts about the product). Default creative.",
1291
+ ),
1292
+ template: z
1293
+ .enum([
1294
+ "boldStatement",
1295
+ "editorialStory",
1296
+ "scrapbook",
1297
+ "featureGrid",
1298
+ "offerCard",
1299
+ "comparison",
1300
+ ])
1301
+ .optional()
1302
+ .describe(
1303
+ "Composition preset. boldStatement/editorialStory/scrapbook are creative; " +
1304
+ "featureGrid/offerCard/comparison are ad-driven. Defaults to the mode default.",
1305
+ ),
1306
+ slide_count: z
1307
+ .number()
1308
+ .optional()
1309
+ .describe("Number of slides, integer 3–10 (default 5)"),
1310
+ aspect_ratio: z
1311
+ .enum(["4:5", "1:1", "9:16"])
1312
+ .optional()
1313
+ .describe(
1314
+ "Canvas ratio (default 4:5, the highest-reach carousel ratio)",
1315
+ ),
1316
+ accent_color: z
1317
+ .string()
1318
+ .regex(/^#[0-9a-fA-F]{6}$/)
1319
+ .optional()
1320
+ .describe(
1321
+ "Brand accent: must start with #, exactly 6 hex digits, e.g. #09EFBE",
1322
+ ),
1323
+ text_position: z
1324
+ .enum(["top", "middle", "bottom"])
1325
+ .optional()
1326
+ .describe(
1327
+ "Vertical placement of the on-image copy across all slides. Omit for the template's natural placement.",
1328
+ ),
1329
+ },
1330
+ annotations: { title: "Create slider", ...WRITE, idempotentHint: true },
1331
+ },
1332
+ tool(
1333
+ async (
1334
+ args: {
1335
+ prompt: string;
1336
+ mode?: string;
1337
+ template?: string;
1338
+ slide_count?: number;
1339
+ aspect_ratio?: string;
1340
+ accent_color?: string;
1341
+ text_position?: string;
1342
+ },
1343
+ client,
1344
+ ) => {
1345
+ // slide_count is a flat z.number() (see registerTool TS2589 note), so
1346
+ // enforce the integer 3..10 range here, matching the server changeset.
1347
+ if (args.slide_count !== undefined) {
1348
+ if (
1349
+ !Number.isInteger(args.slide_count) ||
1350
+ args.slide_count < 3 ||
1351
+ args.slide_count > 10
1352
+ ) {
1353
+ return fail("slide_count must be an integer between 3 and 10");
1354
+ }
1355
+ }
1356
+ const body: Record<string, unknown> = { prompt: args.prompt };
1357
+ if (args.mode !== undefined) body.mode = args.mode;
1358
+ if (args.template !== undefined) body.template = args.template;
1359
+ if (args.slide_count !== undefined) body.slide_count = args.slide_count;
1360
+ if (args.aspect_ratio !== undefined)
1361
+ body.aspect_ratio = args.aspect_ratio;
1362
+ if (args.accent_color !== undefined)
1363
+ body.accent_color = args.accent_color;
1364
+ if (args.text_position !== undefined)
1365
+ body.text_position = args.text_position;
1366
+ const res = await client.post<{ data: { slug: string } }>(
1367
+ "/sliders",
1368
+ body,
1369
+ idemKey(
1370
+ "create-slider",
1371
+ args.prompt,
1372
+ args.mode ?? "",
1373
+ args.template ?? "",
1374
+ ),
1375
+ );
1376
+ return ok({
1377
+ slug: res.data.slug,
1378
+ kind: "slider",
1379
+ next: "generate_slider, then get_slider to read the slide URLs + caption",
1380
+ });
1381
+ },
1382
+ ),
1383
+ );
1384
+
1385
+ registerTool(
1386
+ "generate_slider",
1387
+ {
1388
+ title: "Generate (render) a carousel",
1389
+ description:
1390
+ "Deducts 1 credit per slide (3–10 slides) and starts the pipeline (copy → AI backgrounds → composite) for an existing " +
1391
+ "carousel. Safe to call again: a duplicate while a render is already in flight is reported as still " +
1392
+ "processing (no double charge), and a failed carousel can be re-generated. Then poll with get_slider " +
1393
+ "until status is 'completed' or 'failed'.",
1394
+ inputSchema: {
1395
+ slug: z.string().describe("Slider slug from create_slider"),
1396
+ },
1397
+ annotations: { title: "Generate slider", ...WRITE, idempotentHint: true },
1398
+ },
1399
+ tool(async (args: { slug: string }, client) => {
1400
+ // No stable idempotency key on purpose: a per-slug key would replay the
1401
+ // first response for 24h and silently block an intentional re-generation
1402
+ // (e.g. retrying a failed render). Double-charge is prevented server-side
1403
+ // by the atomic generation claim, which returns 409 when a render is
1404
+ // already running — surface that as "still processing" rather than an error.
1405
+ try {
1406
+ const res = await client.post<{ data: Record<string, unknown> }>(
1407
+ `/sliders/${args.slug}/generate`,
1408
+ );
1409
+ const data = asRecord(asRecord(res).data);
1410
+ return ok({
1411
+ slug: args.slug,
1412
+ kind: "slider",
1413
+ status: data.status ?? "processing",
1414
+ });
1415
+ } catch (e) {
1416
+ if ((e as { status?: number }).status === 409) {
1417
+ return ok({ slug: args.slug, kind: "slider", status: "processing" });
1418
+ }
1419
+ throw e;
1420
+ }
1421
+ }),
1422
+ );
1423
+
1424
+ registerTool(
1425
+ "get_slider",
1426
+ {
1427
+ title: "Get carousel status + deliverable",
1428
+ description:
1429
+ "Returns the carousel's status and, when completed, the per-slide image URLs (download these), the " +
1430
+ "per-slide headline/body/kicker, the caption, and the hashtags. status flows draft → processing → " +
1431
+ "completed | failed. completed:true means every slide image_url is ready to save. Once completed you " +
1432
+ "can restyle_slider (new template/accent) or edit_slider_slide (one slide's text) for free.",
1433
+ inputSchema: { slug: z.string().describe("Slider slug") },
1434
+ annotations: { title: "Get slider", ...RO },
1435
+ },
1436
+ tool(async (args: { slug: string }, client) => {
1437
+ const res = await client.get<{ data: Record<string, unknown> }>(
1438
+ `/sliders/${args.slug}`,
1439
+ );
1440
+ const data = asRecord(asRecord(res).data);
1441
+ const slides = Array.isArray(data.slides)
1442
+ ? (data.slides as Record<string, unknown>[])
1443
+ : [];
1444
+ const status = (data.status as string) ?? "unknown";
1445
+ return ok({
1446
+ slug: args.slug,
1447
+ kind: "slider",
1448
+ status,
1449
+ stage: status,
1450
+ terminal: status === "completed" || status === "failed",
1451
+ // Trust the server's terminal status. The API completes a slider in one
1452
+ // transaction that writes every rendered key, so "completed" always implies
1453
+ // all slides[].image_url are ready — re-deriving it here only created an
1454
+ // ambiguous terminal:true / completed:false dead-end on an edge response.
1455
+ completed: status === "completed",
1456
+ error: (data.error_message as string) ?? null,
1457
+ caption: (data.caption as string) ?? null,
1458
+ hashtags: Array.isArray(data.hashtags) ? data.hashtags : [],
1459
+ slides: slides.map((s) => ({
1460
+ position: s.position,
1461
+ image_url: s.image_url ?? null,
1462
+ headline: s.headline ?? null,
1463
+ body: s.body ?? null,
1464
+ kicker: s.kicker ?? null,
1465
+ status: s.status ?? null,
1466
+ })),
1467
+ });
982
1468
  }),
983
1469
  );
984
1470
 
1471
+ registerTool(
1472
+ "restyle_slider",
1473
+ {
1474
+ title: "Restyle a carousel (free re-composite of every slide)",
1475
+ description:
1476
+ "Re-composites ALL slides of a completed carousel under a new template, accent color and/or text " +
1477
+ "position — for FREE (0 credits). Reuses the already-generated AI backgrounds, so no new images are paid for. The " +
1478
+ "carousel must already be 'completed' (generate it first); a draft/processing carousel returns a " +
1479
+ "409 conflict. Runs async: it marks every slide processing, so poll get_slider until status is back " +
1480
+ "to 'completed' (or 'failed'). Omit a field to leave it unchanged.",
1481
+ inputSchema: {
1482
+ slug: z.string().describe("Slider slug from create_slider"),
1483
+ template: z
1484
+ .enum([
1485
+ "boldStatement",
1486
+ "editorialStory",
1487
+ "scrapbook",
1488
+ "featureGrid",
1489
+ "offerCard",
1490
+ "comparison",
1491
+ ])
1492
+ .optional()
1493
+ .describe(
1494
+ "New composition preset. boldStatement/editorialStory/scrapbook are creative; " +
1495
+ "featureGrid/offerCard/comparison are ad-driven.",
1496
+ ),
1497
+ accent_color: z
1498
+ .string()
1499
+ .regex(/^#[0-9a-fA-F]{6}$/)
1500
+ .optional()
1501
+ .describe(
1502
+ "New brand accent: must start with #, exactly 6 hex digits, e.g. #09EFBE",
1503
+ ),
1504
+ text_position: z
1505
+ .enum(["top", "middle", "bottom"])
1506
+ .optional()
1507
+ .describe(
1508
+ "New vertical placement of the on-image copy across all slides.",
1509
+ ),
1510
+ },
1511
+ annotations: { title: "Restyle slider", ...WRITE, idempotentHint: false },
1512
+ },
1513
+ tool(
1514
+ async (
1515
+ args: {
1516
+ slug: string;
1517
+ template?: string;
1518
+ accent_color?: string;
1519
+ text_position?: string;
1520
+ },
1521
+ client,
1522
+ ) => {
1523
+ const body: Record<string, unknown> = {};
1524
+ if (args.template !== undefined) body.template = args.template;
1525
+ if (args.accent_color !== undefined)
1526
+ body.accent_color = args.accent_color;
1527
+ if (args.text_position !== undefined)
1528
+ body.text_position = args.text_position;
1529
+ try {
1530
+ const res = await client.post<{ data: Record<string, unknown> }>(
1531
+ `/sliders/${args.slug}/restyle`,
1532
+ body,
1533
+ );
1534
+ const data = asRecord(asRecord(res).data);
1535
+ return ok({
1536
+ slug: args.slug,
1537
+ kind: "slider",
1538
+ status: data.status ?? "processing",
1539
+ next: "poll get_slider until status is 'completed' (free re-composite, 0 credits)",
1540
+ });
1541
+ } catch (e) {
1542
+ // 409 = carousel not completed yet, or a re-composite already running.
1543
+ if ((e as { status?: number }).status === 409) {
1544
+ return ok({ slug: args.slug, kind: "slider", status: "processing" });
1545
+ }
1546
+ throw e;
1547
+ }
1548
+ },
1549
+ ),
1550
+ );
1551
+
1552
+ registerTool(
1553
+ "edit_slider_slide",
1554
+ {
1555
+ title: "Edit one carousel slide's on-image text (free re-composite)",
1556
+ description:
1557
+ "Rewrites the on-image copy of a SINGLE slide (by position) and re-composites just that slide — for " +
1558
+ "FREE (0 credits). Reuses the slide's existing AI background, so no new image is paid for. The " +
1559
+ "carousel must already be 'completed' (a draft/processing carousel returns a 409 conflict). Runs " +
1560
+ "async: the slide goes back to processing, so poll get_slider until its status is 'completed'. Pass " +
1561
+ "only the fields you want to change. kicker = the small eyebrow/label line above the headline.",
1562
+ inputSchema: {
1563
+ slug: z.string().describe("Slider slug from create_slider"),
1564
+ position: z
1565
+ .number()
1566
+ .int()
1567
+ .min(1)
1568
+ .describe("1-based slide position (from get_slider)"),
1569
+ headline: z
1570
+ .string()
1571
+ .max(120)
1572
+ .optional()
1573
+ .describe("Slide headline, ≤120 chars"),
1574
+ body: z.string().max(600).optional().describe("Slide body, ≤600 chars"),
1575
+ kicker: z
1576
+ .string()
1577
+ .max(40)
1578
+ .optional()
1579
+ .describe("Small eyebrow/label line above the headline, ≤40 chars"),
1580
+ },
1581
+ annotations: {
1582
+ title: "Edit slider slide",
1583
+ ...WRITE,
1584
+ idempotentHint: false,
1585
+ },
1586
+ },
1587
+ tool(
1588
+ async (
1589
+ args: {
1590
+ slug: string;
1591
+ position: number;
1592
+ headline?: string;
1593
+ body?: string;
1594
+ kicker?: string;
1595
+ },
1596
+ client,
1597
+ ) => {
1598
+ const body: Record<string, unknown> = {};
1599
+ if (args.headline !== undefined) body.headline = args.headline;
1600
+ if (args.body !== undefined) body.body = args.body;
1601
+ if (args.kicker !== undefined) body.kicker = args.kicker;
1602
+ try {
1603
+ const res = await client.patch<{ data: Record<string, unknown> }>(
1604
+ `/sliders/${args.slug}/slides/${args.position}`,
1605
+ body,
1606
+ );
1607
+ const data = asRecord(asRecord(res).data);
1608
+ return ok({
1609
+ slug: args.slug,
1610
+ kind: "slider",
1611
+ position: args.position,
1612
+ status: data.status ?? "processing",
1613
+ next: "poll get_slider until the slide's status is 'completed' (free re-composite, 0 credits)",
1614
+ });
1615
+ } catch (e) {
1616
+ // 409 = carousel not completed yet, or a re-composite already running.
1617
+ if ((e as { status?: number }).status === 409) {
1618
+ return ok({
1619
+ slug: args.slug,
1620
+ kind: "slider",
1621
+ position: args.position,
1622
+ status: "processing",
1623
+ });
1624
+ }
1625
+ throw e;
1626
+ }
1627
+ },
1628
+ ),
1629
+ );
1630
+
985
1631
  // ── Editor ───────────────────────────────────────────────────────────────────
986
1632
 
987
1633
  registerTool(