hermosodocs

Publishing & scheduling

Publish organically to the channels a brand has connected, or queue posts on a schedule.

77 tools. Every name, description and parameter on this page is generated from the running MCP server — see how this reference is built.

META engagement + insights

meta_page_insights#

Facebook Page + Instagram insights read-only

Organic performance for the brand’s connected Facebook Page — views and unique reach (page_media_view / page_total_media_view_unique, Meta’s own replacements for the impressions family it retired), post engagements, video views, daily follows, plus follower and Page-like counts — with the linked Instagram account’s headline numbers alongside. This is ORGANIC reach; use meta_insights for paid ad performance, and instagram_insights for the full Instagram set and its audience demographics. Any metric Meta returns no value for is named as MISSING data, which must never be reported as zero.

ParameterTypeDescription
pageIdstringPage id — omit when the brand has exactly one Page connected
period"day" | "week" | "days_28"window (default week)

meta_post_insights#

Insights for one Facebook/Instagram post read-only

Performance for a single organic post — on Facebook views/reach (post_media_view, post_total_media_view_unique — Meta’s own replacements for the retired impressions family), clicks, reactions and video watch time; on Instagram views, reach, likes, comments, saves, shares, total interactions and (where the media type has them) follows, profile visits, story navigation and reel watch time. Use it to find which organic posts earned their reach before turning one into a paid ad. A metric Meta returns no value for is reported by name as MISSING — never read it as zero.

ParameterTypeDescription
postIdrequiredstringpost/media id returned by post_to_meta
target"facebook" | "instagram"which metric set to ask for (default facebook)
pageIdstringPage id — omit when only one Page is connected

instagram_insights#

Instagram account insights + audience demographics read-only

ACCOUNT-level performance for the brand’s connected Instagram Business account — views, reach, accounts engaged, total interactions, likes, comments, shares, saves, profile link taps, replies, reposts and follows/unfollows — plus the AUDIENCE DEMOGRAPHICS (follower_demographics and engaged_audience_demographics, broken down by age, city, country or gender), which is the read that says WHO the content reached rather than how many. Use meta_post_insights for one post and meta_page_insights for the Facebook Page. THERE IS NO "impressions": Meta deprecated it for every API version on 2025-04-21 and replaced it with "views" — an unknown metric is refused by name rather than quietly dropped. Instagram returns NO demographics for an account under 100 followers (or under 100 engagements in the window), and an absent block means exactly that, never an empty audience. Read-only, 0 credits. Needs Meta connected with an Instagram Business account linked to the Page.

ParameterTypeDescription
metricsstring[]account metrics (default: views, reach, accounts_engaged, total_interactions, likes, comments, shares, saves, profile_links_taps). Add follower_demographics or engaged_audience_demographics for the audience, which also needs a breakdown.
breakdownstring[]contact_button_type / follow_type / media_product_type for account metrics; age / city / country / gender for the demographic metrics (exactly one)
timeframe"last_14_days" | "last_30_days" | "last_90_days" | "prev_month" | "this_month" | "this_week"window for the demographic metrics only (default last_30_days)
period"day" | "week" | "days_28"aggregation for reach, the one time-series metric (default day)
sincestringYYYY-MM-DD window start
untilstringYYYY-MM-DD window end
pageIdstringFacebook Page id the Instagram account is linked to — omit when only one Page is connected

list_instagram_media#

List the brand’s Instagram posts read-only

The connected Instagram Business account’s own recent media — id, caption, media type (feed / reel / story-era), permalink, timestamp, like and comment counts. This is where the media id every other Instagram tool needs comes from: resolve “my latest reel” yourself instead of asking the user for a link, then pass the id to meta_post_insights. Read-only, 0 credits.

ParameterTypeDescription
limitnumberhow many (1–50, default 15)
pageIdstringFacebook Page id — omit when only one Page is connected

list_meta_comments#

Read comments on a Meta post read-only

Read the comments under a Facebook Page post or Instagram media object — customer questions, objections and the exact language real people use about the product. Good raw material for ad copy, and the first step before replying or moderating.

ParameterTypeDescription
postIdrequiredstringpost/media id
pageIdstringPage id — omit when only one Page is connected
limitnumberhow many comments (1–50, default 25)

reply_to_meta_comment#

Reply to a Facebook/Instagram comment

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.

ParameterTypeDescription
commentIdrequiredstringcomment id from list_meta_comments
messagerequiredstringreply text
pageIdstringPage id — omit when only one Page is connected

moderate_meta_comment#

Hide, unhide or delete a Meta comment destructive

Moderate a comment on the brand’s Facebook or Instagram post. Prefer hide over delete — hiding is reversible and invisible to the commenter. Deleting is PERMANENT and requires confirm:true after the user has agreed.

ParameterTypeDescription
commentIdrequiredstringcomment id from list_meta_comments
action"hide" | "unhide" | "delete"default hide
confirmbooleanrequired (true) only for delete
pageIdstringPage id — omit when only one Page is connected

THREADS read + manage

list_threads_posts#

List your Threads posts read-only

List recent posts on the brand’s connected Threads account (id, text, media, permalink, timestamp). Use it to find a post id for threads_insights, list_threads_replies, reply_to_thread or delete_thread.

ParameterTypeDescription
limitnumberhow many posts (1–50, default 15)

threads_insights#

Threads insights read-only

Performance for ONE Threads post (views, likes, replies, reposts, quotes, shares) when postId is given, or for the whole ACCOUNT when it is omitted — views, likes, replies, reposts, quotes, LINK CLICKS, follower count, and follower_demographics broken down by country, city, age or gender. Note the two metric sets differ: "clicks" exists only at account level and "shares" only on a single post, and an unknown metric is refused by name rather than dropped. since/until narrow the account window (Threads has no data before 2024-04-13, and followers_count / follower_demographics are lifetime metrics that ignore a window — the reply says so when that happens). Threads returns no demographics below 100 followers; an absent block means the account is under Meta’s floor, NOT that the audience is empty.

ParameterTypeDescription
postIdstringpost id from list_threads_posts — omit for account-level insights
metricsstring[]account metrics: views, likes, replies, reposts, quotes, clicks, followers_count, follower_demographics
breakdownstring[]country / city / age / gender — required by follower_demographics, exactly one
sincestringYYYY-MM-DD window start (account scope)
untilstringYYYY-MM-DD window end (account scope)

list_threads_replies#

List replies on a Threads post read-only

Read the replies on a Threads post. Set conversation:true to walk the entire thread rather than only direct replies. Use before reply_to_thread so you answer with the actual conversation in view.

ParameterTypeDescription
postIdrequiredstringpost id from list_threads_posts
conversationbooleantrue = the whole thread, not just direct replies
limitnumberhow many replies (1–50, default 25)

reply_to_thread#

Reply on Threads

Post a reply to a Threads post — the brand’s own or someone else’s. This PUBLISHES publicly under the brand’s account, so show the user the exact wording and get their go-ahead first.

ParameterTypeDescription
replyToIdrequiredstringthe post id being replied to
textrequiredstringreply text (max 500 characters)

hide_thread_reply#

Hide or unhide a Threads reply

Hide a reply on the brand’s Threads post (or unhide it with hide:false) — for spam and abuse moderation.

ParameterTypeDescription
replyIdrequiredstringreply id from list_threads_replies
hidebooleanfalse to UNHIDE (default true)

delete_thread#

Delete a Threads post destructive

Permanently delete one of the brand’s Threads posts. IRREVERSIBLE — you must confirm with the user first, then pass confirm:true.

ParameterTypeDescription
postIdrequiredstringpost id from list_threads_posts
confirmrequiredbooleanmust be true; only set it after the user has explicitly agreed to the deletion

list_threads_mentions#

Threads mentions of the brand read-only

Posts where someone MENTIONED the brand on Threads — anywhere, not just under your own posts. This is brand listening: real objections, questions and the exact language customers use, which is strong raw material for ad copy and for mine_angles. Use list_threads_replies instead when you want the conversation under one specific post.

ParameterTypeDescription
limitnumberhow many mentions (1–50, default 25)

search_threads_keyword#

Search Threads by keyword read-only

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.

ParameterTypeDescription
qrequiredstringkeyword or phrase
searchType"TOP" | "RECENT"TOP (default) or RECENT

META publishing + ads management

list_meta_pages#

List Meta pages & ad accounts read-only

List the Facebook Pages (with any linked Instagram business account) and ad accounts on the connected Meta account — use before post_to_meta / create_meta_campaign to pick the target. Requires the user to have connected Meta (Settings ▸ Connectors ▸ Meta); returns a connect hint if not.

No parameters.

upload_file#

Upload a local file → durable public URL

