hermoso 0.1.308 → 0.1.310

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/mcp/http.mjs CHANGED
@@ -72,6 +72,42 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStar
72
72
  app.get('/.well-known/oauth-protected-resource', protectedResourceMetadata);
73
73
  app.get(`/.well-known/oauth-protected-resource${MCP_PATH}`, protectedResourceMetadata);
74
74
 
75
+ // ── THE STATIC SERVER CARD A DIRECTORY READS INSTEAD OF SCANNING (2026-09-25) ─────────────────────────────────────
76
+ // Smithery's re-scan stopped at "Authentication required": its first probe (UA `SmitheryBot/1.0 (+https://…)`) gets
77
+ // the anonymous preview, but its connect step sends NO user-agent, and a UA-less tokenless handshake is exactly
78
+ // Grok's setup probe, which MUST stay challenged (a 200 there made Grok save us as a no-auth connector). Smithery's
79
+ // own answer for an OAuth server is this document (smithery.ai/docs/build/publish, read 2026-09-25: "you can bypass
80
+ // scanning by serving metadata manually at /.well-known/mcp/server-card.json" — serverInfo, authentication, tools,
81
+ // resources, prompts, SEP-1649 shapes). So the roster a crawler would have listed anonymously is published here,
82
+ // built from the SAME registerTools call the anonymous preview makes and read back through a real MCP client, so
83
+ // it cannot drift from what tools/list serves. Built once per process (the roster is static per process).
84
+ let cardPromise = null;
85
+ const buildServerCard = async () => {
86
+ const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
87
+ const { InMemoryTransport } = await import('@modelcontextprotocol/sdk/inMemory.js');
88
+ const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: MCP_INSTRUCTIONS });
89
+ registerTools(server, { only: [...DEFAULT_TOOL_GROUPS], directory: false, widgetHost: false, hosted: true });
90
+ const [a, b] = InMemoryTransport.createLinkedPair();
91
+ const client = new Client({ name: 'server-card', version: '1' });
92
+ await Promise.all([server.connect(a), client.connect(b)]);
93
+ try {
94
+ const list = async (fn, key) => { const out = []; let cursor; do { const r = await fn(cursor ? { cursor } : {}).catch(() => null); if (!r) break; out.push(...(r[key] || [])); cursor = r.nextCursor; } while (cursor); return out; };
95
+ const tools = await list((p) => client.listTools(p), 'tools');
96
+ const resources = await list((p) => client.listResources(p), 'resources');
97
+ const prompts = await list((p) => client.listPrompts(p), 'prompts');
98
+ return { serverInfo: { name: 'hermoso', title: 'Hermoso', version: PKG_VERSION }, instructions: MCP_INSTRUCTIONS,
99
+ authentication: { required: true, schemes: ['oauth2', 'bearer'] },
100
+ transport: { type: 'streamable-http', url: `${BASE}${MCP_PATH}` },
101
+ tools, resources, prompts };
102
+ } finally { try { await client.close(); } catch {} try { await server.close(); } catch {} }
103
+ };
104
+ app.get('/.well-known/mcp/server-card.json', async (req, res) => {
105
+ try {
106
+ cardPromise ||= buildServerCard().catch((e) => { cardPromise = null; throw e; });
107
+ res.set('Cache-Control', 'public, max-age=3600').json(await cardPromise);
108
+ } catch (e) { res.status(503).json({ error: 'server card unavailable, try again', detail: String(e?.message || e).slice(0, 200) }); }
109
+ });
110
+
75
111
  // Per-session Streamable-HTTP transports. Each authenticated session gets its own McpServer with the same tools.
76
112
  //
77
113
  // ── A SESSION WAS EXPENSIVE, AND THIS MAP IS WHY PROD OOM'd (2026-08-01, again 2026-08-24) ───────────────────
@@ -132,7 +168,10 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
132
168
  // then the two that need no browser at all. `WWW-Authenticate` still points at the protected-resource metadata,
133
169
  // which is what a spec-following client uses; this is for the one that is not following it.
134
170
  const challenge = (res) => res.status(401)
