hermoso 0.1.229 → 0.1.231

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/mcp/tools.mjs +59 -26
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -5,7 +5,7 @@ scripts. Research the ads already winning in a market, generate finished image &
5
5
  composited in, copy + CTA included), publish them to your own social channels, and build & manage the ad
6
6
  campaigns behind them — all over [MCP](https://modelcontextprotocol.io) tools, a CLI, or installable Claude skills.
7
7
 
8
- **825 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
8
+ **827 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
9
9
  catalog with exact per-render credit costs plus the full capability map.
10
10
 
11
11
  **What it connects to.** Ad platforms: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads,
@@ -171,7 +171,7 @@ block entirely if you signed in above; it is there for CI, where the process can
171
171
 
172
172
  Then ask your agent: *“Generate an image ad with Hermoso.”*
173
173
 
174
- ### What the 825 tools cover
174
+ ### What the 827 tools cover
175
175
 
176
176
  **Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
177
177
  Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
package/mcp/tools.mjs CHANGED
@@ -2629,6 +2629,17 @@ function buildTools(rawServer, opts = {}, sink = null) {
2629
2629
  const hold = toolHoldReason(name, ctx);
2630
2630
  rows.push({ name, group: grp, score, inRoster: !!h.enabled, callable: !hold, hold, title: String(h.title || ''), description: desc.replace(/\s+/g, ' ').slice(0, 240) });
2631
2631
  }
2632
+ // AN EXACT TOOL NAME OUTRANKS THE GROUP FILTER (2026-09-12). The name-shaped split above made a scoped search for a real
2633
+ // tool in the wrong group find its WORDS in-group (tiktok_creator_info in channels → post_to_tiktok), so `total` was no
2634
+ // longer 0, the off-group branch below never ran, and the agent got three unrelated tools and never the one it named.
2635
+ // A literal that IS a tool name comes back first, with its real group and its own hold, whatever else matched.
2636
+ if (g && q) {
2637
+ for (const lit of new Set(q.split(/[\s,]+/).map((r) => r.replace(/[^a-z0-9_]/g, '')).filter((r) => r.includes('_')))) {
2638
+ const off = _offGroup.get(lit); if (!off) continue;
2639
+ const hold = toolHoldReason(lit, ctx);
2640
+ rows.push({ name: lit, group: off.grp, score: Number.MAX_SAFE_INTEGER, inRoster: !!off.h.enabled, callable: !hold, hold, title: String(off.h.title || ''), description: String(off.h.description || '').replace(/\s+/g, ' ').slice(0, 240) });
2641
+ }
2642
+ }
2632
2643
  rows.sort((a, b) => b.score - a.score || a.name.length - b.name.length || a.name.localeCompare(b.name)); // ties: the shorter, more specific name first
2633
2644
  const total = rows.length, top = rows.slice(0, cap);
2634
2645
  // THE MOST VALUABLE ROW ON THE DEFECT BOARD: what a user asked for, in their agent's words, that our catalog could
@@ -2644,7 +2655,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
2644
2655
  }