Persist an ARBITRARY user file (image or video, 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_reddit / 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: `path` (a local file — works ONLY when Hermoso runs locally over stdio/CLI; the hosted connector can't see the user's machine), or `dataUri` (a base64 data: URI — keep under ~15MB on the hosted connector). 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. Returns {url, kind, bytes}.

ParameterTypeDescription
pathstringlocal filesystem path (stdio/CLI only — refused on the hosted connector)
dataUristringbase64 data: URI of the file bytes (data:<mime>;base64,<…>)
namestringoriginal file name — helps pick the right extension

search_threads_locations#

Find a place to tag on Threads read-only

Search Threads’ public place index by name (or by latitude+longitude) and get location ids. Use this when the brand has a PHYSICAL location — a restaurant, salon, gym, store — so the post can be geotagged to it. Pass the chosen id as post_to_meta(locationId) with target:"threads".

ParameterTypeDescription
qstringplace name to search, e.g. "Osteria Francescana"
latitudenumberlatitude (use with longitude to search near a point)
longitudenumberlongitude

post_to_meta#

Post to Facebook, Instagram or Threads

Publish to a connected Facebook Page, its linked Instagram, OR the brand’s Threads account — text/link/image/VIDEO/CAROUSEL. A MULTI-SLIDE creative is a CAROUSEL, not several posts: pass the slides in order as imageUrls[] and they publish as ONE swipeable post (Instagram album, Threads carousel, Facebook multi-photo post). Never publish slide 1 of a deck on its own — the creative tells the viewer to swipe. target:"facebook" (default) posts to the Page; target:"instagram" publishes a photo or Reel to the linked IG business account (needs an image or video); target:"threads" posts to the connected Threads account (text, image, or video). Works with ANY media — a finished Hermoso ad OR an arbitrary user file: imageUrl/videoUrl accept a public https URL, a data: URI, or a Hermoso /generated path; for a LOCAL file (e.g. on the user’s desktop) call upload_file first and pass the url it returns. This PUBLISHES immediately — confirm the copy + media with the user first. Needs a connected Meta account (Settings ▸ Connectors ▸ Meta) with posting permission; Threads needs its own connection.

ParameterTypeDescription
hookstringWHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer "which hooks work", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. "direct_callout", "mid_problem", "before_after") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works.
subjectstringWHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. "winter coat", "free trial", "founder story"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group.
messagestringpost text / caption
imageUrlstringpublic https URL, a data: URI, or a Hermoso /generated path (upload_file gives you one for a local file)
videoUrlstringpublic https URL, data: URI, or /generated path — FB video post / IG Reel
imageUrlsstring[]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–10 (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.
idempotencyKeystringSAFE RETRIES. Publishing can take minutes (a video upload, a carousel of ten slides) and a transport can time out while the post SUCCEEDS — retrying blind is how the same thing gets posted twice. Pass any stable string here and a repeat of the SAME publish returns the ORIGINAL post id instead of posting again (24h). You do not have to: an identical publish is auto-recognised for 10 minutes anyway. If a call times out or errors ambiguously, CALL AGAIN WITH THE SAME KEY — that is the safe move, and it will either report the original post or publish it for the first time. It never posts twice.
allowDuplicatebooleanpost 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.
asyncbooleanpublish 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.
linkstringa URL to attach (FB text post only)
target"facebook" | "instagram" | "threads"default facebook; instagram → the Page’s linked IG; threads → the brand’s connected Threads account
scheduleAtstringFACEBOOK 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.
locationIdstringThreads only — a place id from search_threads_locations, to geotag the post to a physical location (restaurant, storefront)
pageIdstringtarget Page id (from list_meta_pages); omit = first Page

SCHEDULING

schedule_post#

Schedule a post for later

Queue a post to go out at a future time, to one or more connected channels at once (facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, google_business). A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order — never schedule just its first slide. This is how you run a content calendar: schedule now, and Hermoso publishes at the time you set — you do not need to be around. Either name the exact time in `at`, or pass `useQueue:true` to take the brand’s next free POSTING SLOT (its saved posting times, skipping any already occupied) — that is what “just queue it” means and it saves the user picking a minute. Pass a Hermoso render URL as imageUrl/videoUrl (or an upload_file URL for external media). PINTEREST AND YOUTUBE ALSO SHOW A TITLE: pass `title` (max 100 chars) — omit it and Hermoso derives one from the caption’s first sentence rather than truncating the caption mid-word, which is what a Pin headline used to be. A YOUTUBE ITEM’S CAPTION IS ITS TITLE, NOT ITS DESCRIPTION: pass `description` (≤5000 chars) for the box under the video — the links, the CTA and everything YouTube search reads — plus `tags` (up to 30). Omit them and the upload lands with an empty description, which is not recoverable by the time anyone notices. Use `captions` to give each channel its own wording; anything not listed falls back to `message`. PER-CHANNEL SETTINGS, all carried straight through to the real publisher: TIKTOK takes the paid-partnership disclosure (`brandedContent`) and the own-brand one (`yourBrand`) — set them whenever the post is commercial, they are compliance declarations — plus `disableComment` and, on a video, `disableDuet` / `disableStitch` / `coverTimestampMs`. GOOGLE BUSINESS takes `topicType` (STANDARD / EVENT / OFFER / ALERT) with `event` and `offer`, and a real `actionType` button instead of the hard-coded Learn more. X takes a whole `thread`, a `poll`, `replySettings` and `madeWithAi`. Channels are attempted INDEPENDENTLY, so one failing channel never blocks the others. SOME CHANNELS MUST BE TOLD WHICH ACCOUNT, and Hermoso never guesses one: a Pinterest pin needs `boardId` (list_pinterest_boards) or it is refused outright; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages) and without it the post goes to the connected person’s own profile; a brand with more than one connected Facebook Page needs `pageId` (list_meta_pages) and an account managing more than one Google Business listing needs `locationId` (list_business_locations) — resolve those FIRST and let the user pick, because with several to choose from and no id the post is refused when it fires, hours later. A scheduled post GOES LIVE PUBLICLY by default on every channel — that is what scheduling means, and nothing is ever quietly downgraded to a draft or an unlisted upload. If the user genuinely wants something staged instead, set `visibility` (or `visibilityByChannel` for just one channel): ‘public’ (default, live) · ‘unlisted’ (YouTube only — link-only) · ‘private’ (YouTube private, or TikTok posted SELF_ONLY) · ‘draft’ (TikTok drafts, or an unpublished Facebook Page post for a human to publish). Ask for a weaker visibility only if the user asked for one. If a channel cannot do the visibility requested, the call is REFUSED right now with the reason, rather than posting something weaker later. Most channels publish publicly and nothing else: only YouTube has unlisted/private, only TikTok has private/draft, and only Facebook has draft.

