hermoso 0.1.458 → 0.1.461

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.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/mcp/tools.mjs +97 -23
  3. 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
- **1035 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
8
+ **1037 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 1035 tools cover
245
+ ### What the 1037 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
package/mcp/tools.mjs CHANGED
@@ -5397,8 +5397,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
5397
5397
  // marketplace banner above sits between like_instagram and these, so without this banner they were filed as research.
5398
5398
  server.registerTool('reply_to_meta_comment', {
5399
5399
  title: 'Reply to a Facebook/Instagram comment',
5400
- description: 'Post a public reply to a comment on the brand’s Facebook or Instagram post. This is PUBLIC and posted as the brand — show the user the exact wording and get their go-ahead first.',
5400
+ description: 'Post a public reply to a comment on the brand’s Facebook or Instagram post. This is PUBLIC and posted as the brand — show the user the exact wording and get their go-ahead first. With private:true it instead sends ONE private message to the commenter (Messenger / Instagram Direct) — once per comment, within 7 days.',
5401
5401
  inputSchema: {
5402
+ private: z.boolean().optional().describe('true = send ONE PRIVATE message to the person who left the comment (Messenger / Instagram Direct) instead of a public reply — the way to answer "price?" or "DM me". Meta allows one per comment, within 7 days of it.'),
5402
5403
  commentId: z.string().describe('comment id from list_meta_comments'),
5403
5404
  message: z.string().describe('reply text'),
5404
5405
  account: z.string().optional().describe('which Instagram account — an @handle or id from list_connector_accounts("instagram"). Needed when several are linked, and the way an Instagram Login (standalone) account is reached; omit for the one Page-linked account.'),
@@ -5407,8 +5408,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
5407
5408
  outputSchema: { ok: z.boolean().optional(), id: z.string().optional() },
5408
5409
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
5409
5410
  }, wrap(async (a) => {
5410
- const d = await apiPost('/api/meta/comment/reply', { commentId: a.commentId, message: a.message, account: a.account, pageId: a.pageId });
5411
- return ok(`Replied to comment ${a.commentId} (${d.id}).`, d);
5411
+ const d = await apiPost('/api/meta/comment/reply', { commentId: a.commentId, message: a.message, account: a.account, pageId: a.pageId, ...(a.private === true ? { private: true } : {}) });
5412
+ return ok(d.private ? d.note : `Replied to comment ${a.commentId} (${d.id}).`, d);
5412
5413
  }));
5413
5414
 
5414
5415
  server.registerTool('moderate_meta_comment', {
@@ -5574,13 +5575,19 @@ function buildTools(rawServer, opts = {}, sink = null) {
5574
5575
  title: 'Search Threads by keyword',
5575
5576
  description: 'Search PUBLIC Threads posts for a keyword or topic — competitor listening, finding what people say about a product, or sourcing real customer language for ad copy. Distinct from search_threads, which reads a specific profile.',
5576
5577
  inputSchema: {
5578
+ searchMode: z.enum(['KEYWORD', 'TAG']).optional().describe('KEYWORD (default) or TAG — TAG searches a TOPIC TAG (what is being said under #skincare)'),
5579
+ mediaType: z.enum(['TEXT', 'IMAGE', 'VIDEO']).optional().describe('TEXT, IMAGE or VIDEO — only that kind of post'),
5580
+ since: z.string().optional().describe('only posts on/after this date (YYYY-MM-DD, ISO or unix seconds; Threads search reaches back to 2023-07-05)'),
5581
+ until: z.string().optional().describe('only posts on/before this date'),
5582
+ limit: z.number().int().min(1).max(100).optional().describe('how many results (1–100, default 25)'),
5583
+ authorUsername: z.string().optional().describe("only posts by this Threads account (exact username, no @) — 'what has @competitor said about X'"),
5577
5584
  q: z.string().describe('keyword or phrase'),
5578
5585
  searchType: z.enum(['TOP', 'RECENT']).optional().describe('TOP (default) or RECENT'),
5579
5586
  },
5580
5587
  outputSchema: { count: z.number().optional(), posts: z.array(z.any()).optional() },
5581
5588
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
5582
5589
  }, wrap(async (a) => {
5583
- const d = await apiGet('/api/threads/search', { q: a.q, searchType: a.searchType });
5590
+ const d = await apiGet('/api/threads/search', { q: a.q, searchType: a.searchType, ...Object.fromEntries(['searchMode', 'mediaType', 'since', 'until', 'limit', 'authorUsername'].filter(k => a[k] != null && a[k] !== '').map(k => [k, a[k]])) });
5584
5591
  const lines = (d.posts || []).map(p => `• @${p.username}: ${String(p.text || '').replace(/\s+/g, ' ').slice(0, 90)} — ${p.permalink || p.id}`);
5585
5592
  // Same rule as the locations renderer above: a caution that arrives after the results is not a caution. Until
5586
5593
  // Meta approves threads_keyword_search this searches only the connected account's own posts, so presenting the
@@ -5920,6 +5927,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5920
5927
  async: z.boolean().optional().describe('publish in the BACKGROUND and return a job id to poll with get_job, instead of waiting. USE THIS FOR VIDEO: a Facebook or Instagram video publish routinely outlives an agent transport, and a timeout on the synchronous path leaves you unable to tell whether the post is live. With async:true nothing can time out — the job reports the post id and url when it lands.'),
5921
5928
  link: z.string().optional().describe('a URL to attach (FB text post only)'),
5922
5929
  collaborators: z.array(z.string()).optional().describe('INSTAGRAM COLLAB \u2014 up to 3 Instagram usernames invited to CO-AUTHOR this post. Once one accepts, the post appears on THEIR profile too, with both handles in the header and the likes and comments shared. Pass handles only ("hermosoai"), not profile links; a leading @ is fine. INSTAGRAM ONLY \u2014 Facebook and Threads have no collab post at all and are refused BY NAME rather than silently dropping the co-authors \u2014 and never on a Story. AN INVITE IS NOT A CO-POST: publishing SENDS a request the other account must accept in their Instagram notifications, and until they do the post is on this brand\u2019s profile ALONE; they may also decline, and Instagram sends no notification either way. So never report the post as live on both accounts \u2014 read the invite status back out of the reply, and use instagram_collaborators later to find out whether they accepted.'),
5930
+ userTags: z.array(z.union([z.string(), z.object({ username: z.string(), x: z.number().min(0).max(1).optional(), y: z.number().min(0).max(1).optional() })])).optional().describe('INSTAGRAM USER TAGS — tag people (public accounts) on the post; each is notified. Usernames, or { username, x, y } with x/y from 0 to 1 = where the tag sits on the IMAGE (a tag without a position sits at the centre). A Reel tag takes no position; a story position is optional. Not on a carousel (refused by name).'),
5923
5931
  target: z.enum(['facebook', 'instagram', 'threads']).optional().describe('default facebook; instagram -> the Page’s linked IG; threads -> the brand’s connected Threads account'),
5924
5932
  account: z.string().optional().describe('WHICH Instagram account when target is instagram and the brand has several — Page-linked and Instagram Login accounts alike; an @username or id from list_connector_accounts("instagram"). Several and none named is refused by name; omit when there is one.'),
5925
5933
  scheduleAt: z.string().optional().describe('FACEBOOK ONLY — schedule instead of posting now. ISO timestamp (2026-08-01T09:00:00Z) or unix seconds; must be 10 minutes to 30 days ahead. Facebook holds the post and publishes it at that time, so nothing has to stay running on our side. Instagram and Threads have NO scheduling in Meta’s API — passing this for them is refused rather than silently posted immediately.'),
@@ -5955,7 +5963,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5955
5963
  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.'),
5956
5964
  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.'),
5957
5965
  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.'),
5958
- story: z.boolean().optional().describe('INSTAGRAM 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 Story carries NO CAPTION (there is nowhere to show one), no collaborators, no product tags and no trialReel \u2014 passing any of those is refused by name and nothing is posted, because a story that silently drops the words is worse than one that never went. INSTAGRAM ONLY: a Facebook Page story is a different upload and is not built, so a Facebook channel with `story` set is refused rather than published to the feed.'),
5966
+ 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 Story carries NO CAPTION (there is nowhere to show one), no collaborators, no product tags and no trialReel \u2014 passing any of those is refused by name and nothing is posted, because a story that silently drops the words is worse than one that never went. 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.'),
5959
5967
  aiGenerated: z.boolean().optional().describe('INSTAGRAM / FACEBOOK REEL \u2014 Meta\u2019s is_ai_generated self-disclosure. 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.'),
5960
5968
  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.)'),
5961
5969
  pageId: z.string().optional().describe('target Page id (from list_meta_pages); omit = first Page'),
@@ -5963,6 +5971,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
5963
5971
  // the five worth having on a marketing surface. All Threads-only — Meta ignores them elsewhere.
5964
5972
  linkAttachment: z.string().optional().describe('THREADS TEXT POSTS ONLY — a full http(s) URL rendered as a clickable link card. This is how a Threads post carries a destination at all; without it a link is just text. Meta allows at most 5 links per post and refuses a link attachment on a post that also carries an image or video, so Hermoso refuses that combination up front rather than letting Meta silently drop it.'),
5965
5973
  replyControl: z.enum(['everyone', 'accounts_you_follow', 'mentioned_only', 'parent_post_author_only', 'followers_only']).optional().describe('THREADS ONLY — who may reply. Default is everyone.'),
5974
+ threadsLongText: z.string().optional().describe('THREADS ONLY — LONG-FORM text, up to 10,000 characters, attached under a TEXT-only Threads post (Threads\' text attachment). The way to publish more than Threads\' 500-character post: keep a short lead in the message and put the full text here. Not with an image, video or carousel.'),
5975
+ threadsGhostPost: z.boolean().optional().describe('THREADS ONLY — a GHOST post: text only, Threads archives it automatically after 24 hours (a flash offer, a 24h-only note). No other Threads option can ride it.'),
5976
+ threadsGif: z.string().optional().describe('THREADS ONLY — a GIPHY GIF id (the id in a giphy.com/gifs/… link) attached to a TEXT Threads post. GIPHY is the only provider Threads takes.'),
5966
5977
  quotePostId: z.string().optional().describe('THREADS ONLY — the id of a post to QUOTE. A quote post is a distinct post type, not a formatting option.'),
5967
5978
  topicTag: z.string().optional().describe('THREADS ONLY — one topic tag for discovery, 1–50 characters. A leading # is stripped for you; Threads refuses "." and "&".'),
5968
5979
  countryCodes: z.array(z.string()).optional().describe('THREADS ONLY — restrict the post to these ISO 3166-1 alpha-2 countries. Warning: This is an ALLOWLIST, so the post becomes INVISIBLE in every country not named — it narrows reach, it does not target.'),
@@ -6026,10 +6037,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
6026
6037
  description: z.string().optional().describe('YOUTUBE: the video DESCRIPTION, max 5000 characters, carrying the links, the CTA and what YouTube search reads. Omit it and the caption is used.'),
6027
6038
  tags: z.array(z.string()).optional().describe('YOUTUBE — up to 30 search tags for the video (plain words, no #).'),
6028
6039
  thumbnailUrl: z.string().optional().describe('YOUTUBE: the custom thumbnail, a Hermoso-hosted image (make_thumbnail, or upload_file for the user’s own). Omit it and a frame of the video is used; "auto" keeps YouTube’s pick.'),
6040
+ paidProductPlacement: z.boolean().optional().describe('YOUTUBE: paid product placement (shows the disclosure)'),
6041
+ brandPartner: z.string().optional().describe('YOUTUBE: sponsoring brand channel (UC… or @handle)'),
6029
6042
  // THREADS' OWN POST OPTIONS — schedulable since 2026-08-17. `threadsPublish` has accepted all five since the
6030
6043
  // connector-breadth sweep and `threadsPublishExtras` validates them; only the QUEUE could not carry them, so
6031
6044
  // they worked when you posted now and vanished when you scheduled.
6032
6045
  replyControl: z.enum(['everyone', 'accounts_you_follow', 'mentioned_only', 'parent_post_author_only', 'followers_only']).optional().describe('THREADS ONLY — who may reply. Omit for Threads\' own default (everyone).'),
6046
+ threadsLongText: z.string().optional().describe('THREADS ONLY — LONG-FORM text, up to 10,000 characters, attached under a TEXT-only Threads post (Threads\' text attachment). The way to publish more than Threads\' 500-character post: keep a short lead in the message and put the full text here. Not with an image, video or carousel.'),
6047
+ threadsGhostPost: z.boolean().optional().describe('THREADS ONLY — a GHOST post: text only, Threads archives it automatically after 24 hours (a flash offer, a 24h-only note). No other Threads option can ride it.'),
6048
+ threadsGif: z.string().optional().describe('THREADS ONLY — a GIPHY GIF id (the id in a giphy.com/gifs/… link) attached to a TEXT Threads post. GIPHY is the only provider Threads takes.'),
6033
6049
  quotePostId: z.string().optional().describe('THREADS ONLY — the id of the Threads post this one quotes.'),
6034
6050
  linkAttachment: z.string().optional().describe('THREADS ONLY — a full http(s) URL rendered as a link card. This is the ONLY way a Threads post carries a destination, and Threads attaches it to TEXT-ONLY posts (a post with media cannot also carry a card).'),
6035
6051
  topicTag: z.string().optional().describe('THREADS ONLY — one topic tag for the post, without the leading #.'),
@@ -6074,6 +6090,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
6074
6090
  // and the image is the cover, so the only new fields are the two the Article itself adds.
6075
6091
  xArticle: z.object({ title: z.string(), headings: z.enum(['blocks', 'text']).optional() }).optional().describe('X: publish the X item as a long-form X ARTICLE. title is its headline, the X text (message or captions.x) its markdown body, the image its cover. Refused now if the markdown has formatting X cannot hold. X allows about 5 Articles a day; one that fires into that cap fails with the reset time and can be retried.'),
6076
6092
  collaborators: z.array(z.string()).optional().describe('INSTAGRAM \u2014 a COLLAB post: up to 3 Instagram usernames invited to CO-AUTHOR it, so it appears on their profile too once they accept, with both handles in the header and the engagement shared. Handles only ("hermosoai"); a leading @ is fine. Instagram must be one of the `channels` \u2014 asking for collaborators on a schedule Instagram is not on is REFUSED now rather than discovered when it fires, and the other channels in a mixed schedule simply publish without co-authors. THE INVITE IS SENT WHEN THE POST FIRES, not when you schedule it, and it is PENDING until the other account accepts in their notifications; check with instagram_collaborators afterwards rather than telling the user it is live on both profiles.'),
6093
+ userTags: z.array(z.union([z.string(), z.object({ username: z.string(), x: z.number().min(0).max(1).optional(), y: z.number().min(0).max(1).optional() })])).optional().describe('INSTAGRAM USER TAGS — tag people (public accounts) on the post; each is notified. Usernames, or { username, x, y } with x/y from 0 to 1 = where the tag sits on the IMAGE (a tag without a position sits at the centre). A Reel tag takes no position; a story position is optional. Not on a carousel (refused by name).'),
6077
6094
  // ── THE FACEBOOK PAGE FEED, SAME 2026-09-16 AUDIT ──────────────────────────────────────────────────────────
6078
6095
  // Meta's /{page-id}/feed table lists ~24 parameters and we sent four. `call_to_action` was the instructive
6079
6096
  // one: its value builder already existed in this codebase, inside the ADS creative path, and the organic
@@ -6107,7 +6124,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
6107
6124
  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 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.'),
6108
6125
  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.'),
6109
6126
  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.'),
6110
- story: z.boolean().optional().describe('INSTAGRAM 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 Story carries NO CAPTION (there is nowhere to show one), no collaborators, no product tags and no trialReel \u2014 passing any of those is refused by name and nothing is posted, because a story that silently drops the words is worse than one that never went. INSTAGRAM ONLY: a Facebook Page story is a different upload and is not built, so a Facebook channel with `story` set is refused rather than published to the feed.'),
6127
+ 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 Story carries NO CAPTION (there is nowhere to show one), no collaborators, no product tags and no trialReel \u2014 passing any of those is refused by name and nothing is posted, because a story that silently drops the words is worse than one that never went. 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.'),
6111
6128
  aiGenerated: z.boolean().optional().describe('AI-CONTENT DISCLOSURE (Instagram / Facebook Reel is_ai_generated, TikTok is_aigc, YouTube containsSyntheticMedia, Pinterest ai_disclosures). 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.'),
6112
6129
  // ── WHICH ACCOUNT (server-side SCHED_ID_FIELDS). Every one of these is an answer the publish helper REFUSES
6113
6130
  // to guess, so a schedule that cannot carry it can only fail at fire time with nobody watching.
@@ -6221,6 +6238,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
6221
6238
  // The same six the CREATE path gained — a field you can set at create and not at edit is a one-way door,
6222
6239
  // and reschedule_post is how an agent corrects a queued post it got wrong.
6223
6240
  replyControl: z.enum(['everyone', 'accounts_you_follow', 'mentioned_only', 'parent_post_author_only', 'followers_only']).optional().describe('THREADS ONLY — who may reply.'),
6241
+ threadsLongText: z.string().optional().describe('THREADS ONLY — LONG-FORM text, up to 10,000 characters, attached under a TEXT-only Threads post (Threads\' text attachment). The way to publish more than Threads\' 500-character post: keep a short lead in the message and put the full text here. Not with an image, video or carousel.'),
6242
+ threadsGhostPost: z.boolean().optional().describe('THREADS ONLY — a GHOST post: text only, Threads archives it automatically after 24 hours (a flash offer, a 24h-only note). No other Threads option can ride it.'),
6243
+ threadsGif: z.string().optional().describe('THREADS ONLY — a GIPHY GIF id (the id in a giphy.com/gifs/… link) attached to a TEXT Threads post. GIPHY is the only provider Threads takes.'),
6224
6244
  quotePostId: z.string().optional().describe('THREADS ONLY — the id of the Threads post this one quotes.'),
6225
6245
  linkAttachment: z.string().optional().describe('THREADS ONLY — a full http(s) URL rendered as a link card on a TEXT-ONLY post. The only way a Threads post carries a destination.'),
6226
6246
  topicTag: z.string().optional().describe('THREADS ONLY — one topic tag, without the leading #.'),
@@ -6256,6 +6276,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
6256
6276
  paidPartnership: z.boolean().optional().describe('INSTAGRAM AND X — the paid-partnership label; false turns it off.'),
6257
6277
  xArticle: z.object({ title: z.string().optional(), headings: z.enum(['blocks', 'text']).optional() }).optional().describe('X: replaces the X Article (title, headings); {} makes it an ordinary X post again.'),
6258
6278
  collaborators: z.array(z.string()).optional().describe('INSTAGRAM \u2014 replaces the WHOLE collab list (up to 3 usernames); an explicit [] removes the co-authors and the post goes out as an ordinary single-author post. Only takes effect if the post has not fired yet \u2014 an invite already sent cannot be withdrawn from here.'),
6279
+ userTags: z.array(z.union([z.string(), z.object({ username: z.string(), x: z.number().min(0).max(1).optional(), y: z.number().min(0).max(1).optional() })])).optional().describe('INSTAGRAM USER TAGS — tag people (public accounts) on the post; each is notified. Usernames, or { username, x, y } with x/y from 0 to 1 = where the tag sits on the IMAGE (a tag without a position sits at the centre). A Reel tag takes no position; a story position is optional. Not on a carousel (refused by name).'),
6259
6280
  trialReel: z.enum(['MANUAL', 'SS_PERFORMANCE', '']).optional().describe('INSTAGRAM \u2014 replaces the trial-reel setting on a queued Reel (MANUAL or SS_PERFORMANCE); an explicit "" turns the trial off and it goes out as an ordinary Reel. Only takes effect while the post is still queued \u2014 a Reel already published cannot be converted into a trial.'),
6260
6281
  story: z.boolean().optional().describe('INSTAGRAM — true makes it a 24-hour Story, false an ordinary feed post. One image or one video, no carousel.'),
6261
6282
  coverUrl: z.string().optional().describe('INSTAGRAM REEL — replaces the cover image url; an empty string removes it.'),
@@ -6495,12 +6516,25 @@ function buildTools(rawServer, opts = {}, sink = null) {
6495
6516
  }));
6496
6517
  server.registerTool('post_to_linkedin', {
6497
6518
  title: 'Publish to LinkedIn',
6498
- description: 'Use this when the user wants to post to their personal LinkedIn profile. Publishing costs no credits. Publish a post to the user’s connected LinkedIn profile — text, and optionally an image (pass its served URL as imageUrl). The image does NOT have to be something Hermoso generated: LinkedIn is served the bytes from us, so the URL must be Hermoso-hosted, and upload_file turns ANY file the user already has into exactly that. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Needs a connected LinkedIn account (Settings > Connectors > LinkedIn).',
6519
+ description: 'Use this when the user wants to post to their personal LinkedIn profile. Publishing costs no credits. Publish a post to the user’s connected LinkedIn profile — text, and optionally an image (imageUrl), a VIDEO (videoUrl, with captionsSrt and a videoThumbnailUrl cover), a 2–20 image MULTI-IMAGE post (imageUrls), a LINK post with a real preview card (linkUrl + linkTitle / linkDescription / linkThumbnailUrl — LinkedIn never scrapes the page), a DOCUMENT post (documentUrl: a PDF / PPTX readers swipe through), and @mentions of companies (mentions). The image does NOT have to be something Hermoso generated: LinkedIn is served the bytes from us, so the URL must be Hermoso-hosted, and upload_file turns ANY file the user already has into exactly that. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Needs a connected LinkedIn account (Settings > Connectors > LinkedIn).',
6499
6520
  inputSchema: {
6500
6521
  ...HOOK_ATTR,
6501
6522
  text: z.string().describe('the post text'),
6502
6523
  imageUrl: z.string().optional().describe('a Hermoso-hosted image URL to attach (≤12MB) — a Hermoso render, or ANY file of the user’s own put through upload_file first. An arbitrary external host is refused (we fetch the bytes ourselves).'),
6503
- imageUrls: z.array(z.string()).optional().describe('A CAROUSEL IS NOT AVAILABLE ON A PERSONAL PROFILE — LinkedIn\'s organic multi-image post publishes from a COMPANY PAGE. Passing several here is refused by name rather than posting slide 1; use post_to_linkedin_page instead.'),
6524
+ imageUrls: z.array(z.string()).optional().describe('a MULTI-IMAGE post: 2–20 Hermoso-hosted images in order, one post the reader swipes through (LinkedIn\u2019s MultiImage API takes a member author on w_member_social). Images only.'),
6525
+ videoUrl: z.string().optional().describe('a Hermoso-hosted video (a render, or the user\u2019s footage via upload_file). LinkedIn processes it before publishing, which takes a minute.'),
6526
+ captionsSrt: z.string().optional().describe('SRT caption CONTENT for a videoUrl post (English only, attached during upload)'),
6527
+ videoThumbnailUrl: z.string().optional().describe('cover image for a videoUrl post (Hermoso-hosted)'),
6528
+ title: z.string().optional().describe('video title'),
6529
+ altText: z.union([z.string(), z.array(z.string())]).optional().describe('image alt text — a string for every image, or an array per slide'),
6530
+ linkUrl: z.string().optional().describe('a LINK post with a real preview card. Not with media.'),
6531
+ linkTitle: z.string().optional().describe('the card headline — LinkedIn never scrapes the page, so without it the card is unlabelled'),
6532
+ linkDescription: z.string().optional(),
6533
+ linkThumbnailUrl: z.string().optional().describe('Hermoso-hosted image for the card'),
6534
+ documentUrl: z.string().optional().describe('a DOCUMENT post — a Hermoso-hosted PDF / PPTX / DOCX (upload_file first) that readers swipe through page by page in the feed; LinkedIn takes up to 100MB and 300 pages. One kind of media per post: not with an image, video, slides or linkUrl.'),
6535
+ documentTitle: z.string().optional().describe('the title shown on the document (defaults to the file name)'),
6536
+ mentions: z.array(z.any()).optional().describe('TAG COMPANIES: write "@Name" in the text where each tag goes and list each company here — its vanity name (the part after linkedin.com/company/), its company page URL, or {organizationId, name}. Hermoso resolves it on LinkedIn and LinkedIn renders a real clickable mention; a company with no "@" spot in the text is refused rather than appended.'),
6537
+
6504
6538
  idempotencyKey: z.string().optional().describe('SAFE RETRIES — any stable string: a repeat of the SAME publish within 24h returns the ORIGINAL post id instead of posting again (an identical publish is also auto-recognised for 10 minutes). On a timeout or an ambiguous error, CALL AGAIN WITH THE SAME KEY: it reports the original post or publishes it once. It never posts twice.'),
6505
6539
  allowDuplicate: z.boolean().optional().describe('post it even though an identical post was just made or attempted. Only pass this when the user genuinely wants the same thing posted twice, or when you have LOOKED at the account and confirmed a timed-out attempt did not land.'),
6506
6540
  visibility: z.enum(['PUBLIC', 'CONNECTIONS']).optional().describe('default PUBLIC'),
@@ -8011,19 +8045,42 @@ function buildTools(rawServer, opts = {}, sink = null) {
8011
8045
  }));
8012
8046
  server.registerTool('create_linkedin_event', {
8013
8047
  title: 'Create a LinkedIn event',
8014
- description: 'Create a LinkedIn LIVE event on a company Page the brand administers. Warning: THIS REQUIRES LINKEDIN LIVE ACCESS on the member\u2019s account: `liveVideo` is the only online format LinkedIn documents for event creation, and a member without Live access is refused with \u201cThe member does not have LINKEDIN_LIVE creation access\u201d (measured live 2026-08-31). Check the account has it before offering this. Warning: CREATED BUT NOT ANNOUNCED: LinkedIn requires a SEPARATE post to make an event publicly visible, so creating one puts nothing in anyone\u2019s feed and the brand decides when to tell people. That separation is LinkedIn\u2019s own and it is why an agent cannot accidentally broadcast to a company\u2019s followers. ONLINE events only for now \u2014 an in-person event needs a full address object and a custom background image needs a three-step upload, and neither is offered rather than failing at LinkedIn. Needs the Events permission (administrator flag) and a reconnect, the same two conditions as list_linkedin_events. 0 credits.',
8048
+ description: 'Create an event on a LinkedIn company Page the brand administers: an EXTERNAL online event (a webinar on Zoom, YouTube or your own site \u2014 pass url), an IN-PERSON event (pass address), or a LinkedIn Live event (format:"live" \u2014 that one needs LinkedIn Live access on the member, and without it LinkedIn refuses with \u201cThe member does not have LINKEDIN_LIVE creation access\u201d, measured 2026-08-31). Optionally attach a LinkedIn REGISTRATION FORM (registrationForm:true + privacyPolicyUrl, or your own approved leadFormId); sign-ups arrive as EVENT leads in list_linkedin_leads(leadType:"EVENT"). Read back from LinkedIn. Warning: ANNOUNCING IS A SEPARATE, PUBLIC STEP (announce:true does it in this call): LinkedIn requires a SEPARATE post to make an event publicly visible, so creating one puts nothing in anyone\u2019s feed and the brand decides when to tell people. That separation is LinkedIn\u2019s own and it is why an agent cannot accidentally broadcast to a company\u2019s followers. Edit it with update_linkedin_event until it starts. A custom background image is not offered yet. Needs the Events permission (administrator flag) and a reconnect, the same two conditions as list_linkedin_events. 0 credits.',
8015
8049
  inputSchema: {
8016
8050
  name: z.string().describe('the event title'),
8017
8051
  startsAt: z.string().describe('ISO 8601, must be in the future \u2014 LinkedIn rejects an event that has already begun'),
8018
8052
  endsAt: z.string().optional().describe('ISO 8601, after startsAt; defaults to one hour after the start'),
8019
8053
  description: z.string().optional(),
8020
8054
  organizationId: z.string().optional().describe('the Page \u2014 omit if only one is shared with this profile'),
8055
+ format: z.enum(['external', 'in_person', 'live']).optional().describe('inferred from url / address when omitted; live needs LinkedIn Live access'),
8056
+ url: z.string().optional().describe('external: where it streams or where people join; in_person: an info page'),
8057
+ address: z.object({ line1: z.string().optional(), city: z.string(), region: z.string().optional(), postalCode: z.string().optional(), country: z.string().describe('2-letter code, e.g. US') }).optional().describe('in_person venue'),
8058
+ venueDetails: z.string().optional().describe('room, floor, parking\u2026'),
8059
+ registrationForm: z.boolean().optional().describe('attach a LinkedIn-built registration form (needs privacyPolicyUrl)'),
8060
+ privacyPolicyUrl: z.string().optional(),
8061
+ leadFormId: z.string().optional().describe('or attach your own APPROVED lead form instead of registrationForm'),
8062
+ discoveryMode: z.enum(['LISTED', 'URL_ONLY']).optional(),
8063
+ announce: z.boolean().optional().describe('ALSO publish the Page post that shares the event. LinkedIn makes an event reachable only once a post shares it (an unannounced one stopped being readable within a minute, measured 2026-10-27). PUBLIC — show the user the copy and get a yes first'),
8064
+ announceText: z.string().optional().describe('the copy of the announcing post'),
8021
8065
  },
8022
8066
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
8023
8067
  }, wrap(async (a) => {
8024
8068
  const d = await apiPost('/api/linkedin/events', a);
8025
8069
  return ok(`Created \u201c${d.name}\u201d on ${d.organization} for ${String(d.startsAt).slice(0, 16).replace('T', ' ')}.\n${d.url}\n${d.note}`, d);
8026
8070
  }));
8071
+ server.registerTool('update_linkedin_event', {
8072
+ title: 'Edit a LinkedIn event',
8073
+ description: 'Edit a LinkedIn event the Page organizes: name, description, start / end time, the join or info url, the in-person address or venue details, or discoveryMode (LISTED / URL_ONLY). LinkedIn\u2019s own rules, quoted back when it refuses: an event can be edited only BEFORE it starts (never once cancelled), and online vs in-person never changes. Read back from LinkedIn. 0 credits.',
8074
+ inputSchema: {
8075
+ eventId: z.string().describe('from list_linkedin_events or create_linkedin_event'),
8076
+ organizationId: z.string().optional(),
8077
+ name: z.string().optional(), description: z.string().optional(),
8078
+ startsAt: z.string().optional().describe('ISO 8601'), endsAt: z.string().optional().describe('ISO 8601'),
8079
+ url: z.string().optional(), address: z.object({ line1: z.string().optional(), city: z.string(), region: z.string().optional(), postalCode: z.string().optional(), country: z.string().describe('2-letter code, e.g. US') }).optional(), venueDetails: z.string().optional(),
8080
+ discoveryMode: z.enum(['LISTED', 'URL_ONLY']).optional(),
8081
+ },
8082
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
8083
+ }, wrap(async (a) => { const d = await apiPost('/api/linkedin/events/update', a); return ok(d.note, d); }));
8027
8084
  server.registerTool('list_linkedin_events', {
8028
8085
  title: 'List a LinkedIn Page\u2019s events',
8029
8086
  description: 'The events a LinkedIn company Page is organizing \u2014 name, link, start and end times, online or in-person, visibility and lifecycle state. LinkedIn is the one channel where an event is a first-class object rather than a post about one, so this is how you see what a B2B brand actually has scheduled (webinars, launches, live sessions) and pair it with the posts promoting it. Only Pages the user has SHARED with this profile are readable. Warning: Needs the Events permission: an administrator switches it on for the deployment, and because LinkedIn grants permissions at authorise time and never retroactively, every existing LinkedIn connection must RECONNECT once before it works. The tool tells you which of those two applies rather than failing generically. Read-only, 0 credits. Needs LinkedIn connected.',
@@ -8834,15 +8891,17 @@ function buildTools(rawServer, opts = {}, sink = null) {
8834
8891
  // 200 {"success":true}. Rules, citations and every refusal sentence in lib/instagram-business-discovery.mjs.
8835
8892
  server.registerTool('instagram_profile', {
8836
8893
  title: 'Look up an Instagram account by handle',
8837
- description: 'Turn an Instagram @handle into the NUMERIC Instagram account id, straight from Meta — with the account’s real name, bio, follower count, post count, website and profile picture. Two jobs: it answers "what is @<handle>’s Instagram id?", and it sizes up any brand or creator on Instagram before you work with them (followers and post count are Meta’s own numbers, not a scraper’s). CALL IT BEFORE manage_meta_partnership_creator: that tool’s `allowTagging` list takes NUMERIC IDS ONLY, and an id that did not come from Meta is rejected there with “An unknown error has occurred.” — an error carrying no cause at all, so a scraped or guessed id looks like an outage instead of a bad argument. PROFESSIONAL ACCOUNTS ONLY: Meta reads Instagram Business and Creator accounts here and nothing else. A personal account, a private account, an age-gated account and a handle that does not exist all come back as the SAME refusal, because Meta returns one error code for all four — so never tell a user which of them it was. Read-only, 0 credits, no new permission, no reconnect.',
8894
+ description: 'Turn an Instagram @handle into the NUMERIC Instagram account id, straight from Meta — with the account’s real name, bio, follower count, post count, website and profile picture. Two jobs: it answers "what is @<handle>’s Instagram id?", and it sizes up any brand or creator on Instagram before you work with them (followers and post count are Meta’s own numbers, not a scraper’s). CALL IT BEFORE manage_meta_partnership_creator: that tool’s `allowTagging` list takes NUMERIC IDS ONLY, and an id that did not come from Meta is rejected there with “An unknown error has occurred.” — an error carrying no cause at all, so a scraped or guessed id looks like an outage instead of a bad argument. PROFESSIONAL ACCOUNTS ONLY: Meta reads Instagram Business and Creator accounts here and nothing else. A personal account, a private account, an age-gated account and a handle that does not exist all come back as the SAME refusal, because Meta returns one error code for all four — so never tell a user which of them it was. Read-only, 0 credits, no new permission, no reconnect. RECENT POSTS: pass posts (1–50) to also get that account\'s latest posts with their like, comment and view counts and links, read first-party from Meta for FREE — use this instead of fetch_social_data for a business or creator account\'s recent Instagram posts (competitor organic research); page with cursor.',
8838
8895
  inputSchema: {
8839
8896
  handle: z.string().describe('the Instagram handle — "fellowproducts", "@fellowproducts" or an instagram.com profile link all work. Meta matches it EXACTLY and does not strip a leading @, so Hermoso normalises it first.'),
8840
8897
  pageId: z.string().optional().describe('Facebook Page id — omit when only one Page is connected. Meta requires the lookup to be made AS a professional account, so the Page must have an Instagram account linked.'),
8898
+ posts: z.number().int().min(0).max(50).optional().describe('how many of the account’s recent posts to read with like / comment / view counts and links (0–50, default 0). First-party and free'),
8899
+ cursor: z.string().optional().describe('next page of posts — the cursor from the previous reply'),
8841
8900
  },
8842
8901
  outputSchema: { handle: z.string().optional(), id: z.string().optional(), username: z.string().optional(), name: z.string().optional(), biography: z.string().optional(), followers: z.number().nullable().optional(), follows: z.number().nullable().optional(), mediaCount: z.number().nullable().optional(), profilePicture: z.string().optional(), website: z.string().optional(), published: z.boolean().nullable().optional(), summary: z.string().optional(), warning: z.string().optional(), idUse: z.string().optional(), limitation: z.string().optional() },
8843
8902
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
8844
8903
  }, wrap(async (a) => {
8845
- const d = await apiGet('/api/instagram/profile', { handle: a.handle, pageId: a.pageId });
8904
+ const d = await apiGet('/api/instagram/profile', { handle: a.handle, pageId: a.pageId, ...(a.posts ? { posts: a.posts } : {}), ...(a.cursor ? { cursor: a.cursor } : {}) });
8846
8905
  // THE ID LEADS. It is what the caller came for, and a fact printed below a follower count is one that gets
8847
8906
  // asked for a second time.
8848
8907
  const bio = d.biography ? `\n\u201c${String(d.biography).replace(/\s+/g, ' ').slice(0, 200)}\u201d` : '';
@@ -14209,7 +14268,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14209
14268
  accountId: z.string().describe('from list_x_ads_accounts'),
14210
14269
  campaignId: z.string().describe('from create_x_ads_campaign or list_x_ads_campaigns'),
14211
14270
  name: z.string().optional(),
14212
- objective: z.enum(['APP_ENGAGEMENTS', 'APP_INSTALLS', 'ENGAGEMENTS', 'FOLLOWERS', 'LEAD_GENERATION', 'PREROLL_VIEWS', 'REACH', 'VIDEO_VIEWS', 'WEBSITE_CLICKS']),
14271
+ objective: z.enum(['APP_ENGAGEMENTS', 'APP_INSTALLS', 'ENGAGEMENTS', 'FOLLOWERS', 'PREROLL_VIEWS', 'REACH', 'VIDEO_VIEWS', 'WEBSITE_CLICKS']).describe('one per campaign (X refuses a second objective). For an X LEAD AD use WEBSITE_CLICKS with a lead-form card \u2014 X retired LEAD_GENERATION'),
14213
14272
  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'),
14214
14273
  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'),
14215
14274
  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(),
@@ -14253,7 +14312,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14253
14312
  }, wrap(async (a) => { const d = await apiGet('/api/x-ads/lead-forms', a); return ok(`${d.note}\n${(d.leadForms || []).map(f => `• ${f.name} — id ${f.id}${f.deleted ? ' (archived)' : ''}, fields ${(f.fields || []).join(', ')}`).join('\n')}`, d); }));
14254
14313
  server.registerTool('create_x_ads_lead_form', {
14255
14314
  title: 'Create an X lead form',
14256
- description: "Create an X lead form — the native form an X lead ad opens, so people sign up without leaving X. This is what makes objective LEAD_GENERATION deliverable: lead form → create_x_ads_card(leadFormId) → create_x_ads_post(cardUri) → create_x_ads_promoted_tweet on a LEAD_GENERATION line item → get_x_ads_leads. Needs name, purpose, fields (at least one required), and an https privacy policy URL + company name. X ties every form to a conversion event: an ad account with no X Pixel needs conversionEventId, and the refusal says so plainly. X also builds a customer-list audience of the form's leads. Read back from X. Free.",
14315
+ description: "Create an X lead form — the native form an X lead ad opens, so people sign up without leaving X. The X lead ad chain: lead form → create_x_ads_card(leadFormId) → create_x_ads_post(cardUri, lineItemId) on a WEBSITE_CLICKS line item (X retired the LEAD_GENERATION objective) → get_x_ads_leads. Needs name, purpose, fields (at least one required), and an https privacy policy URL + company name. X ties every form to a conversion event: an ad account with no X Pixel needs conversionEventId, and the refusal says so plainly. X also builds a customer-list audience of the form's leads. Read back from X. Free.",
14257
14316
  inputSchema: {
14258
14317
  accountId: z.string(), name: z.string(), purpose: z.string().describe('what the leads are used for (shown to the person)'),
14259
14318
  fields: z.array(xLeadField).describe('at least one must not be optional'), privacyPolicyUrl: z.string().describe('https'), companyName: z.string(),
@@ -15332,8 +15391,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
15332
15391
  // Product-group promotions: the ADS half of Shopping — what makes a CATALOG_SALES ad group serve products.
15333
15392
  server.registerTool('list_pinterest_ads_product_group_promotions', {
15334
15393
  title: 'List Pinterest product-group promotions',
15335
- description: 'List the PRODUCT GROUPS being promoted in Pinterest Shopping ad groups — optionally for one adGroupId — with status and bid. A CATALOG_SALES ad group with none has no products to serve. Read-only, 0 credits.',
15336
- inputSchema: { adAccountId: pinAcct, adGroupId: z.string().optional() },
15394
+ description: 'List the PRODUCT GROUPS being promoted in one Pinterest Shopping ad group — Pinterest lists them per ad group, so adGroupId is required — with status and bid. A CATALOG_SALES ad group with none has no products to serve. Read-only, 0 credits.',
15395
+ inputSchema: { adAccountId: pinAcct, adGroupId: z.string() },
15337
15396
  outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), promotions: z.array(z.any()).optional(), note: z.string().optional() },
15338
15397
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
15339
15398
  }, wrap(async (a) => { const d = await apiGet('/api/pinterest/product-group-promotions', { adAccountId: a.adAccountId, adGroupId: a.adGroupId }); return ok(`${d.note}\n${JSON.stringify(d.promotions || []).slice(0, 4000)}`, d); }));
@@ -15649,7 +15708,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
15649
15708
  }));
15650
15709
  server.registerTool('create_reddit_ads_ad', {
15651
15710
  title: 'Create a Reddit ad',
15652
- description: 'Create the ad itself — the object that binds a POST (the creative) to an AD GROUP (the targeting and budget). Create the post first with create_reddit_ads_post and pass its id as postId; without a post there is nothing to show. A CATALOG ad (an ad group with a productSetId under a SALES useCatalog campaign) has no post: pass shoppingCreative (headline, callToAction, optional destinationUrl, secondLineCta PRICE/DESCRIPTION, dpaCarouselMode, heroCard for a collection ad) plus redditProfileId, and optionally up to 6 productIds to feature. ALWAYS created PAUSED, and Reddit additionally has to APPROVE it, so a fresh ad reports effective status PENDING_APPROVAL — report that rather than calling it live. UTMs: clickUrlQueryParameters ({"utm_source":"reddit","utm_campaign":"{{CAMPAIGN_ID}}"} — Reddit macros allowed, ≤14) are appended to the click URL by Reddit. PREVIEW: previewDays (1-30) asks Reddit for a preview link, read back as previewUrl (the very first time, Reddit wants the test URL generated once in Ads Manager — the reply says so). GOTCHA: a TEXT ("free form") post’s ad may not carry clickUrl at all — Reddit refuses it. The click destination for image and link ads lives on the POST’s media destinationUrl, not here.',
15711
+ description: 'Create the ad itself — the object that binds a POST (the creative) to an AD GROUP (the targeting and budget). Create the post first with create_reddit_ads_post and pass its id as postId; without a post there is nothing to show. A CATALOG ad (an ad group with a productSetId under a SALES useCatalog campaign) has no post: pass shoppingCreative (headline, callToAction, optional destinationUrl, secondLineCta PRICE/DESCRIPTION, dpaCarouselMode, heroCard for a collection ad) plus redditProfileId, and optionally up to 6 productIds to feature. ALWAYS created PAUSED, and Reddit additionally has to APPROVE it, so a fresh ad reports effective status PENDING_APPROVAL — report that rather than calling it live. UTMs: clickUrlQueryParameters ({"utm_source":"reddit","utm_campaign":"{{CAMPAIGN_ID}}"} — Reddit macros allowed, ≤14) are appended to the click URL by Reddit — Reddit allows that list only on some campaigns and otherwise refuses “URL Parameters cannot be added to this campaign”; then write the parameters into clickUrl itself. PREVIEW: previewDays (1-30) asks Reddit for a preview link, read back as previewUrl (the very first time, Reddit wants the test URL generated once in Ads Manager — the reply says so). GOTCHA: a TEXT ("free form") post’s ad may not carry clickUrl at all — Reddit refuses it. The click destination for image and link ads lives on the POST’s media destinationUrl, not here.',
15653
15712
  inputSchema: {
15654
15713
  adAccountId: z.string().optional(),
15655
15714
  adGroupId: z.string().describe('the ad group whose targeting and budget this ad runs under'),
@@ -15862,7 +15921,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
15862
15921
  }));
15863
15922
  server.registerTool('create_reddit_ads_catalog', {
15864
15923
  title: 'Create a Reddit product catalog',
15865
- description: 'Create an empty Reddit PRODUCT CATALOG on the business behind the shared ad account — the product inventory a catalog SALES campaign advertises (dynamic product ads, collection ads). Spends nothing. Name it, give the CURRENCY your prices are in (USD, GBP, CAD, EUR, AUD, JPY, CHF, NZD, SEK or NOK — Reddit’s list) and optionally the language and the Reddit pixel(s) to match products against (list_reddit_ads_pixels; catalog ads optimize on pixel events such as PURCHASE). The currency and language cannot be changed later. Then fill it — upsert_reddit_ads_products for a handful, or create_reddit_ads_product_feed to have Reddit fetch your product file on a schedule — and define which products to advertise with create_reddit_ads_product_set. Read back from Reddit.',
15924
+ description: 'Create an empty Reddit PRODUCT CATALOG on the business behind the shared ad account — the product inventory a catalog SALES campaign advertises (dynamic product ads, collection ads). Spends nothing. Name it, give the CURRENCY your prices are in (USD, GBP, CAD, EUR, AUD, JPY, CHF, NZD, SEK or NOK — Reddit’s list) and optionally the language and the Reddit pixel(s) to match products against (list_reddit_ads_pixels; catalog ads optimize on pixel events such as PURCHASE). The currency and language cannot be changed later. Then fill it — upsert_reddit_ads_products for a handful, or create_reddit_ads_product_feed to have Reddit fetch your product file on a schedule — and define which products to advertise with create_reddit_ads_product_set. Reddit gates catalog writes on the connected login’s ROLE in the Reddit Ads business (a login that can run campaigns can still be refused “no permissions to Create Catalog action”) — that refusal is about the role, not the connection, and the reply says how to fix it. Read back from Reddit.',
15866
15925
  inputSchema: {
15867
15926
  adAccountId: z.string().optional(),
15868
15927
  name: z.string(),
@@ -19130,7 +19189,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
19130
19189
  }));
19131
19190
  server.registerTool('preview_snapchat_ads_creative', {
19132
19191
  title: 'Preview a Snapchat ad',
19133
- description: 'See a Snapchat ad before it spends: a Snap-hosted preview page of the creative (no login, live for 90 days), its type and its review status. snapcode:true adds a Snapcode image to scan with the Snapchat app (7 days). Pass creativeId, or adId to preview the creative that ad uses. Read-only, free.',
19192
+ description: 'See a Snapchat ad before it spends: a Snap-hosted preview page of the creative (no login, live for 90 days), its type and its review status. snapcode:true adds a Snapcode image to scan with the Snapchat app (its expiry is printed). Pass creativeId, or adId to preview the creative that ad uses. Read-only, free.',
19134
19193
  inputSchema: { adAccountId: z.string().optional(), creativeId: z.string().optional(), adId: z.string().optional(), snapcode: z.boolean().optional() },
19135
19194
  outputSchema: { adAccountId: z.string().optional(), creativeId: z.string().optional(), name: z.string().optional(), type: z.string().nullable().optional(), reviewStatus: z.string().nullable().optional(), previewUrl: z.string().nullable().optional(), expiresAt: z.string().nullable().optional(), snapcode: z.any().optional(), note: z.string().optional() },
19136
19195
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -19238,7 +19297,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
19238
19297
  }));
19239
19298
  server.registerTool('post_to_linkedin_page', {
19240
19299
  title: 'Publish to a LinkedIn company Page',
19241
- description: 'Use this when the user wants to post to a LinkedIn company Page. Publishing costs no credits. Publish a post to one of the user’s LinkedIn COMPANY PAGES — text, plus optionally an image, a video, a 2–20 image CAROUSEL (LinkedIn calls it a MultiImage post; pass the slides in order as imageUrls[]), or a LINK POST with a real preview card (linkUrl). USE linkUrl WHENEVER THE POINT OF THE POST IS A LINK: LinkedIn disables URL scraping for API partners, so a url sitting in the text renders as plain text with no card, and the card’s title, description and image only exist if you pass linkTitle / linkDescription / linkThumbnailUrl — read them off the page and supply them. The media need not be a Hermoso render — it must be Hermoso-HOSTED because we upload the bytes to LinkedIn ourselves, and upload_file turns ANY file the user already has into such a URL. ORGANIC CAROUSELS ARE COMPANY-PAGE ONLY — a personal profile cannot publish one and is refused by name, so send a deck here rather than to post_to_linkedin. This is a DIFFERENT thing from post_to_linkedin, which publishes to the person’s own profile: pick the one the user actually asked for and never substitute. organizationId comes from list_linkedin_pages; omit it only when the account administers exactly one Page. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. A VIDEO POST CAN CARRY CAPTIONS AND ITS OWN COVER, and both are attached only during the upload: pass captionsSrt (SubRip content — LinkedIn is watched with the sound off) and videoThumbnailUrl (otherwise LinkedIn picks a frame for you). LinkedIn does NOT allow the image, video, captions or thumbnail of a published post to be swapped afterwards, so get all of that right first (the copy can still be edited with manage_linkedin_post).',
19300
+ description: 'Use this when the user wants to post to a LinkedIn company Page. Publishing costs no credits. Publish a post to one of the user’s LinkedIn COMPANY PAGES — text, plus optionally an image, a video, a 2–20 image CAROUSEL (LinkedIn calls it a MultiImage post; pass the slides in order as imageUrls[]), or a LINK POST with a real preview card (linkUrl). USE linkUrl WHENEVER THE POINT OF THE POST IS A LINK: LinkedIn disables URL scraping for API partners, so a url sitting in the text renders as plain text with no card, and the card’s title, description and image only exist if you pass linkTitle / linkDescription / linkThumbnailUrl — read them off the page and supply them. The media need not be a Hermoso render — it must be Hermoso-HOSTED because we upload the bytes to LinkedIn ourselves, and upload_file turns ANY file the user already has into such a URL. A DOCUMENT post (documentUrl — a PDF/PPTX deck readers swipe through) is LinkedIn\u2019s strongest organic B2B format. Tag companies with @Name in the text plus mentions. This is a DIFFERENT thing from post_to_linkedin, which publishes to the person’s own profile: pick the one the user actually asked for and never substitute. organizationId comes from list_linkedin_pages; omit it only when the account administers exactly one Page. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. A VIDEO POST CAN CARRY CAPTIONS AND ITS OWN COVER, and both are attached only during the upload: pass captionsSrt (SubRip content — LinkedIn is watched with the sound off) and videoThumbnailUrl (otherwise LinkedIn picks a frame for you). LinkedIn does NOT allow the image, video, captions or thumbnail of a published post to be swapped afterwards, so get all of that right first (the copy can still be edited with manage_linkedin_post).',
19242
19301
  inputSchema: {
19243
19302
  ...HOOK_ATTR,
19244
19303
  organizationId: z.string().optional().describe('numeric Page id from list_linkedin_pages'),
@@ -19247,12 +19306,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
19247
19306
  linkTitle: z.string().optional().describe('the headline ON the preview card. LINKEDIN NEVER SCRAPES THE PAGE — their Posts API disables URL scraping for API partners outright — so if you do not pass this the card renders UNLABELLED. Fetch the page\u2019s own title and pass it.'),
19248
19307
  linkDescription: z.string().optional().describe('the sub-line on the preview card. Same rule as linkTitle: absent means blank, because LinkedIn will not fetch it.'),
19249
19308
  linkThumbnailUrl: z.string().optional().describe('a Hermoso-hosted image used as the card\u2019s picture (uploaded to LinkedIn for you). Without it the card has no image.'),
19309
+ documentUrl: z.string().optional().describe('a DOCUMENT post — a Hermoso-hosted PDF / PPTX / DOCX (upload_file first) that readers swipe through page by page in the feed; LinkedIn takes up to 100MB and 300 pages. One kind of media per post: not with an image, video, slides or linkUrl.'),
19310
+ documentTitle: z.string().optional().describe('the title shown on the document (defaults to the file name)'),
19311
+ mentions: z.array(z.any()).optional().describe('TAG COMPANIES: write "@Name" in the text where each tag goes and list each company here — its vanity name (the part after linkedin.com/company/), its company page URL, or {organizationId, name}. Hermoso resolves it on LinkedIn and LinkedIn renders a real clickable mention; a company with no "@" spot in the text is refused rather than appended.'),
19250
19312
  imageUrl: z.string().optional().describe('a Hermoso-hosted image URL — a render (list_library), or ANY image of the user’s own passed through upload_file first. An arbitrary external host is refused.'),
19251
19313
  videoUrl: z.string().optional().describe('a Hermoso-hosted video URL — a render, or the user’s own footage via upload_file. LinkedIn processes it before publishing, which takes a minute.'),
19252
19314
  imageUrls: z.array(z.string()).optional().describe('CAROUSEL — an ORDERED list of image (and, where the channel allows, video) URLs published as ONE post the viewer swipes through. THIS IS NOT “post several” — it is a single post with several slides, which is what a multi-slide creative (a listicle, a “1/6 · SWIPE” deck) actually needs; publishing only its first slide tells the viewer to swipe at something that cannot. The ORDER is the product. Limits per channel: Instagram 2–20 (images, videos or a mix), Threads 2–20 (mix allowed), Facebook 2+ (Meta publishes no documented maximum; Hermoso caps the upload fan-out at 30 and says so), LinkedIn company Pages 2–20 (images only), Pinterest 2–5 (images only), TikTok up to 35. One url here is simply an ordinary single post. Anything a channel cannot do is REFUSED with the real reason — nothing is ever quietly downgraded to one slide.'),
19253
19315
  idempotencyKey: z.string().optional().describe('SAFE RETRIES — any stable string: a repeat of the SAME publish within 24h returns the ORIGINAL post id instead of posting again (an identical publish is also auto-recognised for 10 minutes). On a timeout or an ambiguous error, CALL AGAIN WITH THE SAME KEY: it reports the original post or publishes it once. It never posts twice.'),
19254
19316
  allowDuplicate: z.boolean().optional().describe('post it even though an identical post was just made or attempted. Only pass this when the user genuinely wants the same thing posted twice, or when you have LOOKED at the account and confirmed a timed-out attempt did not land.'),
19255
- altText: z.union([z.string(), z.array(z.string())]).optional().describe('accessibility alt text (max 4086 characters, ~120 recommended). A STRING describes every image; an ARRAY describes each slide of a multi-image post separately, in slide order \u2014 LinkedIn stores altText per image, and their own sample request carries a different one on each. Not available on a PERSONAL-profile post: LinkedIn\u2019s member posting API has no alt-text field at all.'),
19317
+ altText: z.union([z.string(), z.array(z.string())]).optional().describe('accessibility alt text (max 4086 characters, ~120 recommended). A STRING describes every image; an ARRAY describes each slide of a multi-image post separately, in slide order \u2014 LinkedIn stores altText per image, and their own sample request carries a different one on each. Also works on a personal-profile post (post_to_linkedin).'),
19256
19318
  title: z.string().optional().describe('video title'),
19257
19319
  captionsSrt: z.string().optional().describe('CLOSED CAPTIONS for a videoUrl post — the SubRip (.srt) CONTENT itself, cue numbers and `00:00:00,000 --> 00:00:02,000` timing lines included, NOT a URL and NOT the plain script (a file with no timings is refused, because LinkedIn would accept it and then silently never show it). Most of LinkedIn is watched with the sound off, so an uncaptioned video is one most of the feed never hears. LinkedIn allows ONE caption file per video and ENGLISH ONLY; it can be attached only WHILE the video is uploaded, never added to a published post; and it is processed asynchronously, so the reply confirms it was UPLOADED and never that it is visible yet. Requires videoUrl — passing it on an image, carousel or link post is refused by name.'),
19258
19320
  videoThumbnailUrl: z.string().optional().describe('the COVER IMAGE for a videoUrl post — a Hermoso-hosted image (a render, or any picture of the user\u2019s via upload_file). Without it LinkedIn adds a system-generated thumbnail, which on an ad is usually whatever the first frame happens to be. Like captions this can only be set WHILE the video is uploaded, never afterwards. Requires videoUrl. This is NOT linkThumbnailUrl, which is the picture on a link-preview card.'),
@@ -19329,6 +19391,16 @@ function buildTools(rawServer, opts = {}, sink = null) {
19329
19391
  // Three vendor facts are put in the description rather than left to the model to discover the hard way: the
19330
19392
  // 12-month retention, the ~2-day follower reporting lag, and the fact that LinkedIn OMITS zero-activity rows —
19331
19393
  // which is how "we have no data on that post" becomes a confident "it got no engagement".
19394
+ server.registerTool('linkedin_profile_followers', {
19395
+ title: 'Followers of a personal LinkedIn profile',
19396
+ description: 'Follower count of the PERSONAL LinkedIn profile shared with this brand — the lifetime total and, with startDate / endDate, the new followers per day (LinkedIn\u2019s memberFollowersCount). This is the creator / founder growth number; Company Page followers are in linkedin_page_analytics. It needs a personal profile ticked in Manage accounts (list_connector_accounts provider linkedin) and the LinkedIn analytics authorization on the LinkedIn card — if the reply says re-authorize, say exactly that. Read-only, free.',
19397
+ inputSchema: {
19398
+ startDate: z.string().optional().describe('YYYY-MM-DD — include for a daily series'),
19399
+ endDate: z.string().optional().describe('YYYY-MM-DD, exclusive, default today'),
19400
+ },
19401
+ outputSchema: { ok: z.boolean().optional(), followers: z.number().nullable().optional(), gained: z.number().nullable().optional(), daily: z.array(z.any()).optional(), note: z.string().optional() },
19402
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
19403
+ }, wrap(async (a) => { const d = await apiGet('/api/linkedin/member-followers', a); return ok(d.note, d); }));
19332
19404
  server.registerTool('linkedin_page_analytics', {
19333
19405
  title: 'Organic performance of a LinkedIn company Page',
19334
19406
  description: 'ORGANIC performance for one of the brand’s LinkedIn COMPANY PAGES: total followers, followers gained (organic vs paid) across the window, Page views (all / unique / desktop / mobile), and the impressions, unique impressions, clicks, likes, comments, shares and engagement rate of the Page’s posts. This is what answers “is our LinkedIn actually working” and “did that post land”. It is NOT linkedin_ads_report — that covers PAID campaigns; LinkedIn excludes sponsored activity from these figures entirely. Pass postUrns (the urn:li:share:… / urn:li:ugcPost:… that post_to_linkedin_page returned) for PER-POST numbers; LinkedIn forbids a date range together with named posts, so that switches to lifetime-per-post. Only Pages the user ticked in Manage accounts (or call list_connector_accounts with provider linkedin, then set_connector_accounts) are readable — a Page the account merely administers is refused, by design. LinkedIn keeps 12 months, follower figures run about 2 days behind, and it OMITS posts with no recorded activity rather than returning zeros: report an absent post or an unavailable section as MISSING data, never as zero. Read-only, 0 credits. Needs LinkedIn connected with the organization scopes.',
@@ -19337,6 +19409,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
19337
19409
  startDate: z.string().optional().describe('YYYY-MM-DD, default 28 days ago (LinkedIn keeps 12 months)'),
19338
19410
  endDate: z.string().optional().describe('YYYY-MM-DD, default today'),
19339
19411
  postUrns: z.array(z.string()).optional().describe('urn:li:share:… / urn:li:ugcPost:… — switches to per-post lifetime numbers instead of the Page total'),
19412
+ demographics: z.boolean().optional().describe('also return WHO follows the Page and who visits it (all-time, LinkedIn\u2019s lifetime statistics): the top seniorities, industries, job functions, countries, regions and company sizes, each named — the core B2B "who is our audience" answer'),
19340
19413
  },
19341
19414
  outputSchema: {
19342
19415
  organizationId: z.string().optional(), organizationName: z.string().optional(), url: z.string().optional(),
@@ -19345,7 +19418,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
19345
19418
  followerGains: z.object({ organic: z.number().optional(), paid: z.number().optional(), total: z.number().optional(), through: z.string().optional() }).nullable().optional(),
19346
19419
  pageViews: z.object({ all: z.number().optional(), unique: z.number().nullable().optional(), desktop: z.number().optional(), mobile: z.number().optional() }).nullable().optional(),
19347
19420
  posts: z.any().nullable().optional(), perPost: z.array(z.any()).nullable().optional(),
19348
- noActivity: z.array(z.string()).optional(), unavailable: z.array(z.any()).optional(), note: z.string().optional(),
19421
+ noActivity: z.array(z.string()).optional(), unavailable: z.array(z.any()).optional(), note: z.string().optional(), demographics: z.any().optional(),
19349
19422
  },
19350
19423
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
19351
19424
  }, wrap(async (a) => {
@@ -24589,6 +24662,7 @@ function memoryNoteVerdict(text) {
24589
24662
  title: 'List the Page’s / Instagram account’s own posts',
24590
24663
  description: "List the connected Facebook Page's or Instagram account's OWN existing posts — id, caption, permalink, publish date and format. THIS IS THE TOOL THAT GETS YOU THE postId every other Meta read needs: meta_post_insights, list_meta_comments and manage_meta_post all require one. Use it for \"how did our last few posts do\", to find a post the user describes loosely, or before backfill_posts. Pass target:'instagram' for the linked IG account (Stories are excluded — Meta's media edge does not return them); Facebook hides unpublished drafts unless you ask for them. Only ever reads a Page the brand has connected. Read-only, 0 credits.",
24591
24664
  inputSchema: {
24665
+ tagged: z.boolean().optional().describe('INSTAGRAM — list posts OTHER accounts tagged the brand in (UGC, creator posts, partner mentions), with each author’s @handle, instead of the brand’s own posts. Free.'),
24592
24666
  ...MANAGE_BRAND,
24593
24667
  target: z.enum(['facebook', 'instagram']).optional().describe("default facebook; 'instagram' reads the Page's linked IG business account"),
24594
24668
  account: z.string().optional().describe('which Instagram account — an @handle or id from list_connector_accounts("instagram"). Needed when several are linked, and the way an Instagram Login (standalone) account is reached; omit for the one Page-linked account.'),
@@ -24600,8 +24674,8 @@ function memoryNoteVerdict(text) {
24600
24674
  outputSchema: { target: z.string().optional(), account: z.string().nullable().optional(), pageId: z.string().optional(), posts: z.array(z.any()).optional(), cursor: z.string().nullable().optional(), note: z.string().optional(), sizeNote: z.string().optional() },
24601
24675
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
24602
24676
  }, wrap(async (a) => {
24603
- const d = await apiGet('/api/meta/posts', queryBrand(a, { ...(a.target ? { target: a.target } : {}), ...(a.account ? { account: a.account } : {}), ...(a.pageId ? { pageId: a.pageId } : {}), ...(a.limit ? { limit: a.limit } : {}), ...(a.cursor ? { cursor: a.cursor } : {}), ...(a.includeUnpublished ? { includeUnpublished: 'true' } : {}) }));
24604
- const rows = (d.posts || []).map(p => `• ${String(p.caption || '(no caption)').replace(/\s+/g, ' ').slice(0, 80)} — ${p.id}${p.publishedAt ? ` · ${String(p.publishedAt).slice(0, 10)}` : ''} · ${p.mediaKind}${p.url ? ` ${p.url}` : ''}`);
24677
+ const d = await apiGet('/api/meta/posts', queryBrand(a, { ...(a.target ? { target: a.target } : {}), ...(a.tagged ? { tagged: 'true', target: 'instagram' } : {}), ...(a.account ? { account: a.account } : {}), ...(a.pageId ? { pageId: a.pageId } : {}), ...(a.limit ? { limit: a.limit } : {}), ...(a.cursor ? { cursor: a.cursor } : {}), ...(a.includeUnpublished ? { includeUnpublished: 'true' } : {}) }));
24678
+ const rows = (d.posts || []).map(p => `• ${p.author ? `${p.author}: ` : ''}${String(p.caption || '(no caption)').replace(/\s+/g, ' ').slice(0, 80)} — ${p.id}${p.publishedAt ? ` · ${String(p.publishedAt).slice(0, 10)}` : ''} · ${p.mediaKind}${p.url ? ` ${p.url}` : ''}`);
24605
24679
  // A SHORTENED PAGE HAS TO SAY SO ON THE SURFACE PEOPLE USE (2026-09-17). `sizeNote` is how the server reports
24606
24680
  // that Meta refused the full page and it asked for fewer rows — and it was produced by the route and read by
24607
24681
  // NOBODY, so on MCP a half page was indistinguishable from the whole feed, which is precisely what the ladder's
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.458",
3
+ "version": "0.1.461",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
- "description": "AI marketing agent and AI ad generator, run from your own AI agent. 1035 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.",
5
+ "description": "AI marketing agent and AI ad generator, run from your own AI agent. 1037 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"