hermoso 0.1.500 → 0.1.503
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/mcp/openai-extensions.mjs +6 -3
- package/mcp/roster-scope.mjs +17 -1
- package/mcp/tools.mjs +41 -24
- package/package.json +1 -1
|
@@ -1281,11 +1281,12 @@ const PROVIDER_INFO = {
|
|
|
1281
1281
|
tiktok: ['TikTok', 'channels'], youtube: ['YouTube', 'channels'], linkedin: ['LinkedIn', 'channels'], x: ['X', 'channels'],
|
|
1282
1282
|
pinterest: ['Pinterest', 'channels'], bluesky: ['Bluesky', 'channels'], telegram: ['Telegram', 'channels'],
|
|
1283
1283
|
google_business: ['Google Business Profile', 'channels'], whatsapp: ['WhatsApp', 'channels'],
|
|
1284
|
+
wordpress: ['WordPress', 'channels'], discord: ['Discord', 'channels'], slack: ['Slack', 'channels'], whop: ['Whop', 'channels'],
|
|
1284
1285
|
google_ads: ['Google Ads', 'ads'], tiktok_ads: ['TikTok Ads', 'ads'], linkedin_ads: ['LinkedIn Ads', 'ads'], microsoft_ads: ['Microsoft Advertising', 'ads'],
|
|
1285
1286
|
reddit_ads: ['Reddit Ads', 'ads'], pinterest_ads: ['Pinterest Ads', 'ads'], snapchat_ads: ['Snapchat Ads', 'ads'], x_ads: ['X Ads', 'ads'],
|
|
1286
1287
|
openai_ads: ['ChatGPT Ads', 'ads'], apple_ads: ['Apple Ads', 'ads'], applovin_ads: ['AppLovin Ads', 'ads'],
|
|
1287
1288
|
google_drive: ['Google Drive, Sheets and Docs', 'data'], onedrive: ['OneDrive', 'data'], google_analytics: ['Google Analytics', 'data'],
|
|
1288
|
-
search_console: ['Google Search Console', 'data'], merchant_center: ['Google Merchant Center', 'data'],
|
|
1289
|
+
search_console: ['Google Search Console', 'data'], merchant_center: ['Google Merchant Center', 'data'],
|
|
1289
1290
|
shopify: ['Shopify', 'data'], stripe: ['Stripe', 'data'], hubspot: ['HubSpot', 'data'], notion: ['Notion', 'data'], canva: ['Canva', 'data'],
|
|
1290
1291
|
};
|
|
1291
1292
|
export const providerInfo = (p) => {
|
|
@@ -1295,8 +1296,10 @@ export const providerInfo = (p) => {
|
|
|
1295
1296
|
return { name, group: /_ads$/.test(String(p)) ? 'ads' : 'data' };
|
|
1296
1297
|
};
|
|
1297
1298
|
// The publishing channels a connector carries, for the "post here by default" settings. The ids are schedule_post's.
|
|
1298
|
-
|
|
1299
|
-
|
|
1299
|
+
// WordPress, Discord, Slack and Whop publish through their own connector rows (lib/channel-registry.mjs). A gated
|
|
1300
|
+
// connector (Whop while WHOP_API_KEY is unset) is not in the server's connector list, so it never surfaces here.
|
|
1301
|
+
const CHANNELS_OF = { meta: ['facebook', 'instagram'], instagram: ['instagram'], threads: ['threads'], tiktok: ['tiktok'], youtube: ['youtube'], linkedin: ['linkedin'], x: ['x'], pinterest: ['pinterest'], google_business: ['google_business'], bluesky: ['bluesky'], telegram: ['telegram'], wordpress: ['wordpress'], discord: ['discord'], slack: ['slack'], whop: ['whop'] };
|
|
1302
|
+
const CHANNEL_LABEL = { facebook: 'Facebook', instagram: 'Instagram', threads: 'Threads', tiktok: 'TikTok', youtube: 'YouTube', linkedin: 'LinkedIn', x: 'X', pinterest: 'Pinterest', google_business: 'Google Business Profile', bluesky: 'Bluesky', telegram: 'Telegram', wordpress: 'WordPress', discord: 'Discord', slack: 'Slack', whop: 'Whop' };
|
|
1300
1303
|
export const ALL_CHANNELS = Object.freeze(Object.keys(CHANNEL_LABEL));
|
|
1301
1304
|
|
|
1302
1305
|
// THE HOME SCREEN'S CARD FOR AN ACCOUNT WITH NOTHING TO POST TO (2026-11-02): the first channels on offer, in the server's
|
package/mcp/roster-scope.mjs
CHANGED
|
@@ -243,7 +243,23 @@ export const SHOPIFY_STORE_NOTE = 'works only for a Shopify store that installed
|
|
|
243
243
|
// (server.js reangleOffered(), env REANGLE_VIDEO=on). FAIL CLOSED: no read, a failed read or an older server all keep
|
|
244
244
|
// it hidden, so the docs build, a CLI with no session and an unreadable store never offer a capability that is not
|
|
245
245
|
// ready. The route and its job types refuse it for free as well, so a stale roster cannot spend.
|
|
246
|
-
|
|
246
|
+
// THE WHOP TOOLS SHIP DARK WITH THE WHOP CONNECTOR (2026-11-03, owner: "nothing public until it works"). Whop has no
|
|
247
|
+
// app key in production and the App Store listing is not approved, so CONNECTOR_GATES.whop is shut, and a tool for a
|
|
248
|
+
// connector nobody can link is not a capability. They lift with the gate itself: server.js flagsOnNow() names them in
|
|
249
|
+
// `flagsOn` exactly when CONNECTOR_GATES.whop.open() (WHOP_API_KEY present), so putting the key in production turns
|
|
250
|
+
// them on everywhere with no edit here. Until then the docs, the counts and every roster leave them out.
|
|
251
|
+
export const WHOP_DARK_TOOLS = Object.freeze(['post_to_whop', 'list_whop_destinations', 'list_whop_products', 'edit_whop_post', 'delete_whop_post', 'get_whop_post']);
|
|
252
|
+
export const FLAGGED_OFF_TOOLS = new Set(['reangle_video', ...WHOP_DARK_TOOLS]);
|
|
253
|
+
// THE PRODUCTION FLAG THAT LIFTS A DARK TOOL (2026-11-03, owner: count and document the Whop tools whenever their gate
|
|
254
|
+
// is open in prod, keep the automatic gate). A tool named here is dark only while that CONNECTOR_GATES flag is off in
|
|
255
|
+
// production, as recorded in docs-site/prod-gate-flags.json, the file the docs build already reads. The counts and the
|
|
256
|
+
// docs reference read it through darkToolsUnder(); the per-caller roster still follows `flagsOn` from the server.
|
|
257
|
+
// reangle_video has no entry: it follows its own REANGLE_VIDEO switch, which no production record carries.
|
|
258
|
+
export const DARK_TOOL_GATE_FLAG = Object.freeze(Object.fromEntries(WHOP_DARK_TOOLS.map((n) => [n, 'WHOP_API_KEY'])));
|
|
259
|
+
export function darkToolsUnder(flags) {
|
|
260
|
+
const f = flags && typeof flags === 'object' ? flags : {};
|
|
261
|
+
return new Set([...FLAGGED_OFF_TOOLS].filter((n) => !(DARK_TOOL_GATE_FLAG[n] && f[DARK_TOOL_GATE_FLAG[n]] === true)));
|
|
262
|
+
}
|
|
247
263
|
export function toolFlaggedOff(name, conn) { const n = String(name || ''); return FLAGGED_OFF_TOOLS.has(n) && !(conn && conn.flagsOn instanceof Set && conn.flagsOn.has(n)); }
|
|
248
264
|
export function toolWithheldByServer(name, conn) { return toolFlaggedOff(name, conn) || !!(conn && ((conn.withheld instanceof Set && conn.withheld.has(String(name || ''))) || toolHeldForNoShopifyStore(name, conn))); }
|
|
249
265
|
export function toolHeldBackByConnectors(name, conn) {
|
package/mcp/tools.mjs
CHANGED
|
@@ -3207,7 +3207,7 @@ export const WIDGET_HOST_DESCRIPTIONS = Object.freeze({
|
|
|
3207
3207
|
list_linkedin_events: "The events a LinkedIn company Page organizes: name, link, start and end times, online or in person, visibility and state. Only Pages shared with this profile can be read. It needs LinkedIn's Events permission; if a connection made before that permission was enabled is refused, the user reconnects LinkedIn once. Read-only, 0 credits. Needs LinkedIn connected.",
|
|
3208
3208
|
edit_timeline: "Render one edited video from a timeline the caller describes: segments placed on an output timeline (later segments are drawn on top, and overlapping segments form the transition), each with keyframed position, scale, rotation, speed and opacity, plus optional HTML overlays and masks. Sources are a videoUrl, a Library or render URL, a direct file, or a public TikTok, Reel, Facebook, X or YouTube post link; in and out are source seconds. Audio per segment: gain, mute, and J-cut or L-cut timing. video_frames shows exact frames for free, to find a cut point and to check the result. Costs the flat post_edit price (about 2 credits); a 'generate' segment, which renders a new transition shot with a video model, costs more and is used only when the user asks for one.",
|
|
3209
3209
|
list_openai_ads_feeds: "The product feeds on this ChatGPT Ads account: id, name, countries, currencies and the campaigns using each. A product-feed campaign advertises the products in one of these feeds. Read-only, 0 credits. Needs ChatGPT Ads connected.",
|
|
3210
|
-
create_openai_ads_feed: "Create an empty
|
|
3210
|
+
create_openai_ads_feed: "Create an empty feed on ChatGPT Ads, with a name and the countries it serves (ISO codes). By default it is a product feed, and products are added afterwards with update_openai_ads_feed_products. inventoryType 'hotel' asks for a hotel property feed instead, which OpenAI accepts only for advertisers in its hotel beta and refuses for everyone else. Needs ChatGPT Ads connected.",
|
|
3211
3211
|
update_reddit_ads_ad_group: "Edit an existing Reddit ad group: its name, budget, goal type, bid, schedule dates or targeting. Budget and bid are amounts in the ad account's currency. Targeting is updated per key: each targeting key sent replaces that whole list, keys left out stay as they are, and an empty list clears a key. It does not start or pause delivery (set_reddit_ads_status does). The saved ad group is read back from Reddit. Needs Reddit Ads connected.",
|
|
3212
3212
|
update_reddit_ads_saved_audience: "Edit a Reddit saved audience: its name or its targeting. Targeting is updated per key: each targeting key sent replaces that whole list, keys left out stay as they are, and an empty list clears a key. Changing the targeting of an audience that live ad groups use changes who those ad groups reach right away, so tell the user how many ad groups use it and get a yes first. The saved audience is read back from Reddit. Needs Reddit Ads connected.",
|
|
3213
3213
|
});
|
|
@@ -5099,14 +5099,16 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5099
5099
|
|
|
5100
5100
|
server.registerTool('get_whop_post', {
|
|
5101
5101
|
title: 'Read a Whop post',
|
|
5102
|
-
description: "Read back a Whop forum post or chat message Hermoso posted: its text
|
|
5103
|
-
inputSchema: { ...MANAGE_BRAND, id: z.string(), kind: z.enum(['forum', 'chat']) },
|
|
5104
|
-
outputSchema: { id: z.string().optional(), kind: z.string().optional(), title: z.string().nullable().optional(), content: z.string().optional(), metrics: z.record(z.number()).optional(), absent: z.record(z.string()).optional(), note: z.string().optional() },
|
|
5102
|
+
description: "Read back a Whop forum post or chat message Hermoso posted: its text, the numbers Whop reports (views, likes and comments on a forum post; views and reactions on a chat message), and whether it is still up — state live, deleted or unconfirmed. Whop's read also returns a DELETED chat message, so pass the chat's experienceId (from list_whop_destinations) to confirm a message is still in the chat; one deleted through Hermoso always reads deleted. A count Whop does not report is listed as unavailable, never zero. Free.",
|
|
5103
|
+
inputSchema: { ...MANAGE_BRAND, id: z.string(), kind: z.enum(['forum', 'chat']), experienceId: z.string().optional().describe('the chat it was posted in: confirms a chat message is still up') },
|
|
5104
|
+
outputSchema: { id: z.string().optional(), kind: z.string().optional(), title: z.string().nullable().optional(), content: z.string().optional(), deleted: z.boolean().optional(), state: z.string().optional(), deletedAt: z.string().optional(), metrics: z.record(z.number()).optional(), absent: z.record(z.string()).optional(), note: z.string().optional() },
|
|
5105
5105
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
5106
5106
|
}, wrap(async (a) => {
|
|
5107
|
-
const d = await apiGet('/api/whop/post', queryBrand(a, { id: a.id, kind: a.kind }));
|
|
5107
|
+
const d = await apiGet('/api/whop/post', queryBrand(a, { id: a.id, kind: a.kind, ...(a.experienceId ? { experienceId: a.experienceId } : {}) }));
|
|
5108
|
+
const what = d.kind === 'chat' ? 'chat message' : 'forum post';
|
|
5109
|
+
if (d.deleted) return ok(`Whop ${what} ${d.id}: DELETED — ${d.note || 'no longer up'}`, d);
|
|
5108
5110
|
const m = d.metrics || {};
|
|
5109
|
-
return ok(`Whop ${d.
|
|
5111
|
+
return ok(`Whop ${what} ${d.id}${d.state === 'unconfirmed' ? ' (still up: unconfirmed — pass experienceId to check the chat)' : ''}: ${Object.keys(m).length ? Object.entries(m).map(([k, v]) => `${v} ${k}`).join(', ') : 'no counts'}.`, d);
|
|
5110
5112
|
}));
|
|
5111
5113
|
|
|
5112
5114
|
server.registerTool('list_inbox', {
|
|
@@ -6348,9 +6350,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6348
6350
|
// durable public URL to feed post_to_meta / upload_meta_asset / create_meta_ad. Makes the publishing tools work on the
|
|
6349
6351
|
// user's OWN files, not just generated ones.
|
|
6350
6352
|
const EXT_MIME = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm', m4v: 'video/mp4' };
|
|
6353
|
+
// WHERE THE UPLOAD WAS FILED (2026-11-03). The server keeps every uploaded image, video and PDF in the brand's Library
|
|
6354
|
+
// and answers `library: {saved, id}`; the sentence says so, so the user hears they will not need to upload it again.
|
|
6355
|
+
const uploadLibraryNote = (d) => (d && d.library && d.library.saved ? ` It is also kept in this brand's Library as ${d.library.id || 'its newest item'} (list_library), so it can be reused later without uploading it again.` : '');
|
|
6351
6356
|
server.registerTool('upload_file', {
|
|
6352
6357
|
title: 'Upload a local file -> durable public URL',
|
|
6353
|
-
description: 'Persist an ARBITRARY user file (image, video, audio, PDF or document, up to 150MB) into Hermoso and get back a durable public URL that EVERY publish, schedule and ad-build tool accepts — post_to_meta / post_to_linkedin / post_to_linkedin_page / post_to_youtube / post_to_tiktok / post_to_pinterest / post_to_x / post_to_google_business / schedule_post / upload_meta_asset / upload_google_ads_asset / create_meta_ad / create_linkedin_ads_creative / set_youtube_thumbnail / save_to_drive / save_to_onedrive. THIS IS THE BRING-YOUR-OWN-CREATIVE PATH: it is for files that have NOTHING to do with a Hermoso render (media on the user\'s desktop, an agency\'s finished ad, a photo they shot), and it means you can publish, schedule and run ads through Hermoso without generating anything here. Provide exactly ONE source — passing two is an error, never a silent preference: `url` (ANY public http(s) link — Hermoso fetches it server-side, so nothing crosses this connection and there is no practical size limit; THIS IS THE ONE THAT ALWAYS WORKS, including on the hosted connector), ' + (opts.hosted ? '' : '`path` (a local file, ONLY when Hermoso runs on the user\'s own machine over stdio/CLI; the hosted connector cannot see their disk), or ') + '`dataUri` (a base64 data: URI — keep it under ~15MB, since the bytes travel over this connection). If the file is already at a public https URL, the Meta, Reddit and ChatGPT-Ads tools take it directly and re-host it safely — but LinkedIn (posts and ad creatives), Pinterest, the YouTube thumbnail and upload_google_ads_asset upload the BYTES themselves and therefore refuse an external host, so run it through here first and pass the URL this returns. When in doubt, use this: its URL works everywhere. (Calling the underlying HTTP route directly? POST /api/upload takes the file\'s RAW BYTES as the request body with its own content-type — NOT multipart/form-data — or ?url=<link> with no body.) Returns {url, kind, bytes}.',
|
|
6358
|
+
description: 'Persist an ARBITRARY user file (image, video, audio, PDF or document, up to 150MB) into Hermoso and get back a durable public URL that EVERY publish, schedule and ad-build tool accepts — post_to_meta / post_to_linkedin / post_to_linkedin_page / post_to_youtube / post_to_tiktok / post_to_pinterest / post_to_x / post_to_google_business / schedule_post / upload_meta_asset / upload_google_ads_asset / create_meta_ad / create_linkedin_ads_creative / set_youtube_thumbnail / save_to_drive / save_to_onedrive. Kept in the Library. THIS IS THE BRING-YOUR-OWN-CREATIVE PATH: it is for files that have NOTHING to do with a Hermoso render (media on the user\'s desktop, an agency\'s finished ad, a photo they shot), and it means you can publish, schedule and run ads through Hermoso without generating anything here. Provide exactly ONE source — passing two is an error, never a silent preference: `url` (ANY public http(s) link — Hermoso fetches it server-side, so nothing crosses this connection and there is no practical size limit; THIS IS THE ONE THAT ALWAYS WORKS, including on the hosted connector), ' + (opts.hosted ? '' : '`path` (a local file, ONLY when Hermoso runs on the user\'s own machine over stdio/CLI; the hosted connector cannot see their disk), or ') + '`dataUri` (a base64 data: URI — keep it under ~15MB, since the bytes travel over this connection). If the file is already at a public https URL, the Meta, Reddit and ChatGPT-Ads tools take it directly and re-host it safely — but LinkedIn (posts and ad creatives), Pinterest, the YouTube thumbnail and upload_google_ads_asset upload the BYTES themselves and therefore refuse an external host, so run it through here first and pass the URL this returns. When in doubt, use this: its URL works everywhere. (Calling the underlying HTTP route directly? POST /api/upload takes the file\'s RAW BYTES as the request body with its own content-type — NOT multipart/form-data — or ?url=<link> with no body.) Returns {url, kind, bytes}.',
|
|
6354
6359
|
inputSchema: {
|
|
6355
6360
|
// THE THIRD SOURCE, AND THE ONLY ONE THAT CARRIES BYTES WITHOUT SPENDING THEM AS TOKENS (2026-09-16). Asked
|
|
6356
6361
|
// for by the first real user of the connector: `path` cannot work on a hosted surface and a data: URI puts
|
|
@@ -6389,7 +6394,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6389
6394
|
// The SERVER fetches it, so the bytes never cross this transport and the SSRF guard runs on our side, on
|
|
6390
6395
|
// EVERY redirect hop. This is what makes bring-your-own-creative work on the hosted connector at all.
|
|
6391
6396
|
const d = await apiUploadUrl('/api/upload', String(a.url).trim(), { fileName: fileName0 });
|
|
6392
|
-
return ok(`Ingested ${d.kind || 'file'} (${d.bytes} bytes) from that URL → ${d.url}. Pass this url to any publish, schedule or ad-build tool
|
|
6397
|
+
return ok(`Ingested ${d.kind || 'file'} (${d.bytes} bytes) from that URL → ${d.url}. Pass this url to any publish, schedule or ad-build tool.${uploadLibraryNote(d)}`, { url: d.url, kind: d.kind, bytes: d.bytes, ...(d.library?.saved && d.library.id ? { libraryId: d.library.id } : {}) });
|
|
6393
6398
|
}
|
|
6394
6399
|
let buf, contentType = 'application/octet-stream', fileName = fileName0;
|
|
6395
6400
|
if (a.dataUri) {
|
|
@@ -6416,7 +6421,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6416
6421
|
contentType = EXT_MIME[(fileName.split('.').pop() || '').toLowerCase()] || 'application/octet-stream';
|
|
6417
6422
|
}
|
|
6418
6423
|
const d = await apiUpload('/api/upload', buf, { contentType, fileName });
|
|
6419
|
-
return ok(`Uploaded ${d.kind || 'file'} (${d.bytes || buf.length} bytes) → ${d.url}. Pass this url to post_to_meta / upload_meta_asset / create_meta_ad
|
|
6424
|
+
return ok(`Uploaded ${d.kind || 'file'} (${d.bytes || buf.length} bytes) → ${d.url}. Pass this url to post_to_meta / upload_meta_asset / create_meta_ad.${uploadLibraryNote(d)}`, { url: d.url, kind: d.kind, bytes: d.bytes, ...(d.library?.saved && d.library.id ? { libraryId: d.library.id } : {}) });
|
|
6420
6425
|
}));
|
|
6421
6426
|
|
|
6422
6427
|
server.group('channel_admin');
|
|
@@ -6734,7 +6739,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6734
6739
|
// no need for one edge case just for Facebook."
|
|
6735
6740
|
server.registerTool('schedule_post', {
|
|
6736
6741
|
title: 'Schedule a post for later',
|
|
6737
|
-
description: 'Use this when the user wants to schedule a social media post for later, on one channel or several at once. Scheduling and publishing cost no credits on any Hermoso plan (X alone bills per call: hermoso_credits gives the exact credits per X post). THE FREE PLAN INCLUDES 10 POSTS A MONTH ON UP TO 3 CHANNELS (a post counts once, however many channels it goes to; counted in the UTC month the post goes out); every paid plan posts without a limit. A post over that allowance is refused here and nothing is queued; the refusal says how many are used and when the count resets. Relay it as written, never retry it and never split the post to fit. billing_status and list_scheduled show how many are used. Queue a post for a future time on one or more connected channels at once: facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, bluesky, telegram, wordpress, discord, slack, whop
|
|
6742
|
+
description: 'Use this when the user wants to schedule a social media post for later, on one channel or several at once. Scheduling and publishing cost no credits on any Hermoso plan (X alone bills per call: hermoso_credits gives the exact credits per X post). THE FREE PLAN INCLUDES 10 POSTS A MONTH ON UP TO 3 CHANNELS (a post counts once, however many channels it goes to; counted in the UTC month the post goes out); every paid plan posts without a limit. A post over that allowance is refused here and nothing is queued; the refusal says how many are used and when the count resets. Relay it as written, never retry it and never split the post to fit. billing_status and list_scheduled show how many are used. Queue a post for a future time on one or more connected channels at once: facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, bluesky, telegram, wordpress, discord, slack — every one live. whop is held back until its connector is available, so a whop post is refused, not queued. A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order — never schedule just its first slide. Name the time in `at`, or pass `useQueue:true` for the brand’s next free POSTING SLOT (what “just queue it” means). imageUrl/videoUrl take a Hermoso render URL or an upload_file URL. `captions` gives a channel its own wording; the rest use `message`. PINTEREST AND YOUTUBE SHOW A TITLE: `title` (max 100 chars), derived from the caption when omitted; YOUTUBE also takes `description`, `tags`, `thumbnailUrl`. Every PER-CHANNEL SETTING is a parameter below, carried straight to the real publisher — TikTok’s `brandedContent` / `yourBrand` disclosures (set them whenever the post is commercial), Google Business `topicType` / `event` / `offer` / `actionType`, an X `thread` / `poll`, Instagram `collaborators`, and the rest. Channels are attempted INDEPENDENTLY: one failing never blocks the others. SOME CHANNELS MUST BE TOLD WHICH ACCOUNT, and Hermoso never guesses: Pinterest needs `boardId` (list_pinterest_boards) or is refused; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages) or it goes to the person’s own profile; several Facebook Pages need `pageId` (list_meta_pages), several Google Business listings `locationId` (list_business_locations) — resolve those FIRST and let the user pick, or the post is refused when it fires. A scheduled post GOES LIVE PUBLICLY by default on every channel, never quietly downgraded. Only if the user asks, set `visibility` (or `visibilityByChannel`): ‘unlisted’ (YouTube) · ‘private’ (YouTube, or TikTok SELF_ONLY) · ‘draft’ (TikTok, or an unpublished Facebook Page post). A visibility a channel cannot do is REFUSED now, never posted weaker later.',
|
|
6738
6743
|
inputSchema: {
|
|
6739
6744
|
...HOOK_ATTR,
|
|
6740
6745
|
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram', 'wordpress', 'discord', 'slack', 'whop'])).describe('one or more channels to post to at that time'),
|
|
@@ -6864,6 +6869,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6864
6869
|
whopPinned: z.boolean().optional().describe('WHOP FORUM POSTS ONLY — pin it to the top of the forum.'),
|
|
6865
6870
|
whopMention: z.boolean().optional().describe('WHOP FORUM POSTS ONLY — notify every member (off by default).'),
|
|
6866
6871
|
whopVisibility: z.enum(['members_only', 'globally_visible']).optional().describe('WHOP FORUM POSTS ONLY — who can see it; unset is the forum’s own default.'),
|
|
6872
|
+
whopPaywallCents: z.number().int().min(0).optional().describe('WHOP FORUM POSTS ONLY — a paywall in cents (500 = $5.00): readers pay to see the body; needs a title.'),
|
|
6873
|
+
whopPaywallCurrency: z.string().optional().describe('WHOP — the paywall currency, a 3-letter code (default usd).'),
|
|
6874
|
+
whopPoll: z.array(z.string()).optional().describe('WHOP FORUM POSTS ONLY — a poll: 2 to 10 options, in order.'),
|
|
6867
6875
|
buttons: z.array(z.union([z.object({ text: z.string(), url: z.string(), style: z.enum(['danger', 'success', 'primary']).optional() }), z.array(z.object({ text: z.string(), url: z.string(), style: z.enum(['danger', 'success', 'primary']).optional() }))])).optional().describe('TELEGRAM ONLY — URL buttons under the post, as post_to_telegram takes them; never on an album.'),
|
|
6868
6876
|
parseMode: z.enum(['HTML']).optional().describe("TELEGRAM ONLY — 'HTML' for Telegram's HTML tags; omit for plain text."),
|
|
6869
6877
|
silent: z.boolean().optional().describe('TELEGRAM / DISCORD — no notification.'),
|
|
@@ -7075,6 +7083,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7075
7083
|
discordStartThread: z.string().optional().describe('DISCORD bot: open a thread under the post, this title'),
|
|
7076
7084
|
discordMentions: z.boolean().optional().describe('DISCORD: let mentions ping'),
|
|
7077
7085
|
threadName: z.string().optional().describe('DISCORD: forum thread title'),
|
|
7086
|
+
whopPinned: z.boolean().optional().describe('WHOP: pin the forum post'),
|
|
7087
|
+
whopMention: z.boolean().optional().describe('WHOP: notify every member'),
|
|
7088
|
+
whopVisibility: z.enum(['members_only', 'globally_visible']).optional().describe('WHOP: who can see the forum post'),
|
|
7089
|
+
whopPaywallCents: z.number().int().min(0).optional().describe('WHOP: forum-post paywall in cents (needs a title)'),
|
|
7090
|
+
whopPaywallCurrency: z.string().optional().describe('WHOP: paywall currency'),
|
|
7091
|
+
whopPoll: z.array(z.string()).optional().describe('WHOP: poll options, 2 to 10'),
|
|
7078
7092
|
wpCategories: z.array(z.string()).optional().describe('WORDPRESS — categories (names or ids)'),
|
|
7079
7093
|
wpTags: z.array(z.string()).optional().describe('WORDPRESS — tags (names or ids)'),
|
|
7080
7094
|
excerpt: z.string().optional().describe('WORDPRESS — the excerpt under the title'),
|
|
@@ -17368,7 +17382,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17368
17382
|
savedAudienceId: z.string().optional().describe('point this ad group at a saved audience instead'),
|
|
17369
17383
|
productSetId: z.string().optional().describe('CATALOG SALES — rebind the ad group to another product set (a UUID from Reddit Ads Manager > Catalogs). Reddit stores an edit without checking the set exists or that the campaign uses a catalog, so pass the right id; the note says if it was not kept'),
|
|
17370
17384
|
shoppingType: z.enum(['DYNAMIC', 'STATIC']).optional().describe('CATALOG SALES — DYNAMIC or STATIC product ads. Reddit has been measured keeping the value an ad group was created with, and the note says when it did not change'),
|
|
17371
|
-
targeting: z.record(z.any()).optional().describe('same shape as create_reddit_ads_ad_group
|
|
17385
|
+
targeting: z.record(z.any()).optional().describe('the targeting keys to change, in the same shape as create_reddit_ads_ad_group: each key sent replaces that whole list, keys left out are kept, and an empty list clears one'),
|
|
17372
17386
|
schedule: z.array(z.any()).optional().describe('weekly delivery windows: startDay, endDay, startHour, endHour (0 = Sunday)'),
|
|
17373
17387
|
},
|
|
17374
17388
|
outputSchema: { id: z.string().optional(), name: z.string().optional(), status: z.string().optional(), budget: z.number().nullable().optional(), productSetId: z.string().nullable().optional(), shoppingType: z.string().nullable().optional(), note: z.string().optional() },
|
|
@@ -17985,7 +17999,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17985
17999
|
adAccountId: z.string().optional().describe('Reddit ad account id (a2_...); omit when only one is shared'),
|
|
17986
18000
|
savedAudienceId: z.string().describe('id of the saved audience to edit'),
|
|
17987
18001
|
name: z.string().optional().describe('new saved audience name'),
|
|
17988
|
-
targeting: z.record(z.any()).optional().describe('
|
|
18002
|
+
targeting: z.record(z.any()).optional().describe('the targeting keys to change: each key sent replaces that whole list, keys left out are kept, and an empty list clears one'),
|
|
17989
18003
|
},
|
|
17990
18004
|
outputSchema: { id: z.string().optional(), name: z.string().optional(), status: z.string().optional(), activeAdGroups: z.number().nullable().optional(), note: z.string().optional() },
|
|
17991
18005
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
@@ -26000,9 +26014,10 @@ function memoryNoteVerdict(text) {
|
|
|
26000
26014
|
|
|
26001
26015
|
server.registerTool('list_library', {
|
|
26002
26016
|
title: 'List library',
|
|
26003
|
-
description: "Browse this workspace's Library — every image/video generated in the Studio, newest first (the same Library the web app shows). Returns served URLs you can open directly or hand to fetch_asset for a download link, plus each asset's id (L0, L1…, which plan_ad and render_ad `inserts` take), kind, model, and age. Free, read-only.",
|
|
26017
|
+
description: "Browse this workspace's Library — every image/video generated in the Studio, and uploads (source:'upload'), newest first (the same Library the web app shows). Returns served URLs you can open directly or hand to fetch_asset for a download link, plus each asset's id (L0, L1…, which plan_ad and render_ad `inserts` take), kind, model, and age. Free, read-only.",
|
|
26004
26018
|
inputSchema: {
|
|
26005
|
-
kind: z.enum(['image', 'video', 'all']).optional().describe("filter by asset kind (default 'all')"),
|
|
26019
|
+
kind: z.enum(['image', 'video', 'document', 'all']).optional().describe("filter by asset kind (default 'all')"),
|
|
26020
|
+
uploads: z.boolean().optional(),
|
|
26006
26021
|
limit: z.number().optional().describe('max assets to return (default 20, max 60)'),
|
|
26007
26022
|
},
|
|
26008
26023
|
outputSchema: { assets: z.array(z.object({ id: z.string().optional(), url: z.string(), kind: z.string().optional(), model: z.string().optional(), ageHours: z.number().optional() })).optional() },
|
|
@@ -26013,10 +26028,11 @@ function memoryNoteVerdict(text) {
|
|
|
26013
26028
|
const kind = a.kind && a.kind !== 'all' ? a.kind : null;
|
|
26014
26029
|
const lim = Math.min(60, Math.max(1, +a.limit || 20));
|
|
26015
26030
|
// id: L0, L1… over the whole Library newest first (a kind filter does not renumber), the id plan_ad / render_ad `inserts` take
|
|
26016
|
-
const
|
|
26017
|
-
|
|
26018
|
-
|
|
26019
|
-
|
|
26031
|
+
const up = a.uploads === true;
|
|
26032
|
+
const assets = list.filter(x => x && x.url).map((x, i) => ({ x, id: 'L' + i })).filter(({ x }) => (!kind || x.kind === kind) && (!up || x.source === 'upload')).slice(0, lim)
|
|
26033
|
+
.map(({ x, id }) => ({ id, url: abs(x.url), kind: x.kind || '', model: x.model || '', ...(x.source === 'upload' ? { source: 'upload', ...(x.name ? { name: String(x.name).slice(0, 120) } : {}) } : {}), ageHours: x.at ? Math.round((Date.now() - x.at) / 36e5) : undefined }));
|
|
26034
|
+
if (!assets.length) return ok(up ? 'Nothing uploaded to this workspace\'s Library yet — upload_file keeps an image, video or PDF there.' : 'The Library is empty for this workspace — render something or upload_file a file first.', { assets: [] });
|
|
26035
|
+
return ok(`${assets.length} asset${assets.length === 1 ? '' : 's'} (newest first):\n` + assets.map((x) => ` ${x.id}. [${x.kind || '?'}${x.source === 'upload' ? ' · your upload' + (x.name ? ' “' + x.name + '”' : '') : x.model ? ' · ' + x.model : ''}${x.ageHours != null ? ' · ' + x.ageHours + 'h ago' : ''}] ${x.url}`).join('\n'), { assets });
|
|
26020
26036
|
}));
|
|
26021
26037
|
|
|
26022
26038
|
// DELETE FROM THE LIBRARY (2026-10-23). A customer was told it could not be done and sent to support. The server
|
|
@@ -26785,10 +26801,11 @@ function memoryNoteVerdict(text) {
|
|
|
26785
26801
|
}));
|
|
26786
26802
|
|
|
26787
26803
|
// RE-ANGLE A VIDEO (2026-11-03): POST /api/video/reangle, the same engine as the Studio's card. Two steps, both priced
|
|
26788
|
-
// before anything is spent: the takes at 720p (beats × angles × takes),
|
|
26804
|
+
// before anything is spent: the takes at 720p (beats × angles × takes), each anchored by a still of the new camera, then
|
|
26805
|
+
// the picked take of each beat delivered as itself at 1080p (upscaled, never re-rendered).
|
|
26789
26806
|
server.registerTool('reangle_video', {
|
|
26790
26807
|
title: 'Re-shoot a video from new camera angles',
|
|
26791
|
-
description: "RE-SHOOT an existing video from a NEW CAMERA ANGLE while keeping who and what is in it, the set, the action, the timing and the colour grade: top shot, worms_eye, three_quarter_left / three_quarter_right, left_profile / right_profile, over_the_shoulder, back, macro, wide, or ANY angle in your own words. The clip is split at its hard cuts and every beat is re-shot on its own, from that beat's footage. STEP 1 (video + angles + takes 1-4, default 2): renders every beat × angle × take at 720p, so you or the user can PICK the best take of each beat by looking at it (get_job, then video_frames):
|
|
26808
|
+
description: "RE-SHOOT an existing video from a NEW CAMERA ANGLE while keeping who and what is in it, the set, the action, the timing and the colour grade: top shot, worms_eye, three_quarter_left / three_quarter_right, left_profile / right_profile, over_the_shoulder, back, macro, wide, or ANY angle in your own words. The clip is split at its hard cuts and every beat is re-shot on its own, from that beat's footage; for a named shot each take first draws a still of the beat's first frame from the new camera and opens on it. STEP 1 (video + angles + takes 1-4, default 2): renders every beat × angle × take at 720p, so you or the user can PICK the best take of each beat by looking at it (get_job, then video_frames): a take can still drift back toward the source's camera, so check every take. STEP 2 (winners = the job id of the picked take for each beat): delivers each pick AS ITSELF at 1080p (upscaled, not re-rendered, so the angle is the one picked) and joins them into one video with the source's own sound. dryRun:true on either step returns the plan and the exact credits of both steps, nothing rendered or charged: ALWAYS dryRun first and tell the user beats × angles × takes and both prices. A clip showing a real person's face is usually refused by the video model for free, with choices. Results land in the Library.",
|
|
26792
26809
|
inputSchema: {
|
|
26793
26810
|
video: z.string().optional().describe('the clip to re-shoot (step 1): a render or Library URL, an upload_file URL or a direct video link, up to 60 s'),
|
|
26794
26811
|
angles: z.array(z.string()).optional().describe("step 1: 1-4 camera angles — top, worms_eye, three_quarter_left, three_quarter_right, left_profile, right_profile, over_the_shoulder, back, macro, wide — or any angle in your own words"),
|
|
@@ -26811,8 +26828,8 @@ function memoryNoteVerdict(text) {
|
|
|
26811
26828
|
takes: z.number().optional(),
|
|
26812
26829
|
renders: z.number().optional().describe('how many takes step 1 renders'),
|
|
26813
26830
|
takesCredits: z.number().optional().describe('the exact credits of step 1'),
|
|
26814
|
-
finalCredits: z.number().nullable().optional().describe('the credits of step 2 (the picks
|
|
26815
|
-
totalCredits: z.number().optional().describe('step 2: the exact credits of the 1080p
|
|
26831
|
+
finalCredits: z.number().nullable().optional().describe('the credits of step 2 (the picks upscaled to 1080p); its own dryRun prices the real takes exactly'),
|
|
26832
|
+
totalCredits: z.number().optional().describe('step 2: the exact credits of the 1080p finish'),
|
|
26816
26833
|
jobs: z.array(z.any()).optional().describe('step 1: one queued job per take: {id, beat, angle, take, credits}'),
|
|
26817
26834
|
job: z.any().optional().describe('step 2: the queued 1080p job {id, credits}'),
|
|
26818
26835
|
winners: z.array(z.any()).optional(),
|
|
@@ -26826,8 +26843,8 @@ function memoryNoteVerdict(text) {
|
|
|
26826
26843
|
const nl = notes.length ? '\n' + notes.join('\n') : '';
|
|
26827
26844
|
if (d?.phase === 'final') {
|
|
26828
26845
|
const w = (d.winners || []).map((x) => `beat ${x.beat}: ${x.angle} take ${x.take} (job ${x.job})`).join('; ');
|
|
26829
|
-
if (d.dryRun) return { content: [{ type: 'text', text: `RE-ANGLE 1080p
|
|
26830
|
-
return { content: [{ type: 'text', text: `
|
|
26846
|
+
if (d.dryRun) return { content: [{ type: 'text', text: `RE-ANGLE 1080p FINISH (nothing rendered, nothing charged): ${d.beats} beat${d.beats === 1 ? '' : 's'}, ${w}.\nPrice: ${d.totalCredits} credits. ${PRICE_ESTIMATE_LINE}${nl}\nTo run it, call reangle_video again with the same winners and no dryRun.` }], structuredContent: { reangleId: d.reangleId, phase: 'final', winners: d.winners, totalCredits: d.totalCredits, dryRun: true, ...(notes.length ? { notes } : {}) } };
|
|
26847
|
+
return { content: [{ type: 'text', text: `Delivering the picks at ${d.resolution} (upscaled, not re-rendered) and joining them (${d.totalCredits} credits): ${w}. Job ${d.job?.id}: poll it with get_job; the finished video lands in the Library.${nl}` }], structuredContent: { reangleId: d.reangleId, phase: 'final', winners: d.winners, totalCredits: d.totalCredits, job: d.job, ...(notes.length ? { notes } : {}) } };
|
|
26831
26848
|
}
|
|
26832
26849
|
const rows = Array.isArray(d?.plan) ? d.plan : [];
|
|
26833
26850
|
const ang = (d?.angles || []).map((a) => a.label).join(', ');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.503",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
5
|
"description": "AI marketing agent and AI ad generator, run from your own AI agent. 1182 tools. No markup on models on Starter and up. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on any plan; Free includes 10 posts a month and every paid plan posts without a limit. 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, WordPress, Discord, Slack, Whop 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",
|