ParameterTypeDescription
hookstringWHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer "which hooks work", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. "direct_callout", "mid_problem", "before_after") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works.
subjectstringWHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. "winter coat", "free trial", "founder story"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group.
channelsrequired"facebook" | "instagram" | "threads" | "tiktok" | "youtube" | "linkedin" | "x" | "pinterest" | "google_business"[]one or more channels to post to at that time
atstringwhen to post — ISO timestamp (2026-08-01T09:00:00Z) or epoch milliseconds. Must be in the future, at most 365 days out. Give this OR useQueue, never both.
useQueuebooleaninstead of naming a minute, drop this into the brand’s POSTING QUEUE: Hermoso takes the earliest of its saved posting times that is still free (skipping any slot another queued post already holds). This is the natural answer to “just queue it” / “post it at my next opening”. Mutually exclusive with `at` — passing both is refused rather than one being silently preferred. If the brand has no posting times set, or every slot for the next 90 days is taken, it is refused by name and nothing is scheduled.
timezonestringIANA zone for the queue, e.g. "America/New_York" — only meaningful with useQueue, and it overrides the brand’s saved zone for this one post. A saved slot of "09:00" is a WALL-CLOCK time, so the zone is what turns it into an instant; without either the brand’s saved zone or this, the queue resolves in UTC.
messagestringthe caption/text used for every channel unless overridden in captions
captionsobjectper-channel caption overrides, e.g. { "instagram": "…", "threads": "…" } — platforms want different lengths and hashtag conventions
imageUrlstringa Hermoso render URL (a /generated path, or what upload_file returned), a data: URI, or a public https URL. NOTE: only Facebook/Instagram/Threads accept an arbitrary public URL — X, TikTok, YouTube, LinkedIn, Pinterest and Google Business re-host the bytes and REFUSE anything that is not a Hermoso render, so run an external file through upload_file first and schedule that url.
videoUrlstringa Hermoso render URL (a /generated path, or what upload_file returned), a data: URI, or a public https URL — required for youtube, and for tiktok unless you pass an imageUrl (TikTok takes a photo post too). Same origin rule as imageUrl: everything except Facebook/Instagram/Threads REFUSES a non-Hermoso URL, so pass external video through upload_file first.
imageUrlsstring[]CAROUSEL — an ORDERED list of image URLs to publish as ONE swipeable post on every channel that supports it (Instagram 2–10, Threads 2–20, Facebook, LinkedIn company Pages 2–20, Pinterest 2–5, TikTok up to 35 as a photo post). Use this whenever the creative is a multi-slide deck: a scheduled post carrying only slide 1 of a “1/6 · SWIPE” set is a broken ad that nobody is watching when it fires. THE ORDER IS THE PRODUCT. A channel on this schedule that cannot do carousels — X, YouTube, Google Business Profile — is REFUSED NOW, with the reason, so you can drop it or give it its own single image; it is never quietly downgraded hours later.
titlestringPINTEREST / YOUTUBE — the headline, max 100 characters. Pinterest shows it in search and under the pin; YouTube requires one. Leave it out and Hermoso derives one from that channel’s caption (first sentence, cut on a word boundary, trailing hashtags dropped) — set a real one whenever the caption does not open with a usable headline.
descriptionstringYOUTUBE — the video DESCRIPTION, max 5000 characters: the box under the video carrying the links, the CTA and everything YouTube search reads. It is NOT the caption — a scheduled YouTube item’s text becomes its TITLE — so omitting this publishes the video with an empty description.
tagsstring[]YOUTUBE — up to 30 search tags for the video (plain words, no #).
altTextstringACCESSIBILITY — the screen-reader description of the attached image. X (max 1000 characters), Pinterest (max 500) and LinkedIn COMPANY PAGE posts each attach it to the picture; the schedule is REFUSED if it is longer than the tightest of the channels on it, rather than truncated on the way out. Write one whenever the post carries an image: describe what is IN the picture, never a repeat of the caption, which a screen reader already reads. A LinkedIn post to a PERSONAL profile carries none — LinkedIn’s member posting API has no alt-text field at all.
linkstringa link to attach (Facebook)
brandedContentbooleanTIKTOK — the PAID-PARTNERSHIP disclosure (TikTok’s brand_content_toggle): true when this post promotes a THIRD-PARTY business. It is a compliance declaration, not a preference — set it whenever the post is sponsored. TikTok only accepts branded content on a public or friends-only post, so it cannot ride a private/SELF_ONLY or draft TikTok item and the schedule is refused with the reason.
yourBrandbooleanTIKTOK — the OWN-BRAND disclosure (brand_organic_toggle): true when the post promotes the creator’s own business. TikTok asks for at least one of this and brandedContent once a post is commercial.
disableCommentbooleanTIKTOK — turn comments off on this post.
disableDuetbooleanTIKTOK VIDEO ONLY — block Duets. TikTok’s photo-post API has no Duets, so this is refused on a photo/slideshow item rather than silently dropped.
disableStitchbooleanTIKTOK VIDEO ONLY — block Stitches. Same photo-post rule as disableDuet.
coverTimestampMsnumberTIKTOK VIDEO ONLY — which frame TikTok uses as the cover, in milliseconds from the start. Omit and TikTok uses the first frame.
topicType"STANDARD" | "EVENT" | "OFFER" | "ALERT"GOOGLE BUSINESS — the KIND of Post. STANDARD is the default; EVENT and OFFER both REQUIRE `event` (title + start date), and OFFER also takes `offer`.
actionType"BOOK" | "ORDER" | "SHOP" | "LEARN_MORE" | "SIGN_UP" | "CALL"GOOGLE BUSINESS — the call-to-action button. Every button except CALL needs `link` (CALL dials the number on the listing and takes none). Google IGNORES the button link on an OFFER post — put the destination in offer.redeemOnlineUrl. Omit and a post carrying a link gets LEARN_MORE.
eventobjectGOOGLE BUSINESS — required for an EVENT or OFFER post: {title, startDate:"YYYY-MM-DD", endDate, startTime:"HH:MM", endTime}. `title` is the EVENT’s headline, a different thing from the post `title` (which is the Pinterest/YouTube one). Google documents its TimeInterval as needing all four date/time parts to be valid, so send the times whenever you know them.
offerobjectGOOGLE BUSINESS — OFFER posts only: {couponCode, redeemOnlineUrl, termsConditions}. redeemOnlineUrl is where an offer actually sends people, since the button link is ignored on an Offer.
threadstring[]X — publish a THREAD, one entry per post, each replying to the one before (at most 25, each ≤280 characters). It REPLACES the X caption: with a thread set, `message`/`captions.x` is not sent to X at all. A thread cannot carry a poll.
pollobjectX — attach a poll: {options:["…","…"], durationMinutes}. 2–4 options of at most 25 characters each; voting runs 5–10080 minutes (7 days), default 1440. X makes a poll MUTUALLY EXCLUSIVE with media, so an item carrying an image or video is refused — schedule the poll as its own X-only post.
replySettings"following" | "mentionedUsers" | "subscribers" | "verified"X — who may reply. Omit for everyone, which is the right default for a brand post.
madeWithAibooleanX — X’s AI-media label on this post. Opt-in: X treats it as the poster’s own claim about their media, so it is never set on the user’s behalf.
boardIdstringPINTEREST — REQUIRED whenever pinterest is a channel: the board the Pin goes on, from list_pinterest_boards. The user picks it; a Pin on the wrong board is a public mistake. Scheduling pinterest without one is refused immediately.
linkedinOrganizationIdstringLINKEDIN — publish as a COMPANY PAGE instead of the connected personal profile. The organization id from list_linkedin_pages. Omit and it posts as the person: an unset id means the profile, never “probably the company”. A Page can also carry VIDEO, which a personal profile cannot.
pageIdstringFACEBOOK / INSTAGRAM / THREADS — which connected Facebook Page (and its linked Instagram) publishes, from list_meta_pages. Needed when the brand has more than one Page connected; with several and no id the post is refused at fire time rather than sent from the wrong brand.
locationIdstringGOOGLE BUSINESS PROFILE — which listing, e.g. 'locations/123' from list_business_locations. Needed when the account manages more than one storefront; it is never chosen for the user.
visibility"public" | "unlisted" | "private" | "draft"how it should be published — DEFAULT 'public' (live). Only pass something else if the user explicitly asked to stage/hide it. Not every channel supports every value; an impossible combination is refused when you schedule it, with the reason.
visibilityByChannelobjectoverride visibility for one channel, e.g. { "tiktok": "draft" } to go live everywhere but stage TikTok for review

WHICH ACCOUNT

list_scheduled#

List scheduled and past posts read-only

Show what is queued to post and what already went out. Each fired item reports PER-CHANNEL outcomes, so you can see that (say) Instagram published and TikTok failed on the same item rather than a single misleading verdict. Read-only, 0 credits.

No parameters.

reschedule_post#

Change a scheduled post

Change a post that is still QUEUED — move it to a different time, rewrite the caption, swap the media, add or drop a channel, or change which board / Page / company Page / listing it goes to. PASS ONLY WHAT CHANGES: an omitted field is left exactly as it was, and an explicit empty string CLEARS one (linkedinOrganizationId:"" moves a company-Page post back to the person’s own profile). The edited item is re-checked against the identical rules its create passed — visibility the channel can honour, per-channel length, media the channel can carry — so an edit can never slip past a refusal that a create would have caught. Get the id from list_scheduled. Something that already went out cannot be changed: a published post is edited or removed with manage_meta_post / manage_linkedin_post / delete_x_post, not rescheduled.

ParameterTypeDescription
idrequiredstringthe scheduled post id from list_scheduled
atstringthe new time — ISO timestamp (2026-08-05T09:00:00Z) or epoch milliseconds. Must be in the future, at most 365 days out.
messagestringreplace the caption used for every channel that has no override
captionsobjectreplaces the WHOLE per-channel caption map — send every override you want to keep, not just the new one
channels"facebook" | "instagram" | "threads" | "tiktok" | "youtube" | "linkedin" | "x" | "pinterest" | "google_business"[]replaces the channel list
imageUrlstringswap the image; "" removes it
videoUrlstringswap the video; "" removes it
imageUrlsstring[]replace the CAROUSEL slides, in order; an empty array [] drops the carousel and goes back to a single image. Omit to leave the slides exactly as they are. The edited item is re-checked against the same carousel rules the create passed, so adding a channel that cannot swipe is refused now rather than posting slide 1 later.
titlestringPINTEREST / YOUTUBE — replace the headline; "" clears it and goes back to deriving one from the caption
descriptionstringYOUTUBE — replace the video description; "" clears it. Remember the caption is the TITLE, not the description.
tagsstring[]YOUTUBE — replaces the WHOLE tag list; an empty array [] clears the tags.
altTextstringACCESSIBILITY — replace the image’s screen-reader description (used by X, Pinterest and LinkedIn company Pages); "" clears it.
linkstring
brandedContentbooleanTIKTOK — the paid-partnership disclosure; false turns it off.
yourBrandbooleanTIKTOK — the own-brand disclosure; false turns it off.
disableCommentbooleanTIKTOK — comments off on this post.
disableDuetbooleanTIKTOK VIDEO ONLY — block Duets.
disableStitchbooleanTIKTOK VIDEO ONLY — block Stitches.
coverTimestampMsnumberTIKTOK VIDEO ONLY — cover frame in milliseconds.
topicType"STANDARD" | "EVENT" | "OFFER" | "ALERT"GOOGLE BUSINESS — the kind of Post; EVENT and OFFER both require `event`.
actionType"BOOK" | "ORDER" | "SHOP" | "LEARN_MORE" | "SIGN_UP" | "CALL"GOOGLE BUSINESS — the call-to-action button; "" clears it.
eventobjectGOOGLE BUSINESS — replaces the whole event record {title, startDate, startTime, endDate, endTime}.
offerobjectGOOGLE BUSINESS — replaces the whole offer record {couponCode, redeemOnlineUrl, termsConditions}.
threadstring[]X — replaces the WHOLE thread; an explicit [] drops back to a single post using the caption.
pollobjectX — replaces the poll; an empty options list removes it.
replySettings"following" | "mentionedUsers" | "subscribers" | "verified"X — who may reply; "" goes back to everyone.
madeWithAibooleanX — the AI-media label; false turns it off.
boardIdstringPINTEREST — move the Pin to a different board (list_pinterest_boards)
linkedinOrganizationIdstringLINKEDIN — target a different company Page, or "" to post as the connected person instead
pageIdstringFACEBOOK / INSTAGRAM / THREADS — publish from a different connected Page (list_meta_pages)
locationIdstringGOOGLE BUSINESS — a different listing (list_business_locations)
visibility"public" | "unlisted" | "private" | "draft"NOTE: changing this without also naming visibilityByChannel clears any per-channel overrides, so "make it all draft" is not a no-op
visibilityByChannelobject

cancel_scheduled#

Cancel a scheduled post destructive

Remove a queued post before it goes out. Get the id from list_scheduled. Only works while it is still queued — something already published cannot be unsent (use manage_meta_post to delete a Facebook/Instagram post after the fact).

ParameterTypeDescription
idrequiredstringthe scheduled post id from list_scheduled

THE POSTING REFILL

get_post_refill#

Posting refill status read-only

Show the automatic posting refill for this brand: whether it is on, whether it is in dry-run (preview) mode, how many days ahead it fills, its render budget, when it next runs, and how many posts are queued right now. It also names the channels that CANNOT be posted to and why. Read-only, free.

No parameters.

set_post_refill#

Configure the posting refill

Turn the automatic posting refill on or off and set how it behaves. PASS ONLY WHAT CHANGES. `enabled:false` is the PAUSE — it removes the recurring job outright, and posts already queued are left alone (cancel those with cancel_scheduled if you want them gone). It starts in dryRun, which plans and previews without queueing; set dryRun:false only once a human has read a preview from run_post_refill. THE CADENCE IS THE BRAND’S POSTING TIMES, not a number here: three posting times means three posts a day. Raising maxImagesPerDay / maxVideosPerDay / maxCreditsPerDay above 0 lets it SPEND on new creative — at 0 (the default) it only reuses renders already in the Library and costs nothing.

ParameterTypeDescription
enabledbooleanon/off. false PAUSES it: the recurring job is deleted and nothing new is queued. Already-queued posts are untouched.
dryRunbooleantrue (the default) = plan and preview only, queue nothing. Set false ONLY after a human has read a preview.
daysAheadnumberhow far ahead to keep the queue full, 1–30 (default 7)
postsPerDaynumbercap the posts per day BELOW the number of posting times. 0 (default) = use every posting time, which is where "3 a day" comes from. To post MORE per day, add posting times instead.
assetCooldownDaysnumberhow long before a Library render may be posted again (default 30). It never repeats one inside this window — it queues fewer posts and says so.
maxImagesPerDaynumberhow many NEW images a day it may render when the Library runs dry. 0 (default) = none, spend nothing.
maxVideosPerDaynumberhow many NEW videos a day it may render. 0 (default) = none. Video is the expensive one — hundreds of credits each.
maxCreditsPerDaynumbera hard credit ceiling per day, checked BEFORE any render starts. It binds independently of the counts above.
channels"facebook" | "instagram" | "threads" | "tiktok" | "youtube" | "linkedin" | "x" | "pinterest"[]restrict it to these channels. Omit (or send an empty list) to use every connected channel that can carry each post.
boardIdstringPINTEREST — which board Pins go on (list_pinterest_boards). Without one, Pinterest is skipped: a Pin on the wrong board is a public mistake, so it is never guessed.
linkedinOrganizationIdstringLINKEDIN — which company Page to post as (list_linkedin_pages). A single shared Page is used automatically; a Page that is not shared with this brand is ignored rather than failing the whole post.
pageIdstringFACEBOOK / INSTAGRAM / THREADS — which connected Page to publish from (list_meta_pages). Omit for the brand’s only Page.

run_post_refill#

Run the posting refill

Run the refill NOW instead of waiting for its daily turn. DRY BY DEFAULT: it returns the exact posts it WOULD queue — the caption, the creative, the channels and the per-channel visibility — without queueing anything or spending anything on creative. Pass dryRun:false to actually queue them. SHOW THE PREVIEW TO THE USER BEFORE EVER PASSING dryRun:false; these go onto real public accounts. Every caption is screened against the brand’s own voice rules and a failing one is dropped, so a plan can legitimately come back shorter than the cadence — the reason is in the notes.

ParameterTypeDescription
dryRunbooleandefault TRUE (preview only). false actually queues the posts.
forcebooleanplan even while the refill is switched off — useful for showing someone what it would do before they turn it on. Combined with dryRun:false it still respects a stored dryRun.

post_to_linkedin#

Publish to LinkedIn

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).

