hermoso 0.1.265 → 0.1.268
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/roster-scope.mjs +1 -1
- package/mcp/tools.mjs +158 -6
- 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
|
+
**855 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
|
**Most of it costs nothing.** Publishing and scheduling posts, building and managing paid campaigns, analytics
|
|
@@ -216,7 +216,7 @@ block entirely if you signed in above; it is there for CI, where the process can
|
|
|
216
216
|
|
|
217
217
|
Then ask your agent: *“Generate an image ad with Hermoso.”*
|
|
218
218
|
|
|
219
|
-
### What the
|
|
219
|
+
### What the 855 tools cover
|
|
220
220
|
|
|
221
221
|
**Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
|
|
222
222
|
Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
|
package/mcp/roster-scope.mjs
CHANGED
|
@@ -102,7 +102,7 @@ export const TOOL_PROVIDER_RULES = [
|
|
|
102
102
|
[/bluesky/, 'bluesky'],
|
|
103
103
|
[/telegram/, 'telegram'],
|
|
104
104
|
[/^post_to_tiktok$|^tiktok_|_tiktok_/, 'tiktok'],
|
|
105
|
-
[/^post_to_x$|^delete_x_post$|^edit_x_post$|^post_x_article$|^send_x_dm$|^list_x_dms$|^search_x$|^list_x_blocks
|
|
105
|
+
[/^post_to_x$|^delete_x_post$|^edit_x_post$|^post_x_article$|^send_x_dm$|^list_x_dms$|^search_x$|^list_x_blocks$|_x_broadcast|^x_(mentions|post|account|trends|user|search_counts|follows)/, 'x'],
|
|
106
106
|
[/pinterest/, 'pinterest'],
|
|
107
107
|
[/reddit/, 'reddit'],
|
|
108
108
|
[/snapchat/, 'snapchat'],
|
package/mcp/tools.mjs
CHANGED
|
@@ -198,7 +198,7 @@ export const CAPABILITY_MAP = [
|
|
|
198
198
|
'What Hermoso can do — the full agent surface (every tool below runs over this MCP):',
|
|
199
199
|
// SECOND LINE, deliberately: the map below is a menu, and a menu read as a sequence is the whole defect.
|
|
200
200
|
INDEPENDENCE,
|
|
201
|
-
'A) AD SPY / RESEARCH — spy on the ads already winning in any market, then mine them. find_competitors · competitor_teardown · pull_competitor_ads · research_ads (open brief) · ad libraries search_meta_ads / search_google_ads / search_linkedin_ads · organic social search_tiktok / search_instagram / search_youtube / search_reddit / search_threads · search_instagram_hashtag (LISTENING on the brand’s OWN Meta credentials rather than a scraper: the real public posts carrying a hashtag, with their captions — feed them into mine_angles or write the next post from the language you found. “recent” is the LAST 24 HOURS only, so a huge tag legitimately returns zero on a quiet day; ask again with edge “top” before saying anything about how busy it is) · instagram_profile (any Instagram @handle → the account’s NUMERIC Instagram id from Meta itself, plus its real name, bio, follower and post counts — Meta’s own numbers, not a scraper’s. It is also the ONLY way to get the id manage_meta_partnership_creator’s allowTagging list requires; professional accounts only) · fetch_social_data (any allowlisted endpoint) · mine_angles · analyze_video · check_ad_policy · list_skills / get_skill (teardowns + creative playbooks).',
|
|
201
|
+
'A) AD SPY / RESEARCH — spy on the ads already winning in any market, then mine them. find_competitors · competitor_teardown · pull_competitor_ads · research_ads (open brief) · ad libraries search_meta_ads / search_google_ads / search_linkedin_ads · organic social search_tiktok / search_instagram / search_youtube / search_reddit / search_threads · search_instagram_hashtag (LISTENING on the brand’s OWN Meta credentials rather than a scraper: the real public posts carrying a hashtag, with their captions — feed them into mine_angles or write the next post from the language you found. “recent” is the LAST 24 HOURS only, so a huge tag legitimately returns zero on a quiet day; ask again with edge “top” before saying anything about how busy it is) · instagram_profile (any Instagram @handle → the account’s NUMERIC Instagram id from Meta itself, plus its real name, bio, follower and post counts — Meta’s own numbers, not a scraper’s. It is also the ONLY way to get the id manage_meta_partnership_creator’s allowTagging list requires; professional accounts only) · find_instagram_marketplace_creators / instagram_marketplace_creator / list_instagram_marketplace_audiences (Instagram’s own creator marketplace searched as the brand, with Meta’s first-party creator insights; find_creators ranks by public posts and takes marketplace:true to add it) · fetch_social_data (any allowlisted endpoint) · mine_angles · analyze_video · check_ad_policy · list_skills / get_skill (teardowns + creative playbooks).',
|
|
202
202
|
'B) CREATE — finished, on-brand image & video ads (real product composited in, copy + CTA baked). draft_brand / get_brand / update_brand (patch single fields without re-onboarding) / use_brand · list_brands / create_brand / delete_brand (one account holds MANY brand workspaces — an agency runs every client through here; each has its own brand, memory, swipefile, Library and connectors, and create_brand → draft_brand onboards a new one end to end) · plan_ad (concept + copy) → render_ad (the Studio quality pipeline) or generate_image / generate_video / generate_avatar (UGC creators + lip-sync) · list_creators / save_creator / delete_creator (the workspace’s REUSABLE CAST — saved creators with their portrait urls, so the SAME person stars in every ad; list them before ever generating a new one, then cast one into the ad with render_ad’s `creator`, which also skips the character-portrait render and so costs LESS than casting a stranger) · make_template_ad (native HTML ad formats) · clone_static / recast_motion / reframe_video / upscale_video / dub_video / change_voice / finish_video / fix_beat / hook_variants / stitch_video · plan_variations + score_ad (fan out + rank).',
|
|
203
203
|
'C) RAW MODEL PLAYGROUND — direct access to the full catalog (30+ image / video / voice / writing models, each with the exact per-render credit cost shown above), no ad framing: generate_image / generate_video (useBrand:false) for plain prompt-only renders, generate_voice for raw text-to-speech against any voice engine, and generate_text for the writing models (Claude / Gemini / GPT / Llama / DeepSeek…) — all against ANY catalog id.',
|
|
204
204
|
'D) ACCOUNT — hermoso_credits (balance) · billing_status (plan + your billing role) · buy_credits (one-click top-up on the saved card, or a first-purchase checkout link) · upgrade_plan / set_auto_reload (admin) · list_jobs / get_job (track async renders) · get_settings / update_settings (the LANGUAGE every ad, script, plan and answer is written in — set it once and every render obeys it, over MCP as well as in the app — plus app appearance and the weekly competitor-watch email) · list_team / invite_member / remove_member / set_role (who else can work in this brand).',
|
|
@@ -2318,9 +2318,14 @@ export const holdReasonText = (name, why, ctx = null) => {
|
|
|
2318
2318
|
// ticked accounts on the same brand). The gate is a snapshot; the refusal must not be. Before saying "not connected",
|
|
2319
2319
|
// re-read the connection set ONCE and re-gate (the same regateForWorkspace use_brand runs); a failed read fails open
|
|
2320
2320
|
// as everywhere else, so the worst case is the old answer, never a wrong refusal of a connector that exists.
|
|
2321
|
-
async function holdReasonRechecked(name, ctx) {
|
|
2321
|
+
async function holdReasonRechecked(name, ctx, args = null) {
|
|
2322
2322
|
let why = holdReasonFor(name, ctx);
|
|
2323
2323
|
if (why !== 'not_connected') return why;
|
|
2324
|
+
// A PER-CALL BRAND IS NOT THE PIN (2026-09-21). The connection snapshot above is the PINNED brand's; a tool that
|
|
2325
|
+
// takes `brand` runs against the brand the caller NAMED, which may have the connector the pin lacks. Refusing it
|
|
2326
|
+
// here answered "not connected" for an account that was connected. Run it: the route gates the named brand itself
|
|
2327
|
+
// and answers the one "not connected" shape (401 + connector) when it really is missing.
|
|
2328
|
+
if (callNamesOwnBrand(name, ctx, args)) return null;
|
|
2324
2329
|
// The re-read must be a GOOD read before it replaces the snapshot: regateForWorkspace fails OPEN on a failed read
|
|
2325
2330
|
// (right for use_brand, wrong here — it would turn "not connected" into "run it and 401"), so a read that did not
|
|
2326
2331
|
// succeed keeps the snapshot's answer and the refusal it already earned.
|
|
@@ -2330,6 +2335,12 @@ async function holdReasonRechecked(name, ctx) {
|
|
|
2330
2335
|
try { await regateForWorkspace(ctx, fresh); } catch { return why; }
|
|
2331
2336
|
return holdReasonFor(name, ctx);
|
|
2332
2337
|
}
|
|
2338
|
+
function callNamesOwnBrand(name, ctx, args) {
|
|
2339
|
+
const b = args && typeof args === 'object' ? args.brand : null;
|
|
2340
|
+
if (typeof b !== 'string' || !b.trim()) return false;
|
|
2341
|
+
const h = ctx && ctx.handleOf && ctx.handleOf[name];
|
|
2342
|
+
try { return !!(h && h.inputSchema && h.inputSchema.shape && Object.prototype.hasOwnProperty.call(h.inputSchema.shape, 'brand')); } catch { return false; }
|
|
2343
|
+
}
|
|
2333
2344
|
// MODULE SCOPE, NOT INSIDE buildTools — and the reason is worth keeping. tools/generation-matrix-check.mjs
|
|
2334
2345
|
// derives the generation surface by slicing the source between registerTool() calls and asking which
|
|
2335
2346
|
// /api/generate/* routes each slice touches. Written just above find_tools, this helper's apiGet fell
|
|
@@ -2376,7 +2387,7 @@ export function installHeldToolCalls(mcp, ctx) {
|
|
|
2376
2387
|
const h = name && ctx.handleOf[name];
|
|
2377
2388
|
if (!h && LEGACY_TOOL_NAMES[name]) return legacyToolAnswer(name, request, extra, ctx); // a name only an old snapshot still holds
|
|
2378
2389
|
if (h && h.enabled === false) {
|
|
2379
|
-
const why = await holdReasonRechecked(name, ctx);
|
|
2390
|
+
const why = await holdReasonRechecked(name, ctx, request?.params?.arguments);
|
|
2380
2391
|
if (why) { const t = holdReasonText(name, why, ctx); reportDeadEnd(why, name, t); return withHints({ content: [{ type: 'text', text: t }], isError: true }, holdHints(name, why, ctx)); }
|
|
2381
2392
|
// The call itself is the evidence: this host's tool list still names a tool the session holds out on size,
|
|
2382
2393
|
// i.e. the host is serving a stale roster. Run it (that is the point) and record that it happened.
|
|
@@ -3103,7 +3114,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3103
3114
|
return { content: [{ type: 'text', text: `No tool named "${n}".${near.length ? ` Did you mean: ${near.join(', ')}?` : ''} find_tools({query}) searches every tool by name or task.` }], isError: true };
|
|
3104
3115
|
}
|
|
3105
3116
|
if (n === 'call_tool' || n === 'find_tools' || n === 'enable_tools') return { content: [{ type: 'text', text: `${n} is a roster tool; call it directly.` }], isError: true };
|
|
3106
|
-
const why = await holdReasonRechecked(n, ctx);
|
|
3117
|
+
const why = await holdReasonRechecked(n, ctx, args);
|
|
3107
3118
|
// ONE sentence per hold, from holdReasonText. call_tool used to spell its own copies, so the connect link added there
|
|
3108
3119
|
// never reached claude.ai or ChatGPT, the two hosts that run held tools through here.
|
|
3109
3120
|
if (why) {
|
|
@@ -4102,6 +4113,62 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4102
4113
|
},
|
|
4103
4114
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
4104
4115
|
}, wrap(async (a) => { const d = await apiPost('/api/instagram/engagement', a); return ok(d.note, d); }));
|
|
4116
|
+
|
|
4117
|
+
// ── INSTAGRAM CREATOR MARKETPLACE DISCOVERY (2026-09-21): Meta's own creator directory, searched as the brand's
|
|
4118
|
+
// Instagram account. Rules and refusals: lib/ig-creator-marketplace.mjs. A SOURCE of the creator search, not a rival:
|
|
4119
|
+
// find_creators takes marketplace:true to add it. Free (Graph calls).
|
|
4120
|
+
const mkLine = (c) => { const f = (v) => v >= 1e6 ? `${(v / 1e6).toFixed(1)}M` : v >= 1e3 ? `${(v / 1e3).toFixed(v >= 1e5 ? 0 : 1)}K` : String(v); const b = []; if (c.followers != null) b.push(`${f(c.followers)} followers`); if (c.country) b.push(c.country); if (c.badges && c.badges.length) b.push(`badges: ${c.badges.join(', ')}`); if (c.email) b.push(c.email); return `• @${c.handle}${c.verified ? ' (verified)' : ''}${b.length ? `: ${b.join(', ')}` : ''}${c.bio ? `. ${String(c.bio).slice(0, 120)}` : ''}`; };
|
|
4121
|
+
server.registerTool('find_instagram_marketplace_creators', {
|
|
4122
|
+
title: 'Search Instagram’s creator marketplace',
|
|
4123
|
+
description: "SEARCH INSTAGRAM'S CREATOR MARKETPLACE: Meta's own directory of creators who partner with brands, searched AS the brand's Instagram account, so the recommendations are personalised to it. Use it when the user wants creators with first-party data (followers, audience, badges such as Partnership ads / Branded content / Strong hooks / Responsive, marketplace email) or Meta's recommendations. Search by query (keywords), by similarTo (up to 5 handles, not with query), by recommendation (most_relevant_for_me, high_ad_performance, most_ads_experience, similar_brands, similar_audience, interested_in_collaboration), or filters on the creator (countries, states with exactly one country, minFollowers/maxFollowers bands, ageBucket, gender, up to 5 interests, minEngaged/maxEngaged, language, followerGrowth, lastPostWithin, verifiedAccount, hasPortfolio, hasPublicContactEmail, featuredInPaidAds, excludeMessagedCreators) and their audience (audienceCountries, audienceStates, audienceAgeBucket, audienceGender, audienceDevices), or customAudienceId from list_instagram_marketplace_audiences. username looks up one creator (no other filter allowed). find_creators ranks creators by their public posts in a niche and takes marketplace:true to add this source beside it. Needs the Meta (Facebook Login) connector with the Page linked to the brand's Instagram, and Meta's instagram_creator_marketplace_discovery permission; when it cannot run it says exactly why and what fixes it. Free.",
|
|
4124
|
+
inputSchema: {
|
|
4125
|
+
query: z.string().optional().describe('keywords, e.g. "skincare" or "trail running"'),
|
|
4126
|
+
username: z.string().optional().describe('one creator handle; no other filter allowed with it'),
|
|
4127
|
+
similarTo: z.array(z.string()).optional().describe('up to 5 creator handles to find look-alikes of'),
|
|
4128
|
+
recommendation: z.enum(['most_relevant_for_me', 'high_ad_performance', 'most_ads_experience', 'similar_brands', 'similar_audience', 'interested_in_collaboration']).optional(),
|
|
4129
|
+
countries: z.array(z.string()).optional().describe('2-letter ISO codes'),
|
|
4130
|
+
states: z.array(z.string()).optional().describe('state or region codes; needs exactly one country'),
|
|
4131
|
+
minFollowers: z.number().optional().describe('0, 10000, 25000, 50000, 75000, 100000, 250000 or 1000000'),
|
|
4132
|
+
maxFollowers: z.number().optional().describe('10000, 25000, 50000, 75000, 100000, 250000 or 1000000'),
|
|
4133
|
+
ageBucket: z.enum(['18_to_24', '25_to_34', '35_to_44', '45_to_54', '55_to_64', '65_and_above']).optional(), gender: z.enum(['male', 'female']).optional(),
|
|
4134
|
+
interests: z.array(z.string()).optional().describe('up to 5 of Meta’s categories, e.g. BEAUTY, FASHION, FITNESS_AND_WORKOUTS, FOOD_AND_DRINK, TRAVEL_AND_LEISURE_ACTIVITIES'),
|
|
4135
|
+
minEngaged: z.number().optional().describe('0, 2000, 10000, 50000 or 100000'), maxEngaged: z.number().optional().describe('2000, 10000, 50000 or 100000'),
|
|
4136
|
+
audienceCountries: z.array(z.string()).optional(), audienceStates: z.array(z.string()).optional(),
|
|
4137
|
+
audienceAgeBucket: z.enum(['18_to_24', '25_to_34', '35_to_44', '45_to_54', '55_to_64', '65_and_above']).optional(), audienceGender: z.enum(['male', 'female']).optional(),
|
|
4138
|
+
audienceDevices: z.array(z.enum(['ios', 'android'])).optional(),
|
|
4139
|
+
customAudienceId: z.string().optional().describe('from list_instagram_marketplace_audiences'),
|
|
4140
|
+
reelsInteractionRate: z.number().optional(), language: z.string().optional(),
|
|
4141
|
+
followerGrowth: z.enum(['top_10_percent', 'top_30_percent', 'top_50_percent']).optional(),
|
|
4142
|
+
lastPostWithin: z.enum(['last_7_days', 'last_30_days', 'last_90_days']).optional(),
|
|
4143
|
+
verifiedAccount: z.boolean().optional(), hasPortfolio: z.boolean().optional(), hasPublicContactEmail: z.boolean().optional(),
|
|
4144
|
+
featuredInPaidAds: z.boolean().optional(), excludeMessagedCreators: z.boolean().optional(),
|
|
4145
|
+
limit: z.number().optional().describe('1 to 50'), cursor: z.string().optional(),
|
|
4146
|
+
pageId: z.string().optional(), account: z.string().optional(),
|
|
4147
|
+
},
|
|
4148
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
4149
|
+
}, wrap(async (a) => {
|
|
4150
|
+
const d = await apiPost('/api/instagram/marketplace/creators', a);
|
|
4151
|
+
if (d.creator !== undefined) return ok(d.note, d);
|
|
4152
|
+
return ok(`${d.note}${d.cursor ? ` More: pass cursor "${d.cursor}".` : ''}\n${(d.creators || []).map(mkLine).join('\n')}`, d);
|
|
4153
|
+
}));
|
|
4154
|
+
server.registerTool('instagram_marketplace_creator', {
|
|
4155
|
+
title: 'One creator from Instagram’s creator marketplace, in depth',
|
|
4156
|
+
description: "ONE CREATOR FROM INSTAGRAM'S CREATOR MARKETPLACE, IN DEPTH: Meta's first-party insights (total followers, reach, engaged accounts, reels interaction rate, reels hook rate), audience demographics (engaged accounts by gender, age, top countries, top cities), the brands they partnered with in the past year, and their recent posts, branded-content posts and partnership ads with likes, comments, views and shares. Pass username (an Instagram handle, from find_instagram_marketplace_creators or find_creators). demographics:false or media:false skip those parts. A part Meta does not serve for this creator is listed as unavailable rather than failing the lookup. Then: approve them for partnership ads with manage_meta_partnership_creator, or save them to a swipefile collection. Needs the Meta (Facebook Login) connector and Meta's instagram_creator_marketplace_discovery permission; when it cannot run it says why. Free.",
|
|
4157
|
+
inputSchema: {
|
|
4158
|
+
username: z.string().describe('the creator’s Instagram handle'),
|
|
4159
|
+
demographics: z.boolean().optional().describe('default true'), media: z.boolean().optional().describe('default true'),
|
|
4160
|
+
pageId: z.string().optional(), account: z.string().optional(),
|
|
4161
|
+
},
|
|
4162
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
4163
|
+
}, wrap(async (a) => { const d = await apiPost('/api/instagram/marketplace/creator', a); return ok(d.note, d); }));
|
|
4164
|
+
server.registerTool('list_instagram_marketplace_audiences', {
|
|
4165
|
+
title: 'Custom audiences usable as a creator-marketplace filter',
|
|
4166
|
+
description: "The brand's custom audiences that Instagram's creator marketplace accepts as a creator filter (Meta's creator_marketplace_brand_info). Pass an id from here as customAudienceId to find_instagram_marketplace_creators to find creators whose followers overlap that audience. An agency passes businessId (its Business portfolio id) to see audiences from client ad accounts. Needs the Meta connector, ads_management and Meta's instagram_creator_marketplace_discovery permission. Free.",
|
|
4167
|
+
inputSchema: { businessId: z.string().optional().describe('agency Business portfolio id'), pageId: z.string().optional(), account: z.string().optional() },
|
|
4168
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
4169
|
+
}, wrap(async (a) => { const d = await apiGet('/api/instagram/marketplace/audiences', a); return ok(`${d.note}${(d.audiences || []).length ? '\n' + d.audiences.map(x => `• ${x.name} (id ${x.id})`).join('\n') : ''}`, d); }));
|
|
4170
|
+
// ── INSTAGRAM LIKE / UNLIKE (continued): replying to and moderating comments on the brand's own posts. The creator
|
|
4171
|
+
// marketplace banner above sits between like_instagram and these, so without this banner they were filed as research.
|
|
4105
4172
|
server.registerTool('reply_to_meta_comment', {
|
|
4106
4173
|
title: 'Reply to a Facebook/Instagram comment',
|
|
4107
4174
|
description: 'Post a public reply to a comment on the brand’s Facebook or Instagram post. This is PUBLIC and posted as the brand — show the user the exact wording and get their go-ahead first.',
|
|
@@ -6256,6 +6323,89 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6256
6323
|
const top = (d.users || []).slice(0, 20).map((u) => `• ${u.username}${u.name ? ` (${u.name})` : ''}${u.followers != null ? ` — ${Number(u.followers).toLocaleString()} followers` : ''}`).join('\n');
|
|
6257
6324
|
return ok(`${d.account} blocks ${d.count} account(s)${d.nextToken ? ' (more available — pass paginationToken)' : ''}:\n${top || '(none)'}`, d);
|
|
6258
6325
|
}));
|
|
6326
|
+
// X LIVE BROADCASTS (2026-09-21) — 16 operations in X's OpenAPI 2.168 as five tools; gates and payloads live in
|
|
6327
|
+
// lib/x-broadcast.mjs on the server, so every refusal below is the server's own sentence and costs nothing.
|
|
6328
|
+
const XB_SCOPE_NOTE = ' Needs X connected with the broadcast permissions added 2026-09-21: an X connection made before then must be reconnected once under Settings > Connectors > X (the tool says so without calling X). Costs credits (X bills per call).';
|
|
6329
|
+
server.registerTool('list_x_broadcasts', {
|
|
6330
|
+
title: 'List X live broadcasts',
|
|
6331
|
+
description: "The connected X account's LIVE VIDEO broadcasts: past and current ones (title, state, share URL, live and total viewers, start/end) and SCHEDULED ones (start, end, whether it goes live automatically or waits for go_live, recurring series). kind 'all' (default) reads both, 'live' or 'scheduled' one; pass id for a single broadcast. Read-only." + XB_SCOPE_NOTE,
|
|
6332
|
+
inputSchema: {
|
|
6333
|
+
kind: z.enum(['all', 'live', 'scheduled']).optional(),
|
|
6334
|
+
id: z.string().optional().describe('one broadcast id (alphanumeric, up to 13 characters)'),
|
|
6335
|
+
ids: z.string().optional().describe('comma-separated broadcast ids (live kind, up to 100)'),
|
|
6336
|
+
maxResults: z.number().optional().describe('1-100, default 25'),
|
|
6337
|
+
paginationToken: z.string().optional(),
|
|
6338
|
+
oldestStart: z.string().optional().describe('scheduled kind: earliest start, ISO 8601 or epoch ms'),
|
|
6339
|
+
newestStart: z.string().optional().describe('scheduled kind: latest start, ISO 8601 or epoch ms'),
|
|
6340
|
+
},
|
|
6341
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
6342
|
+
}, wrap(async (a = {}) => {
|
|
6343
|
+
const d = await apiGet('/api/x/broadcasts', a);
|
|
6344
|
+
const live = (d.broadcasts || []).slice(0, 15).map((b) => `- ${b.title || '(untitled)'} [${b.id}] ${b.state || ''}${b.watched != null ? ` - ${b.watched} watched` : ''}${b.shareUrl ? ` - ${b.shareUrl}` : ''}`).join('\n');
|
|
6345
|
+
const sch = (d.scheduled || []).slice(0, 15).map((s) => `- ${s.title || '(untitled)'} [${s.id}] ${s.start || ''}${s.manualPublish ? ' - manual go-live' : ' - auto-publishes'}${s.recurringScheduleId ? ' - recurring' : ''}`).join('\n');
|
|
6346
|
+
return ok([d.broadcasts ? `Broadcasts (${d.broadcasts.length}):\n${live || '(none)'}` : '', d.scheduled ? `Scheduled (${d.scheduled.length}):\n${sch || '(none)'}` : ''].filter(Boolean).join('\n\n') + `\nCost ${d.costCredits ?? '?'} credits.`, d);
|
|
6347
|
+
}));
|
|
6348
|
+
server.registerTool('manage_x_broadcast', {
|
|
6349
|
+
title: 'Schedule, update, go live on or cancel an X broadcast',
|
|
6350
|
+
description: "Schedule, update, go live on, or cancel an X LIVE VIDEO broadcast on the connected account. action 'schedule' needs sourceId (the stream key of the ingest source, from Media Studio > Producer on x.com), startAt, and manualPublish, which is REQUIRED with no default: true = nothing airs until action 'go_live'; false = X puts the brand ON AIR automatically at startAt (needs confirm:true). 'update' changes only the fields you pass (Hermoso re-sends the rest, because X's update replaces the whole schedule). 'go_live' airs a manualPublish schedule NOW, publicly, as the brand (confirm:true). 'cancel' deletes a schedule and cannot be undone (confirm:true; rollForward:true shifts a recurring series instead of leaving a gap). Confirm the exact broadcast, time and stream with the user before passing confirm:true; a refusal is free." + XB_SCOPE_NOTE,
|
|
6351
|
+
inputSchema: {
|
|
6352
|
+
action: z.enum(['schedule', 'update', 'go_live', 'cancel']),
|
|
6353
|
+
id: z.string().optional().describe('broadcast id, for update / go_live / cancel'),
|
|
6354
|
+
sourceId: z.string().optional().describe('stream key of the ingest source (schedule)'),
|
|
6355
|
+
startAt: z.string().optional().describe('ISO 8601 or epoch ms'),
|
|
6356
|
+
endAt: z.string().optional().describe('ISO 8601 or epoch ms; X requires one on update'),
|
|
6357
|
+
manualPublish: z.boolean().optional().describe('required on schedule: true waits for go_live, false auto-publishes at startAt'),
|
|
6358
|
+
title: z.string().optional(), description: z.string().optional(), locale: z.string().optional(),
|
|
6359
|
+
availableForReplay: z.boolean().optional(), isLocked: z.boolean().optional(),
|
|
6360
|
+
chatOption: z.string().optional().describe('X chat permission option (numeric string)'),
|
|
6361
|
+
thumbnailMediaId: z.string().optional().describe('pre-live slate media id'),
|
|
6362
|
+
recurrence: z.object({ frequency: z.enum(['Daily', 'Weekly']), repeats: z.string() }).optional(),
|
|
6363
|
+
rollForward: z.boolean().optional(),
|
|
6364
|
+
confirm: z.boolean().optional().describe('true only after the user approved this exact public or irreversible action'),
|
|
6365
|
+
},
|
|
6366
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
6367
|
+
}, wrap(async (a) => {
|
|
6368
|
+
const d = await apiPost('/api/x/broadcasts/manage', a);
|
|
6369
|
+
if (d.action === 'cancel') return ok(d.deleted ? `Cancelled the scheduled broadcast ${d.id}. Cost ${d.costCredits ?? '?'} credits.` : `X did not confirm cancelling ${d.id}.`, d);
|
|
6370
|
+
const s = d.scheduled || {};
|
|
6371
|
+
return ok(`${d.action === 'go_live' ? 'Went live' : d.action === 'update' ? 'Updated' : 'Scheduled'}: ${s.title || '(untitled)'} [${s.id}] start ${s.start || '?'}, state ${s.state || '?'}.${d.note ? ` ${d.note}` : ''} Cost ${d.costCredits ?? '?'} credits.`, d);
|
|
6372
|
+
}));
|
|
6373
|
+
server.registerTool('read_x_broadcast_chat', {
|
|
6374
|
+
title: 'Read an X broadcast chat',
|
|
6375
|
+
description: "The chat on one of the connected X account's live broadcasts, newest first: message id, text, author handle, time and reply-to. Use it to answer viewers or find a message to moderate. Read-only." + XB_SCOPE_NOTE,
|
|
6376
|
+
inputSchema: { broadcastId: z.string(), maxResults: z.number().optional().describe('1-200, default 100'), paginationToken: z.string().optional() },
|
|
6377
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
6378
|
+
}, wrap(async (a) => {
|
|
6379
|
+
const d = await apiGet(`/api/x/broadcasts/${encodeURIComponent(a.broadcastId)}/chat`, { maxResults: a.maxResults, paginationToken: a.paginationToken });
|
|
6380
|
+
return ok(`${d.count} chat message(s):\n` + (d.messages || []).slice(0, 30).map((m) => `- [${m.id}] ${m.author || '?'}: ${m.text}`).join('\n') + (d.nextToken ? '\nMore available - pass paginationToken.' : ''), d);
|
|
6381
|
+
}));
|
|
6382
|
+
server.registerTool('send_x_broadcast_chat', {
|
|
6383
|
+
title: 'Send a message to an X broadcast chat',
|
|
6384
|
+
description: "Post ONE chat message, as the brand, into a RUNNING live broadcast on the connected X account (max 140 characters; a longer one is refused, never cut). It is PUBLIC in front of everyone watching: show the user the exact wording, get a yes, then pass confirm:true. replyTo answers one specific message." + XB_SCOPE_NOTE,
|
|
6385
|
+
inputSchema: { broadcastId: z.string(), text: z.string(), replyTo: z.string().optional().describe('chat message id to reply to'), confirm: z.boolean().optional() },
|
|
6386
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
6387
|
+
}, wrap(async (a) => {
|
|
6388
|
+
const d = await apiPost(`/api/x/broadcasts/${encodeURIComponent(a.broadcastId)}/chat`, { text: a.text, replyTo: a.replyTo, confirm: a.confirm });
|
|
6389
|
+
return ok(d.sent ? `Sent to the broadcast chat: "${d.text}". Cost ${d.costCredits ?? '?'} credits.` : 'X did not confirm the chat message.', d);
|
|
6390
|
+
}));
|
|
6391
|
+
server.registerTool('moderate_x_broadcast', {
|
|
6392
|
+
title: 'Moderate an X broadcast chat',
|
|
6393
|
+
description: "Moderate the connected X account's live broadcast chat. action 'remove_message' (broadcastId + messageId; cannot be undone, confirm:true), 'mute' (broadcastId + userId or username; until = when a timeout ends, omit to mute for the broadcast; messageId removes that message while muting), 'unmute', and the account's PERSISTENT chat moderators, which apply to every current and future broadcast: 'list_moderators', 'add_moderator', 'remove_moderator' (userId or username). Only the host or a moderator can moderate, and X says so if not. A username costs one extra lookup." + XB_SCOPE_NOTE,
|
|
6394
|
+
inputSchema: {
|
|
6395
|
+
action: z.enum(['remove_message', 'mute', 'unmute', 'list_moderators', 'add_moderator', 'remove_moderator']),
|
|
6396
|
+
broadcastId: z.string().optional(), messageId: z.string().optional(),
|
|
6397
|
+
userId: z.string().optional().describe('numeric X account id'), username: z.string().optional().describe('X handle, resolved to the id for you'),
|
|
6398
|
+
until: z.string().optional().describe('mute end, ISO 8601 or epoch ms'),
|
|
6399
|
+
confirm: z.boolean().optional().describe('required for remove_message'),
|
|
6400
|
+
},
|
|
6401
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
6402
|
+
}, wrap(async (a) => {
|
|
6403
|
+
const d = await apiPost('/api/x/broadcasts/moderate', a);
|
|
6404
|
+
if (d.action === 'list_moderators') return ok(`${d.count} chat moderator(s):\n` + (d.moderators || []).map((u) => `- ${u.username} (${u.id})`).join('\n'), d);
|
|
6405
|
+
if (d.action === 'remove_message') return ok(d.removed ? `Removed message ${d.messageId}.` : `X did not confirm removing ${d.messageId}.`, d);
|
|
6406
|
+
if (d.action === 'mute' || d.action === 'unmute') return ok(`${d.username || d.userId} is ${d.muted ? 'muted' : 'not muted'} in broadcast ${d.broadcastId}.`, d);
|
|
6407
|
+
return ok(`Chat moderators now: ${(d.moderatorIds || []).join(', ') || '(none)'}.`, d);
|
|
6408
|
+
}));
|
|
6259
6409
|
server.registerTool('x_user', {
|
|
6260
6410
|
title: 'Look up any public X account',
|
|
6261
6411
|
description: 'ACCOUNT-LEVEL numbers for ANY public X account by handle \u2014 followers, following, posts, listed and media counts, plus bio, location, verified status and account age. The research twin of `x_account` (which reads YOUR connected account): use this to size a competitor or vet a creator before working with them. Returns the identical shape as x_account, so the two are never described differently, and a counter X does not return is reported as unknown rather than zero. Needs X connected.',
|
|
@@ -18866,7 +19016,7 @@ function memoryNoteVerdict(text) {
|
|
|
18866
19016
|
// already searches. Real people, scored on median views + engagement + consistency; no marketplace scope needed.
|
|
18867
19017
|
server.registerTool('find_creators', {
|
|
18868
19018
|
title: 'Find the creators already winning in a niche',
|
|
18869
|
-
description: 'Scan organic TikTok, Instagram Reels and YouTube for a niche across a few query variants, fold the posts into creators, and rank them on median views, engagement rate and how often they show up for that niche; the top rows get follower counts AND public contact info (an Instagram business email / phone / category, the bio link, an email in a TikTok bio) so outreach can start from the result. Real people, not AI actors — for influencer sourcing, UGC casting and partnership prospecting ("who should we send product to?"). About one credit per search call (platforms × queries, default 3 × 3) plus one per enriched profile; repeats inside 20 minutes are free. Then shortlist (save_to_swipefile), check a profile (instagram_profile / fetch_social_data), draft outreach (generate_text), or approve them for Partnership Ads (manage_meta_partnership_creator).',
|
|
19019
|
+
description: 'Scan organic TikTok, Instagram Reels and YouTube for a niche across a few query variants, fold the posts into creators, and rank them on median views, engagement rate and how often they show up for that niche; the top rows get follower counts AND public contact info (an Instagram business email / phone / category, the bio link, an email in a TikTok bio) so outreach can start from the result. Real people, not AI actors — for influencer sourcing, UGC casting and partnership prospecting ("who should we send product to?"). About one credit per search call (platforms × queries, default 3 × 3) plus one per enriched profile; repeats inside 20 minutes are free. Then shortlist (save_to_swipefile), check a profile (instagram_profile / fetch_social_data), draft outreach (generate_text), or approve them for Partnership Ads (manage_meta_partnership_creator). marketplace:true also searches Instagram’s creator marketplace (Meta’s own creator directory: followers, badges, marketplace email) for the same niche and returns those rows beside the ranked list.',
|
|
18870
19020
|
inputSchema: {
|
|
18871
19021
|
niche: z.string().describe('product category, topic or hashtag — "calorie tracker app", "matcha", "#cleanbeauty"'),
|
|
18872
19022
|
platforms: z.array(z.enum(['tiktok', 'instagram', 'youtube'])).optional().describe('default all three'),
|
|
@@ -18875,6 +19025,7 @@ function memoryNoteVerdict(text) {
|
|
|
18875
19025
|
minAvgViews: z.number().optional(),
|
|
18876
19026
|
minEngagement: z.number().optional().describe('interactions per view, 0–1 (0.05 = 5%)'),
|
|
18877
19027
|
enrich: z.boolean().optional().describe('read follower counts for the top 6 (default true, ~1 credit each)'),
|
|
19028
|
+
marketplace: z.boolean().optional().describe('also search Instagram’s creator marketplace (Meta’s own creator directory, free) for the same niche; needs the Meta connector'),
|
|
18878
19029
|
},
|
|
18879
19030
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
18880
19031
|
}, wrap(async (a) => {
|
|
@@ -18882,7 +19033,8 @@ function memoryNoteVerdict(text) {
|
|
|
18882
19033
|
const fmt = (v) => v >= 1e6 ? `${(v / 1e6).toFixed(1)}M` : v >= 1e3 ? `${(v / 1e3).toFixed(v >= 1e5 ? 0 : 1)}K` : String(v);
|
|
18883
19034
|
if (!(d.creators || []).length) return ok(`${d.summary} Nobody passed the floors — widen the niche, lower minAvgViews / minEngagement, or add platforms.`, d);
|
|
18884
19035
|
const lines = d.creators.map(c => `• @${c.handle} (${c.platform})${c.name && c.name !== c.handle ? ` — ${c.name}` : ''}: ${c.posts} post${c.posts === 1 ? '' : 's'} in this niche, median ${fmt(c.medianPlays)} views, ${c.engagementRate == null ? 'engagement unknown' : `${(100 * c.engagementRate).toFixed(1)}% engagement`}${c.followers != null ? `, ${fmt(c.followers)} followers` : ''}, score ${c.score}${c.top?.link ? ` — top: ${c.top.link}` : ''}${c.profileUrl ? ` — ${c.profileUrl}` : ''}`);
|
|
18885
|
-
|
|
19036
|
+
const mk = d.marketplace && (d.marketplace.creators || []).length ? `\nInstagram creator marketplace:\n${d.marketplace.creators.map(c => `• @${c.handle}${c.followers != null ? `, ${fmt(c.followers)} followers` : ''}${c.country ? `, ${c.country}` : ''}${c.email ? `, ${c.email}` : ''}`).join('\n')}` : '';
|
|
19037
|
+
return ok(`${d.note}\n${lines.join('\n')}${mk}`, d);
|
|
18886
19038
|
}));
|
|
18887
19039
|
// TOPIC SEARCH (2026-09-15, Dave: search a named brand, "but not their ads themselves, just posts about them … similarly
|
|
18888
19040
|
// just broad things like coffee"): the posts ABOUT a subject from anyone, all three organic platforms in one call.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.268",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
|
-
"description": "Marketing on autopilot, run from your own AI agent.
|
|
5
|
+
"description": "Marketing on autopilot, run from your own AI agent. 855 tools. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on every plan; credits are only for generating creative and for Ad Spy research. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"hermoso": "bin/hermoso.mjs"
|