hermoso 0.1.230 → 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.
- package/README.md +2 -2
- package/mcp/tools.mjs +36 -11
- 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
|
-
**
|
|
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
|
|
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 : ''}]
|
|
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.
|
|
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
|
|
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.
|
|
@@ -4321,8 +4333,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4321
4333
|
videoUrl: z.string().optional().describe('swap the video; "" removes it'),
|
|
4322
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.'),
|
|
4323
4335
|
title: z.string().optional().describe('PINTEREST / YOUTUBE — replace the headline; "" clears it and goes back to deriving one from the caption'),
|
|
4324
|
-
description: z.string().optional().describe('YOUTUBE — replace the video description; "" clears it
|
|
4336
|
+
description: z.string().optional().describe('YOUTUBE — replace the video description; "" clears it and the caption is used.'),
|
|
4325
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.'),
|
|
4326
4339
|
// The same six the CREATE path gained — a field you can set at create and not at edit is a one-way door,
|
|
4327
4340
|
// and reschedule_post is how an agent corrects a queued post it got wrong.
|
|
4328
4341
|
replyControl: z.enum(['everyone', 'accounts_you_follow', 'mentioned_only', 'parent_post_author_only', 'followers_only']).optional().describe('THREADS ONLY — who may reply.'),
|
|
@@ -5214,7 +5227,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5214
5227
|
server.group('channels');
|
|
5215
5228
|
server.registerTool('post_to_youtube', {
|
|
5216
5229
|
title: 'Post a video to YouTube',
|
|
5217
|
-
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).',
|
|
5218
5231
|
inputSchema: {
|
|
5219
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."),
|
|
5220
5233
|
...HOOK_ATTR,
|
|
@@ -5222,13 +5235,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5222
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'),
|
|
5223
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'),
|
|
5224
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.'),
|
|
5225
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)'),
|
|
5226
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.'),
|
|
5227
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.'),
|
|
5228
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.'),
|
|
5229
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.'),
|
|
5230
5244
|
},
|
|
5231
|
-
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() },
|
|
5232
5246
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
5233
5247
|
}, wrap(async (a) => {
|
|
5234
5248
|
const d = await apiPost('/api/youtube/upload', a);
|
|
@@ -5236,7 +5250,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5236
5250
|
// unverified project has videos.insert locked to private, and `d.warning` is present exactly when the two differ.
|
|
5237
5251
|
// Printing the request would be a flat lie.
|
|
5238
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);
|
|
5239
|
-
|
|
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);
|
|
5240
5255
|
}));
|
|
5241
5256
|
server.group('channel_admin');
|
|
5242
5257
|
server.registerTool('youtube_channel', {
|
|
@@ -9827,7 +9842,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9827
9842
|
since: z.string().optional().describe('YYYY-MM-DD'), until: z.string().optional().describe('YYYY-MM-DD'),
|
|
9828
9843
|
granularity: z.enum(['hourly', 'daily', 'monthly', 'none']).optional().describe('default daily'),
|
|
9829
9844
|
level: z.enum(['ad_account', 'campaign', 'ad_group', 'ad']).optional().describe('roll rows up to this level'),
|
|
9830
|
-
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'),
|
|
9831
9846
|
limit: z.number().optional(),
|
|
9832
9847
|
},
|
|
9833
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() },
|
|
@@ -11017,7 +11032,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11017
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.'),
|
|
11018
11033
|
countries: z.array(z.string()).optional().describe('2-letter country codes'),
|
|
11019
11034
|
locationIds: z.array(z.string()).optional().describe('ids from openai_ads_geo_search — up to 2,500'),
|
|
11020
|
-
platforms: z.array(z.enum(['ios_app', 'android_app', 'web'])).optional().describe('WHICH CHATGPT SURFACES THIS CAMPAIGN RUNS ON — OpenAI’s “Eligible platforms”:
|
|
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.'),
|
|
11021
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.'),
|
|
11022
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.'),
|
|
11023
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.'),
|
|
@@ -11112,7 +11127,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11112
11127
|
name: z.string().optional(), description: z.string().optional(),
|
|
11113
11128
|
dailyBudget: z.number().optional(), lifetimeBudget: z.number().optional(),
|
|
11114
11129
|
countries: z.array(z.string()).optional(), locationIds: z.array(z.string()).optional(), endTime: z.number().optional(),
|
|
11115
|
-
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.'),
|
|
11116
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.'),
|
|
11117
11132
|
excludedCustomAudienceIds: z.array(z.string()).optional().describe('REPLACES the campaign’s excluded custom audiences — same wholesale rule as customAudienceIds.'),
|
|
11118
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).'),
|
|
@@ -11335,7 +11350,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11335
11350
|
inputSchema: { businessAgentId: z.string(), confirm: z.boolean().optional() }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
11336
11351
|
}, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/business-agent/publish', a))));
|
|
11337
11352
|
server.registerTool('list_openai_ads_spend_windows', {
|
|
11338
|
-
title: 'List ChatGPT Ads
|
|
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.',
|
|
11339
11354
|
inputSchema: {}, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
11340
11355
|
}, wrap(async () => oaiNote(await apiGet('/api/openai-ads/spend-windows'))));
|
|
11341
11356
|
server.registerTool('set_openai_ads_spend_window', {
|
|
@@ -11347,6 +11362,16 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11347
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.',
|
|
11348
11363
|
inputSchema: { windowId: z.string(), confirm: z.boolean().optional() }, annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
11349
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))));
|
|
11350
11375
|
server.registerTool('set_openai_ads_negative_keywords', {
|
|
11351
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.',
|
|
11352
11377
|
inputSchema: { keywords: z.array(z.string()) }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.231",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
|
-
"description": "AI ad studio and marketing MCP server with
|
|
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"
|