ParameterTypeDescription
hookstringWHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer "which hooks work", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. "direct_callout", "mid_problem", "before_after") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works.
subjectstringWHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. "winter coat", "free trial", "founder story"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group.
textrequiredstringthe post text
imageUrlstringa 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).
imageUrlsstring[]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.
idempotencyKeystringSAFE RETRIES. Publishing can take minutes (a video upload, a carousel of ten slides) and a transport can time out while the post SUCCEEDS — retrying blind is how the same thing gets posted twice. Pass any stable string here and a repeat of the SAME publish returns the ORIGINAL post id instead of posting again (24h). You do not have to: an identical publish is auto-recognised for 10 minutes anyway. If a call times out or errors ambiguously, CALL AGAIN WITH THE SAME KEY — that is the safe move, and it will either report the original post or publish it for the first time. It never posts twice.
allowDuplicatebooleanpost 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.
visibility"PUBLIC" | "CONNECTIONS"default PUBLIC

X / TWITTER

post_to_x#

Publish a post to X (Twitter)

Publish to the user’s connected X (Twitter) account — a single post, a post with an image or video render attached, a reply to an existing post, or a whole THREAD (pass `thread` as an array and each part is posted as a reply to the one before). This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Can also run a POLL (2-4 options) instead of media, and restrict who may reply. Each post must be 280 characters or fewer; longer text is REFUSED, never truncated — split it into a thread instead. Write altText whenever you attach a render. COSTS CREDITS: X charges per API request, so every post in a thread is billed, and a post containing a LINK costs roughly 13× one without — mention the cost before publishing a long thread. X ADS are a separate product Hermoso cannot reach: this tool posts ORGANICALLY, it does not create an ad campaign. Needs X connected (Settings ▸ Connectors ▸ X).

ParameterTypeDescription
hookstringWHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer "which hooks work", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. "direct_callout", "mid_problem", "before_after") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works.
subjectstringWHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. "winter coat", "free trial", "founder story"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group.
textstringthe post text, ≤280 characters. Use this OR thread, not both.
threadstring[]a thread: each string is one post (≤280 chars each), published in order, each replying to the previous. Max 25.
mediaUrlstringa Hermoso render (image or video) to attach to the first post — pass its served URL, or an upload_file url for external media
altTextstringaccessibility description of the attached media, max 1000 characters — write one whenever you attach a render. Costs a small extra amount: X bills the metadata write separately.
pollobjectrun a poll on the post. X does not allow a poll and media on the same post, and a poll cannot ride on a thread.
replySettings"following" | "mentionedUsers" | "subscribers" | "verified"restrict who can reply — omit for everyone, which is the right default for a brand post
replyToIdstringnumeric id of an existing X post to reply to

delete_x_post#

Delete a post on X destructive

Permanently delete one of the connected account’s posts on X. This CANNOT be undone — confirm the exact post with the user first. Costs credits (X bills per API call). Needs X connected.

ParameterTypeDescription
idrequiredstringthe numeric X post id — the last part of the post URL

x_post_metrics#

Read performance of a post on X read-only

Read the PUBLIC metrics of a post on X — impressions, likes, reposts, replies, quotes and bookmarks — to judge whether a hook landed before spending more behind it. For the advertiser numbers (link clicks, video views, profile visits) use x_post_insights instead. Costs a small number of credits (X bills per API read). Needs X connected.

ParameterTypeDescription
idrequiredstringthe numeric X post id — the last part of the post URL

x_post_insights#

Advertiser analytics for your own posts on X read-only

Advertiser-grade analytics for the connected account’s OWN posts on X — impressions, engagements, LINK CLICKS, profile visits, video views and video completion quartiles. This is the read that answers “did the creative work”, which x_post_metrics cannot: public metrics show likes and reposts, never clicks or video retention. Takes up to 25 post ids in one call. COSTS CREDITS PER POST READ, so ask about the posts that matter rather than everything. If X returns no rows, say so — that is missing data, not zero performance. Needs X connected.

ParameterTypeDescription
idsrequiredstring[]numeric X post ids (max 25) — the last part of each post URL
granularity"Total" | "Daily" | "Hourly" | "Weekly"default Total

x_post_insights_historical#

Advertiser analytics for your own X posts, over any date range read-only

The same advertiser-grade X analytics as x_post_insights — impressions, engagements, LINK CLICKS, profile visits, video views and video completion quartiles — over ANY date range instead of only the last 28 hours. This is the one to use for “how did last week’s post do”, “compare these three posts over the month”, or any retrospective: x_post_insights physically cannot see past yesterday, so asking it about an older post returns nothing and that is not zero performance. Takes up to 25 post ids at once; the window defaults to the last 28 days when you name none, and the window actually queried is reported back. COSTS CREDITS PER POST READ — X bills us per API call — so say the cost before pulling a big batch and ask about the posts that matter. Needs X connected.

ParameterTypeDescription
idsrequiredstring[]numeric X post ids (max 25) — the last part of each post URL
startDatestringYYYY-MM-DD or a UTC timestamp; defaults to 28 days before the end
endDatestringYYYY-MM-DD or a UTC timestamp; defaults to now
granularity"Total" | "Daily" | "Hourly" | "Weekly"default Total

x_mentions#

Read who is mentioning you on X read-only

Read the posts mentioning the connected X account — who is talking to the brand, in their own words, newest first. Use it to find what deserves a reply (reply with post_to_x + replyToId) and to mine real objections and customer language for ad copy. COSTS CREDITS PER MENTION RETURNED, plus one account lookup — keep maxResults small (default 10) and tell the user the cost before pulling a big page. Needs X connected.

ParameterTypeDescription
maxResultsnumberhow many mentions to pull, 5-100 (default 10) — every one is billed
sinceIdstringonly return mentions newer than this post id
paginationTokenstringnext_token from a previous call, to page further back

REDDIT

post_to_reddit#

Post to a subreddit

Submit a post to ONE named subreddit as the user’s connected Reddit account — a text post, a link post, or a native image post (pass a Hermoso render URL as imageUrl). This PUBLISHES immediately and PUBLICLY under their username, so show the user the exact subreddit, title and body and get an explicit yes BEFORE calling. REDDIT IS NOT A BROADCAST CHANNEL: it punishes undisclosed self-promotion harder than any other platform, and posting the same or near-identical content to several subreddits breaks Reddit’s own developer policy and gets accounts banned. Post to ONE subreddit, written for that specific community — if the user asks to blast several, tell them this instead of doing it. Subreddits that require post flair are detected before anything is posted and the error lists the valid flairs to pass as flairId. Needs Reddit connected (Settings ▸ Connectors ▸ Reddit).

ParameterTypeDescription
hookstringWHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer "which hooks work", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. "direct_callout", "mid_problem", "before_after") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works.
subjectstringWHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. "winter coat", "free trial", "founder story"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group.
subredditrequiredstringthe ONE subreddit to post to, e.g. "SideProject" (an r/ prefix is fine)
titlerequiredstringpost title, max 300 characters
kind"self" | "link" | "image""self" = text post (default), "link" = share a url, "image" = native image upload. Inferred from what you pass if omitted.
textstringbody markdown for a text post
urlstringthe destination url for a link post
imageUrlstringa Hermoso render image URL for a native image post (or an upload_file url)
flairIdstringflair template id — required by some subreddits; the error names the valid ones
flairTextstringflair text, only where that flair is editable
nsfwboolean
spoilerboolean
resubmitbooleanpost a link Reddit says was already submitted — usually reads as spam, so confirm first

reddit_post_stats#

How a Reddit post did read-only

Read one of the connected account’s Reddit posts back — score (net upvotes), comment count, upvote ratio, flair, and whether the subreddit removed it. Use it for "how did that post do" or to judge which framing a community actually rewarded before writing the next one. Read-only, 0 credits. Needs Reddit connected.

ParameterTypeDescription
postIdrequiredstringthe id returned by post_to_reddit, its t3_… fullname, or the full reddit.com permalink

PINTEREST

list_pinterest_boards#