2645
2656
  if (!total) reportDeadEnd('no_match', 'find_tools', `find_tools found nothing for: ${(q || '(empty)').replace(/["'`]/g, '').slice(0, 80)}${g ? ' in group ' + g : ''}`, { query: q, group: g });
2646
2657
  for (const r of top) r.params = compactParams(ctx.handleOf[r.name]);
2647
- const lines = top.map((r) => `• ${r.name} [${r.group}${r.inRoster ? '' : ', not in your list'}${r.hold ? ', ' + r.hold : ''}] — ${r.description}\n params: ${Object.entries(r.params).map(([k, v]) => `${k}: ${v}`).join(' | ') || '(none)'}`);
2658
+ const lines = top.map((r) => `• ${r.name} [${r.group}${g && r.group !== g ? `, outside the ${g} group` : ''}${r.inRoster ? '' : ', not in your list'}${r.hold ? ', ' + r.hold : ''}] —${r.description}\n params: ${Object.entries(r.params).map(([k, v]) => `${k}: ${v}`).join(' | ') || '(none)'}`);
2648
2659
  const text = total
2649
2660
  ? `${total} tool(s) match${q ? ` "${q}"` : ''}${g ? ` in ${g}` : ''}${total > cap ? ` (showing ${cap} — narrow the query)` : ''}. Run any of them with call_tool({name, args}) — a tool that is "not in your list" still runs; one marked not_connected needs that connector first.\n${lines.join('\n')}`
2650
2661
  : `No tool matches${q ? ` "${q}"` : ''}${g ? ` in ${g}` : ''}. Try a broader word (e.g. "lead", "campaign", "report") or a group: ${TOOL_GROUP_NAMES.join(', ')}.`;
@@ -4183,7 +4194,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4183
4194
  // no need for one edge case just for Facebook."
4184
4195
  server.registerTool('schedule_post', {
4185
4196
  title: 'Schedule a post for later',
4186
- 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. This is how you run a content calendar: schedule now, and Hermoso publishes at the time you set — you do not need to be around. 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. A YOUTUBE ITEM’S CAPTION IS ITS TITLE, NOT ITS DESCRIPTION: pass `description` (≤5000 chars) for the box under the video — the links, the CTA and everything YouTube search reads — plus `tags` (up to 30). Omit them and the upload lands with an empty description, which is not recoverable by the time anyone notices. 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.',
4197
+ 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.',
4187
4198
  inputSchema: {
4188
4199
  ...HOOK_ATTR,
4189
4200
  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'),
@@ -4211,8 +4222,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
4211
4222
  // YOUTUBE PUBLISH METADATA (server-side SCHED_META_FIELDS). youtubeUpload has always accepted both and
4212
4223
  // post_to_youtube declares both; the QUEUE had nowhere to put them, so every scheduled upload landed with an
4213
4224
  // empty description box and no tags. The caption cannot stand in for them — it becomes the video TITLE.
4214
- description: z.string().optional().describe('YOUTUBE — the video DESCRIPTION, max 5000 characters: the box under the video carrying the links, the CTA and everything YouTube search reads. It is NOT the caption — a scheduled YouTube item’s text becomes its TITLE — so omitting this publishes the video with an empty description.'),
4225
+ description: z.string().optional().describe('YOUTUBE: the video DESCRIPTION, max 5000 characters, carrying the links, the CTA and what YouTube search reads. Omit it and the caption is used.'),
4215
4226
  tags: z.array(z.string()).optional().describe('YOUTUBE — up to 30 search tags for the video (plain words, no #).'),
4227
+ thumbnailUrl: z.string().optional().describe('YOUTUBE: the custom thumbnail, a Hermoso-hosted image (make_thumbnail, or upload_file for the user’s own). Omit it and a frame of the video is used; "auto" keeps YouTube’s pick.'),
4216
4228
  // THREADS' OWN POST OPTIONS — schedulable since 2026-08-17. `threadsPublish` has accepted all five since the
4217
4229
  // connector-breadth sweep and `threadsPublishExtras` validates them; only the QUEUE could not carry them, so
4218
4230
  // they worked when you posted now and vanished when you scheduled.
@@ -4247,16 +4259,19 @@ function buildTools(rawServer, opts = {}, sink = null) {
4247
4259
  actionType: z.enum(['BOOK', 'ORDER', 'SHOP', 'LEARN_MORE', 'SIGN_UP', 'CALL']).optional().describe('GOOGLE BUSINESS — the call-to-action button. Every button except CALL needs `link` (CALL dials the number on the listing and takes none). Google IGNORES the button link on an OFFER post — put the destination in offer.redeemOnlineUrl. Omit and a post carrying a link gets LEARN_MORE.'),
4248
4260
  event: z.object({ title: z.string().optional(), startDate: z.string().optional(), startTime: z.string().optional(), endDate: z.string().optional(), endTime: z.string().optional() }).optional().describe('GOOGLE BUSINESS — required for an EVENT or OFFER post: {title, startDate:"YYYY-MM-DD", endDate, startTime:"HH:MM", endTime}. `title` is the EVENT’s headline, a different thing from the post `title` (which is the Pinterest/YouTube one). Google documents its TimeInterval as needing all four date/time parts to be valid, so send the times whenever you know them.'),
4249
4261
  offer: z.object({ couponCode: z.string().optional(), redeemOnlineUrl: z.string().optional(), termsConditions: z.string().optional() }).optional().describe('GOOGLE BUSINESS — OFFER posts only: {couponCode, redeemOnlineUrl, termsConditions}. redeemOnlineUrl is where an offer actually sends people, since the button link is ignored on an Offer.'),
4250
- thread: z.array(z.string()).optional().describe('X — publish a THREAD, one entry per post, each replying to the one before (at most 25). 280 characters per part without X Premium, up to 25,000 with it — nothing is truncated, and on a Premium account one long post is usually better AND cheaper than a thread. It REPLACES the X caption: with a thread set, `message`/`captions.x` is not sent to X at all. A thread cannot carry a poll.'),
4262
+ thread: z.array(z.string()).optional().describe('X — publish a THREAD, one entry per post, each replying to the one before (at most 25). 280 characters per part without X Premium, up to 25,000 with it; nothing is truncated. It REPLACES the X caption: with a thread set, `message`/`captions.x` is not sent to X at all. A thread cannot carry a poll.'),
4251
4263
  poll: z.object({ options: z.array(z.string()), durationMinutes: z.number().optional() }).optional().describe('X — attach a poll: {options:["…","…"], durationMinutes}. 2–4 options of at most 25 characters each; voting runs 5–10080 minutes (7 days), default 1440. X makes a poll MUTUALLY EXCLUSIVE with media, so an item carrying an image or video is refused — schedule the poll as its own X-only post.'),
4252
4264
  replySettings: z.enum(['following', 'mentionedUsers', 'subscribers', 'verified']).optional().describe('X — who may reply. Omit for everyone, which is the right default for a brand post.'),
4253
4265
  madeWithAi: z.boolean().optional().describe('X — X’s AI-media label on this post. Opt-in: X treats it as the poster’s own claim about their media, so it is never set on the user’s behalf.'),
4254
4266
  // THE THREE X FIELDS `xPost` HAS ACCEPTED SINCE THE DAY THEY LANDED AND NOTHING COULD SCHEDULE (2026-08-26).
4255
4267
  // Same publisher-can/scheduler-cannot shape as SCHED_ID_FIELDS and the five Threads options one channel over:
4256
4268
  // reachable when you publish NOW, unreachable when you schedule, and invisible until the parity sweep named it.
4257
- xQuotePostId: z.string().optional().describe('X — the numeric id of an X post this one QUOTES: the last part of its URL. X renders that post inside yours and it stands alone on your own timeline, which is what makes a quote different from a reply. NAMED xQuotePostId, NOT quotePostId, because `quotePostId` on this same schedule belongs to THREADS — a schedule going to both channels would otherwise be silently ambiguous. Billed at X’s higher LINK rate, because X appends the quoted post’s t.co URL to yours.'),
4258
- communityId: z.string().optional().describe('X — publish into an X COMMUNITY instead of the main timeline: the number in the community’s own URL (x.com/i/communities/<id>). The connected account must be a MEMBER of it; X answers a non-member and a non-existent id with the same refusal and does not separate them.'),
4259
- paidPartnership: z.boolean().optional().describe('X — label the post a PAID PARTNERSHIP, the same disclosure Hermoso already ships for TikTok. OPT-IN ONLY: set it when the post is sponsored, gifted or otherwise paid for, and never assume it on the user’s behalf.'),
4269
+ xQuotePostId: z.string().optional().describe('X — the numeric id of an X post this one QUOTES: the last part of its URL. NAMED xQuotePostId, NOT quotePostId, because `quotePostId` on this same schedule belongs to THREADS. Billed at X’s higher LINK rate.'),
4270
+ communityId: z.string().optional().describe('X — publish into an X COMMUNITY instead of the main timeline: the number in the community’s own URL (x.com/i/communities/<id>). The connected account must be a MEMBER of it.'),
4271
+ paidPartnership: z.boolean().optional().describe('X — label the post a PAID PARTNERSHIP. OPT-IN ONLY: set it when the post is sponsored, gifted or otherwise paid for, and never assume it on the user’s behalf.'),
4272
+ // AN X ARTICLE, SCHEDULED (2026-09-12). A mode of the x channel, not a channel: the X text is the markdown body
4273
+ // and the image is the cover, so the only new fields are the two the Article itself adds.
4274
+ xArticle: z.object({ title: z.string(), headings: z.enum(['blocks', 'text']).optional() }).optional().describe('X: publish the X item as a long-form X ARTICLE. title is its headline, the X text (message or captions.x) its markdown body, the image its cover. Refused now if the markdown has formatting X cannot hold. X allows about 5 Articles a day; one that fires into that cap fails with the reset time and can be retried.'),
4260
4275
  collaborators: z.array(z.string()).optional().describe('INSTAGRAM \u2014 a COLLAB post: up to 3 Instagram usernames invited to CO-AUTHOR it, so it appears on their profile too once they accept, with both handles in the header and the engagement shared. Handles only ("hermosoai"); a leading @ is fine. Instagram must be one of the `channels` \u2014 asking for collaborators on a schedule Instagram is not on is REFUSED now rather than discovered when it fires, and the other channels in a mixed schedule simply publish without co-authors. THE INVITE IS SENT WHEN THE POST FIRES, not when you schedule it, and it is PENDING until the other account accepts in their notifications; check with instagram_collaborators afterwards rather than telling the user it is live on both profiles.'),
4261
4276
  trialReel: z.enum(['MANUAL', 'SS_PERFORMANCE']).optional().describe('INSTAGRAM TRIAL REEL \u2014 publish this Reel to NON-FOLLOWERS ONLY at first (Instagram allows trials only on accounts above its follower threshold — about 1,000 followers; an ineligible account is refused by name and nothing is posted), so a hook can be tested on a cold audience without spending it on the people who already follow the brand; Instagram shows it to followers only if it graduates. MANUAL = the creator graduates it by hand in the Instagram app; SS_PERFORMANCE = Instagram graduates it automatically if it performs. REELS ONLY and INSTAGRAM ONLY: an image, a carousel, or a Facebook/Threads channel is REFUSED BY NAME rather than quietly published as an ordinary post \u2014 a trial that silently goes to every follower is the exact opposite of what was asked for, so Instagram must be one of the `channels` and the item must carry a video. Omit it for a normal Reel.'),
4262
4277
  aiGenerated: z.boolean().optional().describe('INSTAGRAM / FACEBOOK REEL \u2014 Meta\u2019s is_ai_generated self-disclosure. OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, media that came through upload_file or from an external URL (the user\u2019s own photographs or footage) is NOT \u2014 a real photo must never carry Instagram\u2019s \u201cAI info\u201d label. Pass true or false only to override: false strips the label from something Hermoso would otherwise declare, true declares a render the user uploaded themselves.'),
@@ -4269,13 +4284,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
4269
4284
  locationId: z.string().optional().describe("GOOGLE BUSINESS PROFILE — which listing, e.g. 'locations/123' from list_business_locations. Needed when the account manages more than one storefront; it is never chosen for the user."),
4270
4285
  visibility: z.enum(['public', 'unlisted', 'private', 'draft']).optional().describe("how it should be published — DEFAULT 'public' (live). Only pass something else if the user explicitly asked to stage/hide it. Not every channel supports every value; an impossible combination is refused when you schedule it, with the reason."),
4271
4286
  visibilityByChannel: z.record(z.string()).optional().describe('override visibility for one channel, e.g. { "tiktok": "draft" } to go live everywhere but stage TikTok for review'),
4272
- optimizeCopy: z.boolean().optional().describe('RECOMMENDED when one caption goes to several channels: fit the shared caption to each channel’s own rules when you schedule it (the fitted caption is stored on the scheduled post, so what you scheduled is what publishes) wherever no per-channel caption was written — YouTube gets a keyword title, a structured multi-paragraph description and search tags; Instagram/TikTok hashtags; LinkedIn longer; X/Bluesky short; Pinterest keyword-rich. The angle and every claim stay the author’s; a channel with its own caption is left exactly as written. Off by default so nobody’s words are rewritten unasked.'),
4287
+ optimizeCopy: z.boolean().optional().describe('RECOMMENDED when one caption goes to several channels: fit the shared caption to each channel’s own rules when you schedule it (the fitted caption is stored on the scheduled post, so what you scheduled is what publishes) wherever no per-channel caption was written. The angle and every claim stay the author’s; a channel with its own caption is left exactly as written. Off by default so nobody’s words are rewritten unasked.'),
4273
4288
  },
4274
4289
  outputSchema: { id: z.string().optional(), at: z.string().optional(), channels: z.array(z.string()).optional(), label: z.string().optional() },
4275
4290
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
4276
4291
  }, wrap(async (a) => {
4277
4292
  const d = await apiPost('/api/schedule', a);
4278
- return ok(`Scheduled for ${d.at} → ${(d.channels || []).join(', ')}. It is NOT posted yet; Hermoso publishes it at that time (id ${d.id}).${a.visibility && a.visibility !== 'public' ? ` Visibility: ${a.visibility} (as asked) — it will NOT be publicly live.` : ' It will go LIVE publicly.'}`, d);
4293
+ return ok(`Scheduled for ${d.at} → ${(d.channels || []).join(', ')}. It is NOT posted yet; Hermoso publishes it at that time (id ${d.id}).${a.visibility && a.visibility !== 'public' ? ` Visibility: ${a.visibility} (as asked) — it will NOT be publicly live.` : ' It will go LIVE publicly.'}${d.note ? ` ${d.note}` : ''}`, d);
4279
4294
  }));
4280
4295
  server.registerTool('list_scheduled', {
4281
4296
  title: 'List scheduled and past posts',
@@ -4294,7 +4309,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
4294
4309
  // full of failures as a calendar that ran.
4295
4310
  const bad = (d.history || []).filter(r => r.status === 'error' || (Array.isArray(r.results) && r.results.some(x => x && x.ok === false)));
4296
4311
  const badLine = bad.length ? ` ${bad.length} of those did NOT fully publish: ${bad.slice(0, 5).map(r => `${r.id} (${(Array.isArray(r.results) ? r.results.filter(x => x && x.ok === false).map(x => `${x.channel}: ${x.error || 'failed'}`).join('; ') : '') || r.error || 'failed'})`).join(' · ')}.` : '';
4297
- return ok(`${q} post${q === 1 ? '' : 's'} queued, ${h} already fired.${badLine}`, d);
4312
+ // A QUEUED X ARTICLE IS NAMED IN THE SENTENCE: in a channel list it reads exactly like an ordinary X post, and
4313
+ // its title is what tells an agent which queued item is the long-form one.
4314
+ const arts = (d.scheduled || []).filter(r => r && r.xArticle && r.xArticle.title);
4315
+ return ok(`${q} post${q === 1 ? '' : 's'} queued, ${h} already fired.${badLine}${arts.length ? ` X Article${arts.length === 1 ? '' : 's'} queued: ${arts.slice(0, 5).map(r => `${r.id} "${r.xArticle.title}" at ${r.at}`).join('; ')}.` : ''}`, d);
4298
4316
  }));
4299
4317
  // RESCHEDULE. `PATCH /api/schedule/:id` has existed since drag-to-reschedule shipped and NO agent surface wrapped
4300
4318
  // it, so the only move an agent had was cancel + retype — which is exactly how a calendar loses a caption, and the
@@ -4309,14 +4327,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
4309
4327
  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.'),
4310
4328
  message: z.string().optional().describe('replace the caption used for every channel that has no override'),
4311
4329
  captions: z.record(z.string()).optional().describe('replaces the WHOLE per-channel caption map — send every override you want to keep, not just the new one'),
4312
- optimizeCopy: z.boolean().optional().describe('fit the shared caption to each channel’s own rules when you schedule it (the fitted caption is stored on the scheduled post, so what you scheduled is what publishes) wherever no per-channel caption was written (YouTube keyword title + structured description + tags, Instagram/TikTok hashtags, LinkedIn longer, X/Bluesky short, Pinterest keyword-rich); a channel with its own caption is left exactly as written. Send false to switch it off on this item.'),
4330
+ optimizeCopy: z.boolean().optional().describe('fit the shared caption to each channel’s own rules when you schedule it (the fitted caption is stored on the scheduled post, so what you scheduled is what publishes) wherever no per-channel caption was written; a channel with its own caption is left exactly as written. Send false to switch it off on this item.'),
4313
4331
  channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram'])).optional().describe('replaces the channel list'),
4314
4332
  imageUrl: z.string().optional().describe('swap the image; "" removes it'),
4315
4333
  videoUrl: z.string().optional().describe('swap the video; "" removes it'),
4316
4334
  imageUrls: z.array(z.string()).optional().describe('replace the CAROUSEL slides, in order; an empty array [] drops the carousel and goes back to a single image. Omit to leave the slides exactly as they are. The edited item is re-checked against the same carousel rules the create passed, so adding a channel that cannot swipe is refused now rather than posting slide 1 later.'),
4317
4335
  title: z.string().optional().describe('PINTEREST / YOUTUBE — replace the headline; "" clears it and goes back to deriving one from the caption'),
4318
- description: z.string().optional().describe('YOUTUBE — replace the video description; "" clears it. Remember the caption is the TITLE, not the description.'),
4336
+ description: z.string().optional().describe('YOUTUBE — replace the video description; "" clears it and the caption is used.'),
4319
4337
  tags: z.array(z.string()).optional().describe('YOUTUBE — replaces the WHOLE tag list; an empty array [] clears the tags.'),
4338
+ thumbnailUrl: z.string().optional().describe('YOUTUBE: replace the custom thumbnail; "" goes back to a frame of the video, "auto" to YouTube’s pick.'),
4320
4339
  // The same six the CREATE path gained — a field you can set at create and not at edit is a one-way door,
4321
4340
  // and reschedule_post is how an agent corrects a queued post it got wrong.
4322
4341
  replyControl: z.enum(['everyone', 'accounts_you_follow', 'mentioned_only', 'parent_post_author_only', 'followers_only']).optional().describe('THREADS ONLY — who may reply.'),
@@ -4353,6 +4372,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4353
4372
  xQuotePostId: z.string().optional().describe('X — the post this one QUOTES; an empty string removes the quote. Named apart from the Threads `quotePostId` on this same schedule.'),
4354
4373
  communityId: z.string().optional().describe('X — the community to publish into; an empty string goes back to the main timeline.'),
4355
4374
  paidPartnership: z.boolean().optional().describe('X — the paid-partnership label; false turns it off.'),
4375
+ xArticle: z.object({ title: z.string().optional(), headings: z.enum(['blocks', 'text']).optional() }).optional().describe('X: replaces the X Article (title, headings); {} makes it an ordinary X post again.'),
4356
4376
  collaborators: z.array(z.string()).optional().describe('INSTAGRAM \u2014 replaces the WHOLE collab list (up to 3 usernames); an explicit [] removes the co-authors and the post goes out as an ordinary single-author post. Only takes effect if the post has not fired yet \u2014 an invite already sent cannot be withdrawn from here.'),
4357
4377
  trialReel: z.enum(['MANUAL', 'SS_PERFORMANCE', '']).optional().describe('INSTAGRAM \u2014 replaces the trial-reel setting on a queued Reel (MANUAL or SS_PERFORMANCE); an explicit "" turns the trial off and it goes out as an ordinary Reel. Only takes effect while the post is still queued \u2014 a Reel already published cannot be converted into a trial.'),
4358
4378
  aiGenerated: z.boolean().optional().describe('INSTAGRAM / FACEBOOK REEL \u2014 Meta\u2019s is_ai_generated self-disclosure. OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, media that came through upload_file or from an external URL (the user\u2019s own photographs or footage) is NOT \u2014 a real photo must never carry Instagram\u2019s \u201cAI info\u201d label. Pass true or false only to override: false strips the label from something Hermoso would otherwise declare, true declares a render the user uploaded themselves.'),
@@ -5207,7 +5227,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5207
5227
  server.group('channels');
5208
5228
  server.registerTool('post_to_youtube', {
5209
5229
  title: 'Post a video to YouTube',
5210
- description: 'Publish a finished video to the brand’s connected YouTube channel. Pass a Hermoso render URL (or an upload_file url for a local/external file). DEFAULTS TO UNLISTED (link-only — not on the channel, not searchable, but shareable by link AND usable as a YouTube/Google ad). Pass privacy:"public" to put it ON the channel (a public publish — confirm with the user first) or privacy:"private" for eyes-only. Do NOT use "private" for anything meant to run as an ad — private videos CANNOT be used as ads; unlisted is the ad-ready setting. SCHEDULE it with publishAt, FILE it under the right categoryId (the default 22 "People & Blogs" is wrong for most ads), SUBSCRIBER NOTIFICATIONS FOLLOW PRIVACY — a public publish announces the video to the channel’s subscribers (YouTube’s own default), while unlisted/private uploads stay quiet; pass notifySubscribers explicitly to override either way. Needs a connected YouTube channel (Settings ▸ Connectors ▸ YouTube).',
5230
+ description: 'Publish a finished video to the brand’s connected YouTube channel. Pass a Hermoso render URL (or an upload_file url for a local/external file). DEFAULTS TO UNLISTED (link-only — not on the channel, not searchable, but shareable by link AND usable as a YouTube/Google ad). Pass privacy:"public" to put it ON the channel (a public publish — confirm with the user first) or privacy:"private" for eyes-only. Do NOT use "private" for anything meant to run as an ad — private videos CANNOT be used as ads; unlisted is the ad-ready setting. SCHEDULE it with publishAt, FILE it under the right categoryId (the default 22 "People & Blogs" is wrong for most ads), SUBSCRIBER NOTIFICATIONS FOLLOW PRIVACY — a public publish announces the video to the channel’s subscribers (YouTube’s own default), while unlisted/private uploads stay quiet; pass notifySubscribers explicitly to override either way. THUMBNAIL: pass thumbnailUrl, or a frame of the video is set for free; a thumbnail YouTube refuses never fails the upload, and thumbnailNote says why. Needs a connected YouTube channel (Settings ▸ Connectors ▸ YouTube).',
5211
5231
  inputSchema: {
5212
5232
  account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the brand has more than one youtube account connected (several and none named is refused by name, never guessed); omit when there is one."),
5213
5233
  ...HOOK_ATTR,
@@ -5215,13 +5235,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
5215
5235
  title: z.string().optional().describe('REQUIRED in practice — the headline every search result and the Shorts feed shows (≤100 chars); an upload with no title is refused'),
5216
5236
  description: z.string().optional().describe('REQUIRED in practice — what the video shows, a line about the brand, a link and a few #hashtags (≤5000 chars); YouTube search, suggested videos and the Shorts feed rank on it, so an upload with no description (and no caption) is refused'),
5217
5237
  tags: z.array(z.string()).optional().describe('up to 30 tags'),
5238
+ thumbnailUrl: z.string().optional().describe('the custom thumbnail: a Hermoso-hosted image (make_thumbnail, or upload_file for the user’s own). Omit it and a representative frame of the video is set, free; "auto" keeps YouTube’s pick. Custom thumbnails need a verified channel.'),
5218
5239
  privacy: z.enum(['private', 'unlisted', 'public']).optional().describe('default unlisted (anyone-with-link, ad-ready); public = live + searchable on the channel (confirm first); private = eyes-only (cannot run as an ad)'),
5219
5240
  categoryId: z.string().optional().describe('YouTube category id, NUMERIC — default "22" (People & Blogs), which is wrong for most ads. 1 Film & Animation · 2 Autos & Vehicles · 10 Music · 15 Pets & Animals · 17 Sports · 19 Travel & Events · 20 Gaming · 22 People & Blogs · 23 Comedy · 24 Entertainment · 25 News & Politics · 26 Howto & Style · 27 Education · 28 Science & Technology · 29 Nonprofits & Activism. The assignable set is region-specific, so the value is forwarded as given and YouTube has the last word.'),
5220
5241
  publishAt: z.string().optional().describe('SCHEDULE the publish — ISO 8601, e.g. "2026-09-01T15:00:00Z", and it must be in the future. YouTube only allows this on a PRIVATE video and makes it PUBLIC at that moment, so pass privacy:"private" (or leave privacy unset) — asking for a scheduled "unlisted" or "public" post is refused rather than half-honoured.'),
5221
5242
  notifySubscribers: z.boolean().optional().describe('THE DEFAULT FOLLOWS PRIVACY. privacy:"public" NOTIFIES the channel\'s subscribers — that is YouTube\'s own default and normally what someone publishing publicly wants. privacy:"unlisted" and "private" do NOT: the video is not on the channel, so announcing it is nonsense, and a blast to somebody\'s whole subscriber list cannot be undone. A scheduled publish (publishAt) is PRIVATE at upload, so it does not notify either — pass true to announce one. An explicit value ALWAYS wins in both directions: true announces an unlisted/private upload, false publishes publicly and quietly. The reply reports which way it went and why.'),
5222
5243
  aiGenerated: z.boolean().optional().describe('YouTube\u2019s \u201caltered or synthetic content\u201d declaration (containsSyntheticMedia). OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, a video the user uploaded through upload_file or from an external URL is NOT \u2014 real footage must not carry the label. true/false overrides.'),
5223
5244
  },
5224
- outputSchema: { ok: z.boolean().optional(), videoId: z.string().optional(), url: z.string().optional(), privacy: z.string().optional(), requestedPrivacy: z.string().optional(), categoryId: z.string().optional(), categoryName: z.string().optional(), publishAt: z.string().optional(), scheduled: z.boolean().optional(), notifySubscribers: z.boolean().optional(), notifyNote: z.string().optional(), warning: z.string().optional(), scheduleWarning: z.string().optional(), title: z.string().optional() },
5245
+ outputSchema: { ok: z.boolean().optional(), videoId: z.string().optional(), url: z.string().optional(), privacy: z.string().optional(), requestedPrivacy: z.string().optional(), categoryId: z.string().optional(), categoryName: z.string().optional(), publishAt: z.string().optional(), scheduled: z.boolean().optional(), notifySubscribers: z.boolean().optional(), notifyNote: z.string().optional(), warning: z.string().optional(), scheduleWarning: z.string().optional(), thumbnailSet: z.boolean().optional(), thumbnailSource: z.string().nullable().optional(), thumbnailReadBack: z.string().nullable().optional(), thumbnailNote: z.string().optional(), title: z.string().optional() },
5225
5246
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
5226
5247
  }, wrap(async (a) => {
5227
5248
  const d = await apiPost('/api/youtube/upload', a);
@@ -5229,7 +5250,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
5229
5250
  // unverified project has videos.insert locked to private, and `d.warning` is present exactly when the two differ.
5230
5251
  // Printing the request would be a flat lie.
5231
5252
  const bits = [d.privacy, d.categoryName ? `category ${d.categoryName}` : (d.categoryId ? `category ${d.categoryId}` : null), d.publishAt ? `scheduled for ${d.publishAt}` : null].filter(Boolean);
5232
- return ok(`Posted to YouTube (${bits.join(', ')})${d.url ? ` — ${d.url}` : ''}.${d.warning ? `\n\n${d.warning}` : ''}${d.scheduleWarning ? `\n\n${d.scheduleWarning}` : ''}`, d);
5253
+ // The thumbnail line is YouTube's read-back or the plain reason it did not land; the upload stands either way.
5254
+ return ok(`Posted to YouTube (${bits.join(', ')})${d.url ? ` — ${d.url}` : ''}.${d.thumbnailNote ? ` ${d.thumbnailNote}` : ''}${d.warning ? `\n\n${d.warning}` : ''}${d.scheduleWarning ? `\n\n${d.scheduleWarning}` : ''}`, d);
5233
5255
  }));
5234
5256
  server.group('channel_admin');
5235
5257
  server.registerTool('youtube_channel', {
@@ -9820,7 +9842,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
9820
9842
  since: z.string().optional().describe('YYYY-MM-DD'), until: z.string().optional().describe('YYYY-MM-DD'),
9821
9843
  granularity: z.enum(['hourly', 'daily', 'monthly', 'none']).optional().describe('default daily'),
9822
9844
  level: z.enum(['ad_account', 'campaign', 'ad_group', 'ad']).optional().describe('roll rows up to this level'),
9823
- segment: z.enum(['product', 'country', 'device']).optional().describe('extra group-by dimension (at most one)'),
9845
+ segment: z.enum(['product', 'country', 'device', 'platform']).optional().describe('extra group-by dimension (at most one). platform splits rows by ChatGPT app or browser (ios_app, android_app, desktop_web, ios_web, android_web, and web for rows from before 2026-09-10, which OpenAI does not split retroactively); it reports delivery metrics only, not conversions'),
9824
9846
  limit: z.number().optional(),
9825
9847
  },
9826
9848
  outputSchema: { ok: z.boolean().optional(), scope: z.string().optional(), count: z.number().optional(), rows: z.array(z.any()).optional(), totals: z.any().optional(), note: z.string().optional() },
@@ -11010,7 +11032,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
11010
11032
  biddingType: z.enum(['impressions', 'clicks', 'conversions']).optional().describe('default clicks (CPC). OpenAI suggests starting at a 3–5 max bid per click. "conversions" is oCPC — you still pay per click, but ChatGPT Ads optimises toward a conversion event, and it REQUIRES conversionEventSettingIds naming exactly one active event setting.'),
11011
11033
  countries: z.array(z.string()).optional().describe('2-letter country codes'),
11012
11034
  locationIds: z.array(z.string()).optional().describe('ids from openai_ads_geo_search — up to 2,500'),
11013
- platforms: z.array(z.enum(['ios_app', 'android_app', 'web'])).optional().describe('WHICH CHATGPT SURFACES THIS CAMPAIGN RUNS ON — OpenAI’s “Eligible platforms”: any of ios_app (the ChatGPT iOS app), android_app (the Android app) and web (chatgpt.com in a browser). OMIT IT to run on all three, which is the default and almost always right; naming a subset STOPS the ad serving everywhere else. There is no empty state — ChatGPT Ads refuses an empty list — so widening back means naming all three.'),
11035
+ platforms: z.array(z.enum(['ios_app', 'android_app', 'web', 'desktop_web', 'ios_web', 'android_web'])).optional().describe('WHICH CHATGPT SURFACES THIS CAMPAIGN RUNS ON — OpenAI’s “Eligible platforms”: ios_app (the ChatGPT iOS app), android_app (the Android app), web (ChatGPT in ANY browser), or narrower browser targets desktop_web, ios_web and android_web. OMIT IT to run everywhere, which is the default and almost always right; naming a subset STOPS the ad serving everywhere else. Never combine web with desktop_web / ios_web / android_web: web already includes them, and OpenAI says to name the individual browser values without web when narrowing (that combination is refused before anything is sent). There is no empty state — ChatGPT Ads refuses an empty list — so widening back means naming ios_app, android_app and web.'),
11014
11036
  customAudienceIds: z.array(z.string()).optional().describe('TARGET a CUSTOM AUDIENCE — ids from list_openai_ads_audiences (created with create_openai_ads_audience, then filled with members). Until 2026-08-12 an audience could be created AND uploaded and then pointed at nothing: this is the field that consumes them. Combines with geo — the ad reaches people in the named locations who are ALSO in these audiences.'),
11015
11037
  excludedCustomAudienceIds: z.array(z.string()).optional().describe('EXCLUDE custom audiences — same ids, opposite effect (suppressing existing customers, say). An id in BOTH lists is refused rather than resolved by a guess, because OpenAI does not document which side wins.'),
11016
11038
  conversionEventSettingIds: z.array(z.string()).optional().describe('REQUIRED when biddingType is "conversions" (oCPC): exactly one active conversion-event-setting id from list_openai_ads_conversion_events, the event ChatGPT Ads optimises toward. Ignored for clicks/impressions bidding.'),
@@ -11105,7 +11127,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
11105
11127
  name: z.string().optional(), description: z.string().optional(),
11106
11128
  dailyBudget: z.number().optional(), lifetimeBudget: z.number().optional(),
11107
11129
  countries: z.array(z.string()).optional(), locationIds: z.array(z.string()).optional(), endTime: z.number().optional(),
11108
- platforms: z.array(z.enum(['ios_app', 'android_app', 'web'])).optional().describe('REPLACES which ChatGPT surfaces the campaign runs on (ios_app / android_app / web). Wholesale like the rest of targeting: a patch that changes geo or audiences on a campaign that already restricts platforms is REFUSED by name rather than silently widening it back to every surface. There is no [] — name all three to go back to everywhere.'),
11130
+ platforms: z.array(z.enum(['ios_app', 'android_app', 'web', 'desktop_web', 'ios_web', 'android_web'])).optional().describe('REPLACES which ChatGPT surfaces the campaign runs on (ios_app / android_app / web, or the narrower browser targets desktop_web / ios_web / android_web; never web together with those, since web already includes them). Wholesale like the rest of targeting: a patch that changes geo or audiences on a campaign that already restricts platforms is REFUSED by name rather than silently widening it back to every surface. There is no [] — name all three to go back to everywhere.'),
11109
11131
  customAudienceIds: z.array(z.string()).optional().describe('REPLACES the campaign’s targeted custom audiences. TARGETING IS REPLACED WHOLESALE, not merged — a patch that omits something the campaign already targets is REFUSED by name rather than silently dropping it, so restate it here or pass [] to clear it deliberately.'),
11110
11132
  excludedCustomAudienceIds: z.array(z.string()).optional().describe('REPLACES the campaign’s excluded custom audiences — same wholesale rule as customAudienceIds.'),
11111
11133
  conversionEventSettingIds: z.array(z.string()).optional().describe('REPLACE the conversion-event-setting id a "conversions" campaign optimises toward (exactly one active id from list_openai_ads_conversion_events).'),
@@ -11328,7 +11350,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
11328
11350
  inputSchema: { businessAgentId: z.string(), confirm: z.boolean().optional() }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
11329
11351
  }, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/business-agent/publish', a))));
11330
11352
  server.registerTool('list_openai_ads_spend_windows', {
11331
- title: 'List ChatGPT Ads spend-limit windows', description: 'Account-level SPEND-LIMIT WINDOWS on ChatGPT Ads: a ceiling on what the whole account may spend between an inclusive start date and an exclusive end date, with amount and spent so far. A window caps spend; it never makes anything spend. Read-only, 0 credits.',
11353
+ title: 'List ChatGPT Ads account spending limits', description: 'Account-level SPENDING LIMITS on ChatGPT Ads, both kinds: the date-range SPEND-LIMIT WINDOWS (a ceiling on what the whole account may spend between an inclusive start date and an exclusive end date, with amount and spent so far) and the DAILY LIMIT (a per-day ceiling that renews at midnight in the account timezone, with what is spent and left today). Also returns the configuration `revision` that set_openai_ads_daily_spend_limit and remove_openai_ads_daily_spend_limit need as expectedRevision, and the earliest date a new daily limit may start. A limit caps spend; it never makes anything spend. OpenAI requires an admin of the ad account to read these. Read-only, 0 credits.',
11332
11354
  inputSchema: {}, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
11333
11355
  }, wrap(async () => oaiNote(await apiGet('/api/openai-ads/spend-windows'))));
11334
11356
  server.registerTool('set_openai_ads_spend_window', {
@@ -11340,6 +11362,16 @@ function buildTools(rawServer, opts = {}, sink = null) {
11340
11362
  title: 'Delete a ChatGPT Ads spend-limit window', description: 'Delete an active or scheduled spend-limit window before it ends — this REMOVES a spending ceiling. confirm:true required.',
11341
11363
  inputSchema: { windowId: z.string(), confirm: z.boolean().optional() }, annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
11342
11364
  }, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/spend-window/delete', a))));
11365
+ server.registerTool('set_openai_ads_daily_spend_limit', {
11366
+ title: 'Set the ChatGPT Ads account daily spending limit', description: 'Create or change the ACCOUNT-WIDE DAILY SPENDING LIMIT on ChatGPT Ads: the most all campaigns together may spend each day, renewing at midnight in the account timezone, until it is removed or reaches its optional end date. OpenAI offers it only on ad accounts with postpaid invoice billing, and only an admin of the ad account can set it. amount is per day in the account currency and is required even when only the end date changes (0 stops account spend). A NEW limit starts tomorrow or later: list_openai_ads_spend_windows names the earliest start date. On an existing limit leave startDate out, because its start cannot change. endDate is exclusive; noEndDate:true clears it. A daily limit and a date-range window cannot overlap. Lowering it can stop delivery and raising it lets campaigns spend up to their own budgets, so show the user the current and new values first: without confirm:true AND expectedRevision (the revision list_openai_ads_spend_windows returns) nothing changes and the reply states what would. The result is read back from ChatGPT Ads.',
11367
+ inputSchema: { amount: z.number(), expectedRevision: z.number().optional().describe('the revision from list_openai_ads_spend_windows'), startDate: z.string().optional().describe('YYYY-MM-DD, first day of a NEW daily limit (tomorrow or later)'), endDate: z.string().optional().describe('YYYY-MM-DD, exclusive'), noEndDate: z.boolean().optional().describe('true removes the end date'), confirm: z.boolean().optional() },
11368
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
11369
+ }, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/daily-spend-limit', a))));
11370
+ server.registerTool('remove_openai_ads_daily_spend_limit', {
11371
+ title: 'Remove the ChatGPT Ads account daily spending limit', description: 'Remove the account-wide DAILY spending limit on ChatGPT Ads. This takes a spending CEILING off the whole account, so campaigns may then spend up to their own budgets every day; date-range spend-limit windows are not touched. Only an admin of the ad account can do it. Without confirm:true AND expectedRevision (the revision list_openai_ads_spend_windows returns) nothing changes and the reply names the limit that would be removed. The result is read back from ChatGPT Ads.',
11372
+ inputSchema: { expectedRevision: z.number().optional().describe('the revision from list_openai_ads_spend_windows'), confirm: z.boolean().optional() },
11373
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
11374
+ }, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/daily-spend-limit/delete', a))));
11343
11375
  server.registerTool('set_openai_ads_negative_keywords', {
11344
11376
  title: 'Set ChatGPT Ads account negative keywords', description: 'REPLACE the account-level negative keywords on ChatGPT Ads — conversations matching them are ineligible for every campaign. Pass the COMPLETE list (this is a replace, not an add; [] clears it). The reply names the previous count so the user can see what changed.',
11345
11377
  inputSchema: { keywords: z.array(z.string()) }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
@@ -16209,13 +16241,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
16209
16241
  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).',
16210
16242
  inputSchema: {
16211
16243
  prompt: z.string().describe('the video prompt / shot description (for a refVideo edit, this is the transformation instruction)'),
16212
- 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 provider-mandated corrections still apply, because the vendor hard-fails without them: an @ImageN token that outnumbers the references actually shipped is dropped, and a prompt past the endpoint\'s published character cap is trimmed at a sentence boundary. Billing, durable delivery and per-model validation are unchanged.'),
16244
+ 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.'),
16213
16245
  refImage: z.string().optional().describe('local path or URL to anchor the first frame'),
16214
16246
  refVideo: z.string().optional().describe("URL of an existing video to EDIT rather than generate from scratch — the omni engine accepts a raw clip and transforms it per your prompt, inheriting the SOURCE clip’s canvas (aspect ratio) and length (aspectRatio/durationSeconds are ignored for an edit). Omit to generate a fresh clip."),
16215
16247
  durationSeconds: z.number().optional().describe('length of THIS ONE clip in seconds — pick one of the CHOSEN model’s listed durations from hermoso_capabilities (never a generic guess: the lists differ per model, from 4–8s on the short models up to 30s on the longest-clip one). This is a single continuous generation, so it CANNOT exceed that model’s longest clip: a longer ask is REFUSED with nothing rendered and nothing charged (it is never quietly truncated). Past ~15s only the long-clip models qualify, and an unnamed render is routed by the narrower auto-pool — so NAME the model in `model` when you are asking for a long single take. For a spot longer than any one clip, use plan_ad with durationSeconds then render_ad, which stitches acts of at most one model clip each (on a 15s-clip model, 40s = 15+15+10).'),
16216
16248
  aspectRatio: z.string().optional().describe("default '9:16'"),
16217
- model: z.string().optional().describe('video model id from hermoso_capabilities. Naming one is a DELIBERATE pick — the server asks before ever swapping it (no silent fallback); omit it to let the router pick'),
16218
- resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'1080p' default (what we ship and bill for); '480p'/'720p' = cheaper draft passes, '4k' = premium final delivery (more credits). NOT EVERY MODEL OFFERS EVERY TIER — this enum is what the tool accepts, and each model's OWN `resolutions` list in hermoso_capabilities is what it can actually render (the longest-clip 30s model, for one, tops out at 720p). Ask for a tier the chosen model does not list and it is rendered at that model's best available tier instead, with nothing in the reply saying so — so check `resolutions` before promising anyone 1080p or 4k."),
16249
+ model: z.string().optional().describe('video model id from hermoso_capabilities; a named model is never swapped without asking. Omit to let the router pick'),
16250
+ resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'1080p' default (what we ship and bill for); '480p'/'720p' = cheaper draft passes, '4k' = premium final delivery (more credits). NOT EVERY MODEL OFFERS EVERY TIER — this enum is what the tool accepts, and each model's OWN `resolutions` list in hermoso_capabilities is what it can actually render. Ask for a tier the chosen model does not list and it is rendered at that model's best available tier instead, with nothing in the reply saying so — so check `resolutions` before promising anyone 1080p or 4k."),
16251
+ cameraMove: z.enum(['orbit', 'orbit_half', 'orbit_full', 'rise', 'push_in', 'pull_back']).optional().describe('H3 Max Multi Angle only: camera move (default orbit)'),
16219
16252
  ttsScript: z.string().optional().describe('voiceover script to speak'),
16220
16253
  ttsVoice: z.string().optional().describe('voice name, e.g. Rachel / George'),
16221
16254
  musicMood: z.string().optional().describe('WHICH mood the music bed is composed in (upbeat / calm / warm / epic / tense / playful / elegant / hype / chill / dramatic). It does NOT decide WHETHER there is one: a clip that comes back with no audio track — every model hermoso_capabilities lists as "silent", plus any audio model that returned mute — gets a bed composed and CHARGED automatically, at the flat per-track fee hermoso_capabilities reports as explainerMusicCredits, and omitting this field only means the mood defaults to "warm". Pass audio:false for a genuinely silent clip with no bed and no bed charge.'),
@@ -16223,9 +16256,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
16223
16256
  },
16224
16257
  outputSchema: { ...JOB_OUT,
16225
16258
  refused: z.string().optional().describe("set when NOTHING was rendered and nothing charged — 'duration_exceeds_single_clip' or 'aspect_unsupported'"),
16226
- maxSingleClipSeconds: z.number().optional().describe('the longest single clip any connected video model can render (the ceiling a refusal was measured against)'),
16227
- askedSeconds: z.number().optional().describe('the durationSeconds that was asked for and could not be honored'),
16228
- askedAspectRatio: z.string().optional().describe('the aspectRatio that was asked for and could not be honored'),
16259
+ maxSingleClipSeconds: z.number().optional().describe('the longest single clip any connected video model can render'),
16260
+ askedSeconds: z.number().optional().describe('the durationSeconds that could not be honored'),
16261
+ askedAspectRatio: z.string().optional().describe('the aspectRatio that could not be honored'),
16229
16262
  supportedAspectRatios: z.array(z.string()).optional().describe('the frames the chosen model DOES render'),
16230
16263
  aspectRatioModels: z.array(z.string()).optional().describe('model ids that DO render the asked frame — name one in `model` and re-run'),
16231
16264
  },
@@ -16264,7 +16297,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
16264
16297
  const refImage = a.refImage ? await toRef(a.refImage) : undefined;
16265
16298
  // an agent that NAMES a model made a deliberate pick — modelExplicit gives it the server-side ask-don't-swap
16266
16299
  // treatment (#310) instead of being treated as a system pick the fallback ladders may silently reroute
16267
- const r = await renderJob('video', { ...a, refImage, modelExplicit: !!a.model }, 'MCP video');
16300
+ const r = await renderJob('video', { ...a, refImage, modelExplicit: !!a.model, ...(a.cameraMove ? { cameraTrajectory: a.cameraMove } : {}) }, 'MCP video');
16268
16301
  return okVideo(`Video ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]${switchNote(r)}`, r);
16269
16302
  }));
16270
16303
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.229",
3
+ "version": "0.1.231",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
- "description": "AI ad studio and marketing MCP server with 825 tools. Research the ads already running in any market, generate finished image, video and UGC avatar ads, publish and schedule them to your own channels, build and manage the ad campaigns behind them, and read what they achieved. 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.",
5
+ "description": "AI ad studio and marketing MCP server with 827 tools. Research the ads already running in any market, generate finished image, video and UGC avatar ads, publish and schedule them to your own channels, build and manage the ad campaigns behind them, and read what they achieved. 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",
7
7
  "bin": {
8
8
  "hermoso": "bin/hermoso.mjs"