hermoso 0.1.209 → 0.1.211
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 -4
- 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
|
+
**795 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 795 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
|
@@ -2103,7 +2103,7 @@ const makeEnableToolsHandler = (ctx) => async ({ groups }) => {
|
|
|
2103
2103
|
// THE GROUP FLIP MUST NOT UNDO THE CONNECTOR GATE (2026-08-26). Enabling a group is a statement about SIZE —
|
|
2104
2104
|
// "I am willing to carry these schemas" — not a claim that the workspace has connected eleven ad platforms.
|
|
2105
2105
|
// A blind `h.enable()` here would have re-listed every tool applyToolGates had just held back, so the saving
|
|
2106
|
-
// would survive exactly until the first `enable_tools([
|
|
2106
|
+
// would survive exactly until the first `enable_tools(["ads"])`. Counted, not silently skipped: the reply
|
|
2107
2107
|
// says how many and why, because a group that turns on "8 tools" when the agent expected 240 with no
|
|
2108
2108
|
// explanation is the [[prompt-rosters-go-stale]] failure — the agent concludes the capability is missing.
|
|
2109
2109
|
if (toolHeldBackByConnectors(name, ctx.conn)) { heldBack++; continue; }
|
|
@@ -3474,6 +3474,78 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3474
3474
|
return ok(`${d.summary}${d.note ? ` ${d.note}` : ''}`, d);
|
|
3475
3475
|
}));
|
|
3476
3476
|
|
|
3477
|
+
// INSTAGRAM AUDIO + COLLAB INVITES (2026-09-06) — Instagram's 2026 changelog, on permissions already held. The
|
|
3478
|
+
// audio one is the reason to care: everything it returns is music Instagram has cleared for third-party use, i.e.
|
|
3479
|
+
// safe under a Reel. The invite pair is the OTHER direction from instagram_collaborators — a creator tagged the
|
|
3480
|
+
// brand, and the post reaches the brand's profile only when the brand accepts, which no agent could do before.
|
|
3481
|
+
server.registerTool('search_instagram_audio', {
|
|
3482
|
+
title: 'Trending or searched Instagram audio',
|
|
3483
|
+
description: 'Audio the brand may legally put under a Reel — music or original sound — with title, artist, length, whether it is eligible for ads, a preview link and a download link. Omit the query for what is TRENDING right now; pass one to search. Everything returned is audio Instagram has authorized for third-party use. Download links expire after roughly 1.5 days. Read-only, free.',
|
|
3484
|
+
inputSchema: {
|
|
3485
|
+
audioType: z.enum(['music', 'original_sound']).optional().describe('default music'),
|
|
3486
|
+
query: z.string().optional().describe('omit for trending audio'),
|
|
3487
|
+
limit: z.number().optional().describe('1–50, default 15'),
|
|
3488
|
+
account: z.string().optional().describe('which Instagram account — an @handle or id from list_connector_accounts("instagram"); omit for the one Page-linked account'),
|
|
3489
|
+
pageId: z.string().optional().describe('Facebook Page id — omit when only one Page is connected'),
|
|
3490
|
+
},
|
|
3491
|
+
outputSchema: { instagramId: z.string().optional(), kind: z.string().optional(), query: z.string().nullable().optional(), trending: z.boolean().optional(), count: z.number().optional(), audio: z.array(z.any()).optional(), note: z.string().optional() },
|
|
3492
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
3493
|
+
}, wrap(async (a) => {
|
|
3494
|
+
const d = await apiGet('/api/instagram/audio', { audioType: a.audioType, query: a.query, limit: a.limit, account: a.account, pageId: a.pageId });
|
|
3495
|
+
if (!d.count) return ok(`Instagram returned no ${d.kind === 'music' ? 'tracks' : 'original sounds'}${d.trending ? ' for trending' : ` for "${d.query}"`}.`, d);
|
|
3496
|
+
return ok(`${d.count} ${d.kind === 'music' ? 'track' : 'original sound'}(s)${d.trending ? ' trending' : ` for “${d.query}”`}:\n${d.audio.map(x => `• ${x.title || '(untitled)'} — ${x.artist || '—'}${x.seconds != null ? ` · ${x.seconds}s` : ''}${x.adsEligible ? ' · ads-eligible' : ''} · id ${x.id}${x.preview ? ` · ${x.preview}` : ''}`).join('\n')}\n${d.note}`, d);
|
|
3497
|
+
}));
|
|
3498
|
+
|
|
3499
|
+
server.registerTool('list_instagram_collab_invites', {
|
|
3500
|
+
title: 'Collab invites waiting on the brand',
|
|
3501
|
+
description: 'Posts where a creator tagged this Instagram account as a COLLABORATOR and is waiting for an answer. The post appears on the brand’s profile only once the brand accepts, and Instagram sends no notification here, so this list is the only way to see them. Answer each with respond_instagram_collab_invite. Read-only, free. Instagram allows 300 reads per account per day.',
|
|
3502
|
+
inputSchema: {
|
|
3503
|
+
limit: z.number().optional().describe('1–100, default 25'),
|
|
3504
|
+
after: z.string().optional().describe('cursor from a previous page'),
|
|
3505
|
+
account: z.string().optional().describe('which Instagram account — @handle or id; omit for the one Page-linked account'),
|
|
3506
|
+
pageId: z.string().optional().describe('Facebook Page id — omit when only one Page is connected'),
|
|
3507
|
+
},
|
|
3508
|
+
outputSchema: { instagramId: z.string().optional(), count: z.number().optional(), invites: z.array(z.any()).optional(), cursor: z.string().nullable().optional() },
|
|
3509
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
3510
|
+
}, wrap(async (a) => {
|
|
3511
|
+
const d = await apiGet('/api/instagram/collab-invites', { limit: a.limit, after: a.after, account: a.account, pageId: a.pageId });
|
|
3512
|
+
if (!d.count) return ok('No collab invites are waiting on this Instagram account.', d);
|
|
3513
|
+
return ok(`${d.count} collab invite(s) waiting:\n${d.invites.map(i => `• from @${i.from || '?'} — ${String(i.caption || '(no caption)').replace(/\s+/g, ' ').slice(0, 90)} · media ${i.mediaId}`).join('\n')}\nAnswer each with respond_instagram_collab_invite(mediaId, accept:true|false).`, d);
|
|
3514
|
+
}));
|
|
3515
|
+
|
|
3516
|
+
server.registerTool('respond_instagram_collab_invite', {
|
|
3517
|
+
title: 'Accept or decline a collab invite',
|
|
3518
|
+
description: 'Answer a collab invite from list_instagram_collab_invites. accept:true makes this account a co-author and the creator’s post appears on its profile; accept:false declines. `accept` is REQUIRED — never guess which way the user wants it. The reply is read back from Instagram (the invite leaves the pending list), not taken from the vendor’s 200. Free. Instagram allows 50 answers per account per day.',
|
|
3519
|
+
inputSchema: {
|
|
3520
|
+
mediaId: z.string().describe('the media id from list_instagram_collab_invites'),
|
|
3521
|
+
accept: z.boolean().describe('true = accept and co-author the post; false = decline'),
|
|
3522
|
+
account: z.string().optional().describe('which Instagram account — @handle or id; omit for the one Page-linked account'),
|
|
3523
|
+
pageId: z.string().optional().describe('Facebook Page id — omit when only one Page is connected'),
|
|
3524
|
+
},
|
|
3525
|
+
outputSchema: { mediaId: z.string().optional(), accepted: z.boolean().optional(), vendorSuccess: z.boolean().optional(), confirmed: z.boolean().optional(), summary: z.string().optional() },
|
|
3526
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
3527
|
+
}, wrap(async (a) => {
|
|
3528
|
+
const d = await apiPost('/api/instagram/collab-invites/respond', { mediaId: a.mediaId, accept: a.accept, account: a.account, pageId: a.pageId });
|
|
3529
|
+
return ok(d.summary, d);
|
|
3530
|
+
}));
|
|
3531
|
+
|
|
3532
|
+
server.registerTool('list_instagram_collab_media', {
|
|
3533
|
+
title: 'Posts this account co-authors',
|
|
3534
|
+
description: 'Every collaborative post this Instagram account is a co-author on — who posted it, and the COMBINED engagement across all co-authors (total likes and comments, plus saves, shares and reposts where Instagram provides them). Read-only, free.',
|
|
3535
|
+
inputSchema: {
|
|
3536
|
+
limit: z.number().optional().describe('1–100, default 25'),
|
|
3537
|
+
after: z.string().optional().describe('cursor from a previous page'),
|
|
3538
|
+
account: z.string().optional().describe('which Instagram account — @handle or id; omit for the one Page-linked account'),
|
|
3539
|
+
pageId: z.string().optional().describe('Facebook Page id — omit when only one Page is connected'),
|
|
3540
|
+
},
|
|
3541
|
+
outputSchema: { instagramId: z.string().optional(), count: z.number().optional(), media: z.array(z.any()).optional(), cursor: z.string().nullable().optional() },
|
|
3542
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
3543
|
+
}, wrap(async (a) => {
|
|
3544
|
+
const d = await apiGet('/api/instagram/collab-media', { limit: a.limit, after: a.after, account: a.account, pageId: a.pageId });
|
|
3545
|
+
if (!d.count) return ok('This Instagram account is not a co-author on any collaborative post.', d);
|
|
3546
|
+
return ok(`${d.count} collaborative post(s):\n${d.media.map(m => `• [${m.mediaKind}] with @${m.by || '?'} — ${String(m.caption || '(no caption)').replace(/\s+/g, ' ').slice(0, 70)} — ${m.totalLikes ?? m.likes ?? '—'} likes, ${m.totalComments ?? m.comments ?? '—'} comments${m.saves != null ? `, ${m.saves} saves` : ''}${m.shares != null ? `, ${m.shares} shares` : ''} · ${m.url || m.id}`).join('\n')}`, d);
|
|
3547
|
+
}));
|
|
3548
|
+
|
|
3477
3549
|
server.registerTool('list_meta_comments', {
|
|
3478
3550
|
title: 'Read comments on a Meta post',
|
|
3479
3551
|
description: 'Read the comments under a Facebook Page post or Instagram media object — customer questions, objections and the exact language real people use about the product. Good raw material for ad copy, and the first step before replying or moderating. REPLIES: a reply is a comment ON a comment, and its id exists only under its PARENT — it is never returned by the post. Each row says how many replies it has; to read them (and to get the id reply_to_meta_comment / moderate_meta_comment need), call this tool again with postId set to that COMMENT id.',
|
|
@@ -6132,7 +6204,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6132
6204
|
}));
|
|
6133
6205
|
server.registerTool('reply_to_meta_message', {
|
|
6134
6206
|
title: 'Reply to a Messenger / Instagram DM',
|
|
6135
|
-
description: 'Send a text reply to someone who has messaged the brand. THIS REACHES A REAL PERSON — show the user the exact text and who it goes to, get a yes, then send. Pass `conversationId` as well as `recipientId` and Hermoso checks Meta’s 24-hour window for free BEFORE sending, and refuses with the real reason instead of letting Meta refuse it; without one it sends and discloses that the window could not be checked. HERMOSO SENDS REPLIES ONLY: messaging_type is always RESPONSE, and proactive messages and message tags are not offered at all — that is a deliberate product boundary, not a gap, and a closed window is a RULE that only the person writing again reopens. `recipientId` is a PAGE-SCOPED ID on Messenger and an INSTAGRAM-SCOPED ID on Instagram (read_meta_conversation returns it as `replyTo`); a username or a handle is not one and cannot be turned into one. ACCEPTED IS NOT DELIVERED — never report it as delivered or read. 0 credits.',
|
|
6207
|
+
description: 'Send a text reply to someone who has messaged the brand. THIS REACHES A REAL PERSON — show the user the exact text and who it goes to, get a yes, then send. Pass `conversationId` as well as `recipientId` and Hermoso checks Meta’s 24-hour window for free BEFORE sending, and refuses with the real reason instead of letting Meta refuse it; without one it sends and discloses that the window could not be checked. HERMOSO SENDS REPLIES ONLY: messaging_type is always RESPONSE, and proactive messages and message tags are not offered at all — that is a deliberate product boundary, not a gap. THE ONE PROACTIVE THING MESSENGER ALLOWS IS A MARKETING-MESSAGES SUBSCRIPTION: to ask a person in the inbox to SUBSCRIBE to marketing messages, call request_messenger_optin (Meta’s own opt-in template, in the ads group — enable_tools(["ads"]) or find_tools if it is not in your list). Never answer that with a plain-text reply asking them to say YES, and never say the capability does not exist. And a closed window is a RULE that only the person writing again reopens. `recipientId` is a PAGE-SCOPED ID on Messenger and an INSTAGRAM-SCOPED ID on Instagram (read_meta_conversation returns it as `replyTo`); a username or a handle is not one and cannot be turned into one. ACCEPTED IS NOT DELIVERED — never report it as delivered or read. 0 credits.',
|
|
6136
6208
|
inputSchema: {
|
|
6137
6209
|
recipientId: z.string().describe('the PSID (Messenger) or IGSID (Instagram) to reply to — `replyTo` from read_meta_conversation'),
|
|
6138
6210
|
text: z.string().describe('the reply'),
|
|
@@ -9498,7 +9570,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9498
9570
|
title: z.string().describe('the headline — 3 to 50 characters, enforced'),
|
|
9499
9571
|
body: z.string().describe('the description under the headline — 100 characters maximum, enforced'),
|
|
9500
9572
|
targetUrl: z.string().describe('the landing page (must not block OAI-AdsBot / OAI-SearchBot in robots.txt)'),
|
|
9501
|
-
imageUrl: z.string().optional().describe('public https URL of a STILL image — a video URL is refused, this channel has no video format'),
|
|
9573
|
+
imageUrl: z.string().optional().describe('public https URL of a STILL image — REQUIRED on every text ad (ChatGPT Ads refuses an ad with no creative.file_id unless the campaign is a product feed; measured 2026-09-06); a video URL is refused, this channel has no video format'),
|
|
9502
9574
|
price: z.string().optional().describe('optional price string shown on the card'),
|
|
9503
9575
|
});
|
|
9504
9576
|
server.registerTool('list_openai_ads_campaigns', {
|
|
@@ -18111,7 +18183,7 @@ function memoryNoteVerdict(text) {
|
|
|
18111
18183
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
18112
18184
|
}, wrap(async (a) => {
|
|
18113
18185
|
const d = await apiPost('/api/posts/collect', { ...(a.includeMetered ? { includeMetered: true } : {}), ...(a.max ? { max: a.max } : {}) });
|
|
18114
|
-
const bits = [`Read ${d.collected} post(s)`, d.couldNotTell ? `${d.couldNotTell} could NOT be read (that is "could not tell", not zero engagement)` : null, d.remaining ? `${d.remaining} still due — call again` : null, d.meteredNote || null].filter(Boolean);
|
|
18186
|
+
const bits = [`Read ${d.collected} post(s)`, d.couldNotTell ? `${d.couldNotTell} could NOT be read (that is "could not tell", not zero engagement)` : null, d.gone ? `${d.gone} no longer exist at the platform (deleted or taken down) and will not be read again` : null, d.remaining ? `${d.remaining} still due — call again` : null, d.meteredNote || null].filter(Boolean);
|
|
18115
18187
|
return ok(`${bits.join('. ')}.${d.collected ? ' Ask post_performance which hooks are winning.' : ''}`, d);
|
|
18116
18188
|
}));
|
|
18117
18189
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.211",
|
|
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 795 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"
|