List Pinterest boards read-only

List the boards on the user’s connected Pinterest account — id, name, privacy and pin count. ALWAYS call this before post_to_pinterest and let the USER pick: Pinterest requires a board and Hermoso never chooses one for them. Read-only, 0 credits. Needs Pinterest connected (Settings ▸ Connectors ▸ Pinterest).

ParameterTypeDescription
privacy"ALL" | "PUBLIC" | "PROTECTED" | "SECRET"filter by board privacy; default is everything the connection can see

pinterest_analytics#

Pinterest organic analytics read-only

ORGANIC Pinterest performance — impressions, saves, Pin clicks, outbound clicks and their rates, for the whole ACCOUNT, for the TOP PINS, for the TOP VIDEO PINS (with view-through and average watch time), or for ONE Pin. This is unpaid reach; pinterest_ads_report covers paid. Use scope:"top_pins" to answer "what is actually working on our Pinterest" — it ranks the account’s own Pins by whichever metric you sort on. NOTE Pinterest keeps only 90 DAYS of organic analytics and refuses a longer window, which is refused here with the reason rather than as an opaque error. A VIDEO Pin takes a different metric set from a static one (pass video:true for scope:"pin"). THERE IS NO BOARD ANALYTICS: Pinterest’s v5 API publishes no such endpoint, so board-level performance genuinely does not exist in any API — do not promise it. An unknown metric is refused by name, and a metric Pinterest omits from a row is MISSING data ("if a column has no value, it may not be returned"), never a measured zero. Works on Pinterest’s Trial access tier — unlike creating Pins, every read row in Pinterest’s access-tier table is available on Trial. Read-only, 0 credits.

ParameterTypeDescription
scope"account" | "top_pins" | "top_video_pins" | "pin"default account
pinIdstringrequired for scope:"pin" — the id post_to_pinterest returned
videobooleanscope:"pin" only — true when the Pin is a VIDEO, which has its own metric set
metricTypesstring[]which metrics; omit for all of the ones valid at this scope. Unknown values are refused with the valid list.
sortBystringtop_pins / top_video_pins: the metric to rank by (default the first metric)
sincestringYYYY-MM-DD, default 30 days ago; Pinterest allows at most 90 days back
untilstringYYYY-MM-DD, default today
limitnumbertop_pins / top_video_pins: how many (1–50, default 10)
appTypes"ALL" | "MOBILE" | "TABLET" | "WEB"
splitFieldstringaccount: NO_SPLIT | APP_TYPE | OWNED_CONTENT | SOURCE | PIN_FORMAT

create_pinterest_board#

Create a Pinterest board

Create a board on the connected Pinterest account. Needed because a Pin cannot exist without a board, and a NEW Pinterest business account has none — if list_pinterest_boards comes back empty, make one here rather than telling the user you can't pin. Boards are PUBLIC unless you pass privacy 'SECRET'; a Pin on a secret board is invisible to everyone, so only choose that if the user asked for it.

ParameterTypeDescription
namerequiredstringboard name, e.g. "Product launches" — keep it something a real Pinterest audience would browse
descriptionstringoptional board description (≤500 chars)
privacy"PUBLIC" | "SECRET"default PUBLIC. SECRET hides the board and every Pin on it from everyone but the account owner.

post_to_pinterest#

Create a Pin

Create a Pin on one of the user’s Pinterest boards from any finished visual they have — image, video, or a 2–5 slide CAROUSEL (pass the slides in order as imageUrls[] and Pinterest publishes one swipeable Pin). Title, description and destination link ride along. The link is what makes a Pin drive traffic, so ask for it rather than omitting it, and the description is the text Pinterest search actually reads. boardId is REQUIRED: call list_pinterest_boards first and let the user choose. This PUBLISHES to their public profile — confirm the board, title and link before calling. Video Pins take a minute or two while Pinterest ingests the file. Needs Pinterest connected (Settings ▸ Connectors ▸ Pinterest).

ParameterTypeDescription
hookstringWHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer "which hooks work", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. "direct_callout", "mid_problem", "before_after") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works.
subjectstringWHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. "winter coat", "free trial", "founder story"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group.
boardIdrequiredstringnumeric board id from list_pinterest_boards — the user picks it, never guess
imageUrlstringa Hermoso render image URL (or an upload_file url)
videoUrlstringa Hermoso render video URL — takes 1–2 minutes to ingest
imageUrlsstring[]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–10 (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.
idempotencyKeystringSAFE RETRIES. Publishing can take minutes (a video upload, a carousel of ten slides) and a transport can time out while the post SUCCEEDS — retrying blind is how the same thing gets posted twice. Pass any stable string here and a repeat of the SAME publish returns the ORIGINAL post id instead of posting again (24h). You do not have to: an identical publish is auto-recognised for 10 minutes anyway. If a call times out or errors ambiguously, CALL AGAIN WITH THE SAME KEY — that is the safe move, and it will either report the original post or publish it for the first time. It never posts twice.
allowDuplicatebooleanpost 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.
titlestringPin title, max 100 characters
descriptionstringPin description, max 800 characters — this is what Pinterest search reads
linkstringdestination URL the Pin clicks through to
altTextstringaccessibility alt text, max 500 characters
coverImageUrlstringvideo Pins only — a render to use as the cover frame
boardSectionIdstringoptional section within the board

GOOGLE BUSINESS PROFILE

list_business_locations#

List Google business listings read-only

List the Google Business Profile listings SHARED WITH THIS BRAND — id, title, address, website and Maps link. These are the only listings anything here can post to or read: one Google login often manages several businesses (an agency manages its clients’), and the user ticks which of them belong to this brand. Call this before posting whenever more than one is shared and let the USER pick: a Post on the wrong storefront is a public mistake Hermoso will not make for them. If nothing is shared, ask the user to choose — list_connector_accounts("google_business") then set_connector_accounts — and never name or guess a listing. Read-only, 0 credits. Needs Google Business Profile connected (Settings ▸ Connectors ▸ Google Business Profile).

No parameters.

post_to_google_business#

Post to Google Business Profile

Publish a Post to the brand’s Google Business Profile — the panel that appears on Google Search and Maps for the business. Text, optionally ONE PHOTO, and a call-to-action button. Google’s Posts API accepts NO VIDEO, so pass a still image. This PUBLISHES immediately and publicly on the business listing — show the user the exact text, photo and button and get an explicit yes BEFORE calling. If the account manages several listings, call list_business_locations first and pass locationId. EVENT and OFFER posts both REQUIRE a title and a start date (Google’s rule). On an OFFER, Google IGNORES the button’s link — pass redeemOnlineUrl instead. A CALL button dials the number on the listing and takes no link. Needs Google Business Profile connected (Settings ▸ Connectors ▸ Google Business Profile).

ParameterTypeDescription
hookstringWHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer "which hooks work", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. "direct_callout", "mid_problem", "before_after") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works.
subjectstringWHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. "winter coat", "free trial", "founder story"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group.
summarystringthe body text of the Post
locationIdstringwhich listing, e.g. 'locations/123' from list_business_locations — only needed when the account manages more than one
imageUrlstringa Hermoso render image URL (or an upload_file url) to show on the Post
topicType"STANDARD" | "EVENT" | "OFFER" | "ALERT"default STANDARD
actionType"BOOK" | "ORDER" | "SHOP" | "LEARN_MORE" | "SIGN_UP" | "CALL"the button on the Post
linkstringthe URL the button opens — not for CALL, and ignored on an OFFER
titlestringheadline — REQUIRED for EVENT and OFFER
startDatestringYYYY-MM-DD — REQUIRED for EVENT and OFFER
endDatestringYYYY-MM-DD, defaults to startDate
couponCodestringOFFER only
redeemOnlineUrlstringOFFER only — this is the link Google actually uses on an offer
termsConditionsstringOFFER only
languageCodestringBCP-47 language of the Post, default 'en'

list_google_business_posts#

List Google Business Profile Posts read-only

List the Posts currently on the brand’s Google Business Profile listing — text, topic type, state (LIVE / PROCESSING / REJECTED / SCHEDULED / RECURRING), button and timestamps. Use it to see what is already showing before writing another, or to get the id of one to remove. Read-only, 0 credits. Needs Google Business Profile connected.

ParameterTypeDescription
locationIdstringwhich listing, from list_business_locations — only needed when there is more than one
limitnumberhow many to return, max 100 (default 20)

delete_google_business_post#

Delete a Google Business Profile Post destructive

Remove a Post from the brand’s Google Business Profile listing. This takes it off Google Search and Maps immediately and CANNOT be undone — confirm with the user first. Pass the full post name from list_google_business_posts. Needs Google Business Profile connected.

ParameterTypeDescription
postIdrequiredstringthe full post name from list_google_business_posts (accounts/…/locations/…/localPosts/…)

list_google_business_reviews#

Read the reviews on a Google Business listing read-only

The reviews customers have left on the brand’s Google Business Profile listing — star rating, reviewer, the text, when it landed, and whether the business has replied. For a local business this is the highest-leverage surface there is: an unanswered review sits on the listing next to the ad you paid for. The reply says which ones have NO answer yet, so you can work the list rather than read it. Google reports the listing’s own average rating and total review count alongside the page — use those for "how are we doing", never a mean you computed over one page. An empty page is an empty PAGE, not proof the listing has no reviews. Read-only, 0 credits. Needs Google Business Profile connected AND the project approved for Google’s Business Profile APIs (a pending access request, not a setting — the error says so).

ParameterTypeDescription
locationIdstringwhich listing — omit when only one is shared with this brand
limitnumber1–50, default 20
orderBy"updateTime desc" | "updateTime" | "rating" | "rating desc"default newest first
pageTokenstring

reply_to_google_business_review#

Reply to (or remove a reply from) a Google review destructive

Answer a customer review publicly, as the business, on the brand’s Google Business Profile listing — or delete a reply that is already there. THIS IS AN UPSERT: a listing has exactly one reply per review, so replying to a review that already has an answer REPLACES it rather than adding a second. Google only accepts replies on a VERIFIED listing. Deleting is public and immediate, so it is confirm-gated. Write the reply in the brand’s voice and answer the specific complaint — a generic reply under a one-star review is worse than none. Needs Google Business Profile connected and the project approved.