135
- .set('WWW-Authenticate', `Bearer resource_metadata="${BASE}/.well-known/oauth-protected-resource"`)
171
+ // `scope` rides the challenge (MCP authorization spec, "Protected Resource Metadata Discovery Requirements":
172
+ // servers SHOULD include it; ChatGPT's own auth doc shows the same shape). The same ONE list the PRM and the AS
173
+ // metadata publish, so a client that scopes its authorize request from the challenge asks for exactly that.
174
+ .set('WWW-Authenticate', `Bearer resource_metadata="${BASE}/.well-known/oauth-protected-resource", scope="hermoso.research hermoso.generate"`)
136
175
  .json({
137
176
  error: 'Authentication required',
138
177
  error_description: 'This Hermoso MCP server needs a signed-in account. Normally your client opens a browser consent page. IF NO BROWSER OR CONSENT CARD OPENED, your client cannot complete OAuth — retrying will keep failing the same way. Read the `how_to_connect` field of THIS response and use one of those two browser-free routes instead. Tell the user which one you are taking.',
@@ -341,7 +380,9 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
341
380
 
342
381
  app.all(MCP_PATH, async (req, res) => {
343
382
  const auth = req.headers.authorization || '';
344
- const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
383
+ // The auth-scheme name is case-insensitive (RFC 9110 §11.1, RFC 6750 §2.1): `bearer <key>` is the same
384
+ // credential, and reading only `Bearer ` made a client that lower-cases it look tokenless, challenged for ever.
385
+ const token = (/^Bearer[ \t]+(\S+)[ \t]*$/i.exec(auth) || [])[1] || '';
345
386
  // A HANDSHAKE IS NOT USE. `verifyBearer` stamps the key's last_used_at, and the admin dashboard's "last
346
387
  // active" takes the max of that, the billed ledger and the user's last_seen — so an agent that merely holds a
347
388
  // connection open (initialize, tools/list, ping, a notification) kept reporting the account as ACTIVE while
@@ -420,7 +461,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
420
461
  // connectedProviders() ([[failed-read-is-not-empty]]).
421
462
  const connectors = await mcpCtx.run({ token, remote: true, client: rememberedClient(req) }, () => connectedProviders());
422
463
  const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: MCP_INSTRUCTIONS });
423
- registerTools(server, { only: scope.groups, directory: scope.directory || false, connectors, widgetHost: isWidgetHost(entry?.client || clientInfoOf(req.body), req) , hosted: true, client: entry?.client || rememberedClient(req) }); // the SAME tools as stdio (minus any the caller scoped out) — and every /api call they make carries this user's token
464
+ registerTools(server, { only: scope.groups, directory: scope.directory || false, connectors, widgetHost: isWidgetHost(entry?.client || clientInfoOf(req.body), req) , hosted: true, client: entry?.client || rememberedClient(req), ua: String(req.headers['user-agent'] || '').slice(0, 120) }); // the SAME tools as stdio (minus any the caller scoped out) — and every /api call they make carries this user's token
424
465
  const transport = new StreamableHTTPServerTransport({
425
466
  // CSPRNG, per the spec's SHOULD for session ids (Math.random() is not one).
426
467
  sessionIdGenerator: () => 'sess_' + randomUUID().replace(/-/g, ''),
package/mcp/tools.mjs CHANGED
@@ -299,7 +299,8 @@ export const MCP_INSTRUCTIONS = [
299
299
  // disagree are worse than either one. What the user-facing line has to carry is only that omitting `model` works.
300
300
  // So the honest shape is a TRIGGER, not a gate: call it when you have a question it answers. Keeping the tool
301
301
  // discoverable is the other half of the fix, so the reasons to call it are spelled out rather than merely permitted.
302
- 'ACT ON THE REQUEST, DO NOT SURVEY IT: when the user asks for something to be made, make it. generate_image, generate_video and render_ad all run with `model` omitted, and an unnamed render goes to the server’s own default model, which is a sound general-purpose pick, so there is nothing you have to look up before rendering. Call hermoso_capabilities (free) when you actually need what it holds: a specific model id, an exact credit cost, a model’s live durations / aspect ratios / resolutions, or whether a capability is enabled on this account. Reporting the model catalog back is never the answer to a request to create something.',
302
+ // REMOVED 2026-09-25: the long restatement of this rule that sat here repeated the head's own 'ACT ON THE REQUEST' line
303
+ // word for word in substance; every host already reads the head copy, and the non-truncating ones paid for it twice.
303
304
  'Capability map:',
304
305
  '• AD SPY / RESEARCH: find_competitors, competitor_teardown, pull_competitor_ads, research_ads; ad libraries search_meta_ads / search_google_ads / search_linkedin_ads; organic search_tiktok / search_instagram / search_youtube / search_reddit / search_threads; fetch_social_data; mine_angles; analyze_video; check_ad_policy; list_skills / get_skill.',
305
306
  '• CREATE (finished ads): render_ad (Studio quality pipeline) or generate_image / generate_video / generate_avatar render on their own; plan_ad authors a board first when the ad wants one and render_ad takes it; get_brand (what we already know) / draft_brand (onboard one) / update_brand (patch a field) manage the saved brand, which the create tools hydrate by themselves; list_creators / save_creator / delete_creator (the reusable saved CAST — re-cast the same face instead of generating a new person every time; render_ad’s `creator` stars one of them in the ad); make_template_ad (native HTML formats); make_thumbnail (YouTube / Shorts / Instagram video thumbnails + covers — use it for any thumbnail or video-cover ask, never generate_image); clone_static / recast_motion / reframe_video / upscale_video / dub_video / change_voice / finish_video / fix_beat / hook_variants / stitch_video; plan_variations + score_ad.',
@@ -645,9 +646,9 @@ const HOOK_ATTR = {
645
646
  // is silently attributed to it. Naming the brand per call is the fix, and it is the most specific statement of
646
647
  // intent there is, so the server lets it beat the key pin, the workspace header and the active-brand default — for
647
648
  // that one request only, which is what makes a one-off post to a second client safe.
648
- brand: z.string().optional().describe('WHICH BRAND this post belongs to — the id or exact name from list_brands (a workspace shared with you: its profile id). Use it whenever the account has more than one brand and you are not certain which one this connection is pinned to: it beats the pin for THIS CALL ONLY and changes nothing about the connection. A name that matches no brand, or two brands, is REFUSED and nothing is posted — never resolved to the pin, which is the account you were guarding against.'),
649
- hook: z.string().optional().describe('WHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer "which hooks work", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. "direct_callout", "mid_problem", "before_after") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works.'),
650
- subject: z.string().optional().describe('WHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. "winter coat", "free trial", "founder story"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group.'),
649
+ brand: z.string().optional().describe('WHICH BRAND this post belongs to — id or exact name from list_brands (a shared workspace: its profile id). Beats the connection\'s pin for THIS CALL ONLY; a name that matches no brand, or two, is REFUSED and nothing is posted.'),
650
+ hook: z.string().optional().describe('WHAT ANGLE this post is built on, recorded only at publish time. post_performance ranks hooks on it (a winner needs 5 posts sharing ONE hook), so pass a list_hooks id (e.g. "direct_callout", "before_after") or reuse your own wording EXACTLY across a campaign. Omit it and this post never votes on which hook works.'),
651
+ subject: z.string().optional().describe('WHAT THIS POST IS ABOUT — product, feature, offer or theme (e.g. "winter coat", "free trial"). post_performance\'s second grouping axis: reuse the exact wording, as with hook.'),
651
652
  // THE FORMAT AND THE IDEA (2026-09-23). The server's publish seam has recorded `recipe` since 2026-09-11, and no
652
653
  // agent surface could send it, so every post published over MCP or the CLI said nothing about its format and
653
654
  // post_performance's recipe axis returned no groups at all. Same spread, so every publish and schedule tool gains both.
@@ -674,7 +675,7 @@ const PUBLISH_SAFETY = {
674
675
  // `?brandId=` on a GET/DELETE (the server belt `brandRefOf` consumes both; `?brand=` is /api/product/find's).
675
676
  // tools/brand-per-call-roster-check.mjs derives the tool set by running registerTools and fails on a new one without it.
676
677
  const MANAGE_BRAND = {
677
- brand: z.string().optional().describe('WHICH BRAND the post lives in — the id or exact name from list_brands (a workspace shared with you: its profile id). Needed when it was published in a brand this connection is not pinned to: a post you can CREATE in a brand is manageable there too, for THIS CALL ONLY, without switching the connection. A name that matches no brand, or two, is REFUSED and nothing is done.'),
678
+ brand: z.string().optional().describe('WHICH BRAND the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a brand this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no brand, or two, is REFUSED and nothing is done.'),
678
679
  };
679
680
  const namedBrand = (a) => (a && typeof a.brand === 'string' && a.brand.trim() ? a.brand.trim() : '');
680
681
  // POST body: the belt reads a string `brand` and deletes it before the route sees the body.
@@ -2058,8 +2059,24 @@ export const CORE_FIRST_HEADLINE = Object.freeze([
2058
2059
  ]);
2059
2060
  export const CORE_FIRST_EXTRA = Object.freeze(['get_brand', 'get_job', 'list_jobs', 'list_connectors', 'list_scheduled', ...CORE_FIRST_HEADLINE]);
2060
2061
  // Hosts that have been SEEN to follow find_tools → call_tool. A name that does not match keeps the full roster.
2061
- export const CORE_FIRST_VERIFIED_HOST_RE = /claude|chatgpt|openai-mcp/i;
2062
- export const hostTakesCoreFirst = (client) => CORE_FIRST_VERIFIED_HOST_RE.test(String(client || ''));
2062
+ // WIDENED 2026-09-25 ON EVIDENCE: Gemini CLI (0.60, clientInfo "gemini-cli-mcp-client"), Grok (clientInfo "grok", UA
2063
+ // grok-connectors-manager) and Mistral Le Chat / Vibe (clientInfo "mcp", so it is recognised by its UA MistralAI-MCPClient)
2064
+ // were each asked for something no short list carries ("show my Google Ads campaigns") and each ran find_tools ->
2065
+ // call_tool(list_google_ads_campaigns) to the real campaigns — Gemini CLI on `?tools=core`, stricter than the short list.
2066
+ // Measured on Gemini CLI, the same one-question task sent 473K input tokens on the full roster and 127K on the core list:
2067
+ // these hosts send every listed schema on every model call. Mistral is matched by UA only: "mcp" names nothing.
2068
+ export const CORE_FIRST_VERIFIED_HOST_RE = /claude|chatgpt|openai-mcp|^grok\b|gemini-cli/i;
2069
+ export const CORE_FIRST_VERIFIED_UA_RE = /^(grok-connectors-manager|MistralAI-MCPClient|gemini-cli)/i;
2070
+ export const hostTakesCoreFirst = (client, ua) => CORE_FIRST_VERIFIED_HOST_RE.test(String(client || '')) || CORE_FIRST_VERIFIED_UA_RE.test(String(ua || ''));
2071
+ // HOSTS WITH A HARD TOOL CAP GET THE SHORT LIST TOO (2026-09-25, measured on prod). VS Code sends at most 128 tools per
2072
+ // request (code.visualstudio.com/docs/agents/run/tools) and Windsurf holds 100 across every server (docs.devin.ai
2073
+ // cascade/mcp). The full default roster is ~180, so on both the list was over the cap before the user's other servers
2074
+ // were counted: VS Code blocks the request or folds the overflow behind its own activate_* stubs, and Windsurf makes the
2075
+ // user untick tools by hand. Neither shows the product as it is, so the core-first list (under 50, the headline verb of
2076
+ // every area, find_tools + call_tool for the rest) is the one roster that fits. Matched on the clientInfo name VS Code
2077
+ // sends ("Visual Studio Code", seen on prod) or a Windsurf name/UA. NOT /vscode/: Cursor has called itself "cursor-vscode".
2078
+ export const TOOL_CAPPED_HOST_RE = /^visual studio code\b|windsurf/i;
2079
+ export const hostHasToolCap = (client, ua) => TOOL_CAPPED_HOST_RE.test(String(client || '')) || /windsurf/i.test(String(ua || ''));
2063
2080
  export function defaultToolGroups(env = process.env) { return coreFirstRoster(env) ? ['core'] : [...DEFAULT_TOOL_GROUPS]; }
2064
2081
 
2065
2082
  // Parse a `tools=` scope. Returns {groups} or {error} — an unknown name is REFUSED BY NAME rather than dropped,
@@ -2617,7 +2634,7 @@ function newToolScope(opts) {
2617
2634
  // whose connector was tested on exactly that route. Cursor, Codex and anything unnamed are unverified, so on the
2618
2635
  // HOSTED transport they keep the full default roster and nothing can look smaller than it is. The stdio CLI has no
2619
2636
  // host to ask and stays a plain env opt-in. Widen CORE_FIRST_VERIFIED_HOST_RE on evidence, never on a guess.
2620
- const coreFirst = !opts.only && coreFirstRoster() && (!opts.hosted || hostTakesCoreFirst(opts.client));
2637
+ const coreFirst = !opts.only && coreFirstRoster() && (!opts.hosted || hostTakesCoreFirst(opts.client, opts.ua) || hostHasToolCap(opts.client, opts.ua));
2621
2638
  const asked = opts.only ? new Set(opts.only) : new Set(coreFirst ? ['core'] : [...DEFAULT_TOOL_GROUPS]);
2622
2639
  asked.add('core'); // discovery/credits/billing/jobs must exist in EVERY roster or the connection is unusable
2623
2640
  // `connectors` is `{connected:Set<provider>, readOk:boolean}` from the transport's own free read, or absent.
@@ -4820,7 +4837,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4820
4837
  videoUrl: z.string().optional().describe('public https URL, data: URI, or /generated path — FB video post / IG Reel'),
4821
4838
  productTags: z.array(z.any()).optional().describe('INSTAGRAM SHOPPING — make the post SHOPPABLE by tagging products from the brand’s own catalog; tapping a tag opens the product’s price sheet inside Instagram. Instagram only. ON A PHOTO each tag is {product_id, x, y}, where x and y are FRACTIONS of the image from 0.0 (left/top) to 1.0 (right/bottom) — 0.5,0.5 is the middle — and BOTH are required, max 20. ON A REEL it is {product_id} ALONE with no coordinates, max 30. ON A CAROUSEL it is an array PER SLIDE ([[{…}], [], [{…}]]) because Instagram tags each slide’s own container, max 5 per slide and 20 across the post. Ids come from search_instagram_shopping_products; call list_instagram_shopping_catalogs FIRST, because tagging needs an APPROVED Instagram Shop and without one this fails after the media is already uploaded. A tag whose product is not “approved” is stored and shown to nobody.'),
4822
4839
  imageUrls: z.array(z.string()).optional().describe('CAROUSEL — an ORDERED list of image (and, where the channel allows, video) URLs published as ONE post the viewer swipes through. THIS IS NOT “post several” — it is a single post with several slides, which is what a multi-slide creative (a listicle, a “1/6 · SWIPE” deck) actually needs; publishing only its first slide tells the viewer to swipe at something that cannot. The ORDER is the product. Limits per channel: Instagram 2–10 (images, videos or a mix), Threads 2–20 (mix allowed), Facebook 2+ (Meta publishes no documented maximum; Hermoso caps the upload fan-out at 30 and says so), LinkedIn company Pages 2–20 (images only), Pinterest 2–5 (images only), TikTok up to 35. One url here is simply an ordinary single post. Anything a channel cannot do is REFUSED with the real reason — nothing is ever quietly downgraded to one slide.'),
4823
- idempotencyKey: z.string().optional().describe('SAFE RETRIES. Publishing can take minutes (a video upload, a carousel of ten slides) and a transport can time out while the post SUCCEEDS — retrying blind is how the same thing gets posted twice. Pass any stable string here and a repeat of the SAME publish returns the ORIGINAL post id instead of posting again (24h). You do not have to: an identical publish is auto-recognised for 10 minutes anyway. If a call times out or errors ambiguously, CALL AGAIN WITH THE SAME KEY — that is the safe move, and it will either report the original post or publish it for the first time. It never posts twice.'),
4840
+ idempotencyKey: z.string().optional().describe('SAFE RETRIES — any stable string: a repeat of the SAME publish within 24h returns the ORIGINAL post id instead of posting again (an identical publish is also auto-recognised for 10 minutes). On a timeout or an ambiguous error, CALL AGAIN WITH THE SAME KEY: it reports the original post or publishes it once. It never posts twice.'),
4824
4841
  allowDuplicate: z.boolean().optional().describe('post it even though an identical post was just made or attempted. Only pass this when the user genuinely wants the same thing posted twice, or when you have LOOKED at the account and confirmed a timed-out attempt did not land.'),
4825
4842
  async: z.boolean().optional().describe('publish in the BACKGROUND and return a job id to poll with get_job, instead of waiting. USE THIS FOR VIDEO: a Facebook or Instagram video publish routinely outlives an agent transport, and a timeout on the synchronous path leaves you unable to tell whether the post is live. With async:true nothing can time out — the job reports the post id and url when it lands.'),
4826
4843
  link: z.string().optional().describe('a URL to attach (FB text post only)'),
@@ -4889,7 +4906,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4889
4906
  // no need for one edge case just for Facebook."
4890
4907
  server.registerTool('schedule_post', {
4891
4908
  title: 'Schedule a post for later',
4892
- description: 'Queue a post to go out at a future time, to one or more connected channels at once (facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, bluesky, telegram — TEN channels, and every one of them is live. google_business is accepted by the schema too but is currently HELD BACK: Google allowlists that API per project and ours reads 0 QPM, so it is refused at enqueue rather than failing hours later). A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order — never schedule just its first slide. Either name the exact time in `at`, or pass `useQueue:true` to take the brand’s next free POSTING SLOT (its saved posting times, skipping any already occupied) — that is what “just queue it” means and it saves the user picking a minute. Pass a Hermoso render URL as imageUrl/videoUrl (or an upload_file URL for external media). PINTEREST AND YOUTUBE ALSO SHOW A TITLE: pass `title` (max 100 chars) — omit it and Hermoso derives one from the caption\u2019s first sentence rather than truncating the caption mid-word. YOUTUBE: `description` (≤5000 chars) is the box under the video for the links and CTA, and the caption stands in when it is omitted; `tags` up to 30; `thumbnailUrl` sets the custom thumbnail. Use `captions` to give each channel its own wording; anything not listed falls back to `message`. PER-CHANNEL SETTINGS, all carried straight through to the real publisher: TIKTOK takes the paid-partnership disclosure (`brandedContent`) and the own-brand one (`yourBrand`) — set them whenever the post is commercial, they are compliance declarations — plus `disableComment` and, on a video, `disableDuet` / `disableStitch` / `coverTimestampMs`. GOOGLE BUSINESS takes `topicType` (STANDARD / EVENT / OFFER / ALERT) with `event` and `offer`, and a real `actionType` button instead of the hard-coded Learn more. X takes a whole `thread`, a `poll`, `replySettings` and `madeWithAi`. INSTAGRAM takes `collaborators` — up to 3 usernames invited to CO-AUTHOR the post, which puts it on their profile too once they accept (the invite is sent when the post fires, and is pending until then). Channels are attempted INDEPENDENTLY, so one failing channel never blocks the others. SOME CHANNELS MUST BE TOLD WHICH ACCOUNT, and Hermoso never guesses one: a Pinterest pin needs `boardId` (list_pinterest_boards) or it is refused outright; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages) and without it the post goes to the connected person’s own profile; a brand with more than one connected Facebook Page needs `pageId` (list_meta_pages) and an account managing more than one Google Business listing needs `locationId` (list_business_locations) — resolve those FIRST and let the user pick, because with several to choose from and no id the post is refused when it fires, hours later. A scheduled post GOES LIVE PUBLICLY by default on every channel — that is what scheduling means, and nothing is ever quietly downgraded to a draft or an unlisted upload. If the user genuinely wants something staged instead, set `visibility` (or `visibilityByChannel` for just one channel): ‘public’ (default, live) · ‘unlisted’ (YouTube only — link-only) · ‘private’ (YouTube private, or TikTok posted SELF_ONLY) · ‘draft’ (TikTok drafts, or an unpublished Facebook Page post for a human to publish). Ask for a weaker visibility only if the user asked for one. If a channel cannot do the visibility requested, the call is REFUSED right now with the reason, rather than posting something weaker later. Most channels publish publicly and nothing else: only YouTube has unlisted/private, only TikTok has private/draft, and only Facebook has draft.',
4909
+ description: 'Queue a post for a future time on one or more connected channels at once: facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, bluesky, telegram — TEN channels, every one live. google_business is in the schema but HELD BACK (Google’s API allowlist) and is refused at enqueue. A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order — never schedule just its first slide. Name the time in `at`, or pass `useQueue:true` for the brand’s next free POSTING SLOT (what “just queue it” means). imageUrl/videoUrl take a Hermoso render URL or an upload_file URL. `captions` gives a channel its own wording; the rest use `message`. PINTEREST AND YOUTUBE SHOW A TITLE: `title` (max 100 chars), derived from the caption when omitted; YOUTUBE also takes `description`, `tags`, `thumbnailUrl`. Every PER-CHANNEL SETTING is a parameter below, carried straight to the real publisher — TikTok’s `brandedContent` / `yourBrand` disclosures (set them whenever the post is commercial), Google Business `topicType` / `event` / `offer` / `actionType`, an X `thread` / `poll`, Instagram `collaborators`, and the rest. Channels are attempted INDEPENDENTLY: one failing never blocks the others. SOME CHANNELS MUST BE TOLD WHICH ACCOUNT, and Hermoso never guesses: Pinterest needs `boardId` (list_pinterest_boards) or is refused; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages) or it goes to the person’s own profile; several Facebook Pages need `pageId` (list_meta_pages), several Google Business listings `locationId` (list_business_locations) — resolve those FIRST and let the user pick, or the post is refused when it fires. A scheduled post GOES LIVE PUBLICLY by default on every channel, never quietly downgraded. Only if the user asks, set `visibility` (or `visibilityByChannel`): ‘unlisted’ (YouTube) · ‘private’ (YouTube, or TikTok SELF_ONLY) · ‘draft’ (TikTok, or an unpublished Facebook Page post). A visibility a channel cannot do is REFUSED now, never posted weaker later.',
4893
4910
  inputSchema: {
4894
4911
  ...HOOK_ATTR,
4895
4912
  channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram'])).describe('one or more channels to post to at that time'),
@@ -5086,7 +5103,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5086
5103
  title: 'Change a scheduled post',
5087
5104
  description: 'Change a post that is still QUEUED — move it to a different time, rewrite the caption, swap the media, add or drop a channel, or change which board / Page / company Page / listing it goes to. PASS ONLY WHAT CHANGES: an omitted field is left exactly as it was, and an explicit empty string CLEARS one (linkedinOrganizationId:"" moves a company-Page post back to the person\u2019s own profile). The edited item is re-checked against the identical rules its create passed — visibility the channel can honour, per-channel length, media the channel can carry — so an edit can never slip past a refusal that a create would have caught. Get the id from list_scheduled. Something that already went out cannot be changed: a published post is edited or removed with manage_meta_post / manage_linkedin_post / delete_x_post, not rescheduled.',
5088
5105
  inputSchema: {
5089
- brand: z.string().optional().describe('WHICH BRAND this post is in — the id or exact name from list_brands. Needed when the post lives in a brand this connection is not pinned to: a post you can CREATE in a brand must be manageable there too, without switching the whole connection. A name that matches no brand, or two, is REFUSED.'),
5106
+ brand: z.string().optional().describe('WHICH BRAND this post is in — id or exact name from list_brands; needed when it is not the brand this connection is pinned to. A name that matches no brand, or two, is REFUSED.'),
5090
5107
  ...POST_INTENT,
5091
5108
  id: z.string().describe('the scheduled post id from list_scheduled'),
5092
5109
  at: z.string().optional().describe('the new time — ISO timestamp (2026-08-05T09:00:00Z) or epoch milliseconds. Must be in the future, at most 365 days out.'),
@@ -5179,7 +5196,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5179
5196
  // schedule_post put a post in another brand, and then cancelling it needed use_brand — i.e. changing the whole
5180
5197
  // connection to undo one call. The wire spelling is `brandId` because these are GET/DELETE calls and `?brand=`
5181
5198
  // already means a brand NAME on /api/product/find.
5182
- inputSchema: { id: z.string().describe('the scheduled post id from list_scheduled'), brand: z.string().optional().describe('WHICH BRAND this post is in — the id or exact name from list_brands. Needed when the post lives in a brand this connection is not pinned to: a post you can CREATE in a brand must be manageable there too, without switching the whole connection. A name that matches no brand, or two, is REFUSED.') },
5199
+ inputSchema: { id: z.string().describe('the scheduled post id from list_scheduled'), brand: z.string().optional().describe('WHICH BRAND this post is in — id or exact name from list_brands; needed when it is not the brand this connection is pinned to. A name that matches no brand, or two, is REFUSED.') },
5183
5200
  outputSchema: { cancelled: z.string().optional() },
5184
5201
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
5185
5202
  }, wrap(async (a) => {
@@ -5195,7 +5212,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5195
5212
  title: 'Retry a failed scheduled post',
5196
5213
  description: 'Send a post that FAILED again. A scheduled post fans out across its channels INDEPENDENTLY, so a failure is usually PARTIAL — LinkedIn 401s while Instagram published fine — and this re-fires ONLY the channels that did not succeed by default (list_scheduled reports them as `retryable`). It re-queues the same content as a NEW post that goes out RIGHT AWAY — the queue picks it up on its next pass, within seconds — and the original keeps its failure record so the history still shows what went wrong. Naming a channel that already published is REFUSED rather than quietly posting a second time. Two independent belts stop a double-post: a channel that genuinely published can only REPLAY (nothing is posted), and a channel whose outcome is UNRESOLVED — the platform timed out and may be holding the post — refuses with that reason instead of guessing. Retry after fixing the cause — and you can fix it IN THIS CALL: pass `boardId`, `pageId`, `linkedinOrganizationId`, `locationId`, `message` or `captions` to correct the value that failed, and the corrected post is re-validated exactly like a fresh schedule. That matters because the commonest cause is a field, not an outage: a Pin aimed at the wrong board fails identically however many times it is re-sent. Anything you do not name is copied from the original. To send the same thing again ON PURPOSE, use duplicate_scheduled.',
5197
5214
  inputSchema: {
5198
- brand: z.string().optional().describe('WHICH BRAND this post is in — the id or exact name from list_brands. Needed when the post lives in a brand this connection is not pinned to: a post you can CREATE in a brand must be manageable there too, without switching the whole connection. A name that matches no brand, or two, is REFUSED.'),
5215
+ brand: z.string().optional().describe('WHICH BRAND this post is in — id or exact name from list_brands; needed when it is not the brand this connection is pinned to. A name that matches no brand, or two, is REFUSED.'),
5199
5216
  id: z.string().describe('the scheduled post id from list_scheduled'),
5200
5217
  channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram'])).optional().describe('retry only these channels (default: every channel that did not publish)'),
5201
5218
  at: z.string().optional().describe('hold the retry until a later time — ISO timestamp or epoch milliseconds. Leave it out to retry immediately, which is almost always what you want. A time you name here must be at least a minute from now, exactly like any other scheduled post.'),
@@ -5219,7 +5236,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5219
5236
  title: 'Duplicate a scheduled post',
5220
5237
  description: 'Copy an existing scheduled or already-published post into a NEW queued post — the way to run a creative again, reuse a post that worked as the starting point for the next one, or re-send something after it went out. It copies the caption, media, per-channel captions, title, description, tags and the target board / Page / company Page / listing, and ANY of those can be overridden in the same call. Give a new time in `at`, or useQueue:true to drop it into the brand’s next free posting slot. The copy is INDEPENDENT — editing or cancelling it never touches the original — and it is a genuinely new post rather than a re-send, so it publishes even where the original already did. To re-fire only the channels that FAILED, use retry_scheduled instead.',
5221
5238
  inputSchema: {
5222
- brand: z.string().optional().describe('WHICH BRAND this post is in — the id or exact name from list_brands. Needed when the post lives in a brand this connection is not pinned to: a post you can CREATE in a brand must be manageable there too, without switching the whole connection. A name that matches no brand, or two, is REFUSED.'),
5239
+ brand: z.string().optional().describe('WHICH BRAND this post is in — id or exact name from list_brands; needed when it is not the brand this connection is pinned to. A name that matches no brand, or two, is REFUSED.'),
5223
5240
  id: z.string().describe('the post to copy, from list_scheduled'),
5224
5241
  ...POST_INTENT, // the copy inherits the original's format and idea (and hook and subject); pass either to change it
5225
5242
  at: z.string().optional().describe('when the copy goes out — ISO timestamp or epoch milliseconds (default: an hour from now)'),
@@ -5324,7 +5341,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5324
5341
  text: z.string().describe('the post text'),
5325
5342
  imageUrl: z.string().optional().describe('a Hermoso-hosted image URL to attach (≤12MB) — a Hermoso render, or ANY file of the user’s own put through upload_file first. An arbitrary external host is refused (we fetch the bytes ourselves).'),
5326
5343
  imageUrls: z.array(z.string()).optional().describe('A CAROUSEL IS NOT AVAILABLE ON A PERSONAL PROFILE — LinkedIn\'s organic multi-image post publishes from a COMPANY PAGE. Passing several here is refused by name rather than posting slide 1; use post_to_linkedin_page instead.'),
5327
- idempotencyKey: z.string().optional().describe('SAFE RETRIES. Publishing can take minutes (a video upload, a carousel of ten slides) and a transport can time out while the post SUCCEEDS — retrying blind is how the same thing gets posted twice. Pass any stable string here and a repeat of the SAME publish returns the ORIGINAL post id instead of posting again (24h). You do not have to: an identical publish is auto-recognised for 10 minutes anyway. If a call times out or errors ambiguously, CALL AGAIN WITH THE SAME KEY — that is the safe move, and it will either report the original post or publish it for the first time. It never posts twice.'),
5344
+ idempotencyKey: z.string().optional().describe('SAFE RETRIES — any stable string: a repeat of the SAME publish within 24h returns the ORIGINAL post id instead of posting again (an identical publish is also auto-recognised for 10 minutes). On a timeout or an ambiguous error, CALL AGAIN WITH THE SAME KEY: it reports the original post or publishes it once. It never posts twice.'),
5328
5345
  allowDuplicate: z.boolean().optional().describe('post it even though an identical post was just made or attempted. Only pass this when the user genuinely wants the same thing posted twice, or when you have LOOKED at the account and confirmed a timed-out attempt did not land.'),
5329
5346
  visibility: z.enum(['PUBLIC', 'CONNECTIONS']).optional().describe('default PUBLIC'),
5330
5347
  },
@@ -5645,7 +5662,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5645
5662
  imageUrl: z.string().optional().describe('a Hermoso render image URL (or an upload_file url)'),
5646
5663
  videoUrl: z.string().optional().describe('a Hermoso render video URL — takes 1–2 minutes to ingest'),
5647
5664
  imageUrls: z.array(z.string()).optional().describe('CAROUSEL — an ORDERED list of image (and, where the channel allows, video) URLs published as ONE post the viewer swipes through. THIS IS NOT “post several” — it is a single post with several slides, which is what a multi-slide creative (a listicle, a “1/6 · SWIPE” deck) actually needs; publishing only its first slide tells the viewer to swipe at something that cannot. The ORDER is the product. Limits per channel: Instagram 2–10 (images, videos or a mix), Threads 2–20 (mix allowed), Facebook 2+ (Meta publishes no documented maximum; Hermoso caps the upload fan-out at 30 and says so), LinkedIn company Pages 2–20 (images only), Pinterest 2–5 (images only), TikTok up to 35. One url here is simply an ordinary single post. Anything a channel cannot do is REFUSED with the real reason — nothing is ever quietly downgraded to one slide.'),
5648
- idempotencyKey: z.string().optional().describe('SAFE RETRIES. Publishing can take minutes (a video upload, a carousel of ten slides) and a transport can time out while the post SUCCEEDS — retrying blind is how the same thing gets posted twice. Pass any stable string here and a repeat of the SAME publish returns the ORIGINAL post id instead of posting again (24h). You do not have to: an identical publish is auto-recognised for 10 minutes anyway. If a call times out or errors ambiguously, CALL AGAIN WITH THE SAME KEY — that is the safe move, and it will either report the original post or publish it for the first time. It never posts twice.'),
5665
+ idempotencyKey: z.string().optional().describe('SAFE RETRIES — any stable string: a repeat of the SAME publish within 24h returns the ORIGINAL post id instead of posting again (an identical publish is also auto-recognised for 10 minutes). On a timeout or an ambiguous error, CALL AGAIN WITH THE SAME KEY: it reports the original post or publishes it once. It never posts twice.'),
5649
5666
  allowDuplicate: z.boolean().optional().describe('post it even though an identical post was just made or attempted. Only pass this when the user genuinely wants the same thing posted twice, or when you have LOOKED at the account and confirmed a timed-out attempt did not land.'),
5650
5667
  title: z.string().optional().describe('Pin title, max 100 characters'),
5651
5668
  description: z.string().optional().describe('Pin description, max 800 characters — this is what Pinterest search reads'),
@@ -7064,7 +7081,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
7064
7081
  };
7065
7082
  server.registerTool('create_meta_ad', {
7066
7083
  title: 'Build a full Meta ad (campaign -> ad set -> ad, paused)',
7067
- description: 'Build a complete, ready-to-run Meta ad: campaign -> ad set (FULL targeting + budget + schedule + bidding) -> creative -> ad(s), ALL created PAUSED — it spends NOTHING until you activate the campaign with set_meta_campaign_status(confirm:true). This is the "create a campaign and put the ads on it" path. IMAGE, VIDEO (uploaded, transcoded and thumbnailed for you) and CAROUSEL (format:"carousel", 2–10 cards each with its own headline/description/link) all work. Targeting is the `targeting` object: geo down to cities with a radius, age, gender, interests, behaviours, custom audiences and lookalikes, languages, placements, devices and OS. For a conversion objective pass pixelId + conversionEvent and the ad set optimizes for that conversion. Schedule with startTime/endTime + dayparting; bid with bidStrategy + bidAmountUsd/minRoas; use lifetimeBudgetUsd (with endTime) for a fixed flight. Attach to an existing campaign with campaignId or an existing ad set with adSetId. Everything is READ BACK from Meta before you are told it exists — print the returned summary verbatim (it now carries Meta-rendered PREVIEW LINKS for the first ad, valid 24 hours — hand them to the user so they can see the ad; preview_meta_ad renders any ad in any placement). Needs ads-management on the connected account. BOOST AN EXISTING POST: pass boostPostId — a post you have ALREADY published (numeric id, the <pageId>_<postId> form, or a permalink) — INSTEAD of any creative, and the ad promotes that post exactly as published, comments and all. Meta ignores creative overrides on an existing post, so message/headline/cta/link do NOT apply; targeting, budget, schedule, bidding and PAUSED-by-default all work identically. Find ids with list_meta_posts. AN INSTAGRAM POST NEEDS boostTarget:"instagram" — an IG media id and a Facebook post id are both bare digits, so Hermoso will NOT guess which one you meant, and a Facebook boost given an IG media id is refused rather than built against a fabricated id. Instagram eligibility is checked for free before anything is created (Meta refuses to boost a post carrying licensed music or an interactive element).',
7084
+ description: 'Build a complete, ready-to-run Meta ad: campaign -> ad set (FULL targeting + budget + schedule + bidding) -> creative -> ad(s), ALL created PAUSED — it spends NOTHING until you activate the campaign with set_meta_campaign_status(confirm:true). This is the "create a campaign and put the ads on it" path. IMAGE, VIDEO (uploaded, transcoded and thumbnailed for you) and CAROUSEL (format:"carousel", 2–10 cards each with its own headline/description/link) all work. Targeting is the `targeting` object: geo down to cities with a radius, age, gender, interests, behaviours, custom audiences and lookalikes, languages, placements, devices and OS. For a conversion objective pass pixelId + conversionEvent and the ad set optimizes for that conversion. Schedule with startTime/endTime + dayparting; bid with bidStrategy + bidAmountUsd/minRoas; use lifetimeBudgetUsd (with endTime) for a fixed flight. Attach to an existing campaign with campaignId or an existing ad set with adSetId. Everything is READ BACK from Meta before you are told it exists — print the returned summary verbatim (it carries 24-hour Meta PREVIEW LINKS for the first ad — hand them to the user; preview_meta_ad renders any ad in any placement). Needs ads-management on the connected account. BOOST AN EXISTING POST: pass boostPostId — a post you have ALREADY published (numeric id, the <pageId>_<postId> form, or a permalink) — INSTEAD of any creative, and the ad promotes that post exactly as published, comments and all. Meta ignores creative overrides on an existing post, so message/headline/cta/link do NOT apply; targeting, budget, schedule, bidding and PAUSED-by-default all work identically. Find ids with list_meta_posts. AN INSTAGRAM POST NEEDS boostTarget:"instagram" — IG media ids and Facebook post ids are both bare digits, so Hermoso never guesses, and a Facebook boost given an IG media id is refused. Instagram eligibility is checked for free before anything is created (Meta refuses to boost a post carrying licensed music or an interactive element).',
7068
7085
  inputSchema: {
7069
7086
  boostPostId: z.string().optional().describe('Promote a post that ALREADY EXISTS instead of building a new ad from media. Accepts the numeric post id, <pageId>_<postId>, or a permalink (an Instagram post is its NUMERIC media id — an instagram.com link carries only a shortcode, which Meta cannot resolve). Cannot be combined with image/video inputs, and creative fields do not apply — a boost shows the post as published.'), boostTarget: z.enum(['facebook','instagram']).optional().describe("Which surface the boosted post lives on. Default facebook. REQUIRED for an Instagram post: an IG media id and a Facebook post id are both bare digits, so this is never inferred — Meta takes a different creative for each (object_story_id for a Page post; object_id + instagram_user_id + source_instagram_media_id for an IG post). list_meta_posts(target:'instagram') returns the ids."),
7070
7087
  adAccountId: z.string().describe('ad account id (act_… or digits — from list_meta_pages)'),
@@ -8740,7 +8757,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
8740
8757
  }));
8741
8758
  server.registerTool('create_google_ads_performance_max_campaign', {
8742
8759
  title: 'Create a Google Ads Performance Max campaign',
8743
- description: 'Build a PERFORMANCE MAX campaign — Google’s cross-surface campaign type (Search, YouTube, Display, Discover, Gmail, Maps) and the one Google pushes hardest at small advertisers. ALWAYS created PAUSED; it spends NOTHING until you enable it with set_google_ads_status(confirm:true). PMax has NO manual bidding and NO keywords: it bids only on conversions, so the account MUST already have a conversion action — check with list_google_ads_conversion_actions, because this REFUSES rather than build a campaign that cannot optimise. Creative lives in an ASSET GROUP, and Google’s minimums are enforced before anything is sent: 3–15 headlines (≤30 chars), 1–5 longHeadlines (≤90), 2–5 descriptions (≤90), one businessName (≤25), at least one LOGO (1:1), one MARKETING_IMAGE (1.91:1) and one SQUARE_MARKETING_IMAGE (1:1) — upload the images with upload_google_ads_asset first and pass their asset resource names. A YouTube video is optional (Google generates one from the asset group if you omit it). Brand guidelines: since Google Ads API v21 they are ON by default for new PMax campaigns, which means the businessName and LOGO assets are linked to the CAMPAIGN (CampaignAsset), not to the asset group — Hermoso does that for you. Leave brandGuidelinesEnabled alone unless the user wants the older asset-group layout, and pass false for that. Budget, campaign, location/language targeting, the asset group and every asset link go up in ONE ATOMIC operation — if any part is rejected, nothing at all is created — and the whole tree is READ BACK from Google before you are told it exists. Print the returned note verbatim; if it says the campaign cannot serve, say that instead of calling it finished. RETAIL/SHOPPING: pass merchantCenterId (from list_merchant_accounts) to make it a Shopping-feed Performance Max — it then advertises the WHOLE feed (one root listing group); feedLabel narrows it to a single feed. Partitioning the feed by brand/category/custom label is NOT built and is refused by name, so a caller can never believe they narrowed it when they did not.',
8760
+ description: 'Build a PERFORMANCE MAX campaign — Google’s cross-surface campaign type (Search, YouTube, Display, Discover, Gmail, Maps). ALWAYS created PAUSED; it spends NOTHING until you enable it with set_google_ads_status(confirm:true). PMax has NO manual bidding and NO keywords: it bids only on conversions, so the account MUST already have a conversion action — check with list_google_ads_conversion_actions, because this REFUSES rather than build a campaign that cannot optimise. Creative lives in an ASSET GROUP, and Google’s minimums are enforced before anything is sent: 3–15 headlines (≤30 chars), 1–5 longHeadlines (≤90), 2–5 descriptions (≤90), one businessName (≤25), at least one LOGO (1:1), one MARKETING_IMAGE (1.91:1) and one SQUARE_MARKETING_IMAGE (1:1) — upload the images with upload_google_ads_asset first and pass their asset resource names. A YouTube video is optional (Google generates one from the asset group if you omit it). Brand guidelines: since Google Ads API v21 they are ON by default for new PMax campaigns, which means the businessName and LOGO assets are linked to the CAMPAIGN (CampaignAsset), not to the asset group — Hermoso does that for you. Leave brandGuidelinesEnabled alone unless the user wants the older asset-group layout, and pass false for that. Budget, campaign, location/language targeting, the asset group and every asset link go up in ONE ATOMIC operation — if any part is rejected, nothing at all is created — and the whole tree is READ BACK from Google before you are told it exists. Print the returned note verbatim; if it says the campaign cannot serve, say that instead of calling it finished. RETAIL/SHOPPING: pass merchantCenterId (from list_merchant_accounts) to make it a Shopping-feed Performance Max — it then advertises the WHOLE feed (one root listing group); feedLabel narrows it to a single feed. Partitioning the feed by brand/category/custom label is NOT built and is refused by name, so a caller can never believe they narrowed it when they did not.',
8744
8761
  inputSchema: {
8745
8762
  customerId: z.string().optional().describe('10-digit account id (dashes ok) — omit to use the brand’s selected default account'),
8746
8763
  name: z.string().describe('campaign name'),
@@ -12746,7 +12763,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
12746
12763
  inputSchema: {
12747
12764
  adAccountId: z.string().optional(),
12748
12765
  budget: z.number().describe('budget in the ad account’s currency (not micro-currency — the conversion is handled)'),
12749
- objective: z.enum(['APP_INSTALLS', 'CATALOG_SALES', 'CLICKS', 'CONVERSIONS', 'IMPRESSIONS', 'LEAD_GENERATION', 'VIDEO_VIEWABLE_IMPRESSIONS']).optional().describe('default CLICKS'),
12766
+ objective: z.enum(['APP_INSTALLS', 'BRAND_AWARENESS', 'CATALOG_SALES', 'CLICKS', 'CONVERSIONS', 'IMPRESSIONS', 'LEAD_GENERATION', 'SALES', 'VIDEO_VIEWABLE_IMPRESSIONS']).optional().describe('default CLICKS'),
12750
12767
  goalType: z.enum(['DAILY_SPEND', 'LIFETIME_SPEND']).optional(),
12751
12768
  bidType: z.enum(['CPC', 'CPM', 'CPV', 'CPV6', 'CPV15']).optional(),
12752
12769
  bidStrategy: z.enum(['BIDLESS', 'MANUAL_BIDDING', 'MAXIMIZE_VOLUME', 'TARGET_CPX']).optional(),
@@ -12767,7 +12784,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
12767
12784
  inputSchema: {
12768
12785
  adAccountId: z.string().optional(),
12769
12786
  budget: z.number().describe('budget in the ad account’s currency'),
12770
- objective: z.enum(['APP_INSTALLS', 'CATALOG_SALES', 'CLICKS', 'CONVERSIONS', 'IMPRESSIONS', 'LEAD_GENERATION', 'VIDEO_VIEWABLE_IMPRESSIONS']).optional().describe('default CLICKS'),
12787
+ objective: z.enum(['APP_INSTALLS', 'BRAND_AWARENESS', 'CATALOG_SALES', 'CLICKS', 'CONVERSIONS', 'IMPRESSIONS', 'LEAD_GENERATION', 'SALES', 'VIDEO_VIEWABLE_IMPRESSIONS']).optional().describe('default CLICKS'),
12771
12788
  bidType: z.enum(['CPC', 'CPM', 'CPV', 'CPV6', 'CPV15']).optional().describe('default CPC — must fit the campaign objective'),
12772
12789
  bidStrategy: z.enum(['BIDLESS', 'MANUAL_BIDDING', 'MAXIMIZE_VOLUME', 'TARGET_CPX']).optional(),
12773
12790
  goalType: z.enum(['DAILY_SPEND', 'LIFETIME_SPEND']).optional(),
@@ -12839,14 +12856,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
12839
12856
  }));
12840
12857
  server.registerTool('create_reddit_ads_campaign', {
12841
12858
  title: 'Create a Reddit campaign',
12842
- description: 'Create the top tier of a Reddit ad — the campaign, which sets the OBJECTIVE everything under it optimises toward and (optionally) a lifetime spend cap. ALWAYS created PAUSED, with no override; it spends nothing until set_reddit_ads_status(confirm:true). Pick the objective deliberately, because the ad group\u2019s bid type has to match it and it cannot be changed afterwards: CLICKS is Reddit\u2019s name for traffic to a website (there is no TRAFFIC), CONVERSIONS optimises toward pixel events and needs a working pixel, LEAD_GENERATION drives in-feed lead forms, IMPRESSIONS and VIDEO_VIEWABLE_IMPRESSIONS buy reach, APP_INSTALLS and CATALOG_SALES are for apps and product feeds. A campaign on its own can never serve: create an ad group under it, then an ad pointing at a post. The result is read back from Reddit.',
12859
+ description: 'Create the top tier of a Reddit ad — the campaign, which sets the OBJECTIVE everything under it optimises toward and (optionally) a lifetime spend cap. ALWAYS created PAUSED, with no override; it spends nothing until set_reddit_ads_status(confirm:true). Pick the objective deliberately, because the ad group\u2019s bid type has to match it and it cannot be changed afterwards: CLICKS is Reddit\u2019s name for traffic to a website (there is no TRAFFIC), CONVERSIONS optimises toward pixel events and needs a working pixel, LEAD_GENERATION optimises toward LEAD / SIGN_UP pixel events (Reddit retired onsite lead forms), IMPRESSIONS and VIDEO_VIEWABLE_IMPRESSIONS buy reach, APP_INSTALLS and CATALOG_SALES are for apps and product feeds. Reddit\u2019s NEW names (since 2026-09-21) are accepted too: BRAND_AWARENESS (= IMPRESSIONS / VIDEO_VIEWABLE_IMPRESSIONS) and SALES (= CONVERSIONS, or CATALOG_SALES when useCatalog is true); Reddit rolls them out per account, so an account without them yet answers with Reddit\u2019s own refusal \u2014 use the legacy name then. A campaign on its own can never serve: create an ad group under it, then an ad pointing at a post. The result is read back from Reddit.',
12843
12860
  inputSchema: {
12844
12861
  adAccountId: z.string().optional().describe('Reddit ad account id (a2_\u2026) \u2014 omit when only one is shared'),
12845
12862
  name: z.string(),
12846
- objective: z.enum(['APP_INSTALLS', 'CATALOG_SALES', 'CLICKS', 'CONVERSIONS', 'IMPRESSIONS', 'LEAD_GENERATION', 'VIDEO_VIEWABLE_IMPRESSIONS']).optional().describe('default CLICKS \u2014 which is what Reddit calls website traffic'),
12863
+ objective: z.enum(['APP_INSTALLS', 'BRAND_AWARENESS', 'CATALOG_SALES', 'CLICKS', 'CONVERSIONS', 'IMPRESSIONS', 'LEAD_GENERATION', 'SALES', 'VIDEO_VIEWABLE_IMPRESSIONS']).optional().describe('default CLICKS \u2014 which is what Reddit calls website traffic'),
12847
12864
  spendCapCents: z.number().optional().describe('lifetime spend ceiling for the whole campaign, in minor units of the ad account\u2019s currency'),
12865
+ useCatalog: z.boolean().optional().describe('SALES only: true makes it a product-catalog campaign (the new spelling of CATALOG_SALES) \u2014 every ad group under it must then use a catalog'),
12848
12866
  },
12849
- outputSchema: { id: z.string().optional(), name: z.string().optional(), objective: z.string().optional(), status: z.string().optional(), adAccountId: z.string().optional() },
12867
+ outputSchema: { id: z.string().optional(), name: z.string().optional(), objective: z.string().optional(), status: z.string().optional(), useCatalog: z.boolean().optional(), adAccountId: z.string().optional() },
12850
12868
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
12851
12869
  }, wrap(async (a) => {
12852
12870
  const d = await apiPost('/api/reddit-ads/campaigns', a);
@@ -14841,7 +14859,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14841
14859
  }));
14842
14860
  server.registerTool('create_tiktok_ads_ad', {
14843
14861
  title: 'Create a TikTok ad',
14844
- description: 'Create the ad itself — the creative that runs under an existing TikTok ad group. CREATED PAUSED with no override (TikTok’s own default is ENABLED); it spends nothing until set_tiktok_ads_status(confirm:true), and TikTok additionally reviews it before it can ever show. WHERE THE CREATIVE COMES FROM: run upload_tiktok_ads_creative on a finished Hermoso render (or any public https video) and pass back the videoId AND the coverImageId it returns as imageIds — a TikTok video ad needs BOTH, and TikTok rejects any cover whose dimensions differ from the video, so the video’s own cover is the only one that reliably fits. A videoId with no imageIds is refused here for free rather than failing at TikTok with "Unsupported image size", which blames the image for a problem in the video. AN IDENTITY IS MANDATORY AND HAS NO DEFAULT: identityId AND identityType both come from list_tiktok_ads_identities and the ad appears publicly as that TikTok account, so let the USER pick and never guess — the call is refused outright without either. SPARK ADS — RUN A REAL ORGANIC POST INSTEAD: pass tiktokItemId (from list_tiktok_ads_identity_posts or list_tiktok_ads_spark_posts) INSTEAD OF videoId, and the ad IS that TikTok post, keeping its own comments, likes and shares under the account that made it. Spark needs an identityType of TT_USER, BC_AUTH_TT or AUTH_CODE — never CUSTOMIZED_USER, which is a Custom Identity and cannot carry one. THIS MATTERS BEYOND STYLE: TikTok is phasing Custom Identity out — ad accounts created on or after January 15, 2026 cannot create non-Spark ads at all, and existing accounts can no longer create them either, for any ad group delivering to Automatic or Select Placement with TikTok included (only Pangle / Global App Bundle campaigns are unaffected). Defaults: adFormat SINGLE_VIDEO, callToAction LEARN_MORE. THE STATUS IS READ BACK FROM TIKTOK’S OWN STORED ROW, never assumed: if it comes back anything other than DISABLE the note is a Warning: warning that the ad would serve the moment its campaign is enabled — print that verbatim and act on it before touching anything above it.',
14862
+ description: 'Create the ad itself — the creative that runs under an existing TikTok ad group. CREATED PAUSED with no override (TikTok’s own default is ENABLED); it spends nothing until set_tiktok_ads_status(confirm:true), and TikTok additionally reviews it before it can ever show. WHERE THE CREATIVE COMES FROM: run upload_tiktok_ads_creative on a finished Hermoso render (or any public https video) and pass back the videoId AND the coverImageId it returns as imageIds — a TikTok video ad needs BOTH, and TikTok rejects any cover whose dimensions differ from the video, so the video’s own cover is the only one that reliably fits. A videoId with no imageIds is refused here, for free. AN IDENTITY IS MANDATORY AND HAS NO DEFAULT: identityId AND identityType both come from list_tiktok_ads_identities and the ad appears publicly as that TikTok account, so let the USER pick and never guess — the call is refused outright without either. SPARK ADS — RUN A REAL ORGANIC POST INSTEAD: pass tiktokItemId (from list_tiktok_ads_identity_posts or list_tiktok_ads_spark_posts) INSTEAD OF videoId, and the ad IS that TikTok post, keeping its own comments, likes and shares under the account that made it. Spark needs an identityType of TT_USER, BC_AUTH_TT or AUTH_CODE — never CUSTOMIZED_USER, which is a Custom Identity and cannot carry one. THIS MATTERS BEYOND STYLE: TikTok is phasing Custom Identity out — ad accounts created on or after January 15, 2026 cannot create non-Spark ads at all, and existing accounts can no longer create them either, for any ad group delivering to Automatic or Select Placement with TikTok included (only Pangle / Global App Bundle campaigns are unaffected). Defaults: adFormat SINGLE_VIDEO, callToAction LEARN_MORE. THE STATUS IS READ BACK FROM TIKTOK’S OWN STORED ROW, never assumed: if it comes back anything other than DISABLE the note is a Warning: warning that the ad would serve the moment its campaign is enabled — print that verbatim and act on it before touching anything above it.',
14845
14863
  inputSchema: {
14846
14864
  advertiserId: z.string().optional(),
14847
14865
  adgroupId: z.string().describe('the ad group whose targeting, budget and schedule this ad runs under'),
@@ -15875,7 +15893,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
15875
15893
  imageUrl: z.string().optional().describe('a Hermoso-hosted image URL — a render (list_library), or ANY image of the user’s own passed through upload_file first. An arbitrary external host is refused.'),
15876
15894
  videoUrl: z.string().optional().describe('a Hermoso-hosted video URL — a render, or the user’s own footage via upload_file. LinkedIn processes it before publishing, which takes a minute.'),
15877
15895
  imageUrls: z.array(z.string()).optional().describe('CAROUSEL — an ORDERED list of image (and, where the channel allows, video) URLs published as ONE post the viewer swipes through. THIS IS NOT “post several” — it is a single post with several slides, which is what a multi-slide creative (a listicle, a “1/6 · SWIPE” deck) actually needs; publishing only its first slide tells the viewer to swipe at something that cannot. The ORDER is the product. Limits per channel: Instagram 2–10 (images, videos or a mix), Threads 2–20 (mix allowed), Facebook 2+ (Meta publishes no documented maximum; Hermoso caps the upload fan-out at 30 and says so), LinkedIn company Pages 2–20 (images only), Pinterest 2–5 (images only), TikTok up to 35. One url here is simply an ordinary single post. Anything a channel cannot do is REFUSED with the real reason — nothing is ever quietly downgraded to one slide.'),
15878
- idempotencyKey: z.string().optional().describe('SAFE RETRIES. Publishing can take minutes (a video upload, a carousel of ten slides) and a transport can time out while the post SUCCEEDS — retrying blind is how the same thing gets posted twice. Pass any stable string here and a repeat of the SAME publish returns the ORIGINAL post id instead of posting again (24h). You do not have to: an identical publish is auto-recognised for 10 minutes anyway. If a call times out or errors ambiguously, CALL AGAIN WITH THE SAME KEY — that is the safe move, and it will either report the original post or publish it for the first time. It never posts twice.'),
15896
+ idempotencyKey: z.string().optional().describe('SAFE RETRIES — any stable string: a repeat of the SAME publish within 24h returns the ORIGINAL post id instead of posting again (an identical publish is also auto-recognised for 10 minutes). On a timeout or an ambiguous error, CALL AGAIN WITH THE SAME KEY: it reports the original post or publishes it once. It never posts twice.'),
15879
15897
  allowDuplicate: z.boolean().optional().describe('post it even though an identical post was just made or attempted. Only pass this when the user genuinely wants the same thing posted twice, or when you have LOOKED at the account and confirmed a timed-out attempt did not land.'),
15880
15898
  altText: z.union([z.string(), z.array(z.string())]).optional().describe('accessibility alt text (max 4086 characters, ~120 recommended). A STRING describes every image; an ARRAY describes each slide of a multi-image post separately, in slide order \u2014 LinkedIn stores altText per image, and their own sample request carries a different one on each. Not available on a PERSONAL-profile post: LinkedIn\u2019s member posting API has no alt-text field at all.'),
15881
15899
  title: z.string().optional().describe('video title'),
@@ -17114,9 +17132,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
17114
17132
  server.group('create');
17115
17133
  server.registerTool('make_thumbnail', {
17116
17134
  title: 'Make video thumbnail',
17117
- description: "Render a click-driving YOUTUBE / Shorts / Instagram THUMBNAIL or video cover — the full production pipeline (concept framework -> casting -> scene -> render -> surgical tweaks -> text), not a bare image prompt. Use this for any \"thumbnail\", \"video cover\", \"video preview\" or MrBeast-style packaging ask INSTEAD of generate_image. About 9 credits per variant; the headline overlay is free.\n\nCONCEPT — every thumbnail must open an INFORMATION GAP (the image raises a question the title answers) while staying truthful to the video. Brainstorm ≥5 concepts across the 16 frameworks before you pick, and feel free to combine two. Frameworks (pass as `framework`): before_after · social_ui · three_step · screenshot · posed_portrait (the default) · posed_action · specific_day · graphical · landscape · map_aerial · product · adding_text · repetition · size_difference · news_clip · amplified_reality. Call hermoso_capabilities for each one's full 'realize it with' note plus the emotion, overlay-style, font and rim-colour catalogs.\n\nTHREE GATES, all BEFORE you render:\n1. WHO IS IN FRAME — never assume and never silently substitute a stranger. If the framework puts a person in frame and no face photo is attached, the tool refuses (nothing rendered, nothing charged) and tells you to ask the user once: themselves (send a face photo -> the identity gets locked), a generated person (`castGenericPerson:true`), or a people-free framework.\n2. TEXT — the default is a CLEAN render with the headline TYPESET OVER THE TOP afterwards (free, always legible, correctly spelled). Just pass `headline`. Only set `bakeText:true` if the user explicitly asks for the words painted INTO the image — verified live, that renders the asked-for words correctly but leaks garbled invented text across the rest of the frame. Never infer text intent from the topic or the framework.\n3. HOW MANY — ask once whether they want one thumbnail or a SET (offer 4: the same concept at different emotions and/or camera takes). Default is 1; `variants` caps at 16.\n\nIDENTITY LOCK is automatic for every attached face photo. `emotion` is the single biggest CTR lever on a face: shock · hype · fear · confusion · determination · smug · charisma · disgust · awe · rage · laugh (or your own phrase). Finished thumbnail needs a fix? Re-call with `tweak` + `sourceImage` for a surgical, pixel-faithful edit (emotion / background / background_color / rim_light) instead of re-rendering — tweaks chain. ALWAYS check the returned postRenderCheck against the image before you present it.\n\nPROMPT LANGUAGE — write every DESCRIPTIVE field in ENGLISH (`sceneBrief`, `keyElements`, `location`, `composition`, `background`, `topic`, each person's `describe`, and every `reference` field), translating the user's wording where needed: the image models are trained on English and a non-English scene description renders noticeably worse. Text that gets BAKED OR TYPESET stays verbatim in the user's own language — `headline`, `headlineLines` and `bakedUiText` are never translated.",
17135
+ description: "Render a click-driving YOUTUBE / Shorts / Instagram THUMBNAIL or video cover through the full production pipeline (concept, casting, scene, render, tweaks, text), not a bare image prompt. Use it for any \"thumbnail\", \"video cover\" or MrBeast-style packaging ask INSTEAD of generate_image. About 9 credits per variant; the headline overlay is free.\n\nCONCEPT — open an INFORMATION GAP (the image raises a question the title answers) while staying truthful to the video. Brainstorm ≥5 concepts across the 16 frameworks (ids on `framework`; combining two is fine) before you pick; hermoso_capabilities has each one's 'realize it with' note and the emotion, overlay, font and rim-colour catalogs.\n\nTHREE GATES, all BEFORE you render:\n1. WHO IS IN FRAME — never assume or silently substitute a stranger. A framework with a person and no face photo is refused (nothing charged): ask the user once — themselves (a face photo, identity-locked), a generated person (`castGenericPerson:true`), or a people-free framework.\n2. TEXT — default is a CLEAN render with the headline TYPESET over it (free, legible, correctly spelled): pass `headline`. `bakeText:true` only on an explicit ask for words painted INTO the image. Never infer text intent from the topic.\n3. HOW MANY — ask once: one, or a SET (offer 4: one concept at different emotions / camera takes). Default 1; `variants` caps at 16.\n\n`emotion` is the biggest CTR lever on a face (identity lock is automatic for every face photo). To fix a finished one, re-call with `tweak` + `sourceImage` for a surgical edit (emotion / background / background_color / rim_light) — tweaks chain. ALWAYS check the returned postRenderCheck against the image before presenting it.\n\nPROMPT LANGUAGE — write every DESCRIPTIVE field (`sceneBrief`, `keyElements`, `location`, `composition`, `background`, `topic`, each person's `describe`, every `reference`) in ENGLISH, translating the user's words: the models render English better. `headline`, `headlineLines` and `bakedUiText` stay verbatim in the user's language.",
17118
17136
  inputSchema: {
17119
- framework: z.string().optional().describe("concept framework id (default 'posed_portrait'), or your own concept in words"),
17137
+ framework: z.string().optional().describe("concept framework id (default 'posed_portrait') — before_after · social_ui · three_step · screenshot · posed_portrait · posed_action · specific_day · graphical · landscape · map_aerial · product · adding_text · repetition · size_difference · news_clip · amplified_reality — or your own concept in words"),
17120
17138
  frameworkRequested: z.boolean().optional().describe('true ONLY when the USER named this framework — it is what authorizes a text-carrying framework (social_ui / news_clip / specific_day / map_aerial) to bake its short UI label'),
17121
17139
  sceneBrief: z.string().optional().describe('what the thumbnail depicts — the concept in one dense sentence, rendered exactly'),
17122
17140
  topic: z.string().optional().describe("the video's topic — used to pick the hero object when you don't name keyElements"),
@@ -17130,7 +17148,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17130
17148
  faceImages: z.array(z.string()).optional().describe('up to 3 face photos (URLs or local paths) — each becomes a locked CHARACTER identity, in order'),
17131
17149
  people: z.array(z.object({ describe: z.string() }).passthrough()).optional().describe('people described in prose instead of by photo (each still gets the chosen expression)'),
17132
17150
  castGenericPerson: z.boolean().optional().describe('pass true only after the user has explicitly chosen a generated stranger over their own face'),
17133
- emotion: z.string().optional().describe("the expression on the face (default 'shock') — a preset id or your own phrase"),
17151
+ emotion: z.string().optional().describe("the expression on the face (default 'shock') — shock · hype · fear · confusion · determination · smug · charisma · disgust · awe · rage · laugh, or your own phrase"),
17134
17152
  emotions: z.array(z.string()).optional().describe('render one variant per emotion (variants = emotions × takes, max 16)'),
17135
17153
  takes: z.number().optional().describe('camera takes per emotion, 1–4: designed framing / low-angle hero / extreme close-up / wide dutch tilt'),
17136
17154
  variants: z.number().optional().describe('how many thumbnails to render (default 1, max 16). Each is its own billed render — offer a set of 4 rather than assuming'),
@@ -17298,9 +17316,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
17298
17316
 
17299
17317
  server.registerTool('make_template_ad', {
17300
17318
  title: 'Make template ad',
17301
- description: "An ad or post rendered from HTML: no AI model, ~30s, a couple of credits. Presets are SHORTCUTS; 'custom' is YOUR OWN design as config.html (+ css), so no layout, type, colour or motion is 'unsupported'. custom: { html, css?, size? ('9:16' default | '4:5' | '1:1' | '16:9' | any 'W:H' | {w,h} px), durationSeconds? (1-60 = VIDEO; CSS/SVG animation and <video> are frame-stepped, scripts stripped), slides?:[{html, css?}] (2-35 = carousel) }; {{logo}} {{brandName}} {{domain}} {{accent}} fill from the brand; images and fonts load by https URL; notes[] lists what failed to load. YOU author preset copy: short, casual, believable, finished phrases within budget. 'slideshow' (IMAGES, TikTok photo mode / Reels 1080x1920, or size:'4:5' feed carousels; no branding): { slides:[{text, sub?, image?, blur?, background?, position?}] (2-35; words never rewritten), style? ('tiktok-classic'|'clean-minimal'|'note-style' or a look in words), textStyle?, video?:true (+ an MP4) }; 2 credits, +1 per slide past 5, +2 for the MP4. 'imessage-chat' (VIDEO ~15s): { thread:{contactName, messages:[{from:'them'|'me', text?, product?:{image,title,domain}}]}, theme?, endCard }. 'chatgpt-chat' (VIDEO): { question, answer (may **bold** the brand), productImage?, endCard }. 'apple-notes' (VIDEO): { title, lines[], theme?, endCard }. 'value-prop' (VIDEO ~17s): { hook ≤40ch, claims[3-5 ≤34ch], productImages[2-3], palette[], endCard }. 'static-mockup' (IMAGE): { style:'imessage'|'notes'|'card', size?:{w,h}, ...fields }. 'airdrop-carousel' (VIDEO): { brandName, products:[{image, title?}] (3-16), endCard }. 'app-ui-tour' (VIDEO): { hook?, appName, iconImage?, beats:[{screenImage, caption}] (2-6), endCard }. 'imessage-cascade' (VIDEO): { notifications:[{sender, text}] (4-8), backgroundImage?, endCard }. 'photo-grid' (VIDEO): { title?, photos:[{image, label?}] (4-9), endCard }. 'vignette' (VIDEO): { hook, lines[2-4 ≤40ch], heroImage, endCard }. 'kinetic-type' (VIDEO, own SFX): { phrases[3-6 ≤34ch], productImages?[≤4], endCard }. 'myth-vs-fact' (VIDEO with a real VOICEOVER, small extra charge): { pairs:[{myth ≤50ch, fact ≤60ch}] (2-4; [brackets] accent), endCard }, real truths only. 'carousel' (IMAGES, 5-10 branded 1080x1080): { cover:{hook?, title}, slides:[{headline, support?, stat?:{value, label}}] (3-8), cta:{headline, cta?, domain?}, productImage?, logo? }. endCard = { headline, cta, domain?, logo?, color? }; palette and fontStack optional. config.music on a VIDEO: omit for the format's free library bed, 'off' for silence, or any words (a mood or a description) to compose a bed to them (a flat music fee, in hermoso_capabilities). Image URLs may be any public URL.",
17319
+ description: "An ad or post rendered from HTML: no AI model, ~30s, a couple of credits. Presets are SHORTCUTS; 'custom' is YOUR OWN design as config.html (+ css), so no layout, type, colour or motion is 'unsupported'. custom: { html, css?, size? ('9:16' default | '4:5' | '1:1' | '16:9' | any 'W:H' | {w,h} px), durationSeconds? (1-60 = VIDEO; CSS/SVG animation and <video> are frame-stepped, scripts stripped), slides?:[{html, css?}] (2-35 = carousel) }; {{logo}} {{brandName}} {{domain}} {{accent}} fill from the brand; images and fonts load by https URL; notes[] lists what failed to load. YOU author preset copy: short, casual, believable, finished phrases within budget. The preset ids — slideshow, imessage-chat, chatgpt-chat, apple-notes, value-prop, static-mockup, airdrop-carousel, app-ui-tour, imessage-cascade, photo-grid, vignette, kinetic-type, myth-vs-fact, carousel — and each one's fields are listed on `config`. config.music on a VIDEO: omit for the format's free library bed, 'off' for silence, or any words (a mood or a description) to compose a bed to them (a flat music fee, in hermoso_capabilities). Image URLs may be any public URL.",
17302
17320
  inputSchema: {
17303
- config: z.object({}).passthrough().describe("MUST include config.template: 'custom' or a preset id above, plus its fields"),
17321
+ config: z.object({}).passthrough().describe("MUST include config.template: 'custom' or a preset id, plus its fields. PRESETS: 'slideshow' (IMAGES, TikTok photo mode / Reels 1080x1920, or size:'4:5' feed carousels; no branding): { slides:[{text, sub?, image?, blur?, background?, position?}] (2-35; words never rewritten), style? ('tiktok-classic'|'clean-minimal'|'note-style' or a look in words), textStyle?, video?:true (+ an MP4) }; 2 credits, +1 per slide past 5, +2 for the MP4. 'imessage-chat' (VIDEO ~15s): { thread:{contactName, messages:[{from:'them'|'me', text?, product?:{image,title,domain}}]}, theme?, endCard }. 'chatgpt-chat' (VIDEO): { question, answer (may **bold** the brand), productImage?, endCard }. 'apple-notes' (VIDEO): { title, lines[], theme?, endCard }. 'value-prop' (VIDEO ~17s): { hook ≤40ch, claims[3-5 ≤34ch], productImages[2-3], palette[], endCard }. 'static-mockup' (IMAGE): { style:'imessage'|'notes'|'card', size?:{w,h}, ...fields }. 'airdrop-carousel' (VIDEO): { brandName, products:[{image, title?}] (3-16), endCard }. 'app-ui-tour' (VIDEO): { hook?, appName, iconImage?, beats:[{screenImage, caption}] (2-6), endCard }. 'imessage-cascade' (VIDEO): { notifications:[{sender, text}] (4-8), backgroundImage?, endCard }. 'photo-grid' (VIDEO): { title?, photos:[{image, label?}] (4-9), endCard }. 'vignette' (VIDEO): { hook, lines[2-4 ≤40ch], heroImage, endCard }. 'kinetic-type' (VIDEO, own SFX): { phrases[3-6 ≤34ch], productImages?[≤4], endCard }. 'myth-vs-fact' (VIDEO with a real VOICEOVER, small extra charge): { pairs:[{myth ≤50ch, fact ≤60ch}] (2-4; [brackets] accent), endCard }, real truths only. 'carousel' (IMAGES, 5-10 branded 1080x1080): { cover:{hook?, title}, slides:[{headline, support?, stat?:{value, label}}] (3-8), cta:{headline, cta?, domain?}, productImage?, logo? }. endCard = { headline, cta, domain?, logo?, color? }; palette and fontStack optional."),
17304
17322
  },
17305
17323
  outputSchema: { ...JOB_OUT },
17306
17324
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
@@ -17521,7 +17539,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17521
17539
  // leg priced by the same videoCostUsd the Models catalog quotes. Every lane SETTLES to the exact cost afterwards.
17522
17540
  server.registerTool('clip_video', {
17523
17541
  title: 'Clip a long video',
17524
- description: "Cut ONE long video into several RANKED, ready-to-post short clips (podcast, webinar, interview, conference talk, long ad cut -> Reels/Shorts/TikTok). Transcribes the source with timestamps, picks the strongest SELF-CONTAINED moments, then cuts + reframes each with ffmpeg — no video model renders anything, which is why it's fast and cheap. THE VERTICAL REFRAME IS SUBJECT-AWARE: a few stills per clip go to ONE cheap vision call, which decides a SINGLE crop offset that is held for that clip's whole length — so a speaker sitting camera-left is not cropped out of their own clip, while the framing still never drifts INSIDE a clip. It costs one small vision call per clip, billed as its own event. When nothing is being discarded, or no single subject can be located, the crop stays dead centre — read `reframedToSubject` and each clip's `reframeWhy` back off the result rather than assuming either way. ACCEPTS: (a) a YouTube link (or Vimeo / Loom / Dailymotion / Streamable / Rumble / Wistia / Twitch / TED) — the server pulls the video down itself; (b) a direct https .mp4/.mov/.webm; (c) a Hermoso /generated/ URL (upload_file turns a local file into one). NOT supported: TikTok / Instagram / Facebook links, and anything age-restricted, private, members-only, geo-blocked or still LIVE — those fail fast with the real reason and are fully refunded, so ask for a direct file or an upload rather than retrying. Source must be at least ~15s and under ~600MB; only the first ~40 minutes is analysed (the result reports truncated:true when it hits that). Cost: a ~7-credit hold, settled to the exact transcription + encode cost, plus the clip-selection model's tokens billed as their own small event. RETURNS clips[] — each with its OWN served mp4 URL, title, hook, ready-to-post caption, 0-100 score and source timecode — not a single video. SUBTITLES ARE BURNED IN BY DEFAULT — slim white CAPS, thin black outline, bottom safe band, no box and no plate — because short-form is watched on mute; pass captions:false for clean footage. TIMING IS APPROXIMATE, NOT WORD-LEVEL: each cue is anchored to the transcript's own per-sentence timestamp and split inside a sentence by character count, so it tracks the speech closely but is not frame-accurate sync — never promise that. Read captionsBurned back off the result: it counts the clips that actually carry a burned track, and captionNote says why any are bare.",
17542
+ description: "Cut ONE long video into several RANKED, ready-to-post short clips (podcast, webinar, interview, talk, long ad cut -> Reels/Shorts/TikTok). Transcribes with timestamps, picks the strongest SELF-CONTAINED moments, then cuts + reframes each with ffmpeg — no video model renders anything, so it is fast and cheap. THE VERTICAL REFRAME IS SUBJECT-AWARE: one cheap vision call per clip (billed as its own event) picks a SINGLE crop offset held for the whole clip, so a speaker sitting camera-left is not cropped out and the framing never drifts inside a clip; with nothing to discard or no single subject it stays dead centre — read `reframedToSubject` and each clip's `reframeWhy` back rather than assuming either way. ACCEPTS: a YouTube link (or Vimeo / Loom / Dailymotion / Streamable / Rumble / Wistia / Twitch / TED), a direct https .mp4/.mov/.webm, or a Hermoso /generated/ URL (upload_file turns a local file into one). NOT supported: TikTok / Instagram / Facebook links, and anything age-restricted, private, members-only, geo-blocked or still LIVE — those fail fast with the real reason and are fully refunded, so ask for a direct file or an upload rather than retrying. Source ~15s to ~600MB; only the first ~40 minutes is analysed (truncated:true says so). Cost: a ~7-credit hold settled to the exact transcription + encode cost, plus the clip-selection model's tokens as their own small event. RETURNS clips[] — each its OWN served mp4 URL, title, hook, ready-to-post caption, 0-100 score and source timecode. SUBTITLES ARE BURNED IN BY DEFAULT (slim white CAPS, thin black outline, bottom safe band, no box) because short-form is watched on mute; captions:false for clean footage. TIMING IS APPROXIMATE, NOT WORD-LEVEL — cues follow the transcript's per-sentence timestamps, split by character count; never promise frame-accurate sync. captionsBurned counts the clips that really carry a burned track and captionNote says why any are bare.",
17525
17543
  inputSchema: {
17526
17544
  video: z.string().describe('the long video to clip — a YouTube/Vimeo/Loom/Dailymotion/Streamable/Rumble/Wistia/Twitch/TED watch URL, a direct https .mp4/.mov/.webm, or a Hermoso /generated/ URL'),
17527
17545
  count: z.number().optional().describe('how many clips to cut, 1-8 (default 4)'),
@@ -17640,7 +17658,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17640
17658
 
17641
17659
  server.registerTool('generate_video', {
17642
17660
  title: 'Generate video',
17643
- description: 'Render a RAW video clip from your own prompt and return its served mp4 URL. For finished brand ADS prefer render_ad (it runs the Studio quality pipeline — composited text, clean speech, end card, music); use this for raw/experimental clips or precise manual control. ONE generation = one continuous clip up to the model’s longest listed duration — the longest-clip model in the catalog today renders a full multi-beat spot of up to 30 SECONDS in ONE unbroken take with native synchronized audio, so never assume a generic 8–10s cap and never stitch something that fits one clip; durationSeconds must be one of the model’s durations from hermoso_capabilities, which is the live list. TO GET A SPECIFIC MODEL, NAME IT in `model`: an unnamed render is routed by the server’s own auto-pool, which is narrower than the catalog, so the longest-clip and highest-resolution models are reached by naming them and not by omitting the field. Renders take 1–3 min. refImage anchors the opening frame; ttsScript adds a voiceover. AUDIO IS NOT FREE AND NOT OPTIONAL BY DEFAULT: a clip delivered with no audio of its own gets a music bed composed and CHARGED on top of the render (see musicMood and audio) — on a cheap short draft the bed can cost as much as the clip. Pass refVideo (a clip URL) to EDIT an existing video instead of generating from scratch — the omni engine transforms that clip per your prompt, inheriting the source clip’s canvas + length (aspectRatio/durationSeconds are ignored for an edit). RAW MODEL ACCESS: your prompt is NOT dispatched verbatim by default — a few small guards are appended (packaging/label safety when no reference image rides, a negative prompt on the models that take one, reference-binding lines when references ride) and hex colour codes are rewritten to colour names. Pass raw:true for none of that. ' + RAW_TOOL_NOTE + ' Spends credits (Starter plan is video-blocked server-side).',
17661
+ description: 'Render a RAW video clip from your own prompt and return its served mp4 URL. For finished brand ADS prefer render_ad (it runs the Studio quality pipeline — composited text, clean speech, end card, music); use this for raw/experimental clips or precise manual control. ONE generation = one continuous clip up to the model’s longest listed duration — the longest-clip model in the catalog today renders a full multi-beat spot of up to 30 SECONDS in ONE unbroken take with native synchronized audio, so never assume a generic 8–10s cap and never stitch something that fits one clip; durationSeconds must be one of the model’s durations from hermoso_capabilities, which is the live list. TO GET A SPECIFIC MODEL, NAME IT in `model`: an unnamed render is routed by the server’s own auto-pool, which is narrower than the catalog, so the longest-clip and highest-resolution models are reached by naming them. Renders take 1–3 min. refImage anchors the opening frame; ttsScript adds a voiceover. AUDIO IS NOT FREE AND NOT OPTIONAL BY DEFAULT: a clip delivered with no audio of its own gets a music bed composed and CHARGED on top of the render (see musicMood and audio) — on a cheap short draft the bed can cost as much as the clip. Pass refVideo (a clip URL) to EDIT an existing video instead — the omni engine transforms that clip per your prompt, inheriting its canvas + length (aspectRatio/durationSeconds are ignored for an edit). RAW MODEL ACCESS: by default a few small guards are appended (packaging/label safety with no reference image, a negative prompt where the model takes one, reference-binding lines) and hex colour codes become colour names; ' + RAW_TOOL_NOTE + ' Spends credits (Starter plan is video-blocked server-side).',
17644
17662
  inputSchema: {
17645
17663
  prompt: z.string().describe('the video prompt / shot description (for a refVideo edit, this is the transformation instruction)'),
17646
17664
  raw: z.boolean().optional().describe('RAW MODEL ACCESS: dispatch this prompt to the model BYTE-IDENTICAL — no appended packaging/label guidance, no negative prompt, no reference-binding lines, no hex-to-colour-name rewrite. Use it when you want the model itself rather than Hermoso\'s render craft. Two vendor-required fixes still apply: extra @ImageN tokens are dropped and an over-long prompt is trimmed at a sentence. Billing, durable delivery and per-model validation are unchanged.'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.308",
3
+ "version": "0.1.310",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "Marketing on autopilot, run from your own AI agent. 863 tools. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on every plan; credits are only for generating creative and for Ad Spy research. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
6
6
  "type": "module",