hermoso 0.1.177 → 0.1.178
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 +76 -1
- 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
|
+
**745 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,
|
|
@@ -163,7 +163,7 @@ block entirely if you signed in above; it is there for CI, where the process can
|
|
|
163
163
|
|
|
164
164
|
Then ask your agent: *“Generate an image ad with Hermoso.”*
|
|
165
165
|
|
|
166
|
-
### What the
|
|
166
|
+
### What the 745 tools cover
|
|
167
167
|
|
|
168
168
|
**Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
|
|
169
169
|
Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
|
package/mcp/tools.mjs
CHANGED
|
@@ -10283,6 +10283,49 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10283
10283
|
outputSchema: { audienceId: z.string().optional(), name: z.string().optional(), membersSent: z.number().optional(), rejected: z.number().optional(), note: z.string().optional() },
|
|
10284
10284
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
10285
10285
|
}, wrap(async (a) => { const d = await apiPost('/api/openai-ads/audience', a); return ok(d.note, d); }));
|
|
10286
|
+
server.registerTool('get_openai_ads_audience', {
|
|
10287
|
+
title: 'Read one ChatGPT Ads custom audience',
|
|
10288
|
+
description: 'Read one ChatGPT Ads custom audience: its processing status, how many users matched, what it can actually be USED for, and its membership revision. Read-only, free. STATUS is not the same question as eligibility — "ready" only means processing succeeded. EXCLUSION (suppression) has NO minimum size, but INCLUSION and bid multipliers need roughly 25,000 matched users, so a small list used for inclusion simply never serves. The membershipRevision this returns is what update_openai_ads_audience_members REQUIRES for a replace.',
|
|
10289
|
+
inputSchema: { audienceId: z.string() },
|
|
10290
|
+
outputSchema: { audience: z.any().optional(), note: z.string().optional() },
|
|
10291
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
10292
|
+
}, wrap(async (a) => { const d = await apiGet('/api/openai-ads/audience', a); return ok(d.note, d); }));
|
|
10293
|
+
server.registerTool('update_openai_ads_audience_members', {
|
|
10294
|
+
title: 'Add, remove or replace ChatGPT Ads audience members',
|
|
10295
|
+
description: 'Change who is IN a ChatGPT Ads custom audience. Pass plain emails and/or phone numbers: Hermoso normalises and SHA-256 hashes them locally and only the digests are sent. THREE OPERATIONS: "add" puts people in, "remove" takes the named people OUT (the only way to stop advertising to a segment already in a list), "replace" swaps the WHOLE membership. THIS IS ASYNCHRONOUS — ChatGPT Ads returns an operation id and the change is NOT applied when this returns; poll it with get_openai_ads_audience_operation until it reports succeeded or failed. A replace REQUIRES expectedRevision (read membershipRevision from get_openai_ads_audience) so a wholesale swap cannot land on top of someone else’s change; a mismatch is refused by ChatGPT Ads and applies nothing.',
|
|
10296
|
+
inputSchema: {
|
|
10297
|
+
audienceId: z.string(),
|
|
10298
|
+
operation: z.enum(['add', 'remove', 'replace']).describe('add | remove | replace — replace swaps the entire membership'),
|
|
10299
|
+
members: z.array(z.string()).describe('emails and/or phone numbers (already-SHA256-hashed emails pass through as-is)'),
|
|
10300
|
+
expectedRevision: z.union([z.number(), z.string()]).optional().describe('REQUIRED for replace, optional for add/remove — membershipRevision from get_openai_ads_audience'),
|
|
10301
|
+
},
|
|
10302
|
+
outputSchema: { operationId: z.string().optional(), audienceId: z.string().optional(), operation: z.string().optional(), status: z.string().optional(), membersSent: z.number().optional(), rejected: z.number().optional(), note: z.string().optional() },
|
|
10303
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
10304
|
+
}, wrap(async (a) => { const d = await apiPost('/api/openai-ads/audience/members', a); return ok(d.note, d); }));
|
|
10305
|
+
server.registerTool('get_openai_ads_audience_operation', {
|
|
10306
|
+
title: 'Check a ChatGPT Ads audience membership operation',
|
|
10307
|
+
description: 'Poll an add/remove/replace submitted by update_openai_ads_audience_members until it reports succeeded or failed. Read-only, free. Until it succeeds the membership has NOT changed, so never report an audience update as done on the strength of the submission alone.',
|
|
10308
|
+
inputSchema: { audienceId: z.string(), operationId: z.string() },
|
|
10309
|
+
outputSchema: { operationId: z.string().optional(), audienceId: z.string().optional(), operation: z.string().optional(), status: z.string().optional(), note: z.string().optional() },
|
|
10310
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
10311
|
+
}, wrap(async (a) => { const d = await apiGet('/api/openai-ads/audience/operation', a); return ok(d.note, d); }));
|
|
10312
|
+
server.registerTool('merge_openai_ads_audiences', {
|
|
10313
|
+
title: 'Merge ChatGPT Ads custom audiences',
|
|
10314
|
+
description: 'Combine 2 to 64 existing ChatGPT Ads custom audiences into ONE new audience. The SOURCE audiences are left unchanged, later updates to them do NOT propagate into the merged one, and no existing campaign switches to the new id by itself — re-point targeting deliberately if that is the intent. Creates a new audience; changes nothing that is already serving.',
|
|
10315
|
+
inputSchema: {
|
|
10316
|
+
name: z.string().describe('name for the new merged audience, at least 3 characters'),
|
|
10317
|
+
audienceIds: z.array(z.string()).describe('2 to 64 DISTINCT audience ids from list_openai_ads_audiences'),
|
|
10318
|
+
},
|
|
10319
|
+
outputSchema: { audienceId: z.string().optional(), name: z.string().optional(), mergedFrom: z.array(z.string()).optional(), note: z.string().optional() },
|
|
10320
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
10321
|
+
}, wrap(async (a) => { const d = await apiPost('/api/openai-ads/audience/merge', a); return ok(d.note, d); }));
|
|
10322
|
+
server.registerTool('archive_openai_ads_audience', {
|
|
10323
|
+
title: 'Archive a ChatGPT Ads custom audience (permanent)',
|
|
10324
|
+
description: 'Retire a ChatGPT Ads custom audience. ARCHIVING IS PERMANENT AND THERE IS NO DELETE TO UNDO IT: an archived audience can never be restored, targeted or bid on again, and any campaign that includes or excludes it loses that audience. Without confirm:true this archives NOTHING and instead reports the audience’s real name, status and matched size read back from ChatGPT Ads, so the cost is visible before it is paid. Pass confirm:true only once that is what the user wants.',
|
|
10325
|
+
inputSchema: { audienceId: z.string(), confirm: z.boolean().optional().describe('must be true to actually archive — this cannot be undone') },
|
|
10326
|
+
outputSchema: { audienceId: z.string().optional(), audience: z.any().optional(), archived: z.boolean().optional(), alreadyArchived: z.boolean().optional(), note: z.string().optional() },
|
|
10327
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
10328
|
+
}, wrap(async (a) => { const d = await apiPost('/api/openai-ads/audience/archive', a); return ok(d.note, d); }));
|
|
10286
10329
|
server.registerTool('create_openai_ads_campaign', {
|
|
10287
10330
|
title: 'Build a ChatGPT Ads campaign (paused)',
|
|
10288
10331
|
description: 'Build a campaign on the connected ChatGPT Ads account — the ads that appear below ChatGPT answers. ALWAYS created PAUSED at every level, with no override: it spends NOTHING until you activate it with set_openai_ads_status(confirm:true). The object graph is campaign → ad group → ad, and a campaign ON ITS OWN CANNOT SERVE AN IMPRESSION, so pass adGroup{name, maxBid, contextHints, ad{creative}} and this builds the whole tree. THE CREATIVE IS A TEXT + IMAGE CARD AND NOTHING ELSE — title 3–50 characters, body 100 maximum, one landing page, one still image. THERE IS NO VIDEO ON THIS CHANNEL: never offer a video ad here, and if the brand only has video, pull a frame from it first. TARGETING IS SEMANTIC: context hints are natural-language descriptions of the conversations where this ad belongs (up to 2,000 per ad group). Geo (countries / locationIds) and PLATFORMS (which of the iOS app, Android app and web the ad runs on) are the only other dimensions — leave platforms out to run on all three. They guide matching, they are NOT exact-match keywords, and they do not guarantee delivery. OpenAI’s own guidance is BREADTH — many genuinely distinct hints and many distinct title/body angles beat one message repeated — which is exactly what plan_variations and mine_angles produce. OpenAI has no atomic multi-object write available here, so the whole tree is VALIDATED before the first write; if a level below the campaign is still rejected, the campaign is left PAUSED (spending nothing) and the note says exactly what exists — nothing is archived behind your back, because archiving is irreversible. Everything is READ BACK from OpenAI before you are told it exists: print the returned note verbatim, and if it says the campaign cannot serve yet, say that rather than calling it a finished ad.',
|
|
@@ -14759,7 +14802,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14759
14802
|
durationSeconds: z.number().optional().describe('total ad length in seconds (supported range 4–180; outside that it is clamped). Omit to honor the plan’s own duration — that is almost always right. This only RE-TIMES an already-authored board (its scenes are scaled to fit), it does NOT re-write it, so to change the length of the ad the user asked for, re-run plan_ad with durationSeconds instead. A length that fits ONE clip of the render model renders as one continuous pass; longer is stitched from acts filled to that model’s clip maximum with the remainder last — the maximum is 15s on most models and 30s on the longest-clip one, so use dryRun:true to see the exact act split for free before spending.'),
|
|
14760
14803
|
aspectRatio: z.string().optional().describe('output aspect ratio, e.g. 9:16 (default) / 1:1 / 16:9'),
|
|
14761
14804
|
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."),
|
|
14762
|
-
captions: z.boolean().optional().describe('
|
|
14805
|
+
captions: z.boolean().optional().describe('burn the plan\'s per-scene on-screen words as caption pills. DEFAULT FALSE — leave it off unless the user asks for on-screen text (no captions, or true subtitles of what is said; never scene or emphasis labels); a recipe whose format IS on-screen text keeps its text either way'),
|
|
14763
14806
|
endCard: z.boolean().optional().describe('branded end card on/off (default: on, except organic recipes)'),
|
|
14764
14807
|
music: z.boolean().optional().describe('licensed music bed on/off (default on)'),
|
|
14765
14808
|
lockup: z.boolean().optional().describe('persistent brand-logo lockup overlay on/off'),
|
|
@@ -16824,6 +16867,38 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16824
16867
|
return okVideo(`Edited clip: ${r.url}`, r);
|
|
16825
16868
|
}));
|
|
16826
16869
|
|
|
16870
|
+
// AD MULTIPLIER (2026-09-01): ONE winning ad → N variants (new character / outfit / location / objects) with the edit, the
|
|
16871
|
+
// motion and the ORIGINAL AUDIO untouched. The plan is one server call; every variant is an ordinary videoedit job.
|
|
16872
|
+
server.registerTool('multiply_ad', {
|
|
16873
|
+
title: 'Multiply an ad',
|
|
16874
|
+
description: "MULTIPLY a winning video ad into N variants: each gets a NEW character, outfit, location and/or objects while the cut, the camera motion, the pacing and the ORIGINAL AUDIO stay exactly as they were (that is what made the ad work), and any burned-in captions are removed. Pass the source video URL (a previous render, a job result, list_library, or the top performer from post_performance / meta_insights). Returns the plan and ONE JOB PER VARIANT — call get_job on each until it reports done; do not describe a variant before its URL arrives. Cost is quoted per variant in the reply (use dryRun:true to see the plan and the quote without rendering). Regions: pass regions:['Berlin','Tokyo'] to restyle variants per market; translation is a separate, explicit step — dub_video on a finished variant.",
|
|
16875
|
+
inputSchema: {
|
|
16876
|
+
video: z.string().describe('the source video URL'),
|
|
16877
|
+
count: z.number().optional().describe('how many variants, 1-12 (default 6)'),
|
|
16878
|
+
axes: z.array(z.enum(['character', 'outfit', 'location', 'objects'])).optional().describe('which axes to vary (default: all four)'),
|
|
16879
|
+
notes: z.string().optional().describe('anything the variants must respect, e.g. "keep it women 25-40", "no gyms"'),
|
|
16880
|
+
regions: z.array(z.string()).optional().describe('markets to restyle for, one or more variants each, e.g. ["Berlin","Tokyo","São Paulo"] — visuals only; audio is never translated here'),
|
|
16881
|
+
dryRun: z.boolean().optional().describe('true = return the plan and the quote, render nothing'),
|
|
16882
|
+
},
|
|
16883
|
+
outputSchema: { jobs: z.array(z.any()).optional(), plan: z.any().optional(), perVariantCredits: z.number().optional(), totalCredits: z.number().optional(), dryRun: z.boolean().optional() },
|
|
16884
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
16885
|
+
}, wrap(async ({ video, count, axes, notes, regions, dryRun }) => {
|
|
16886
|
+
const src = String(video || '').trim();
|
|
16887
|
+
if (!/^https?:\/\//.test(src)) return { content: [{ type: 'text', text: 'Pass the source video as a URL — a previous render, a job result, or an entry from list_library.' }], isError: true };
|
|
16888
|
+
const n = Math.max(1, Math.min(12, Math.round(+count) || 6));
|
|
16889
|
+
const plan = await apiPost('/api/multiply/plan', { video: src, count: n, ...(axes ? { axes } : {}), ...(notes ? { notes } : {}), ...(regions ? { regions } : {}) });
|
|
16890
|
+
const p = plan?.data || plan;
|
|
16891
|
+
const lines = (p.variants || []).map((v, i) => `${i + 1}. ${v.label} — ${['character', 'outfit', 'location', 'objects'].filter(a => v[a] && v[a] !== 'same').map(a => a + ': ' + v[a]).join('; ')}`);
|
|
16892
|
+
const quote = `~${p.perVariantCredits} credits per variant · ~${p.totalCredits} for ${(p.variants || []).length}`;
|
|
16893
|
+
if (dryRun) return { content: [{ type: 'text', text: `Plan (nothing rendered). Source: ${Object.entries(p.source || {}).map(([k, v]) => k + ': ' + v).join('; ')}\n${lines.join('\n')}\n${quote}. Run again without dryRun to render.` }], structuredContent: { plan: p, perVariantCredits: p.perVariantCredits, totalCredits: p.totalCredits, dryRun: true } };
|
|
16894
|
+
const jobs = [];
|
|
16895
|
+
for (const v of (p.variants || [])) {
|
|
16896
|
+
const job = await submitJob('videoedit', { video: src, prompt: v.instruction, keepAudio: true }, { label: `Multiply · ${v.label}` });
|
|
16897
|
+
jobs.push({ id: job.id, label: v.label });
|
|
16898
|
+
}
|
|
16899
|
+
return { content: [{ type: 'text', text: `Multiplying — ${jobs.length} variant(s) queued, original audio kept on all of them. ${quote}.\n${jobs.map((j, i) => `${i + 1}. ${j.label} → job ${j.id}`).join('\n')}\nEach is a normal video edit (1-4 minutes). Call get_job with each id until it reports done; a variant has NO file until then.` }], structuredContent: { jobs, plan: p, perVariantCredits: p.perVariantCredits, totalCredits: p.totalCredits } };
|
|
16900
|
+
}));
|
|
16901
|
+
|
|
16827
16902
|
server.registerTool('dub_video', {
|
|
16828
16903
|
title: 'Dub video',
|
|
16829
16904
|
description: "Localize a finished video into another language WITHOUT re-rendering it: the spoken track is transcribed, translated, re-voiced and lip-synced back onto the SAME footage, so the visuals, timing and edit are untouched. Just pass the video and the language — the script is read off the source automatically (pass `script` only to override what it heard). Paid; returns the served URL of the localized video.",
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.178",
|
|
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 745 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"
|