ParameterTypeDescription
reviewIdrequiredstringfrom list_google_business_reviews
commentstringthe public reply text — required unless you are deleting
locationIdstringwhich listing — omit when only one is shared
deletebooleantrue removes the existing reply instead of writing one
confirmbooleanrequired for delete:true

list_google_business_questions#

Read the Q&A on a Google Business listing read-only

The questions the public has asked on the brand’s Google Business Profile listing, with the answers so far and how many people upvoted each question. Unanswered questions sit publicly on the listing and are read as "this business does not respond" — the reply names the ones with no answer at all. Read-only, 0 credits. Needs Google Business Profile connected and the project approved.

ParameterTypeDescription
locationIdstring
limitnumber1–20, default 10
pageTokenstring

answer_google_business_question#

Answer a question on a Google Business listing destructive

Post the business’s answer to a public question on the brand’s Google Business Profile listing, or delete the answer already there. THIS IS AN UPSERT — one answer per account, so answering again REPLACES the previous one rather than adding a second. Deleting is public and immediate and is confirm-gated. Needs Google Business Profile connected and the project approved.

ParameterTypeDescription
questionIdrequiredstringfrom list_google_business_questions
textstringthe answer — required unless deleting
locationIdstring
deleteboolean
confirmbooleanrequired for delete:true

google_business_insights#

Google Business Profile performance read-only

How the brand’s Google Business Profile listing actually performed — impressions on Google Search and Maps (desktop and mobile), calls, website clicks, direction requests, messages and bookings — over the last N days. For a local business this is the real-world demand signal, and it is the number an ad campaign should be judged against. NOTE: Google discontinued PER-POST insights in February 2023 and published no replacement, so these are listing-level figures and per-post performance genuinely does not exist in any API — do not promise it. Read-only, 0 credits. Needs Google Business Profile connected.

ParameterTypeDescription
locationIdstringwhich listing, from list_business_locations
daysnumberhow many days back, default 30
metricsstring[]optional subset of Google’s daily metrics (BUSINESS_IMPRESSIONS_DESKTOP_MAPS, BUSINESS_IMPRESSIONS_DESKTOP_SEARCH, BUSINESS_IMPRESSIONS_MOBILE_MAPS, BUSINESS_IMPRESSIONS_MOBILE_SEARCH, BUSINESS_CONVERSATIONS, BUSINESS_DIRECTION_REQUESTS, CALL_CLICKS, WEBSITE_CLICKS, BUSINESS_BOOKINGS, BUSINESS_FOOD_ORDERS, BUSINESS_FOOD_MENU_CLICKS). Omit for all of them. An unknown name is refused rather than quietly dropped, so a total is never reported under a metric you did not get.

THE LISTING ITSELF, AND THE ACCOUNT UNDER IT

get_business_location#

Read a Google Business Profile listing read-only

Read everything Google holds on one of the brand’s Google Business Profile listings — business name, address, phone numbers, website, categories, description, regular and special hours, service area, labels, store code, open state, and whether the listing can carry a Post at all. This is the listing AS THE MERCHANT LAST SET IT, which is exactly what update_business_location edits; it can differ from what Google Maps shows today, because Google and the public can suggest changes on top. Call it before offering to change anything, and to answer “what does our Google listing actually say?”. Read-only, 0 credits. Needs Google Business Profile connected (Settings ▸ Connectors ▸ Google Business Profile).

ParameterTypeDescription
locationIdstringwhich listing, e.g. 'locations/123' from list_business_locations — only needed when more than one is shared with this brand

update_business_location#

Update a Google Business Profile listing destructive

Change the brand’s Google Business Profile listing — hours, phone, website, description, categories, service area, labels, store code, address or the business name. THIS EDITS THE PANEL ON GOOGLE SEARCH AND MAPS, immediately and publicly: there is no draft, no preview and no undo. Pass ONLY what changes, in `fields`, keyed by Google’s own field names: websiteUri, phoneNumbers, regularHours, specialHours, moreHours, profile, categories, storefrontAddress, title, labels, storeCode, openInfo, serviceArea, serviceItems, latlng, adWordsLocationExtensions, relationshipData. CALL IT WITHOUT confirm FIRST — nothing is written, Google validates the payload for you, and you get back the CURRENT value of every field you are about to change, so you can show the user the exact before-and-after; then call again with confirm:true once they approve. Changing the business NAME (title) or ADDRESS (storefrontAddress) additionally needs confirmName set to the listing’s CURRENT name, because Google can suspend a listing over either. Output-only fields (metadata) and immutable ones (languageCode) are refused by name rather than dropped. Use dryRun:true to validate a payload with Google and write nothing. Needs Google Business Profile connected.

ParameterTypeDescription
fieldsrequiredobjectthe changes, keyed by Google’s Location field names, e.g. {"websiteUri":"https://example.com"} or {"regularHours":{"periods":[…]}}
locationIdstringwhich listing, from list_business_locations — only needed when more than one is shared with this brand
confirmbooleantrue ONLY after the user has seen the exact before-and-after and approved it
confirmNamestringthe listing’s CURRENT name, echoed back — required when changing title or storefrontAddress
dryRunbooleanvalidate with Google and write nothing (needs no confirm)

google_business_account#

Google Business Profile account for a listing read-only

Read the Google Business Profile ACCOUNT that owns one of the brand’s listings — the account name, its type (a personal Google account, a location group, a user group or an organization), the connected user’s role on it (primary owner / owner / manager / site manager), the account’s verification state and the permission level. Use it to answer “can we actually edit this listing?” and “whose account is it on?” before offering an edit that Google would refuse anyway. It reads exactly ONE account — the parent of a listing already shared with this brand — and never lists the other accounts the connected Google login can reach; that roster belongs to the account picker (list_connector_accounts). Read-only, 0 credits. Needs Google Business Profile connected.

ParameterTypeDescription
locationIdstringwhich listing, from list_business_locations — only needed when more than one is shared with this brand

post_to_youtube#

Post a video to YouTube

Publish a finished video to the brand’s connected YouTube channel. Pass a Hermoso render URL (or an upload_file url for a local/external file). DEFAULTS TO UNLISTED (link-only — not on the channel, not searchable, but shareable by link AND usable as a YouTube/Google ad). Pass privacy:"public" to put it ON the channel (a public publish — confirm with the user first) or privacy:"private" for eyes-only. Do NOT use "private" for anything meant to run as an ad — private videos CANNOT be used as ads; unlisted is the ad-ready setting. Needs a connected YouTube channel (Settings ▸ Connectors ▸ YouTube).

ParameterTypeDescription
hookstringWHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer "which hooks work", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. "direct_callout", "mid_problem", "before_after") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works.
subjectstringWHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. "winter coat", "free trial", "founder story"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group.
videoUrlrequiredstringthe video to post — a Hermoso render URL or an upload_file url
titlestringvideo title (≤100 chars)
descriptionstringvideo description (≤5000 chars)
tagsstring[]up to 30 tags
privacy"private" | "unlisted" | "public"default unlisted (anyone-with-link, ad-ready); public = live + searchable on the channel (confirm first); private = eyes-only (cannot run as an ad)

youtube_channel#

Get the connected YouTube channel read-only

Read the brand’s connected YouTube channel — title + subscriber / view / video counts (for reporting). Needs a connected YouTube channel.

No parameters.

YOUTUBE: MEASURE + MANAGE

list_youtube_videos#

List the brand’s own YouTube uploads read-only

List the connected channel’s OWN recent uploads — video id, title, publish date and privacy — so you can resolve a video WITHOUT asking the user for a link. Call this whenever the user names a video loosely ("my latest", "the shorts one", part of a title) and match it yourself; only ask them when two titles are genuinely ambiguous. This is the tool that gets you the videoId every other YouTube tool needs — youtube_channel returns counts only, and search_youtube searches the PUBLIC index, not your uploads. Includes UNLISTED and PRIVATE videos, which are invisible to any public search. Read-only, 0 credits. Needs a connected YouTube channel.

ParameterTypeDescription
limitnumberhow many recent uploads to return (default 25, max 50)

youtube_video_insights#

Performance of one of your YouTube videos read-only

Per-VIDEO performance for a video on the connected channel — views, estimated minutes watched, average view duration, average view PERCENTAGE (the retention number that tells you whether the hook held), likes, comments, shares and subscribers gained. Use it for "how did that video do", "which upload performed best", or to judge an ad before spending more behind it. youtube_channel only returns channel-wide totals and cannot answer this. Defaults to the last 28 days; pass startDate/endDate (YYYY-MM-DD) for another window. Read-only, 0 credits. Needs a connected YouTube channel.

ParameterTypeDescription
videoIdrequiredstringthe YouTube video id (the v= part of the watch URL, or the videoId returned by post_to_youtube)
startDatestringYYYY-MM-DD, default 28 days ago
endDatestringYYYY-MM-DD, default today

youtube_channel_report#

YouTube analytics broken down by dimension read-only

The YouTube Analytics reports that say WHERE views came from, WHO watched and WHERE they stopped watching — the questions youtube_channel (totals) and youtube_video_insights (one video, flat) cannot answer. Pick a report: day / month (time series) · country / province (US states) / city / dma (geography) · trafficSource (search vs browse vs suggested vs shorts feed vs external — the single most useful one for judging a thumbnail and title) · trafficSourceDetail (the actual search terms, inside ONE source — pass parent, e.g. "YT_SEARCH") · playbackLocation / playbackLocationDetail (which sites embedded it) · device / operatingSystem · demographics (age + gender) · sharingService · subscribedStatus · audienceRetention (the drop-off CURVE, 100 points across ONE video — the read that tells you whether the hook held and exactly when people left) · topVideos (the channel’s best in the window). Scope it to one or more videoIds, or omit for the whole channel. An unknown report name is refused WITH the list rather than quietly swapped. TWO THINGS TO SAY OUT LOUD WHEN REPORTING: demographics returns viewerPercentage and NOTHING else — YouTube publishes no absolute demographic counts, so never convert it into a number of viewers — and a capped report (city 250, topVideos 200, the *Detail reports 25) is the TOP N, not the whole set. Zero rows means missing data for that window, never zero views. Read-only, 0 credits.

