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.
@@ -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'], slack: ['Slack', '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
- 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'] };
1299
- 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' };
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
@@ -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
- export const FLAGGED_OFF_TOOLS = new Set(['reangle_video']);
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 product feed on ChatGPT Ads, with a name and the countries it serves (ISO codes). Products are added afterwards with update_openai_ads_feed_products. Needs ChatGPT Ads connected.",
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 and the numbers Whop reports (views, likes and comments on a forum post; views and reactions on a chat message). 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']) },
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.kind === 'chat' ? 'chat message' : 'forum post'} ${d.id}: ${Object.keys(m).length ? Object.entries(m).map(([k, v]) => `${v} ${k}`).join(', ') : 'no counts'}.`, 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.`, { url: d.url, kind: d.kind, bytes: d.bytes });
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.`, { url: d.url, kind: d.kind, bytes: d.bytes });
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 — FOURTEEN channels, every one live. 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.',
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 — REPLACES the existing targeting'),
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('REPLACES the existing targeting'),
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 assets = list.filter(x => x && x.url).map((x, i) => ({ x, id: 'L' + i })).filter(({ x }) => !kind || x.kind === kind).slice(0, lim)
26017
- .map(({ x, id }) => ({ id, url: abs(x.url), kind: x.kind || '', model: x.model || '', ageHours: x.at ? Math.round((Date.now() - x.at) / 36e5) : undefined }));
26018
- if (!assets.length) return ok('The Library is empty for this workspace — render something first.', { assets: [] });
26019
- return ok(`${assets.length} asset${assets.length === 1 ? '' : 's'} (newest first):\n` + assets.map((x) => ` ${x.id}. [${x.kind || '?'}${x.model ? ' · ' + x.model : ''}${x.ageHours != null ? ' · ' + x.ageHours + 'h ago' : ''}] ${x.url}`).join('\n'), { assets });
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), then the picked take of each beat at 1080p.
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): the model does not always take the new angle, so check every take. STEP 2 (winners = the job id of the picked take for each beat): re-runs only the picks at 1080p 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. The 1080p re-run is a new render with the same angle and direction (same staging, not a pixel copy); for the exact take bigger, use upscale_video. A clip showing a real person's face is usually refused by the video model for free, with choices. Results land in the Library.",
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 at 1080p)'),
26815
- totalCredits: z.number().optional().describe('step 2: the exact credits of the 1080p re-run'),
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 RE-RUN (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 } : {}) } };
26830
- return { content: [{ type: 'text', text: `Re-running the picks at ${d.resolution} 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 } : {}) } };
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.500",
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",