hermoso 0.1.485 → 0.1.487
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/openai-extensions.mjs +1 -1
- package/mcp/roster-scope.mjs +2 -2
- package/mcp/tools.mjs +171 -33
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ scripts. Research the ads already winning in a market, generate image & video ad
|
|
|
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
|
+
**1096 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
|
**No markup on models.** Stop overpaying markups on AI models. Plans: [hermoso.ai/pricing](https://hermoso.ai/pricing).
|
|
@@ -242,7 +242,7 @@ block entirely if you signed in above; it is there for CI, where the process can
|
|
|
242
242
|
|
|
243
243
|
Then ask your agent: *“Generate an image ad with Hermoso.”*
|
|
244
244
|
|
|
245
|
-
### What the
|
|
245
|
+
### What the 1096 tools cover
|
|
246
246
|
|
|
247
247
|
**Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
|
|
248
248
|
Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
|
|
@@ -1256,7 +1256,7 @@ export const neutralNote = (t) => {
|
|
|
1256
1256
|
// sweeps and drift watches queued for later (measured on a fresh local account: one `agentidle` "nudge", queued for
|
|
1257
1257
|
// tomorrow, would have shown as "1 render in progress"). Mirrors server.js JOB_TYPES_HIDDEN_FROM_V1, and the check
|
|
1258
1258
|
// fails if that list gains a type this one lacks. A job queued for LATER is not in progress either.
|
|
1259
|
-
export const PANEL_HIDDEN_JOB_TYPES = Object.freeze(['fallate', 'whatsappcoexsync', 'purge', 'agentidle', 'welcomewait', 'agentsetupmail', 'uploadretention', 'miniappretention', 'connectordrift', 'falwatch', 'faceassetsweep', 'searchvis', 'mcpcanary', 'welcomeads', 'lifecycleedu', 'connhealth', 'chargednothing', 'emailfeedback']);
|
|
1259
|
+
export const PANEL_HIDDEN_JOB_TYPES = Object.freeze(['fallate', 'storysweep', 'ttpublicurl', 'whatsappcoexsync', 'purge', 'agentidle', 'welcomewait', 'agentsetupmail', 'uploadretention', 'miniappretention', 'connectordrift', 'falwatch', 'faceassetsweep', 'searchvis', 'mcpcanary', 'welcomeads', 'lifecycleedu', 'connhealth', 'chargednothing', 'emailfeedback']);
|
|
1260
1260
|
const ageText = (h) => (h == null || !Number.isFinite(+h) ? '' : +h < 1 ? 'just now' : +h < 48 ? `${Math.round(+h)}h ago` : `${Math.round(+h / 24)}d ago`);
|
|
1261
1261
|
const VIDEO_RE = /\.(mp4|webm|mov|m4v)([?#]|$)/i;
|
|
1262
1262
|
const clip = (s, n) => { const t = String(s ?? '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
|
package/mcp/roster-scope.mjs
CHANGED
|
@@ -119,7 +119,7 @@ export const TOOL_PROVIDER_RULES = [
|
|
|
119
119
|
// tools/tiktok-roster-gate-check.mjs derives each tool's credential from server.js and fails when this disagrees.
|
|
120
120
|
[/^(post_to_tiktok|tiktok_post_status|tiktok_creator_info|tiktok_account|list_tiktok_videos)$/, 'tiktok'],
|
|
121
121
|
[/tiktok/, 'tiktok_ads'],
|
|
122
|
-
[/^post_to_x$|^delete_x_post$|^edit_x_post$|^post_x_article$|^send_x_dm$|^list_x_dms$|^search_x$|^list_x_blocks$|^hide_x_reply$|_x_broadcast|_x_chat|^x_chat_settings$|^x_(mentions|post|account|trends|user|search_counts|follows)/, 'x'],
|
|
122
|
+
[/^post_to_x$|^delete_x_post$|^edit_x_post$|^post_x_article$|^send_x_dm$|^list_x_dms$|^search_x$|^list_x_blocks$|^hide_x_reply$|_x_broadcast|_x_stream_source|_x_chat|^x_chat_settings$|^x_(mentions|post|account|trends|user|search_counts|follows)/, 'x'],
|
|
123
123
|
[/pinterest/, 'pinterest'],
|
|
124
124
|
[/reddit/, 'reddit'],
|
|
125
125
|
[/snapchat/, 'snapchat'],
|
|
@@ -176,7 +176,7 @@ export function toolProvider(name) {
|
|
|
176
176
|
// could connect Instagram and then had no tool to post, reply, read its inbox or measure with. Only routes verified
|
|
177
177
|
// to take the Login token are listed; a Page-only tool (ads, hashtag search, comment_on_meta_post) stays gated on meta.
|
|
178
178
|
export const INSTAGRAM_LOGIN_TOOLS = new Set([
|
|
179
|
-
'post_to_meta', 'list_meta_posts', 'list_instagram_media', 'list_meta_stories', 'get_meta_story', 'instagram_insights', 'meta_post_insights',
|
|
179
|
+
'post_to_meta', 'list_meta_posts', 'list_instagram_media', 'list_meta_stories', 'get_meta_story', 'instagram_story_insights', 'instagram_insights', 'meta_post_insights',
|
|
180
180
|
'list_meta_comments', 'reply_to_meta_comment', 'moderate_meta_comment', 'like_instagram',
|
|
181
181
|
'list_meta_conversations', 'read_meta_conversation', 'reply_to_meta_message',
|
|
182
182
|
'list_instagram_collab_invites', 'list_instagram_collab_media', 'respond_instagram_collab_invite', 'search_instagram_audio',
|
package/mcp/tools.mjs
CHANGED
|
@@ -2610,15 +2610,30 @@ export const WIDGET_HOST_SPLITS = Object.freeze(withSplitAccounts({
|
|
|
2610
2610
|
splitOp('delete_youtube_comment', { action: 'delete' }, ['commentId!', 'confirm'], splitW(true, true), 'Delete a YouTube comment', 'Use this when a comment must be removed for good; YouTube has no undelete. Needs confirm:true. To only take it out of public view use reject_youtube_comment. Free.', { confirm: 'must be true to delete' }),
|
|
2611
2611
|
],
|
|
2612
2612
|
manage_x_broadcast: [
|
|
2613
|
-
splitOp('schedule_x_broadcast', { action: 'schedule' }, ['sourceId!', 'startAt!', 'manualPublish!', 'endAt', 'title', 'description', 'locale', 'availableForReplay', 'isLocked', 'chatOption', 'thumbnailMediaId', '
|
|
2614
|
-
splitOp('update_x_broadcast', { action: 'update' }, ['id!', 'startAt', 'endAt', 'manualPublish', 'title', 'description', 'locale', 'availableForReplay', 'isLocked', 'chatOption', 'thumbnailMediaId', '
|
|
2613
|
+
splitOp('schedule_x_broadcast', { action: 'schedule' }, ['sourceId!', 'startAt!', 'manualPublish!', 'endAt', 'title', 'description', 'locale', 'availableForReplay', 'isLocked', 'chatOption', 'thumbnailMediaId', 'confirm'], splitW(false, false), 'Schedule an X live broadcast', 'Use this when the brand wants to schedule an X live video broadcast. sourceId comes from list_x_stream_sources. manualPublish is required: true waits for start_x_broadcast; false makes X put the brand on air automatically at startAt and needs confirm:true.' + X_BILLED),
|
|
2614
|
+
splitOp('update_x_broadcast', { action: 'update' }, ['id!', 'startAt', 'endAt', 'manualPublish', 'title', 'description', 'locale', 'availableForReplay', 'isLocked', 'chatOption', 'thumbnailMediaId', 'confirm'], splitW(true, true), 'Change a scheduled X broadcast', 'Use this when a scheduled X broadcast needs a new time, title or setting. Only the fields you pass change. Turning manualPublish off needs confirm:true.' + X_BILLED),
|
|
2615
2615
|
splitOp('start_x_broadcast', { action: 'go_live' }, ['id!', 'confirm'], splitW(true, false), 'Go live on a scheduled X broadcast', 'Use this when a broadcast scheduled with manualPublish:true should go on air NOW, publicly, as the brand. Needs confirm:true after the user approved the exact broadcast and stream.' + X_BILLED, { confirm: 'must be true: this goes on air publicly' }),
|
|
2616
|
-
splitOp('
|
|
2616
|
+
splitOp('create_x_broadcast', { action: 'create' }, ['sourceId!'], splitW(false, false), 'Create an X broadcast on a stream source', 'Use this when the brand wants a broadcast without a schedule. It posts nothing until publish_x_broadcast, and X deletes it after 7 days if never published.' + X_BILLED),
|
|
2617
|
+
splitOp('publish_x_broadcast', { action: 'publish' }, ['id!', 'title', 'locale', 'chatOption', 'skipPost', 'confirm'], splitW(true, false), 'Publish an X broadcast', 'Use this when a created X broadcast should go on air NOW, publicly, as the brand. X also posts it unless skipPost is true. Needs confirm:true.' + X_BILLED, { confirm: 'must be true: this goes on air publicly' }),
|
|
2618
|
+
splitOp('end_x_broadcast', { action: 'end' }, ['id!', 'confirm'], splitW(true, true), 'End an X broadcast', 'Use this when a running X broadcast should stop for everyone watching. Cannot be undone; needs confirm:true.' + X_BILLED, { confirm: 'must be true to end' }),
|
|
2619
|
+
splitOp('delete_x_broadcast', { action: 'delete' }, ['id!', 'confirm'], splitW(true, true), 'Delete an X broadcast', 'Use this when a broadcast and its replay should be removed. End a running one first. Needs confirm:true.' + X_BILLED, { confirm: 'must be true to delete' }),
|
|
2620
|
+
splitOp('cancel_x_broadcast', { action: 'cancel' }, ['id!', 'confirm'], splitW(true, true), 'Cancel a scheduled X broadcast', 'Use this when a scheduled X broadcast should be deleted. Cannot be undone; needs confirm:true.' + X_BILLED, { confirm: 'must be true to cancel' }),
|
|
2621
|
+
],
|
|
2622
|
+
manage_x_stream_source: [
|
|
2623
|
+
splitOp('list_x_stream_sources', { action: 'list' }, [], SPLIT_RO, 'List the X stream sources', 'Use this when you need the brand’s RTMP stream sources on X: id, stream key, RTMPS URL, region, whether video is arriving. Read-only.' + X_BILLED),
|
|
2624
|
+
splitOp('get_x_stream_source', { action: 'get' }, ['id!'], SPLIT_RO, 'Read one X stream source', 'Use this when you need one stream source’s stream key, RTMPS URL, region and whether video is arriving. Read-only.' + X_BILLED),
|
|
2625
|
+
splitOp('get_x_ingest_region', { action: 'region' }, [], SPLIT_RO, 'Read the nearest X ingest region', 'Use this when you need the ingest region nearest X’s server, to pass when creating a stream source. Read-only.' + X_BILLED),
|
|
2626
|
+
splitOp('create_x_stream_source', { action: 'create' }, ['name!', 'region'], splitW(false, false), 'Create an X stream source', 'Use this when the brand needs a new RTMP ingest point for live video on X. The stream key is a secret, so never post it.' + X_BILLED),
|
|
2627
|
+
splitOp('rename_x_stream_source', { action: 'rename' }, ['id!', 'name!'], splitW(false, true), 'Rename an X stream source', 'Use this when a stream source needs a new label. The stream key does not change.' + X_BILLED),
|
|
2628
|
+
splitOp('delete_x_stream_source', { action: 'delete' }, ['id!', 'confirm'], splitW(true, true), 'Delete an X stream source', 'Use this when a stream source is no longer used. It kills the stream key; needs confirm:true.' + X_BILLED, { confirm: 'must be true to delete' }),
|
|
2617
2629
|
],
|
|
2618
2630
|
moderate_x_broadcast: [
|
|
2619
2631
|
splitOp('remove_x_broadcast_chat_message', { action: 'remove_message' }, ['broadcastId!', 'messageId!', 'confirm'], splitW(true, true), 'Remove a message from an X broadcast chat', 'Use this when one message in the brand’s live broadcast chat must be removed. Cannot be undone; needs confirm:true. Get messageId from read_x_broadcast_chat.' + X_BILLED, { confirm: 'must be true to remove' }),
|
|
2620
|
-
splitOp('mute_x_broadcast_chat_user', { action: 'mute' }, ['broadcastId!', 'userId', 'username', 'until', 'messageId'], splitW(true, true), 'Mute someone in an X broadcast chat', 'Use this when a viewer should be muted in the brand’s live broadcast chat. Pass userId or username; until ends a timeout (omit to mute
|
|
2632
|
+
splitOp('mute_x_broadcast_chat_user', { action: 'mute' }, ['broadcastId!', 'userId', 'username', 'until', 'messageId'], splitW(true, true), 'Mute someone in an X broadcast chat', 'Use this when a viewer should be muted in the brand’s live broadcast chat. Pass userId or username; until ends a timeout (omit to mute indefinitely); messageId also removes that message.' + X_BILLED),
|
|
2621
2633
|
splitOp('unmute_x_broadcast_chat_user', { action: 'unmute' }, ['broadcastId!', 'userId', 'username'], splitW(true, true), 'Unmute someone in an X broadcast chat', 'Use this when a muted viewer may chat again in the brand’s live broadcast. Pass userId or username.' + X_BILLED),
|
|
2634
|
+
splitOp('list_x_broadcast_chat_mutes', { action: 'list_mutes' }, [], SPLIT_RO, 'List who is muted in the X broadcast chats', 'Use this when you need everyone muted across the brand’s broadcast chats, with when each timeout ends. Read-only.' + X_BILLED),
|
|
2635
|
+
splitOp('mute_x_chat_user_everywhere', { action: 'mute_everywhere' }, ['userId', 'username', 'until', 'broadcastId', 'messageId'], splitW(true, true), 'Mute someone in every X broadcast chat', 'Use this when a viewer should be muted in all the brand’s broadcast chats. Pass userId or username; until ends a timeout. broadcastId is for moderating another host.' + X_BILLED),
|
|
2636
|
+
splitOp('unmute_x_chat_user_everywhere', { action: 'unmute_everywhere' }, ['userId', 'username', 'broadcastId'], splitW(true, true), 'Unmute someone in every X broadcast chat', 'Use this when a viewer muted across the brand’s broadcast chats may chat again. Pass userId or username.' + X_BILLED),
|
|
2622
2637
|
splitOp('list_x_broadcast_chat_moderators', { action: 'list_moderators' }, [], SPLIT_RO, 'List the X broadcast chat moderators', 'Use this when you need the brand’s persistent X broadcast chat moderators, who moderate every current and future broadcast. Read-only.' + X_BILLED),
|
|
2623
2638
|
splitOp('add_x_broadcast_chat_moderator', { action: 'add_moderator' }, ['userId', 'username'], splitW(false, true), 'Add an X broadcast chat moderator', 'Use this when someone should moderate the chat on every current and future broadcast of the brand. Pass userId or username.' + X_BILLED),
|
|
2624
2639
|
splitOp('remove_x_broadcast_chat_moderator', { action: 'remove_moderator' }, ['userId', 'username'], splitW(true, true), 'Remove an X broadcast chat moderator', 'Use this when someone should stop moderating the brand’s broadcast chats. Pass userId or username.' + X_BILLED),
|
|
@@ -4758,6 +4773,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4758
4773
|
title: 'Credit balance',
|
|
4759
4774
|
description: 'Return the credit balance, recent priced calls with their credits, and costModel (the one-sentence rule of what costs credits). THE RULE: only AI model runs and Ad Spy research spend credits; publishing, scheduling, ads management, analytics, comments, DMs and connectors are FREE on every plan (X is the single per-call exception; xCost gives its exact credits per post, with and without a link). Check before kicking off paid generation; answer "does posting cost credits?" with NO. Paid plans: media models at cost + 10% (memberRate).',
|
|
4760
4775
|
inputSchema: {}, outputSchema: {
|
|
4776
|
+
accountHeld: z.number().nullable().optional().describe('credits on hold for a render in progress: not spent yet, and already taken out of accountBalance, which is what can be spent now. If you state the balance while this is above 0, say both once'),
|
|
4761
4777
|
accountBalance: z.number().nullable().optional().describe('the account’s Hermoso credit balance (authoritative when authed)'),
|
|
4762
4778
|
balance: z.number().optional().describe('raw vendor meter balance (operator/local-dev surface)'),
|
|
4763
4779
|
sessionStart: z.number().nullable().optional().describe('vendor balance at session start (operator surface)'),
|
|
@@ -5253,22 +5269,47 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5253
5269
|
// row carries no CDN urls, and get_meta_story returns one Story in full.
|
|
5254
5270
|
server.registerTool('list_meta_stories', {
|
|
5255
5271
|
title: 'List the Stories live right now',
|
|
5256
|
-
description: 'The Stories live right now on the connected Instagram accounts and Facebook Pages: id, caption (Instagram), posted time, hours left and permalink. Use it to check that a post or reel really went to Stories, or to match a story id against what is live. Meta serves a Story for 24 hours and leaves live video and reshared stories out of this list. A source that could not be read comes back in `unreadable`, never as an empty list. Read-only, 0 credits.',
|
|
5272
|
+
description: 'The Stories live right now on the connected Instagram accounts and Facebook Pages: id, caption (Instagram), posted time, hours left and permalink, and on Instagram `insights`: reach, views, replies, link clicks and navigation (forward, back, next story, exit). Use it to check that a post or reel really went to Stories, or to match a story id against what is live. includeEnded also returns `ended`, the Stories that have ended with the numbers kept from before they ended. Meta marks nothing on a Story to say it is a reshared reel, so `origin` appears only when the share went through Hermoso, and says how it was found. Meta serves a Story for 24 hours and leaves live video and reshared stories out of this list. A source that could not be read comes back in `unreadable`, never as an empty list. Read-only, 0 credits.',
|
|
5257
5273
|
inputSchema: {
|
|
5258
5274
|
...MANAGE_BRAND,
|
|
5259
5275
|
channel: z.enum(['instagram', 'facebook', 'all']).optional().describe('default all'),
|
|
5260
5276
|
account: z.string().optional().describe('one Instagram account: an @handle or id'),
|
|
5261
5277
|
pageId: z.string().optional().describe('one Facebook Page, and its linked Instagram'),
|
|
5262
5278
|
limit: z.number().optional().describe('rows, newest first (1-100, default 25)'),
|
|
5279
|
+
includeEnded: z.boolean().optional().describe('also list Stories that ended, with their kept numbers'),
|
|
5280
|
+
days: z.number().optional().describe('how far back includeEnded goes (1-400, default 30)'),
|
|
5263
5281
|
},
|
|
5264
|
-
outputSchema: { count: z.number().optional(), stories: z.array(z.any()).optional(), read: z.array(z.any()).optional(), unreadable: z.array(z.any()).optional(), note: z.string().optional(), caveat: z.string().optional() },
|
|
5282
|
+
outputSchema: { count: z.number().optional(), stories: z.array(z.any()).optional(), ended: z.array(z.any()).optional(), read: z.array(z.any()).optional(), unreadable: z.array(z.any()).optional(), note: z.string().optional(), caveat: z.string().optional() },
|
|
5265
5283
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
5266
5284
|
}, wrap(async (a) => {
|
|
5267
|
-
const d = await apiGet('/api/meta/stories', queryBrand(a, { channel: a.channel, account: a.account, pageId: a.pageId, limit: a.limit }));
|
|
5268
|
-
const lines = (d.stories || []).map(s => `• [${s.channel} ${s.account}] ${s.mediaType || 'story'}${s.caption ? ` ${JSON.stringify(s.caption.slice(0, 90))}` : ''} · posted ${s.postedAt || 'unknown'}${s.hoursLeft != null ? ` · ${s.hoursLeft}h left` : ''}\n id ${s.id}${s.permalink ? ` · ${s.permalink}` : ''}`);
|
|
5285
|
+
const d = await apiGet('/api/meta/stories', queryBrand(a, { channel: a.channel, account: a.account, pageId: a.pageId, limit: a.limit, ...(a.includeEnded ? { includeEnded: 'true' } : {}), ...(a.days ? { days: a.days } : {}) }));
|
|
5286
|
+
const lines = (d.stories || []).map(s => `• [${s.channel} ${s.account}] ${s.mediaType || 'story'}${s.caption ? ` ${JSON.stringify(s.caption.slice(0, 90))}` : ''} · posted ${s.postedAt || 'unknown'}${s.hoursLeft != null ? ` · ${s.hoursLeft}h left` : ''}\n id ${s.id}${s.permalink ? ` · ${s.permalink}` : ''}${s.insights ? `\n ${s.insights.summary}` : ''}${s.origin?.sameFileAsReel ? `\n Posted through Hermoso with the same file as reel ${s.origin.sameFileAsReel.postId}${s.origin.sameFileAsReel.url ? ` (${s.origin.sameFileAsReel.url})` : ''}.` : ''}`);
|
|
5287
|
+
const endedLines = (d.ended || []).map(s => `• [ended ${s.account}] posted ${s.postedAt || 'unknown'}${s.caption ? ` ${JSON.stringify(s.caption.slice(0, 90))}` : ''}\n id ${s.storyId}\n ${s.summary}`);
|
|
5269
5288
|
const read = (d.read || []).map(r => `${r.account} ${r.count}`).join(', ');
|
|
5270
5289
|
const bad = (d.unreadable || []).map(u => `${u.account}: ${u.error}`).join('; ');
|
|
5271
|
-
return ok(`${d.count} Stor${d.count === 1 ? 'y' : 'ies'} live${read ? ` (${read})` : ''}.\n${lines.join('\n')}${bad ? `\nCould not read ${bad}. That is not the same as no Stories there.` : ''}${d.note ? `\n${d.note}` : ''}\n${d.caveat || ''}`.trim(), d);
|
|
5290
|
+
return ok(`${d.count} Stor${d.count === 1 ? 'y' : 'ies'} live${read ? ` (${read})` : ''}.\n${lines.join('\n')}${endedLines.length ? `\nEnded:\n${endedLines.join('\n')}` : ''}${bad ? `\nCould not read ${bad}. That is not the same as no Stories there.` : ''}${d.note ? `\n${d.note}` : ''}\n${d.caveat || ''}`.trim(), d);
|
|
5291
|
+
}));
|
|
5292
|
+
// A STORY'S NUMBERS OUTLIVE IT (2026-10-31). Meta keeps them for 24 hours, so the server reads every live Story on a schedule
|
|
5293
|
+
// and takes the story_insights webhook; this reads what is kept. Every state but `numbers` is a statement about what is
|
|
5294
|
+
// unknown, never a zero.
|
|
5295
|
+
server.registerTool('instagram_story_insights', {
|
|
5296
|
+
title: 'An Instagram Story\u2019s numbers, kept past its 24 hours',
|
|
5297
|
+
description: 'An Instagram Story\u2019s numbers: reach, views, replies, shares, link clicks, profile visits, follows, and navigation (forward, back, next story, exit). Meta keeps them for 24 hours, so Hermoso reads every live Story on a schedule, including ones posted from the phone, and they stay readable here after the Story ends. Pass storyId for one, or nothing for the recent ones. `state` says what is known: numbers, below_minimum (Meta withholds them under 5 viewers), not_captured (it ended before a read), or why a read failed; none of those means zero. Meta returns 0 replies for Stories created in Europe and Japan, and the answer says so. Link clicks need a Facebook-login connection. Read-only, 0 credits.',
|
|
5298
|
+
inputSchema: {
|
|
5299
|
+
...MANAGE_BRAND,
|
|
5300
|
+
storyId: z.string().optional().describe('one Story id, from list_meta_stories'),
|
|
5301
|
+
account: z.string().optional().describe('one Instagram account: an @handle or id'),
|
|
5302
|
+
pageId: z.string().optional().describe('one Facebook Page, for its linked Instagram'),
|
|
5303
|
+
days: z.number().optional().describe('how far back (1-400, default 30)'),
|
|
5304
|
+
limit: z.number().optional().describe('rows, newest first (1-100, default 25)'),
|
|
5305
|
+
},
|
|
5306
|
+
outputSchema: { count: z.number().optional(), days: z.number().optional(), stories: z.array(z.any()).optional(), unreadable: z.array(z.any()).optional(), state: z.string().optional(), note: z.string().optional(), caveat: z.string().optional() },
|
|
5307
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
5308
|
+
}, wrap(async (a) => {
|
|
5309
|
+
const d = await apiGet('/api/meta/story-insights', queryBrand(a, { storyId: a.storyId, account: a.account, pageId: a.pageId, days: a.days, limit: a.limit }));
|
|
5310
|
+
const lines = (d.stories || []).map(s => `• [${s.account}] posted ${s.postedAt || 'unknown'}${s.ended ? ' (ended)' : ''}${s.caption ? ` ${JSON.stringify(s.caption.slice(0, 90))}` : ''}\n id ${s.storyId}${s.permalink ? ` · ${s.permalink}` : ''}\n ${s.summary}`);
|
|
5311
|
+
const bad = (d.unreadable || []).map(u => `${u.account || 'Instagram'}: ${u.error}`).join('; ');
|
|
5312
|
+
return ok(`${d.count} Stor${d.count === 1 ? 'y' : 'ies'} with a record in the last ${d.days} days.${d.state ? ` ${d.note}` : ''}\n${lines.join('\n')}${bad ? `\nCould not read ${bad}. That is not the same as no numbers.` : ''}${d.note && !d.state ? `\n${d.note}` : ''}\n${d.caveat || ''}`.trim(), d);
|
|
5272
5313
|
}));
|
|
5273
5314
|
server.registerTool('get_meta_story', {
|
|
5274
5315
|
title: 'Look up one Story by id',
|
|
@@ -6072,7 +6113,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6072
6113
|
paidPartnership: z.boolean().optional().describe('INSTAGRAM — the PAID PARTNERSHIP label. A COMPLIANCE DECLARATION: set it whenever the post is sponsored, gifted or otherwise paid for. Opt-in and never inferred — it is the poster’s own statement about their commercial relationship.'),
|
|
6073
6114
|
brandedContentSponsorIds: z.array(z.string()).optional().describe('INSTAGRAM — the numeric Instagram USER IDS of the brands behind that label (at most 2, and ids rather than @handles). Naming sponsors IS asking for the label, so setting these with `paidPartnership:false` is refused instead of publishing brand credits with no disclosure.'),
|
|
6074
6115
|
trialReel: z.enum(['MANUAL', 'SS_PERFORMANCE']).optional().describe('INSTAGRAM TRIAL REEL \u2014 publish this Reel to NON-FOLLOWERS ONLY at first (Instagram allows trials only on accounts above its follower threshold — about 1,000 followers; an ineligible account is refused by name and nothing is posted), so a hook can be tested on a cold audience without spending it on the people who already follow the brand. Instagram then shows it to followers only if it graduates. MANUAL = the creator graduates it by hand in the Instagram app; SS_PERFORMANCE = Instagram graduates it automatically if it performs well. REELS ONLY and INSTAGRAM ONLY: an image, a carousel, a Facebook post or a Threads post is REFUSED BY NAME rather than quietly published as an ordinary post \u2014 a trial that silently goes to every follower is the exact opposite of what was asked for. Omit it for a normal Reel.'),
|
|
6075
|
-
story: z.boolean().optional().describe('INSTAGRAM OR FACEBOOK PAGE STORY \u2014 publish this as a 24-hour Story instead of a feed post. One image OR one video: Instagram has no carousel story, so a carousel is REFUSED BY NAME rather than quietly posted to the feed. An Instagram Story keeps its caption: people see the start under the Story in the app and tap for the rest (instagram.com does not show it). It takes NO collaborators, product tags or trialReel \u2014 any of those is refused by name and nothing is posted. FACEBOOK PAGE STORIES WORK TOO (target facebook): one image, or one vertical 9:16 video of 3–90 seconds, no caption (a message is refused by name), not schedulable natively — use schedule_post to queue one; the reply reads the story back from the Page.'),
|
|
6116
|
+
story: z.boolean().optional().describe('INSTAGRAM OR FACEBOOK PAGE STORY \u2014 publish this as a 24-hour Story instead of a feed post. One image OR one video: Instagram has no carousel story, so a carousel is REFUSED BY NAME rather than quietly posted to the feed. A link sticker cannot be added through Meta\u2019s publishing API on either channel: add it in the Instagram app. An Instagram Story keeps its caption: people see the start under the Story in the app and tap for the rest (instagram.com does not show it). It takes NO collaborators, product tags or trialReel \u2014 any of those is refused by name and nothing is posted. FACEBOOK PAGE STORIES WORK TOO (target facebook): one image, or one vertical 9:16 video of 3–90 seconds, no caption (a message is refused by name), not schedulable natively — use schedule_post to queue one; the reply reads the story back from the Page.'),
|
|
6076
6117
|
aiGenerated: z.boolean().optional().describe('INSTAGRAM / FACEBOOK REEL / THREADS \u2014 Meta\u2019s is_ai_generated self-disclosure (Threads: is_made_with_ai, the AI info label; a text-only Threads post is declared only when you pass true). OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, media that came through upload_file or from an external URL (the user\u2019s own photographs or footage) is NOT \u2014 a real photo must never carry Instagram\u2019s \u201cAI info\u201d label. Pass true or false only to override: false strips the label from something Hermoso would otherwise declare, true declares a render the user uploaded themselves.'),
|
|
6077
6118
|
altText: z.union([z.string(), z.array(z.string())]).optional().describe('ACCESSIBILITY \u2014 the description screen readers announce, and what the platform otherwise auto-generates badly or not at all. Describe what is actually IN the picture, never the caption. ONE STRING describes the picture; on a CAROUSEL it describes EVERY slide. Pass an ARRAY of strings instead to describe each slide separately, aligned to the slide order \u2014 use that on a multi-slide post, because one sentence read out over six different pictures is wrong for five of them. More descriptions than pictures is refused rather than dropped. WHERE IT LANDS, per Meta\u2019s own docs: INSTAGRAM image posts and the IMAGE slides of an Instagram carousel (up to 1000 characters; dropped rather than sent on a Reel or a video slide, which Meta do not support); FACEBOOK Page photos including every photo of an album, via alt_text_custom (Meta publish no length for it, so nothing is truncated); THREADS on a single-image or single-video post ONLY \u2014 Meta document no way to attach alt text to a Threads CAROUSEL slide, so a Threads carousel publishes undescribed and the reply says so rather than risking the whole post on a guess. (Meta\u2019s AI disclosure defaults from PROVENANCE: a Hermoso render is declared is_ai_generated; media that came through upload_file or an external URL, i.e. the user\u2019s own photos or footage, is NOT. Pass `aiGenerated` to force it either way.)'),
|
|
6078
6119
|
pageId: z.string().optional().describe('target Page id (from list_meta_pages); omit = first Page'),
|
|
@@ -6235,7 +6276,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6235
6276
|
instagramLocationId: z.string().optional().describe('INSTAGRAM — tag a place (called locationId on post_to_meta; locationId here is the Google Business listing). It is the pageId from search_meta_places, the NUMERIC ID OF A FACEBOOK PAGE associated with that location, not a place name and not coordinates; a non-numeric value is refused rather than sent.'),
|
|
6236
6277
|
brandedContentSponsorIds: z.array(z.string()).optional().describe('INSTAGRAM — the numeric Instagram USER IDS of the brands behind that label (at most 2, and ids rather than @handles). Naming sponsors IS asking for the label, so setting these with `paidPartnership:false` is refused instead of publishing brand credits with no disclosure.'),
|
|
6237
6278
|
trialReel: z.enum(['MANUAL', 'SS_PERFORMANCE']).optional().describe('INSTAGRAM TRIAL REEL \u2014 publish this Reel to NON-FOLLOWERS ONLY at first (Instagram allows trials only on accounts above its follower threshold — about 1,000 followers; an ineligible account is refused by name and nothing is posted), so a hook can be tested on a cold audience without spending it on the people who already follow the brand; Instagram shows it to followers only if it graduates. MANUAL = the creator graduates it by hand in the Instagram app; SS_PERFORMANCE = Instagram graduates it automatically if it performs. REELS ONLY and INSTAGRAM ONLY: an image, a carousel, or a Facebook/Threads channel is REFUSED BY NAME rather than quietly published as an ordinary post \u2014 a trial that silently goes to every follower is the exact opposite of what was asked for, so Instagram must be one of the `channels` and the item must carry a video. Omit it for a normal Reel.'),
|
|
6238
|
-
story: z.boolean().optional().describe('INSTAGRAM OR FACEBOOK PAGE STORY \u2014 publish this as a 24-hour Story instead of a feed post. One image OR one video: Instagram has no carousel story, so a carousel is REFUSED BY NAME rather than quietly posted to the feed. An Instagram Story keeps its caption: people see the start under the Story in the app and tap for the rest (instagram.com does not show it). It takes NO collaborators, product tags or trialReel \u2014 any of those is refused by name and nothing is posted. FACEBOOK PAGE STORIES WORK TOO (target facebook): one image, or one vertical 9:16 video of 3–90 seconds, no caption (a message is refused by name), not schedulable natively — use schedule_post to queue one; the reply reads the story back from the Page.'),
|
|
6279
|
+
story: z.boolean().optional().describe('INSTAGRAM OR FACEBOOK PAGE STORY \u2014 publish this as a 24-hour Story instead of a feed post. One image OR one video: Instagram has no carousel story, so a carousel is REFUSED BY NAME rather than quietly posted to the feed. A link sticker cannot be added through Meta\u2019s publishing API on either channel: add it in the Instagram app. An Instagram Story keeps its caption: people see the start under the Story in the app and tap for the rest (instagram.com does not show it). It takes NO collaborators, product tags or trialReel \u2014 any of those is refused by name and nothing is posted. FACEBOOK PAGE STORIES WORK TOO (target facebook): one image, or one vertical 9:16 video of 3–90 seconds, no caption (a message is refused by name), not schedulable natively — use schedule_post to queue one; the reply reads the story back from the Page.'),
|
|
6239
6280
|
aiGenerated: z.boolean().optional().describe('AI-CONTENT DISCLOSURE (Instagram / Facebook Reel is_ai_generated, TikTok is_aigc, YouTube containsSyntheticMedia, Pinterest ai_disclosures, Threads is_made_with_ai). OMIT IT and Hermoso decides from provenance: a Hermoso render is declared AI-generated, media that came through upload_file or from an external URL (the user\u2019s own photos or footage) is NOT. Pass true or false only to override.'),
|
|
6240
6281
|
// ── WHICH ACCOUNT (server-side SCHED_ID_FIELDS). Every one of these is an answer the publish helper REFUSES
|
|
6241
6282
|
// to guess, so a schedule that cannot carry it can only fail at fire time with nobody watching.
|
|
@@ -6304,7 +6345,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6304
6345
|
...(r.xArticle && r.xArticle.title ? { xArticle: { title: r.xArticle.title } } : {}),
|
|
6305
6346
|
media: r.videoUrl ? 'video' : slides > 1 ? `carousel of ${slides}` : (r.imageUrl || slides) ? 'image' : 'none',
|
|
6306
6347
|
...((r.videoUrl || r.imageUrl) ? { mediaUrl: r.videoUrl || r.imageUrl } : {}),
|
|
6307
|
-
...(Array.isArray(r.results) ? { results: r.results.map(x => ({ channel: x?.channel, ok: x?.ok, ...(x?.id ? { id: String(x.id) } : {}), ...(x?.url ? { url: x.url } : {}), ...(x?.error ? { error: String(x.error).slice(0, 300) } : {}) })) } : {}),
|
|
6348
|
+
...(Array.isArray(r.results) ? { results: r.results.map(x => ({ channel: x?.channel, ok: x?.ok, ...(x?.id ? { id: String(x.id) } : {}), ...(x?.url ? { url: x.url } : {}), ...(x?.urlStatus && x.urlStatus !== 'ready' ? { urlStatus: x.urlStatus, ...(x.urlNote ? { urlNote: x.urlNote } : {}) } : {}), ...(x?.error ? { error: String(x.error).slice(0, 300) } : {}) })) } : {}),
|
|
6308
6349
|
...(r.error ? { error: String(r.error).slice(0, 300) } : {}),
|
|
6309
6350
|
};
|
|
6310
6351
|
};
|
|
@@ -7943,10 +7984,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7943
7984
|
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).';
|
|
7944
7985
|
server.registerTool('list_x_broadcasts', {
|
|
7945
7986
|
title: 'List X live broadcasts',
|
|
7946
|
-
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
|
|
7987
|
+
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). kind 'all' (default) reads both, 'live' or 'scheduled' one; pass id for a single broadcast. Read-only." + XB_SCOPE_NOTE,
|
|
7947
7988
|
inputSchema: {
|
|
7948
7989
|
kind: z.enum(['all', 'live', 'scheduled']).optional().describe('all (default), live or scheduled'),
|
|
7949
|
-
id: z.string().optional().describe('one broadcast id (alphanumeric, up to
|
|
7990
|
+
id: z.string().optional().describe('one broadcast id (alphanumeric, up to 24 characters)'),
|
|
7950
7991
|
ids: z.string().optional().describe('comma-separated broadcast ids (live kind, up to 100)'),
|
|
7951
7992
|
maxResults: z.number().optional().describe('1-100, default 25'),
|
|
7952
7993
|
paginationToken: z.string().optional().describe('next page token from the previous reply'),
|
|
@@ -7957,16 +7998,16 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7957
7998
|
}, wrap(async (a = {}) => {
|
|
7958
7999
|
const d = await apiGet('/api/x/broadcasts', a);
|
|
7959
8000
|
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');
|
|
7960
|
-
const sch = (d.scheduled || []).slice(0, 15).map((s) => `- ${s.title || '(untitled)'} [${s.id}] ${s.start || ''}${s.manualPublish ? ' - manual go-live' : ' - auto-publishes'}
|
|
7961
|
-
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);
|
|
8001
|
+
const sch = (d.scheduled || []).slice(0, 15).map((s) => `- ${s.title || '(untitled)'} [${s.id}] ${s.start || ''}${s.manualPublish ? ' - manual go-live' : ' - auto-publishes'}`).join('\n');
|
|
8002
|
+
return ok([d.broadcastsRefused ? `Past and live broadcasts could not be read: ${d.broadcastsRefused}` : '', 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);
|
|
7962
8003
|
}));
|
|
7963
8004
|
server.registerTool('manage_x_broadcast', {
|
|
7964
8005
|
title: 'Schedule, update, go live on or cancel an X broadcast',
|
|
7965
|
-
description: "Schedule, update, go live on, or cancel an X LIVE VIDEO broadcast on the connected account. action 'schedule' needs sourceId (
|
|
8006
|
+
description: "Schedule, update, go live on, or cancel an X LIVE VIDEO broadcast on the connected account. action 'schedule' needs sourceId (from manage_x_stream_source), 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). X retired recurring broadcasts: schedule each occurrence on its own. WITHOUT A SCHEDULE: 'create' makes a broadcast on sourceId that posts nothing, 'publish' puts it on air (confirm:true; skipPost:true skips the announcing post), 'end' stops one for everyone (confirm:true), 'delete' removes one and its replay (confirm:true; end it first). X limits broadcasting by account (Premium or 1,000 followers) and says so. Confirm the exact broadcast, time and stream with the user before passing confirm:true; a refusal is free." + XB_SCOPE_NOTE,
|
|
7966
8007
|
inputSchema: {
|
|
7967
|
-
action: z.enum(['schedule', 'update', 'go_live', 'cancel']).describe('schedule, update, go_live or
|
|
7968
|
-
id: z.string().optional().describe('broadcast id, for
|
|
7969
|
-
sourceId: z.string().optional().describe('stream
|
|
8008
|
+
action: z.enum(['schedule', 'update', 'go_live', 'cancel', 'create', 'publish', 'end', 'delete']).describe('schedule, update, go_live, cancel, create, publish, end or delete'),
|
|
8009
|
+
id: z.string().optional().describe('broadcast id, for every action but schedule and create'),
|
|
8010
|
+
sourceId: z.string().optional().describe('stream source id from manage_x_stream_source (schedule, create)'),
|
|
7970
8011
|
startAt: z.string().optional().describe('ISO 8601 or epoch ms'),
|
|
7971
8012
|
endAt: z.string().optional().describe('ISO 8601 or epoch ms; X requires one on update'),
|
|
7972
8013
|
manualPublish: z.boolean().optional().describe('required on schedule: true waits for go_live, false auto-publishes at startAt'),
|
|
@@ -7974,17 +8015,40 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7974
8015
|
availableForReplay: z.boolean().optional().describe('true = keep the broadcast available as a replay'), isLocked: z.boolean().optional().describe("true = lock the broadcast (X's isLocked flag)"),
|
|
7975
8016
|
chatOption: z.string().optional().describe('X chat permission option (numeric string)'),
|
|
7976
8017
|
thumbnailMediaId: z.string().optional().describe('pre-live slate media id'),
|
|
7977
|
-
|
|
7978
|
-
rollForward: z.boolean().optional().describe('cancel only: true shifts a recurring series forward instead of leaving a gap'),
|
|
8018
|
+
skipPost: z.boolean().optional().describe('publish: true skips the post X would make announcing it'),
|
|
7979
8019
|
confirm: z.boolean().optional().describe('true only after the user approved this exact public or irreversible action'),
|
|
7980
8020
|
},
|
|
7981
8021
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
7982
8022
|
}, wrap(async (a) => {
|
|
7983
8023
|
const d = await apiPost('/api/x/broadcasts/manage', a);
|
|
7984
8024
|
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);
|
|
8025
|
+
if (d.action === 'create') return ok(`Created broadcast [${d.broadcast?.id}] on the source, not on air. ${d.note}${d.shareUrl ? ` ${d.shareUrl}` : ''} Cost ${d.costCredits ?? '?'} credits.`, d);
|
|
8026
|
+
if (d.action === 'publish') return ok(`Broadcast ${d.id} published, state ${d.state || '?'}. Cost ${d.costCredits ?? '?'} credits.`, d);
|
|
8027
|
+
if (d.action === 'end') return ok(d.ended ? `Ended broadcast ${d.id}. Cost ${d.costCredits ?? '?'} credits.` : `X did not confirm ending ${d.id} (state ${d.state || '?'}).`, d);
|
|
8028
|
+
if (d.action === 'delete') return ok(d.deleted ? `Deleted broadcast ${d.id} and its replay. Cost ${d.costCredits ?? '?'} credits.` : `X did not confirm deleting ${d.id}.`, d);
|
|
7985
8029
|
const s = d.scheduled || {};
|
|
7986
8030
|
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);
|
|
7987
8031
|
}));
|
|
8032
|
+
server.registerTool('manage_x_stream_source', {
|
|
8033
|
+
title: 'Manage X stream sources',
|
|
8034
|
+
description: "The connected X account's STREAM SOURCES, the RTMP ingest points a live broadcast plays from. action 'list' (default) and 'get' (id) show each source's id, stream key, RTMPS URL, region and whether video is arriving; 'create' (name, optional region) makes one; 'rename' (id, name); 'delete' (id, confirm:true: it kills the stream key); 'region' reads the nearest ingest region. Pass a source's id as sourceId to manage_x_broadcast. The stream key is a secret, so never post it." + XB_SCOPE_NOTE,
|
|
8035
|
+
inputSchema: {
|
|
8036
|
+
action: z.enum(['list', 'get', 'create', 'rename', 'delete', 'region']).optional().describe("default 'list'"),
|
|
8037
|
+
id: z.string().optional().describe('source id, for get, rename and delete'),
|
|
8038
|
+
name: z.string().optional().describe('label, for create and rename'),
|
|
8039
|
+
region: z.string().optional().describe("create only: 'atla' or 'pdxa', or an AWS region X maps onto one"),
|
|
8040
|
+
confirm: z.boolean().optional().describe('true only after the user approved deleting this source'),
|
|
8041
|
+
},
|
|
8042
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
8043
|
+
}, wrap(async (a = {}) => {
|
|
8044
|
+
const d = await apiPost('/api/x/stream-sources', a);
|
|
8045
|
+
const cost = `Cost ${d.costCredits ?? '?'} credits.`;
|
|
8046
|
+
if (d.action === 'region') return ok(`Nearest ingest region: ${d.region || '?'}. ${cost}`, d);
|
|
8047
|
+
if (d.action === 'delete') return ok(`Deleted stream source ${d.id}. ${cost}`, d);
|
|
8048
|
+
if (d.action === 'list') return ok(`${d.count} stream source(s):\n` + (d.sources || []).map((x) => `- ${x.name || '(no name)'} [${x.id}] ${x.region || ''}${x.receivingVideo ? ' - receiving video' : ''}\n ${x.rtmpsUrl || ''}`).join('\n') + `\n${cost}`, d);
|
|
8049
|
+
const x = d.source || {};
|
|
8050
|
+
return ok(`${x.name || '(no name)'} [${x.id}] ${x.region || ''}${x.receivingVideo ? ' - receiving video' : ''}\nRTMPS ${x.rtmpsUrl || '?'}, stream key ${x.streamKey || '?'}.${d.note ? ` ${d.note}` : ''} ${cost}`, d);
|
|
8051
|
+
}));
|
|
7988
8052
|
server.registerTool('read_x_broadcast_chat', {
|
|
7989
8053
|
title: 'Read an X broadcast chat',
|
|
7990
8054
|
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,
|
|
@@ -8005,9 +8069,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8005
8069
|
}));
|
|
8006
8070
|
server.registerTool('moderate_x_broadcast', {
|
|
8007
8071
|
title: 'Moderate an X broadcast chat',
|
|
8008
|
-
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
|
|
8072
|
+
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 indefinitely; messageId removes that message while muting), 'unmute', 'list_mutes' (everyone muted across the brand's chats), 'mute_everywhere' / 'unmute_everywhere' (userId or username, every chat of the brand; when the brand moderates another host, broadcastId scopes it to that host), 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,
|
|
8009
8073
|
inputSchema: {
|
|
8010
|
-
action: z.enum(['remove_message', 'mute', 'unmute', 'list_moderators', 'add_moderator', 'remove_moderator']).describe('remove_message, mute, unmute, list_moderators, add_moderator or
|
|
8074
|
+
action: z.enum(['remove_message', 'mute', 'unmute', 'list_moderators', 'add_moderator', 'remove_moderator', 'list_mutes', 'mute_everywhere', 'unmute_everywhere']).describe('remove_message, mute, unmute, list_moderators, add_moderator, remove_moderator, list_mutes, mute_everywhere or unmute_everywhere'),
|
|
8011
8075
|
broadcastId: z.string().optional().describe('broadcast id; for remove_message, mute and unmute'), messageId: z.string().optional().describe('chat message id to remove; with mute, also removes that message'),
|
|
8012
8076
|
userId: z.string().optional().describe('numeric X account id'), username: z.string().optional().describe('X handle, resolved to the id for you'),
|
|
8013
8077
|
until: z.string().optional().describe('mute end, ISO 8601 or epoch ms'),
|
|
@@ -8019,6 +8083,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8019
8083
|
if (d.action === 'list_moderators') return ok(`${d.count} chat moderator(s):\n` + (d.moderators || []).map((u) => `- ${u.username} (${u.id})`).join('\n'), d);
|
|
8020
8084
|
if (d.action === 'remove_message') return ok(d.removed ? `Removed message ${d.messageId}.` : `X did not confirm removing ${d.messageId}.`, d);
|
|
8021
8085
|
if (d.action === 'mute' || d.action === 'unmute') return ok(`${d.username || d.userId} is ${d.muted ? 'muted' : 'not muted'} in broadcast ${d.broadcastId}.`, d);
|
|
8086
|
+
if (d.action === 'list_mutes') return ok(`${d.count} muted in the brand's chats:\n` + (d.mutes || []).map((m) => `- ${m.username || m.userId}${m.until ? ` until ${m.until}` : ''}`).join('\n'), d);
|
|
8087
|
+
if (d.action === 'mute_everywhere' || d.action === 'unmute_everywhere') return ok(`${d.username || d.userId} is ${d.muted ? 'muted' : 'not muted'} across the brand's chats.`, d);
|
|
8022
8088
|
return ok(`Chat moderators now: ${(d.moderatorIds || []).join(', ') || '(none)'}.`, d);
|
|
8023
8089
|
}));
|
|
8024
8090
|
// X CHAT (2026-10-02) — the /2/chat/* endpoints X's OpenAPI 2.169 added, as four tools; gates and payloads live in
|
|
@@ -8054,7 +8120,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8054
8120
|
}));
|
|
8055
8121
|
server.registerTool('list_x_chats', {
|
|
8056
8122
|
title: 'List X Chat conversations',
|
|
8057
|
-
description: "The connected X account's X CHAT conversations: id (group ids start with g), type, muted, disappearing-message timer, screenshot controls, admins, members, PENDING join requests, which actions are admin-only, and the active invite link. X Chat is END-TO-END ENCRYPTED, so message text and group names cannot be read here (list_x_dms reads the plain direct-message history). view 'admin_requests' (id = a group the brand admins) lists who asked to become an admin; view 'invite' (inviteLink, or id + inviteToken) previews an invite link: group name, member count, whether the brand is in it. view 'message_requests' lists the MESSAGE REQUESTS waiting outside the inbox (inbox 'priority', the default, or 'low_quality' for X's filtered tab): who sent each one (@handle, name, followers, verified), which accounts the brand follows also follow them, and whether they changed their name or picture recently (X's impersonation hint); the message itself is encrypted. Show that to the user before manage_x_chat accept_request. withMembers:true resolves member @handles, and each user X returns is billed. Read-only. Billed per conversation or request returned, plus one user lookup per request sender, so keep maxResults small." + XC_SCOPE_NOTE,
|
|
8123
|
+
description: "The connected X account's X CHAT conversations: id (group ids start with g), type, muted, disappearing-message timer, screenshot controls, admins, members, PENDING join requests, which actions are admin-only, and the active invite link. X Chat is END-TO-END ENCRYPTED, so message text and group names cannot be read here (list_x_dms reads the plain direct-message history). A filtered page can be short or empty while more remain: keep paging with the same filters. view 'admin_requests' (id = a group the brand admins) lists who asked to become an admin; view 'invite' (inviteLink, or id + inviteToken) previews an invite link: group name, member count, whether the brand is in it. view 'message_requests' lists the MESSAGE REQUESTS waiting outside the inbox (inbox 'priority', the default, or 'low_quality' for X's filtered tab): who sent each one (@handle, name, followers, verified), which accounts the brand follows also follow them, and whether they changed their name or picture recently (X's impersonation hint); the message itself is encrypted. Show that to the user before manage_x_chat accept_request. withMembers:true resolves member @handles, and each user X returns is billed. Read-only. Billed per conversation or request returned, plus one user lookup per request sender, so keep maxResults small." + XC_SCOPE_NOTE,
|
|
8058
8124
|
inputSchema: {
|
|
8059
8125
|
view: z.enum(['conversations', 'admin_requests', 'invite', 'message_requests']).optional().describe("default 'conversations'"),
|
|
8060
8126
|
inbox: z.enum(['priority', 'low_quality']).optional().describe("message_requests: X's Priority tab (default) or the filtered low-quality one"),
|
|
@@ -8063,6 +8129,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8063
8129
|
maxResults: z.number().optional().describe('1-100 (message_requests 1-50), default 10; every conversation or request returned is billed'),
|
|
8064
8130
|
paginationToken: z.string().optional().describe('nextToken from a previous call'),
|
|
8065
8131
|
withMembers: z.boolean().optional().describe('also resolve member/admin @handles (each one billed)'),
|
|
8132
|
+
unreadOnly: z.boolean().optional().describe('only unread conversations'),
|
|
8133
|
+
type: z.enum(['direct', 'group']).optional().describe('only 1:1 or only group conversations'),
|
|
8134
|
+
isArchived: z.boolean().optional().describe('true only archived, false only not archived'),
|
|
8135
|
+
isMuted: z.boolean().optional().describe('true only muted, false only not muted'),
|
|
8136
|
+
participantIds: z.string().optional().describe('1-10 numeric X account ids, comma-separated, all in the chat'),
|
|
8137
|
+
participantQuery: z.string().optional().describe('name or @username prefix, 1-50 characters'),
|
|
8138
|
+
startTime: z.string().optional().describe('latest message at or after this ISO 8601 time'),
|
|
8139
|
+
endTime: z.string().optional().describe('latest message before this ISO 8601 time'),
|
|
8066
8140
|
inviteLink: z.string().optional().describe('an x.com/i/chat/group_join/... link, for view invite'),
|
|
8067
8141
|
inviteToken: z.string().optional().describe('the 10-character token, with id, for view invite'),
|
|
8068
8142
|
},
|
|
@@ -8468,29 +8542,30 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8468
8542
|
yourBrand: z.boolean().optional().describe('discloses that this promotes the creator’s own brand'),
|
|
8469
8543
|
platformCover: z.boolean().optional().describe('VIDEO COVER. Omit it (the default) and Hermoso sets the video\u2019s best frame \u2014 the same frame as its Library thumbnail \u2014 as the cover (TikTok video_cover_timestamp_ms, on a direct post \u2014 a draft takes no cover, you pick it in the TikTok app). true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins.'),
|
|
8470
8544
|
},
|
|
8471
|
-
outputSchema: { ok: z.boolean().optional(), publishId: z.string().optional(), status: z.string().optional(), destination: z.string().optional(), media: z.string().optional(), images: z.number().optional(), coverIndex: z.number().optional(), postId: z.string().nullable().optional(), url: z.string().nullable().optional(), account: z.string().nullable().optional(), pending: z.boolean().optional(), mediaConformedNote: z.string().optional(), reencodedNote: z.string().optional() },
|
|
8545
|
+
outputSchema: { ok: z.boolean().optional(), publishId: z.string().optional(), status: z.string().optional(), destination: z.string().optional(), media: z.string().optional(), images: z.number().optional(), coverIndex: z.number().optional(), postId: z.string().nullable().optional(), url: z.string().nullable().optional(), urlStatus: z.string().optional(), urlNote: z.string().optional(), account: z.string().nullable().optional(), pending: z.boolean().optional(), mediaConformedNote: z.string().optional(), reencodedNote: z.string().optional() },
|
|
8472
8546
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
8473
8547
|
}, wrap(async (a) => {
|
|
8474
8548
|
const d = await apiPost('/api/tiktok/post', a);
|
|
8475
8549
|
const what = d.media === 'photo' ? (d.images > 1 ? `photo post (${d.images} slides)` : 'photo post') : 'video';
|
|
8476
8550
|
if (d.destination === 'draft') return ok(`Sent the ${what} to TikTok${d.account ? ` on @${d.account}` : ''} — it's waiting in the TikTok app (inbox notification, or + ▸ drafts) for the user to finish and post.${[d.mediaConformedNote, d.reencodedNote].filter(Boolean).map(n => ` ${n}`).join('')}${d.pending ? ' TikTok was still processing when polling stopped; it usually lands within a minute.' : ''}`, d);
|
|
8477
|
-
return ok(`Posted the ${what} to TikTok${d.account ? ` as @${d.account}` : ''}${d.url ? ` — ${d.url}` : ''}.${d.pending ? ' TikTok was still processing when polling stopped — it normally appears within a minute or two. Do not post it again.' : ''}${[d.mediaConformedNote, d.reencodedNote].filter(Boolean).map(n => ` ${n}`).join('')}`, d);
|
|
8551
|
+
return ok(`Posted the ${what} to TikTok${d.account ? ` as @${d.account}` : ''}${d.url ? ` — ${d.url}` : ''}.${d.pending ? ' TikTok was still processing when polling stopped — it normally appears within a minute or two. Do not post it again.' : ''}${!d.url && d.urlNote ? ` ${d.urlNote}` : ''}${[d.mediaConformedNote, d.reencodedNote].filter(Boolean).map(n => ` ${n}`).join('')}`, d);
|
|
8478
8552
|
}));
|
|
8479
8553
|
// A publish TikTok was still PROCESSING when post_to_tiktok stopped polling came back `pending:true` with a publishId
|
|
8480
8554
|
// and the words "do not post it again", and nothing to follow it with: an agent asked call_tool for
|
|
8481
8555
|
// `tiktok_post_status` on 2026-10-01 and dead-ended. Same read as the web app's /api/tiktok/publish-status.
|
|
8482
8556
|
server.registerTool('tiktok_post_status', {
|
|
8483
8557
|
title: 'Check whether a TikTok post finished publishing',
|
|
8484
|
-
description: 'Follow a TikTok post that post_to_tiktok sent but TikTok was still processing (it came back pending:true). Pass the publishId it returned. Answers one of three states: done (PUBLISH_COMPLETE, with the public
|
|
8558
|
+
description: 'Follow a TikTok post that post_to_tiktok sent but TikTok was still processing (it came back pending:true). Pass the publishId it returned. Answers one of three states: done (PUBLISH_COMPLETE, with the public `url` once TikTok\u2019s moderation has named it; `urlStatus` is ready, pending, private or draft), failed (with TikTok\u2019s reason), or still processing, which is normal for a few minutes and is NOT a failure. Never post the video again while it is processing. Free, read-only. Needs TikTok connected (Settings > Connectors > TikTok).',
|
|
8485
8559
|
inputSchema: {
|
|
8486
8560
|
publishId: z.string().describe('the publishId post_to_tiktok returned'),
|
|
8487
8561
|
account: z.string().optional().describe('which connected TikTok account the post went out on (@handle or id from list_connector_accounts) when the profile has more than one'),
|
|
8488
8562
|
},
|
|
8489
|
-
outputSchema: { publishId: z.string().optional(), status: z.string().optional(), done: z.boolean().optional(), failed: z.boolean().optional(), postId: z.string().nullable().optional(), failReason: z.string().nullable().optional(), failMessage: z.string().optional(), uploadedBytes: z.number().nullable().optional() },
|
|
8563
|
+
outputSchema: { publishId: z.string().optional(), status: z.string().optional(), done: z.boolean().optional(), failed: z.boolean().optional(), postId: z.string().nullable().optional(), url: z.string().nullable().optional(), urlStatus: z.string().optional(), urlNote: z.string().optional(), foundBy: z.string().optional(), failReason: z.string().nullable().optional(), failMessage: z.string().optional(), uploadedBytes: z.number().nullable().optional() },
|
|
8490
8564
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
8491
8565
|
}, wrap(async (a) => {
|
|
8492
8566
|
const d = await apiGet('/api/tiktok/publish-status', { publishId: a.publishId, ...(a.account ? { account: a.account } : {}) });
|
|
8493
|
-
if (d.done) return ok(`TikTok finished publishing it${d.postId ? ` (post id ${d.postId})` : ''}
|
|
8567
|
+
if (d.done) return ok(`TikTok finished publishing it${d.url ? `: ${d.url}` : d.postId ? ` (post id ${d.postId})` : ''}.${d.urlNote ? ` ${d.urlNote}` : ''}`, d);
|
|
8568
|
+
if (d.urlStatus === 'draft') return ok(`It is waiting as a draft in the TikTok app, for the user to finish and post. ${d.urlNote || ''}`.trim(), d);
|
|
8494
8569
|
if (d.failed) return ok(d.failMessage || `TikTok did not publish it: ${d.failReason || 'no reason given'}.`, d);
|
|
8495
8570
|
return ok(`TikTok is still processing it (${d.status || 'no status yet'}). That is normal for a few minutes; check again shortly and do not post it again.`, d);
|
|
8496
8571
|
}));
|
|
@@ -10435,6 +10510,43 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10435
10510
|
// for — print it rather than re-asserting `a.status`, which would be a claim about the request, not the account.
|
|
10436
10511
|
return ok(d.note || `${d.level || 'campaign'} → ${d.verifiedStatus || a.status}.`, d);
|
|
10437
10512
|
}));
|
|
10513
|
+
server.registerTool('manage_google_ads_budget_networks', {
|
|
10514
|
+
title: 'Google Ads waterfall priorities and network spend caps',
|
|
10515
|
+
description: "Google Ads WATERFALL AND NETWORK CAPS: which network a budget spends on first and how much the others may take. action 'read' (default), 'set' (settings) or 'clear', on the ACCOUNT DEFAULT or, with budgetId, on one campaign budget, which then wins. Every channel a campaign type supports must be in a tier and caps only go on tier2; Google says which if not. A budget's read says whether it can use these and why not. 'set' and 'clear' move real spend on the next auction: show the user, get a yes, then confirm:true; dryRun:true validates without changing anything.",
|
|
10516
|
+
inputSchema: {
|
|
10517
|
+
action: z.enum(['read', 'set', 'clear']).optional().describe("default 'read'"),
|
|
10518
|
+
customerId: z.string().optional().describe('10-digit account id (dashes ok); omit to use the brand’s selected default account'),
|
|
10519
|
+
budgetId: z.string().optional().describe('numeric campaign budget id; omit for the account default'),
|
|
10520
|
+
settings: z.array(z.object({
|
|
10521
|
+
channelType: z.string().describe('campaign type: SEARCH, DISPLAY, SHOPPING, VIDEO, PERFORMANCE_MAX, DEMAND_GEN and so on'),
|
|
10522
|
+
tier1: z.array(z.enum(['OWNED_AND_OPERATED', 'SEARCH_PARTNER', 'GOOGLE_DISPLAY'])).optional().describe('channels that spend first'),
|
|
10523
|
+
tier2: z.array(z.enum(['OWNED_AND_OPERATED', 'SEARCH_PARTNER', 'GOOGLE_DISPLAY'])).optional().describe('channels that get what is left'),
|
|
10524
|
+
caps: z.array(z.object({ channel: z.string().describe('a channel in tier2'), percent: z.number().describe('most of the budget it may spend, 0-100') })).optional().describe('spend caps on tier2 channels'),
|
|
10525
|
+
})).optional().describe('for set: one entry per campaign type'),
|
|
10526
|
+
confirm: z.boolean().optional().describe('REQUIRED true for set and clear, after the user approved'),
|
|
10527
|
+
dryRun: z.boolean().optional().describe('validate with Google, write nothing'),
|
|
10528
|
+
loginCustomerId: z.string().optional().describe('manager id if operating through an MCC'),
|
|
10529
|
+
},
|
|
10530
|
+
outputSchema: { ok: z.boolean().optional(), changed: z.boolean().optional(), eligible: z.boolean().nullable().optional(), settings: z.array(z.any()).optional(), note: z.string().optional() },
|
|
10531
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
10532
|
+
}, wrap(async (a = {}) => {
|
|
10533
|
+
const d = await apiPost('/api/google-ads/budget-networks', a);
|
|
10534
|
+
return ok(d.note, d);
|
|
10535
|
+
}));
|
|
10536
|
+
server.registerTool('google_ads_local_services_eligibility', {
|
|
10537
|
+
title: 'Check Google Local Services eligibility',
|
|
10538
|
+
description: 'Ask Google whether a Business Profile listing can run LOCAL SERVICES ads on this Google Ads account: eligible or not, why not, and which lead types and categories it qualifies for. listingId is the numeric id at the end of a location name from list_business_locations. Read-only.',
|
|
10539
|
+
inputSchema: {
|
|
10540
|
+
listingId: z.string().describe('numeric Business Profile location id'),
|
|
10541
|
+
customerId: z.string().optional().describe('10-digit account id (dashes ok); omit to use the brand’s selected default account'),
|
|
10542
|
+
loginCustomerId: z.string().optional().describe('manager id if operating through an MCC'),
|
|
10543
|
+
},
|
|
10544
|
+
outputSchema: { ok: z.boolean().optional(), eligible: z.boolean().optional(), reasons: z.array(z.any()).optional(), goals: z.array(z.any()).optional(), note: z.string().optional() },
|
|
10545
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
10546
|
+
}, wrap(async (a) => {
|
|
10547
|
+
const d = await apiPost('/api/google-ads/local-services-eligibility', a);
|
|
10548
|
+
return ok(d.note, d);
|
|
10549
|
+
}));
|
|
10438
10550
|
server.registerTool('set_google_ads_networks', {
|
|
10439
10551
|
title: 'Change where a Google Ads campaign serves (Search partners / Display)',
|
|
10440
10552
|
description: 'Change WHERE an existing Google Ads campaign serves: Google Search, Search partners (target_search_network) and the Display Network (target_content_network), each true/false. The classic use is turning Search partners OFF on a Search campaign, or Display off. Current settings are read first and a no-op says so. On a LIVE (ENABLED) campaign this moves real spend on the next auction — show the user the before -> after, get an explicit yes, then call again with confirm:true. dryRun:true validates with Google and writes nothing. The result is READ BACK from Google before you are told it took; Google’s own rules (a Display campaign cannot take Google Search on, a Search campaign keeps Google Search on) are relayed by name.',
|
|
@@ -14668,7 +14780,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14668
14780
|
name: z.string().optional().describe('new campaign name'),
|
|
14669
14781
|
dailyBudget: z.number().optional().describe("in the ad account's currency"),
|
|
14670
14782
|
totalBudget: z.number().optional().describe('new lifetime budget in ad account currency'),
|
|
14671
|
-
standardDelivery: z.boolean().optional().describe('false = accelerated
|
|
14783
|
+
standardDelivery: z.boolean().optional().describe('false = accelerated. Works on a campaign-budget campaign; with line item budgets set it on the line item'),
|
|
14672
14784
|
purchaseOrderNumber: z.string().optional().describe('purchase order number recorded on the campaign'),
|
|
14673
14785
|
},
|
|
14674
14786
|
outputSchema: { accountId: z.string().optional(), campaignId: z.string().optional(), updated: z.array(z.string()).optional(), changed: z.number().optional(), status: z.string().nullable().optional(), note: z.string().optional() },
|
|
@@ -14686,6 +14798,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14686
14798
|
startTime: z.string().optional().describe('ISO 8601'), endTime: z.string().optional().describe('ISO 8601 end time for this line item'),
|
|
14687
14799
|
dailyBudget: z.number().optional().describe('only if the campaign is not budget-optimised'),
|
|
14688
14800
|
totalBudget: z.number().optional().describe('only if the campaign is not budget-optimised'),
|
|
14801
|
+
standardDelivery: z.boolean().optional().describe('false = accelerated, only with line item budgets; X refuses it under a campaign budget'),
|
|
14689
14802
|
},
|
|
14690
14803
|
outputSchema: { accountId: z.string().optional(), lineItemId: z.string().optional(), updated: z.array(z.string()).optional(), changed: z.number().optional(), status: z.string().nullable().optional(), note: z.string().optional() },
|
|
14691
14804
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
@@ -14775,6 +14888,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14775
14888
|
productType: z.enum(['MEDIA', 'PROMOTED_ACCOUNT', 'PROMOTED_TWEETS']).optional().describe('default PROMOTED_TWEETS \u2014 PROMOTED_ACCOUNT when objective is FOLLOWERS, and that line item serves the ACCOUNT attached with create_x_ads_promoted_account'),
|
|
14776
14889
|
placements: z.array(z.enum(['ALL_ON_TWITTER', 'PUBLISHER_NETWORK', 'TAP_BANNER', 'TAP_FULL', 'TAP_FULL_LANDSCAPE', 'TAP_MRECT', 'TAP_NATIVE', 'TWITTER_MEDIA_VIEWER', 'TWITTER_PROFILE', 'TWITTER_REPLIES', 'TWITTER_SEARCH', 'TWITTER_TIMELINE'])).optional().describe('default ALL_ON_TWITTER'),
|
|
14777
14890
|
goal: z.enum(['APP_CLICKS', 'APP_INSTALLS', 'APP_PURCHASES', 'ENGAGEMENT', 'FOLLOWERS', 'LINK_CLICKS', 'MAX_REACH', 'PREROLL', 'PREROLL_STARTS', 'REACH_WITH_ENGAGEMENT', 'SITE_VISITS', 'SOCIAL_ENGAGEMENT', 'VIDEO_VIEW', 'VIEW_15S', 'VIEW_3S_100PCT', 'VIEW_6S', 'WEBSITE_CONVERSIONS', 'WEBSITE_CONVERSIONS_V2']).optional().describe('optimization goal, e.g. LINK_CLICKS or WEBSITE_CONVERSIONS; these are goals, not objectives'),
|
|
14891
|
+
standardDelivery: z.boolean().optional().describe('false = accelerated, only with line item budgets; X refuses it under a campaign budget'),
|
|
14778
14892
|
bidStrategy: z.enum(['AUTO', 'GUARANTEED', 'MAX', 'TARGET']).optional().describe('AUTO lets X set the bid; MAX and TARGET need bidAmount'),
|
|
14779
14893
|
bidAmount: z.number().optional().describe("required for MAX/TARGET; in the ad account's currency"),
|
|
14780
14894
|
totalBudget: z.number().optional().describe('lifetime budget for this line item in ad account currency'),
|
|
@@ -16027,6 +16141,28 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16027
16141
|
const d = await apiPost('/api/reddit-ads/forecast', a);
|
|
16028
16142
|
return ok(d.note, d);
|
|
16029
16143
|
}));
|
|
16144
|
+
server.registerTool('reddit_ads_plan_forecast', {
|
|
16145
|
+
title: 'Forecast a Reddit campaign plan',
|
|
16146
|
+
description: 'Reddit Campaign Planner’s forecast for a proposed campaign: the audience, projected reach, impressions, clicks, frequency and CPM/CPC/CPV for this budget, and a curve of reach at ten budgets to compare. Creates nothing and spends nothing. Use reddit_ads_forecast for a quick audience size. Reddit allows 30 of these a minute. These are Reddit’s estimates, not a guarantee, and they reserve nothing; say so, and keep each result beside the inputs that made it.',
|
|
16147
|
+
inputSchema: {
|
|
16148
|
+
adAccountId: z.string().optional().describe('Reddit ad account id (a2_...); omit when only one is shared'),
|
|
16149
|
+
objective: z.enum(['APP_INSTALLS', 'BRAND_AWARENESS', 'CATALOG_SALES', 'CLICKS', 'CONVERSIONS', 'IMPRESSIONS', 'LEAD_GENERATION', 'SALES', 'VIDEO_VIEWABLE_IMPRESSIONS']).describe('campaign objective'),
|
|
16150
|
+
goalType: z.enum(['DAILY_SPEND', 'LIFETIME_SPEND']).describe('whether budget is per day or for the whole plan'),
|
|
16151
|
+
budget: z.number().describe('budget in the ad account’s currency (not micro-currency)'),
|
|
16152
|
+
startTime: z.string().optional().describe('ISO 8601; default now'),
|
|
16153
|
+
endTime: z.string().optional().describe('ISO 8601; default 30 days after the start, at most 365'),
|
|
16154
|
+
bidType: z.enum(['CPC', 'CPM', 'CPV', 'CPV6', 'CPV15']).optional().describe('CPC, CPM, CPV, CPV6 or CPV15'),
|
|
16155
|
+
bidStrategy: z.enum(['BIDLESS', 'MANUAL_BIDDING', 'MAXIMIZE_VOLUME', 'TARGET_CPX']).optional().describe('BIDLESS, MANUAL_BIDDING, MAXIMIZE_VOLUME or TARGET_CPX'),
|
|
16156
|
+
bidAmount: z.number().optional().describe('bid in the ad account currency'),
|
|
16157
|
+
useCatalog: z.boolean().optional().describe('SALES objective: true forecasts catalog sales'),
|
|
16158
|
+
targeting: z.record(z.any()).optional().describe('same shape as create_reddit_ads_ad_group targeting'),
|
|
16159
|
+
},
|
|
16160
|
+
outputSchema: { audience: z.any().optional(), projection: z.any().optional(), curve: z.array(z.any()).optional(), note: z.string().optional() },
|
|
16161
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
16162
|
+
}, wrap(async (a) => {
|
|
16163
|
+
const d = await apiPost('/api/reddit-ads/plan-forecast', a);
|
|
16164
|
+
return ok(d.note, d);
|
|
16165
|
+
}));
|
|
16030
16166
|
server.registerTool('reddit_ads_bid_suggestion', {
|
|
16031
16167
|
title: 'Ask Reddit what to bid',
|
|
16032
16168
|
description: 'Reddit’s own suggested bid for a given objective, bid type and targeting — a median, a sensible range, and the hard floor below which Reddit will not accept a bid. Amounts come back in the ad account’s currency. Free, creates nothing. Use it to pick bidAmount for create_reddit_ads_ad_group instead of guessing a number that either never wins an auction or overpays.',
|
|
@@ -25552,10 +25688,12 @@ function memoryNoteVerdict(text) {
|
|
|
25552
25688
|
const folTxt = Array.isArray(d.followers) ? d.followers.slice(0, 12).map(f => `• ${f.channel}${f.label ? ` ${f.label}` : ''}: ${f.last ? `${fmtN(f.last.followers)} on ${f.last.day}${f.change != null && f.first && f.first.day !== f.last.day ? ` (${f.change >= 0 ? '+' : ''}${fmtN(f.change)} since ${f.first.day})` : ''}` : 'unknown'}${f.lastWhy ? ` — latest read unknown: ${f.lastWhy.why}` : ''}`) : [];
|
|
25553
25689
|
const archTxt = d.archive?.used ? `\n\nIncludes ${d.archive.rows} older post(s) from the brand's archive.` : (d.archive?.unreadable ? `\n\n⚠ ${d.archive.why}` : '');
|
|
25554
25690
|
const overTime = `${boardTxt.length ? `\n\nBEST AND WORST POSTS (last ${d.days || 30} days):\n${boardTxt.join('\n')}` : ''}${trendTxt.length ? `\n\nOVER TIME (last ${d.trend.weeks.length} weeks, 7-day readings where they exist):\n${trendTxt.join('\n')}` : ''}${healthTxt.length ? `\n\nMEASUREMENT GAPS:\n${healthTxt.join('\n')}` : ''}${folTxt.length ? `\n\nFOLLOWERS (daily snapshot):\n${folTxt.join('\n')}` : (d.followers?.unreadable ? `\n\nFOLLOWERS: ${d.followers.why}` : '')}${archTxt}`;
|
|
25555
|
-
|
|
25691
|
+
// INSTAGRAM STORIES (2026-10-31): kept numbers for Stories posted by any route. Unknown is printed as unknown, never 0.
|
|
25692
|
+
const storyTxt = d.stories ? `\n\nINSTAGRAM STORIES (${d.stories.count ?? 0}, ${d.stories.withNumbers ?? 0} with numbers):\n${(d.stories.rows || []).slice(0, 10).map(x => `• ${x.account} ${x.postedAt || 'unknown'}${x.caption ? ` ${JSON.stringify(x.caption.slice(0, 60))}` : ''}: ${x.summary}`).join('\n')}${(d.stories.unreadable || []).length ? `\nCould not read some of them: ${d.stories.unreadable[0].error}` : ''}` : '';
|
|
25693
|
+
if (!gs.length) return ok(`Nothing to compare on "${d.axis}" yet. ${d.finding?.why || ''}`.trim() + overTime + storyTxt, d);
|
|
25556
25694
|
const rows = gs.map(g => `• "${g.key}" · ${g.channel} — ${g.meanRate == null ? (g.meanEngagement == null ? 'no measurable engagement' : `${g.meanEngagement.toFixed(1)} engagements (no reach denominator on this channel, so no rate)`) : `${(g.meanRate * 100).toFixed(2)}% engagement`} · ${g.n} post(s), ${g.nRated} measured${g.verdict === 'ready' ? '' : ` — ${g.suppressed}`}`);
|
|
25557
25695
|
const head = d.finding?.finding ? `FINDING: ${d.finding.finding}` : `NO FINDING YET: ${d.finding?.why || 'not enough measured posts'}`;
|
|
25558
|
-
return ok(`${head}\n\nBy ${d.axis}:\n${rows.join('\n')}${d.excludedUnattributed ? `\n\n${d.excludedUnattributed} post(s) were excluded from this axis because no hook was recorded for them — they still count toward channel and format totals.` : ''}${overTime}`, d);
|
|
25696
|
+
return ok(`${head}\n\nBy ${d.axis}:\n${rows.join('\n')}${d.excludedUnattributed ? `\n\n${d.excludedUnattributed} post(s) were excluded from this axis because no hook was recorded for them — they still count toward channel and format totals.` : ''}${overTime}${storyTxt}`, d);
|
|
25559
25697
|
}));
|
|
25560
25698
|
|
|
25561
25699
|
server.registerTool('diagnose_posts', {
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.487",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
|
-
"description": "AI marketing agent and AI ad generator, run from your own AI agent.
|
|
5
|
+
"description": "AI marketing agent and AI ad generator, run from your own AI agent. 1096 tools. No markup on models. Publishing, scheduling, ad campaign management, comments, DMs and analytics are free on every plan. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads, ChatGPT Ads and AppLovin Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky, Telegram and Google Business Profile. INSTAGRAM DM AUTOMATIONS: a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply from the brand. 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"
|