ParameterTypeDescription
report"day" | "month" | "country" | "province" | "city" | "dma" | "trafficSource" | "trafficSourceDetail" | "playbackLocation" | "playbackLocationDetail" | "device" | "operatingSystem" | "demographics" | "sharingService" | "subscribedStatus" | "audienceRetention" | "topVideos"which report (default day)
videoIdsstring[]narrow to these videos — audienceRetention requires exactly ONE, because the curve is per video
parentstringrequired by the *Detail reports: the ONE parent to drill into, e.g. "YT_SEARCH" / "SUBSCRIBER" / "RELATED_VIDEO" for trafficSourceDetail, "EMBEDDED" for playbackLocationDetail
startDatestringYYYY-MM-DD, default 28 days ago
endDatestringYYYY-MM-DD, default today
limitnumberrows, within YouTube’s own cap for that report

YOUTUBE REPORTING API

youtube_bulk_report#

YouTube bulk report (thumbnail CTR, cards, end screens) read-only

THE ONLY PLACE YOUTUBE PUBLISHES THUMBNAIL IMPRESSIONS AND THUMBNAIL CTR. This is a different API from youtube_channel_report — YouTube’s bulk Reporting API — and for a product that generates thumbnails it is the number that says whether the thumbnail actually worked. Reports: thumbnails (impressions + CTR per video per day) · thumbnails_by_source (the same, split by traffic source, traffic source DETAIL, device and OS) · cards (per-card impressions, clicks and click rate by card_id) · end_screens (per end-screen element) · traffic_source (with the UNCAPPED traffic_source_detail — youtube_channel_report caps that at 25 rows) · basic. IT IS SCHEDULED, NOT ON-DEMAND, AND THIS IS THE ONE THING YOU MUST EXPLAIN TO THE USER: the first call SCHEDULES a job and returns NO DATA. YouTube then writes one CSV per 24-hour Pacific day — the first within 48 hours — plus a backfill of the 30 days before scheduling, and files expire after 60 days. It can NEVER answer about a period before the job existed, so "we have no thumbnail history yet" is a real and correct answer on day one. An unknown report name is refused with the list. Zero rows means missing data for that window, never zero impressions. Read-only, 0 credits.

ParameterTypeDescription
report"thumbnails" | "thumbnails_by_source" | "cards" | "end_screens" | "traffic_source" | "basic"default thumbnails
daysnumberhow many recent daily files to read (1–14, default 7)
sincestringYYYY-MM-DD — only files whose data starts on or after this
untilstringYYYY-MM-DD — only files whose data starts before this
schedulebooleanfalse = do not create the job if it is missing; just report that none exists

list_youtube_report_jobs#

List YouTube bulk reporting jobs read-only

The YouTube BULK reporting jobs running on this channel — which report each one generates, its report type id, and when it was scheduled. Call this to find out whether thumbnail-CTR history is already accumulating, and since when, BEFORE promising a user a number: the bulk API can only answer about days after a job existed. Read-only, 0 credits.

No parameters.

delete_youtube_report_job#

Delete a YouTube bulk reporting job destructive

Stop a YouTube bulk reporting job. IRREVERSIBLE IN A WAY THAT IS EASY TO MISS: the job IS the history — deleting it discards every daily CSV it has accumulated, and a replacement job starts over with only a 30-day backfill, so anything older than that is gone for good. Call WITHOUT confirm first: nothing is deleted and you get the real job read back from YouTube (its report type and when it was scheduled) to show the user. Then call again with confirm:true. Needs a connected YouTube channel.

ParameterTypeDescription
jobIdrequiredstringfrom list_youtube_report_jobs
confirmbooleantrue only after the user has seen the job and said yes

update_youtube_video#

Update a YouTube video’s title, description, tags or privacy

Edit an existing video on the connected channel: title, description, tags, and/or privacy (unlisted | public | private). THIS IS HOW YOU FLIP AN UNLISTED UPLOAD PUBLIC — post_to_youtube defaults to UNLISTED, and without this there was no way to publish it afterwards. Making a video PUBLIC puts it on the channel where anyone can find it, so show the user exactly what will change and get an explicit yes before calling with privacy:"public". Fields you omit are left untouched. Needs a connected YouTube channel.

ParameterTypeDescription
videoIdrequiredstringthe YouTube video id
titlestring≤100 chars
descriptionstring≤5000 chars
tagsstring[]
privacy"unlisted" | "public" | "private"public = live on the channel; confirm with the user first

delete_youtube_video#

Delete a video from the connected YouTube channel destructive

PERMANENTLY delete a video from the connected YouTube channel. IRREVERSIBLE — YouTube has no trash and no undelete, and the video's views, comments and every link or embed pointing at it go with it. Call it WITHOUT confirm first: nothing is deleted, and it reports the video's REAL title, privacy, view count and comment count read back from YouTube. Show the user that, get an unambiguous yes, then call again with confirm:true — plus, once the video is public or has any views or comments, confirmTitle set to its exact title. confirmTitle exists because confirming that you meant to delete SOMETHING does not prove you aimed at the right video, and a wrong id must not be confirmable blind. If the user only wants it out of public view, use update_youtube_video(privacy:"private") instead — that is reversible and this is not. Get the videoId from list_youtube_videos. 0 credits. Needs a connected YouTube channel.

ParameterTypeDescription
videoIdrequiredstringthe YouTube video id (from list_youtube_videos)
confirmbooleanREQUIRED true — deletion is permanent and cannot be undone
confirmTitlestringthe video's EXACT title as the unconfirmed call reported it — required once the video is public or has any views or comments

set_youtube_thumbnail#

Set the custom thumbnail on a YouTube video

Set the CUSTOM THUMBNAIL on a video already on the connected channel, using a Hermoso image — a make_thumbnail render, a generated image, or a frame. The thumbnail is the single biggest lever on YouTube click-through and YouTube otherwise auto-picks a frame, so a published video without one is leaving reach on the table. It changes ONLY the thumbnail — video, title and privacy are untouched — but it is public and immediate, so show the user which image is going on which video and get a yes first. Custom thumbnails require a VERIFIED YouTube channel (a phone number at youtube.com/verify); without it YouTube refuses and the error says so. Images over YouTube’s 2MB cap are compressed automatically. The image must be Hermoso-HOSTED, which is not the same as Hermoso-GENERATED: the user’s own artwork works, put it through upload_file first and pass the URL that returns. An arbitrary external host is refused. 0 credits. Needs a connected YouTube channel.

ParameterTypeDescription
videoIdrequiredstringthe YouTube video id (what post_to_youtube returned)
imageUrlrequiredstringa Hermoso-hosted image URL — a make_thumbnail / list_library render, OR any image of the user’s own passed through upload_file first. An arbitrary external host is refused.

list_youtube_comments#

Read comments on one of your YouTube videos read-only

Read the comments under a video on the connected channel — the questions, objections and exact wording real viewers use. Same raw material for ad copy that list_meta_comments gives you on Meta. Returns author, text, like count, timestamp and reply count, newest first. Read-only, 0 credits. Needs a connected YouTube channel.

ParameterTypeDescription
videoIdrequiredstringthe YouTube video id
limitnumbermax comments, default 25, cap 100

reply_to_youtube_comment#

Reply to a YouTube comment

Post a public reply to a comment on the connected channel, as the channel. This is PUBLIC and immediate — show the user the exact reply text and get an explicit yes before calling. Get commentId from list_youtube_comments. Needs a connected YouTube channel.

ParameterTypeDescription
commentIdrequiredstringid of the comment to reply to (from list_youtube_comments)
textrequiredstringthe reply, shown publicly under the video

tiktok_creator_info#

Read the connected TikTok creator’s posting options read-only

Read the connected TikTok creator’s REAL posting options BEFORE posting: which privacy levels THEY are allowed to use, whether comments / duet / stitch are available on their account, their maximum video length, and their nickname. TikTok REQUIRES that the user is shown these actual options and picks a privacy level — never assume or default one. Call this first, show the options, get the user’s pick, then call post_to_tiktok with destination:"post". The SAME privacy options govern PHOTO posts (slideshows), not just video — TikTok takes the same four levels on both. Needs TikTok connected (Settings ▸ Connectors ▸ TikTok).

No parameters.

post_to_tiktok#

Post a video or photo post to TikTok

Publish to the user’s connected TikTok account — a finished VIDEO, or a PHOTO POST (TikTok’s photo/slideshow format). A photo post carries 1 to 35 images and ONE image is simply a one-slide photo post, so there is nothing special to do for a single picture: pass imageUrls, in the order the slides should appear, and optionally coverIndex. Pass videoUrl for a video. Never pass both — TikTok has no mixed post. TWO destinations either way: destination:"post" puts it LIVE on their profile now — that requires `privacy`, and you must call tiktok_creator_info first, show the creator’s real privacy options and get an explicit yes before calling. destination:"draft" (the default, and the safer one) sends it to TikTok for the user to finish and post themselves from the app. Pass Hermoso render URLs (or upload_file urls for local/external files). Needs TikTok connected (Settings ▸ Connectors ▸ TikTok).

ParameterTypeDescription
hookstringWHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer "which hooks work", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. "direct_callout", "mid_problem", "before_after") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works.
subjectstringWHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. "winter coat", "free trial", "founder story"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group.
videoUrlstringthe video to post — a Hermoso render URL or an upload_file url. Omit for a photo post.
imageUrlsstring[]a PHOTO POST: 1–35 image URLs in slide order. One url = a single-image photo post. Do not combine with videoUrl.
coverIndexnumberphoto posts: which slide is the cover, 0-based. Default 0 (the first slide).
destination"post" | "draft""post" = live on the profile now (needs privacy + an explicit user yes); "draft" = to TikTok for the user to review and post themselves. Default "draft".
titlestringthe caption — hashtags go here (video ≤2200 chars, photo post ≤4000)
photoTitlestringphoto posts only: a short title above the caption (≤90 chars). Defaults to the caption’s first line.
privacy"PUBLIC_TO_EVERYONE" | "MUTUAL_FOLLOW_FRIENDS" | "FOLLOWER_OF_CREATOR" | "SELF_ONLY"REQUIRED for destination:"post", for photos and video alike. Must be one the creator actually allows — read them from tiktok_creator_info, never guess.
disableCommentboolean
disableDuetbooleanvideo only — TikTok has no duet on a photo post
disableStitchbooleanvideo only — TikTok has no stitch on a photo post
autoAddMusicbooleanphoto posts only: let TikTok add a recommended track (default true — a silent slideshow reads as broken)
coverTimestampMsnumbervideo only: which frame to use as the cover, in ms
brandedContentbooleandiscloses a paid partnership — cannot be combined with SELF_ONLY privacy
yourBrandbooleandiscloses that this promotes the creator’s own brand

tiktok_account#

Read the connected TikTok account read-only

Read the connected TikTok account: display name, username, bio, verified status, and their follower / following / total-likes / video counts. Use it for “how many followers do we have on TikTok”, “how is our TikTok doing”, or to confirm whose account is linked before posting. Read-only. Needs TikTok connected (Settings ▸ Connectors ▸ TikTok).

No parameters.

list_tiktok_videos#

List the connected account’s TikTok posts read-only

List the connected account’s own recent PUBLIC TikTok posts with per-video stats — views, likes, comments, shares, duration, cover image and link. Use it for “how did our last TikToks do”, “which of our videos performed best”, or to pick a reference before making a new ad. Only ever the connected user’s OWN videos. Read-only. Needs TikTok connected.

ParameterTypeDescription
limitnumber1-20, default 10

upload_meta_asset#

Upload an asset to a Meta ad account

Upload creative(s) — a finished Hermoso ad OR arbitrary user files (e.g. a folder of media from the user’s desktop) — into a connected ad account’s ASSET LIBRARY so the user or a later ad-build step can use them in their OWN campaigns. Pass `url` for one file, or `urls` (up to 20) to BULK-upload in a single call. Each accepts a public https URL, a data: URI, or a Hermoso /generated path; for LOCAL files call upload_file first and pass the url(s) it returns. Image → image hash; video → video id. Pass adAccountId from list_meta_pages.

ParameterTypeDescription
adAccountIdrequiredstringad account id (digits or act_… — from list_meta_pages)
urlstringa single public https URL / data: URI / /generated path
urlsstring[]up to 20 media URLs/paths for a one-call BULK upload
kind"image" | "video"inferred from the URL if omitted
namestringa label for the asset

create_meta_campaign#

Create a Meta ad campaign (paused)

Create a campaign on a connected Meta ad account. Always created PAUSED — it spends NOTHING until you activate it with set_meta_campaign_status(confirm:true). Optionally set a dailyBudgetUsd. Pass adAccountId (from list_meta_pages) + an objective. Needs ads-management permission on the connected account.

ParameterTypeDescription
namerequiredstringcampaign name
adAccountIdrequiredstringad account id (digits or act_… — from list_meta_pages)
objective"OUTCOME_TRAFFIC" | "OUTCOME_AWARENESS" | "OUTCOME_ENGAGEMENT" | "OUTCOME_LEADS" | "OUTCOME_SALES" | "OUTCOME_APP_PROMOTION"default OUTCOME_TRAFFIC
dailyBudgetUsdnumberoptional campaign daily budget in USD (1–10000); real spend once ACTIVE

set_meta_campaign_status#

Activate or pause a Meta campaign

Turn a campaign ON (ACTIVE) or OFF (PAUSED). ACTIVATING STARTS REAL AD SPEND — you MUST first show the user the campaign name + its daily budget, get an explicit yes, then call with status:"ACTIVE" and confirm:true. Pausing is always safe. Needs ads-management permission.

ParameterTypeDescription
campaignIdrequiredstringthe campaign id (from create_meta_campaign)
statusrequired"ACTIVE" | "PAUSED"ACTIVE = start spending; PAUSED = stop
confirmbooleanREQUIRED true to activate (real spend) — set only after the user explicitly approved the budget

POST PERFORMANCE

list_meta_posts#

List the Page’s / Instagram account’s own posts read-only

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, and until now the only way to have a postId was to have just published it yourself with post_to_meta. 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.

ParameterTypeDescription
target"facebook" | "instagram"default facebook; 'instagram' reads the Page's linked IG business account
pageIdstringwhich connected Page — omit when the brand has only one
limitnumberhow many posts (default 25, max 100)
cursorstringpaging cursor returned by a previous call
includeUnpublishedbooleanFacebook only — also return unpublished drafts (hidden by default)

list_published_posts#

List what this brand has published read-only

List every post Hermoso has recorded publishing for this brand — channel, permalink, caption, format, the HOOK and SUBJECT it was written to, and its measured engagement. This is the brand's own publishing history across all nine channels in one place, and it is the memory that makes 'which hook worked?' answerable at all. Each row says how it was recorded: 'captured' (written at publish time — the hook is what the author actually intended) or 'backfilled' (reconstructed from the platform afterwards, where the hook is only known if the post matched a Hermoso creation). A dash for engagement means the platform reported no number — that is NOT zero engagement. Read-only, 0 credits.

ParameterTypeDescription
channelstringfilter to one channel: facebook, instagram, threads, x, linkedin, youtube, tiktok, reddit, pinterest, google_business
limitnumbermax posts (default 50, max 200), newest first

list_hooks#

The hook + setting libraries, and which hooks are working read-only

The curated menu of VISUAL scroll-stop HOOKS (how an ad opens) and SETTINGS (where it is staged) that plan_ad and render_ad accept, PLUS this brand's measured traction per hook. Call it before planning an ad to pick a hook deliberately instead of letting the model improvise one, and call it after publishing to see which ones are actually landing. Three things it will not do: it never recommends a hook from thin data — a verdict is SUPPRESSED below 5 measured posts and the reason is stated; it never compares across channels; and it reports hooks you have NEVER TRIED as a fact, not as advice, because 'you haven't tried this' is an observation and 'you should' would be a verdict drawn from zero data. A hook marked unusable in this brief says WHY (an on-screen-text hook cannot ride an authentic/UGC render, which carries zero on-screen text). Read-only, 0 credits.

ParameterTypeDescription
channelstringrestrict the performance half to one channel (facebook, instagram, threads, x, linkedin, youtube, tiktok, reddit, pinterest)
authenticbooleantrue if the planned ad is an authentic/UGC/creator-register render — on-screen-text hooks are then reported unusable, with the reason
categorystringthe product category (e.g. 'skincare serum', 'protein powder', 'sunglasses') — returns the setting Higgsfield's Location x Tier matrix puts that category in, with the reason
tier"luxury" | "premium" | "drugstore"product tier, used with category — changes the FINISH of the room, never the room. Default premium.

post_performance#

Which hooks and subjects are getting traction read-only

Aggregate this brand's published posts to answer WHICH HOOKS AND SUBJECTS WORK. Groups by hook (default), subject, channel, media format or posting hour, and reports the engagement RATE within each channel. THREE THINGS IT DELIBERATELY WILL NOT DO, and you should repeat them rather than paper over them: (1) it never sums metrics across channels — a LinkedIn impression and a TikTok view are different units, so every comparison is within one channel; (2) it SUPPRESSES a verdict below 5 measured posts and says so, because a confident recommendation from 3 posts is worse than none; (3) a post with no recorded hook (published outside Hermoso, or backfilled without a creation match) counts toward channel and format totals but never votes on which hook works. Present the `finding` verbatim if there is one, and the reason if there is not. Read-only, 0 credits.

ParameterTypeDescription
axis"hook" | "subject" | "channel" | "media" | "hour"what to group by — default hook
channelstringrestrict to one channel

collect_post_metrics#

Read how the recorded posts performed

Fetch fresh performance numbers for this brand's recorded posts and store them as a time-series. Metrics ACCRUE, so a post is read at ~24 hours and again at ~7 days; this collects whichever readings are due and skips the ones already taken. A channel that cannot report a metric records it as ABSENT with the reason — never as zero — and a read that fails is recorded as 'could not tell', which contributes to nothing. X IS SKIPPED BY DEFAULT because X bills us per API call: pass includeMetered:true to include it, and tell the user it costs credits BEFORE you do. The skip is always reported so a channel missing from the numbers is never mistaken for one that performed badly. Free except for X.

ParameterTypeDescription
includeMeteredbooleanalso read X, which BILLS CREDITS per post read — ask the user first
maxnumbercap how many posts to read in this run (default 40)

backfill_posts#

Import a channel’s past posts

Import this brand's PAST posts from a channel into the performance record, so 'which hook works' can draw on history rather than only on what was published since Hermoso started recording. Supports facebook, instagram, threads, youtube, tiktok and pinterest; the others say plainly why they cannot (LinkedIn and Reddit have no enumerate-my-posts endpoint on our grant, and X bills per read so it is excluded from bulk import). BOUNDED, RESUMABLE AND QUOTED: it runs as a DRY RUN by default and tells you how many posts it found and what reading them will cost — pass confirm:true to import, and pass the returned cursor to continue. AN IMPORTED POST IS WEAKER EVIDENCE THAN A RECORDED ONE and is labelled 'backfilled': its hook is recovered ONLY where the post matches a Hermoso creation by asset or caption. A post made outside Hermoso stays UNATTRIBUTED — it counts toward channel and format totals but never votes on which hook works. Never guess a hook from a caption. Free.

ParameterTypeDescription
channelrequired"facebook" | "instagram" | "threads" | "youtube" | "tiktok" | "pinterest"which channel to import from
confirmbooleanactually import — omit for a dry run that only quotes the cost
limitnumberhow many posts this page (default 50, max 200)
cursorstringresume from a previous run
accountRefstringwhich Page / account, when the brand has more than one