hermosodocs

Publishing & scheduling

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

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

THE INBOX

post_to_bluesky#

Post to Bluesky

Publish a post to Bluesky as the connected account. Text up to 300 characters — Bluesky ALSO caps a post at 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; Hermoso checks both before spending the round trip and says which limit and by how much. MEDIA: either up to 4 images (imageUrls + altText) OR one MP4 video (videoUrl + videoAlt), never both — a Bluesky post record carries a single embed and images and video are two different embed types. Video is MP4 only, up to 300MB at Bluesky's end (Hermoso can fetch up to 150MB from a URL), with optional WebVTT caption tracks; the aspect ratio is measured from the file. Bluesky requires a CONFIRMED EMAIL on the account before it will process any video — if it is unconfirmed you get a refusal saying so, and reconnecting will not help. Links in the text are made clickable automatically. LINK CARDS: Bluesky does NOT scrape links, so a URL posted bare renders as plain blue text — the client composing the post has to build the card. Hermoso builds one AUTOMATICALLY when the post has a URL and NO media: it fetches the page, uses its title/description and uploads its image as the card thumbnail. Pass linkCard:false to suppress it, or linkCard:{uri,title,description,thumbUrl} to control it (give both title and description and the page is not fetched at all). A POST CARRIES ONE EMBED, so a card and images/video cannot both ride: if you pass linkCard explicitly ALONGSIDE media the call is REFUSED by name rather than silently dropping one, and if the URL was merely in the text the MEDIA WINS and the reply says the card was skipped (the link stays clickable either way). Returns the post's public bsky.app URL. Connect at Settings ▸ Connectors ▸ Bluesky, or here with connect_connector, with a handle and an APP PASSWORD.

ParameterTypeDescription
accountstringWHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the brand has more than one bluesky account connected (several and none named is refused by name, never guessed); omit when there is one.
textrequiredstringThe post, up to 300 characters / 3000 UTF-8 bytes.
imageUrlsstring[]Up to 4 public image URLs to attach. Cannot be combined with videoUrl.
altTextstring | string[]Alt text — an ARRAY, one per image in the same order, or a single STRING to describe every image with it. WRITE ONE: Bluesky’s own lexicon makes `alt` a REQUIRED property of every image, so a post without it is undescribed by design rather than by omission, and Bluesky users expect it. No maximum length is published, so nothing is truncated.
videoUrlstringOne public MP4 URL. Cannot be combined with imageUrls. Bluesky transcodes it, which takes a minute or two.
videoAltstringAlt text describing the video, for accessibility.
captionsobject[]Up to 20 WebVTT caption tracks: [{lang:'en', url:'https://…/en.vtt'}] or [{lang:'en', content:'WEBVTT\n\n00:00…'}]. Each file is capped at 20000 bytes.
langsstring[]BCP-47 language tags, e.g. ['en'].
linkCardboolean | objectRich link card (`app.bsky.embed.external`). OMIT for the default (a card is built automatically when the post has a URL and no media). `false` never builds one. `true` builds one from the first URL in the text. An object {uri,title,description,thumbUrl} overrides any field — supply BOTH title and description and the page is never fetched. Cannot be combined with imageUrls/videoUrl: a post has ONE embed, so an explicit linkCard beside media is refused.

delete_bluesky_post#

Delete a post from the connected Bluesky account destructive

PERMANENTLY delete one of the connected Bluesky account's OWN posts. IRREVERSIBLE — the AT Protocol removes the record from the account's repo, there is no trash and no undelete, and the post's likes, reposts, replies and quotes go with it. Call it WITHOUT confirm first: nothing is deleted, and it reports the post's REAL text and its live like / repost / reply / quote counts read back from Bluesky. Show the user that, get an unambiguous yes, then call again with confirm:true — plus, once the post has ANY engagement, confirmText echoing the post's own text (the first 40 characters is enough; any longer leading run works too). confirmText exists because confirming that you meant to delete SOMETHING does not prove you aimed at the right post, and a wrong id must not be confirmable blind. A brand-new post with nothing on it stays a ONE-call delete. Identify the post by its AT-URI or by just its RECORD KEY — the short id at the end of its bsky.app link, e.g. 3mtc4n3fibn2x. Deleting only ever works on the connected account's own posts; another account's URI is refused. 0 credits. Needs Bluesky connected (Settings ▸ Connectors ▸ Bluesky, or connect_connector).

ParameterTypeDescription
urirequiredstringthe post's AT-URI (at://did:plc:…/app.bsky.feed.post/…) as post_to_bluesky returned it, the handle form of the same URI for the CONNECTED account only (at://<its handle>/app.bsky.feed.post/…), or just its record key (3mtc4n3fibn2x)
confirmbooleanREQUIRED true — deletion is permanent and cannot be undone
confirmTextstringthe post's own text as the unconfirmed call reported it — the first 40 characters is enough. Required once the post has any likes, reposts, replies or quotes. A post with no text asks for its cid instead.

TELEGRAM

post_to_telegram#

Post to Telegram

Publish to a Telegram channel, group or chat as the brand's own bot. WHICH CHAT IS ALWAYS REQUIRED AND IS NEVER GUESSED: pass chatId as the public channel's @username (e.g. @hermosoai) or its numeric id (a group is negative; a supergroup or channel starts with -100). There is no default and there cannot be one — the Telegram Bot API publishes no method that lists the chats a bot belongs to, so Hermoso genuinely cannot know them. list_telegram_chats reports the chats that have MESSAGED the bot in the last 24 hours, which is a shortcut for finding an id and is NOT a roster: a chat missing from it can still be posted to. TEXT: up to 4096 characters on a text-only message, but only 1024 the moment ANY photo or video is attached — a caption is not a message, and Hermoso refuses the over-long one before spending the round trip and says which budget applied. MEDIA: one image (imageUrl), one video (videoUrl), or an ALBUM of 2–10 (imageUrls, in order) in which photos and videos may be MIXED — pass a videoUrl alongside imageUrls and it joins the album as one more item. Hermoso uploads the bytes rather than handing Telegram a link, which is what buys the larger ceilings: 10MB per photo and 50MB per video, where a link would be 5MB and 20MB. THE BOT MUST BE IN THE CHAT — an administrator with Post Messages for a channel, an unrestricted member for a group; if it is not, Telegram refuses and the error says to add it rather than to reconnect. Returns the message id and, for a PUBLIC chat, its t.me link — a private group has no public web link, so `url` comes back null rather than as a link that would 404 for whoever you hand it to. 0 credits. Connect at Settings ▸ Connectors ▸ Telegram, or here with connect_connector, by pasting a bot token from @BotFather.

ParameterTypeDescription
accountstringWHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the brand has more than one telegram account connected (several and none named is refused by name, never guessed); omit when there is one.
chatIdrequiredstringREQUIRED — the destination: a public channel's @username, or the numeric chat id. Never guessed; ask the user, or use list_telegram_chats.
textstringthe message. ≤4096 characters on its own; ≤1024 once any image or video is attached.
imageUrlstringone image (≤10MB after upload)
imageUrlsstring[]an ALBUM of 2–10 media, in order. Photos and videos may be mixed — Telegram allows it.
videoUrlstringone video (≤50MB). Passed alongside imageUrls it joins the album as one more item.
disablePreviewbooleansuppress the link-preview card on a text-only post. Default is Telegram’s own behaviour (previews on).
silentbooleandeliver without a notification sound (Telegram’s disable_notification). This is NOT a visibility setting — the message is just as visible.

list_telegram_chats#

Find Telegram chat ids read-only

Find the chat ids this Telegram bot can be addressed by. STATE THE LIMIT WHENEVER YOU USE IT: this is NOT the list of chats the bot belongs to — the Bot API publishes no such method — it is every chat that SENT the bot an update in the last 24 hours, which is as long as Telegram keeps an update. A channel the bot posts to every day but nobody messages will NOT appear here, and its absence means nothing at all: post to it by @username or numeric id anyway. If the bot has an outgoing WEBHOOK configured the list is empty for that reason alone (Telegram: getUpdates "will not work if an outgoing webhook is set up"), and the reply says so rather than reading as an empty account. Nothing is consumed — no update offset is confirmed, so this cannot eat the bot’s pending updates. Free, 0 credits.

ParameterTypeDescription
limitnumberhow many recent updates to scan, 1–100 (default 100)

TELEGRAM INBOUND

list_telegram_dms#

Read messages sent to your Telegram bot read-only

Read what people have SENT to the connected Telegram bot — the newest message from each chat, newest chat first, so you can see who is waiting on a reply. Reply with post_to_telegram using the chatId shown. ⚠ THIS IS A ROLLING 24-HOUR WINDOW, NOT AN INBOX. Telegram keeps undelivered updates for 24 hours and publishes NO history endpoint at all, so anything older is unrecoverable — never report an empty result as 'you have no messages', report it as 'nothing in the last 24 hours'. Two more Telegram rules worth stating before someone concludes the feature is broken: a bot that has an outgoing WEBHOOK configured gets nothing from this at all (Telegram's own rule, and the reply says so), and a bot can never message someone first — they have to write to it. Free — no vendor charge and no credits. Needs Telegram connected (Settings ▸ Connectors ▸ Telegram, or connect_connector).

ParameterTypeDescription
limitnumberhow many raw updates to scan, 1-100 (default 100). Messages are grouped per chat, so this is not the number of rows you get back.

delete_telegram_message#

Delete a Telegram message destructive

PERMANENTLY delete one message the bot posted to a Telegram chat. Call it WITHOUT confirm first: nothing is deleted and you get a sentence to show the user. There is deliberately NO preview of the message — the Bot API has no method that reads one message back, so anything shown would be invented, and for the same reason the result after deleting is Telegram’s own success answer rather than a verified read-back. TWO VENDOR LIMITS, both Telegram’s and neither ours: "A message can only be deleted if it was sent less than 48 hours ago", and in a CHANNEL the bot needs the Post Messages right to remove even its own posts. Takes the same chatId as post_to_telegram plus the messageId post_to_telegram returned. 0 credits. Needs Telegram connected (Settings ▸ Connectors ▸ Telegram, or connect_connector).

ParameterTypeDescription
chatIdrequiredstringthe chat the message is in — the same @username or numeric id it was posted with
messageIdrequirednumberthe message id post_to_telegram returned (also the number at the end of a t.me link)
confirmbooleanREQUIRED true — Telegram has no trash and no undelete

list_inbox#

One inbox for comments, replies, mentions, reviews and DMs read-only

EVERYTHING PEOPLE SAID TO THIS BRAND, across every connected channel, in one list: Facebook and Instagram comments, Threads replies and mentions, YouTube and Reddit comments, Google Business reviews, Bluesky replies and mentions, and X mentions — plus DIRECT MESSAGES on Meta (Messenger and Instagram Direct), Instagram, Bluesky, X and Telegram. Use this for 'what do I need to reply to', 'any new comments', 'any new DMs', 'how are people responding'. Each item carries a composite id you hand straight to reply_to_inbox_item. A channel that is not connected is skipped silently; a channel that FAILS to read is named in `notes` rather than dropped, so a short list is never mistaken for a quiet week. DMs are read one conversation at a time and fold to ONE item — the newest thing the other person said — so pass conversationId (from list_meta_conversations, list_bluesky_convos or list_x_dms) to include them. FREE ON EVERY CHANNEL EXCEPT X, which bills per API call — X mentions and X DMs cost credits per item returned, and every other source only re-reads what the per-channel tools already read.

ParameterTypeDescription
sourcesstring[]Limit to these sources: facebook, instagram, threads, meta_dm, instagram_dm, meta_webhook, threads_mention, youtube, reddit, google_business, x, bluesky, bluesky_mention, bluesky_dm, x_dm, telegram_dm. Omit for every connected channel.
postIdstringRestrict Facebook/Instagram to one post or media id (Meta reads comments per post).
videoIdstringRestrict YouTube to one video.
conversationIdstringRestrict DMs to one conversation (from list_meta_conversations, list_bluesky_convos or list_x_dms). DMs are read per conversation, and on X reading one costs credits.
limitnumberMax items per source (default 25).
unansweredOnlybooleanOnly items with no reply from the brand yet.

reply_to_inbox_item#

Reply to anything in the inbox

Answer an inbox item BY ITS COMPOSITE ID — the `id` list_inbox returned, e.g. 'facebook:123_456' or 'google_business:accounts/1/locations/2/reviews/3'. Routes to the right channel for you; you do not need to know which reply tool a platform uses. The reply is posted as the brand — PUBLIC on every comment, reply, mention and review, and PRIVATE on the two DM sources (meta_dm, bluesky_dm) — so show the user the exact wording and get their go-ahead first either way. X is the one exception and says so: a reply there is a new post, so it goes through post_to_x with replyTo.

ParameterTypeDescription
idrequiredstringThe composite id from list_inbox — '<source>:<platform id>'. A bare platform id is refused: replying to the wrong network as the brand cannot be taken back.
textrequiredstringThe reply, exactly as it should appear publicly.

hermoso_capabilities#

Start here: what Hermoso can do and what it costs read-only

Probe what this Hermoso account can do RIGHT NOW: available image/video model ids + their exact credit costs, aspect ratios, video durations, the recipe ids, and the canEdit/canAvatar flags. Call it when you need a specific model id, an exact cost, or a capability you are not sure of. It is NOT a prerequisite for rendering: generate_image, generate_video and render_ad all run with `model` omitted and route to the server’s own default. Read-only, free.

No parameters.

hermoso_credits#

Credit balance read-only

Return the account credit balance, the credits this account has spent on the calls listed, those recent priced calls, and costModel — the one-sentence rule of what costs credits. THE RULE: only AI model runs and Ad Spy research spend credits; publishing, scheduling, ads management, analytics, comments, DMs and connectors are FREE on every plan (X is the single per-call exception). Check before kicking off paid generation; answer "does posting cost credits?" with NO.

No parameters.

buy_credits#

Buy credits

Out of credits? Top up with a credit PACK. Call with no argument to list the available packs (id · credits · price). If the account has a saved card and you have billing-admin rights, calling with `pack` quotes the exact charge and calling again with confirm:true AND the quote's quote_token charges the saved card instantly (same one-click top-up as the app — no redirect). If there's no saved card yet, you get a Stripe checkout URL to hand your human for the FIRST purchase; their card saves for one-click after that. Packs only; subscriptions are managed by a person in Settings → Billing. IF YOU ARE AN AGENT HOLDING YOUR OWN PAYMENT CREDENTIAL, there is a third path that needs no human at all: `POST /api/billing/machine-payment` with a `packId` answers HTTP 402 carrying an MPP challenge, and grants the pack once you authorise and retry with the credential — the same packs, the same prices, the same credits. `GET /api/billing/config` carries a `machinePayments` block listing the packs with their per-credit rates and saying whether that lane is enabled on this server. Most agents do NOT have their own credential yet, so the checkout link above remains the normal path. To stop running out entirely, turn on auto-reload with set_auto_reload (admin) — low balances then top themselves up from the saved card automatically.

ParameterTypeDescription
packstringthe pack id to buy (e.g. pack-2k) — omit to list the available packs first
confirmbooleanset true to actually charge the saved card for `pack` (required for the one-click charge; ignored on the checkout-link path)
quote_tokenstringthe quoteToken returned by the quote step — REQUIRED (with confirm:true) to charge; it binds the exact pack + price you quoted (10-minute validity) and makes a retried confirm idempotent

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)
accountstringwhich Instagram account — an @handle or id from list_connector_accounts("instagram"). Needed when several are linked, and the way an Instagram Login (standalone) account is reached; omit for the one Page-linked account.
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 the Instagram account connected, either through Meta (an Instagram account linked to a Facebook Page) or on its own through the Instagram connector; when several are connected, name the one you mean with account.

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
accountstringwhich Instagram account — an @handle or id from list_connector_accounts("instagram"). Needed when several are linked, and the way an Instagram Login (standalone) account is reached; omit for the one Page-linked account.
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)
accountstringwhich Instagram account — an @handle or id from list_connector_accounts("instagram"). Needed when several are linked, and the way an Instagram Login (standalone) account is reached; omit for the one Page-linked account.
pageIdstringFacebook Page id — omit when only one Page is connected

instagram_collaborators#

Check who accepted a collab invite read-only

Did the collab invites on an Instagram post get accepted? Reports every collaborator on one of the brand’s OWN Instagram posts with the status Instagram actually holds for it — Accepted (the post is live on their profile too, and its reach now includes their followers), Pending (invited, sitting in their notifications, NOT yet on their profile) or Declined. This is the tool for “did @creator accept yet?”, and the only way to find out: Instagram sends no notification either way. The media id is what post_to_meta returned as `postId`, or any id from list_instagram_media. A post with no collaborators simply reports none. Read-only, 0 credits.

ParameterTypeDescription
mediaIdrequiredstringthe Instagram media id — post_to_meta returns it as postId, list_instagram_media lists the account’s own posts
pageIdstringFacebook Page id — omit when only one Page is connected

search_instagram_audio#

Trending or searched Instagram audio read-only

Audio the brand may legally put under a Reel — music or original sound — with title, artist, length, whether it is eligible for ads, a preview link and a download link. Omit the query for what is TRENDING right now; pass one to search. Everything returned is audio Instagram has authorized for third-party use. Download links expire after roughly 1.5 days. Read-only, free.

ParameterTypeDescription
audioType"music" | "original_sound"default music
querystringomit for trending audio
limitnumber1–50, default 15
accountstringwhich Instagram account — an @handle or id from list_connector_accounts("instagram"); omit for the one Page-linked account
pageIdstringFacebook Page id — omit when only one Page is connected

list_instagram_collab_invites#

Collab invites waiting on the brand read-only

Posts where a creator tagged this Instagram account as a COLLABORATOR and is waiting for an answer. The post appears on the brand’s profile only once the brand accepts, and Instagram sends no notification here, so this list is the only way to see them. Answer each with respond_instagram_collab_invite. Read-only, free. Instagram allows 300 reads per account per day.

ParameterTypeDescription
limitnumber1–100, default 25
afterstringcursor from a previous page
accountstringwhich Instagram account — @handle or id; omit for the one Page-linked account
pageIdstringFacebook Page id — omit when only one Page is connected

respond_instagram_collab_invite#

Accept or decline a collab invite

Answer a collab invite from list_instagram_collab_invites. accept:true makes this account a co-author and the creator’s post appears on its profile; accept:false declines. `accept` is REQUIRED — never guess which way the user wants it. The reply is read back from Instagram (the invite leaves the pending list), not taken from the vendor’s 200. Free. Instagram allows 50 answers per account per day.

ParameterTypeDescription
mediaIdrequiredstringthe media id from list_instagram_collab_invites
acceptrequiredbooleantrue = accept and co-author the post; false = decline
accountstringwhich Instagram account — @handle or id; omit for the one Page-linked account
pageIdstringFacebook Page id — omit when only one Page is connected

list_instagram_collab_media#

Posts this account co-authors read-only

Every collaborative post this Instagram account is a co-author on — who posted it, and the COMBINED engagement across all co-authors (total likes and comments, plus saves, shares and reposts where Instagram provides them). Read-only, free.

ParameterTypeDescription
limitnumber1–100, default 25
afterstringcursor from a previous page
accountstringwhich Instagram account — @handle or id; omit for the one Page-linked account
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. REPLIES: a reply is a comment ON a comment, and its id exists only under its PARENT — it is never returned by the post. Each row says how many replies it has; to read them (and to get the id reply_to_meta_comment / moderate_meta_comment need), call this tool again with postId set to that COMMENT id.

ParameterTypeDescription
postIdrequiredstringpost/media id — or a COMMENT id, which returns that comment’s replies
accountstringwhich Instagram account — an @handle or id from list_connector_accounts("instagram"). Needed when several are linked, and the way an Instagram Login (standalone) account is reached; omit for the one Page-linked account.
pageIdstringPage id — omit when only one Page is connected
limitnumberhow many comments (1–50, default 25)
cursorstringthe cursor from a previous call. A post with more comments than one page comes back with hasMore + a truncationNote — counts or sentiment drawn from ONE page describe a sample, not the conversation.

comment_on_meta_post#

Comment on your own Facebook post

Leave a NEW top-level comment on one of the brand’s own Facebook Page posts, as the brand. This is the "link in the first comment" tactic — Facebook suppresses reach on a post with an outbound link in the body, so brands put the link underneath — and it is also how you add a pinned clarification, an offer code or a correction without editing the post. It is PUBLIC and posted as the brand: show the user the exact wording and get their go-ahead first. postId comes from list_meta_posts. FACEBOOK ONLY: Meta does not let an app add a top-level comment to an INSTAGRAM post — the only comment write Instagram documents is a reply to a comment somebody else left (reply_to_meta_comment) — so an Instagram media id is refused by name rather than attempted. Free.

ParameterTypeDescription
postIdrequiredstringthe Facebook Page post id — from list_meta_posts, or returned by post_to_meta
messagestringthe comment text. Required unless attachmentUrl is given.
attachmentUrlstringoptional public image URL to post as the comment’s attachment
pageIdstringPage id — omit when only one Page is connected

INSTAGRAM LIKE / UNLIKE

like_instagram#

Like or unlike on Instagram as the brand

Like (or unlike) an Instagram post, Reel, comment or reply AS the brand’s Instagram account — Meta’s Like Media and Comments API (April 2026). The cheapest engagement a brand does: like the good comments on your own posts and the posts you are tagged in. Pass exactly one of mediaId or commentId; undo:true unlikes. Stories and private accounts cannot be liked. Needs the Meta (Facebook Login) connector — a direct Instagram login has no likes edge. Roughly 200 likes per account per hour. Free.

ParameterTypeDescription
mediaIdstringan IG media id (post or Reel), from list_instagram_media
commentIdstringan IG comment or reply id, from list_meta_comments
undobooleantrue to unlike
pageIdstring
accountstring

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
accountstringwhich Instagram account — an @handle or id from list_connector_accounts("instagram"). Needed when several are linked, and the way an Instagram Login (standalone) account is reached; omit for the one Page-linked account.
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
accountstringwhich Instagram account — an @handle or id from list_connector_accounts("instagram"). Needed when several are linked, and the way an Instagram Login (standalone) account is reached; omit for the one Page-linked account.
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 — Threads has no undelete. Call it WITHOUT confirm first: nothing is deleted, and it answers with the post’s real text plus its views, likes, replies and reposts read back from Threads. Show the user that, get an unambiguous yes, then call again with confirm:true — plus confirmName (the post’s exact text as it was reported) once anyone has engaged with it. confirmName exists because confirming that you meant to delete SOMETHING does not prove you aimed at the right post, and a wrong id must not be confirmable blind. Threads allows only 100 deletions per account per rolling 24 hours; threads_publishing_limit says how many are left, and a quota refusal otherwise reads like a broken connection. Note Meta documents nothing about what a delete does to the replies underneath a post, so do not promise the conversation survives. 0 credits.

ParameterTypeDescription
postIdrequiredstringpost id from list_threads_posts
confirmbooleanREQUIRED true — deletion is permanent; only set it after the user has explicitly agreed
confirmNamestringthe post’s exact text as the unconfirmed call reported it — required once it has any likes, replies or reposts

repost_thread#

Repost a Threads post

Repost an existing Threads post to the brand’s own Threads profile — the Threads equivalent of a retweet. It is how a brand amplifies a customer’s post, a mention, or one of its own older threads without copying the text, and there was previously no way to do it. Works on any Threads post id: list_threads_posts, list_threads_mentions and search_threads_keyword all return them. This creates a NEW post on the profile, so show the user what is being reposted and get a yes first. Threads publishes NO un-repost endpoint — because a repost returns its own media id, deleting THAT id with delete_thread is the likely undo, but Meta does not document it, so check the profile afterwards rather than promising it worked. 0 credits. Needs Threads connected.

ParameterTypeDescription
postIdrequiredstringthe Threads post id to repost

threads_publishing_limit#

Threads quota remaining read-only

How much of the brand’s Threads quota is left right now — posts (250 per rolling 24 hours), replies (1,000), DELETIONS (100) and location searches (500) — each as used, total and REMAINING. Check it before any bulk operation, and read it the moment Threads starts refusing: a quota refusal is otherwise indistinguishable from a broken connection or a missing permission, and reconnecting cannot fix it. A number comes back null when Threads did not report it, never as 0 — "none left" and "we could not tell" are different answers. Read-only, 0 credits. Needs Threads connected.

No parameters.

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, video, audio, PDF or document, up to 150MB) into Hermoso and get back a durable public URL that EVERY publish, schedule and ad-build tool accepts — post_to_meta / post_to_linkedin / post_to_linkedin_page / post_to_youtube / post_to_tiktok / post_to_pinterest / post_to_x / post_to_google_business / schedule_post / upload_meta_asset / upload_google_ads_asset / create_meta_ad / create_linkedin_ads_creative / set_youtube_thumbnail / save_to_drive / save_to_onedrive. THIS IS THE BRING-YOUR-OWN-CREATIVE PATH: it is for files that have NOTHING to do with a Hermoso render (media on the user's desktop, an agency's finished ad, a photo they shot), and it means you can publish, schedule and run ads through Hermoso without generating anything here. Provide exactly ONE source — passing two is an error, never a silent preference: `url` (ANY public http(s) link — Hermoso fetches it server-side, so nothing crosses this connection and there is no practical size limit; THIS IS THE ONE THAT ALWAYS WORKS, including on the hosted connector), `path` (a local file, ONLY when Hermoso runs on the user's own machine over stdio/CLI; the hosted connector cannot see their disk), or `dataUri` (a base64 data: URI — keep it under ~15MB, since the bytes travel over this connection). If the file is already at a public https URL, the Meta, Reddit and ChatGPT-Ads tools take it directly and re-host it safely — but LinkedIn (posts and ad creatives), Pinterest, the YouTube thumbnail and upload_google_ads_asset upload the BYTES themselves and therefore refuse an external host, so run it through here first and pass the URL this returns. When in doubt, use this: its URL works everywhere. (Calling the underlying HTTP route directly? POST /api/upload takes the file's RAW BYTES as the request body with its own content-type — NOT multipart/form-data — or ?url=<link> with no body.) Returns {url, kind, bytes}.

ParameterTypeDescription
getUploadUrlbooleanASK FOR A ONE-TIME UPLOAD URL instead of uploading now — use this whenever the file is on the user’s machine and you can run a shell or an HTTP request. Returns a uploadUrl you PUT the raw bytes to (any HTTP client), which answers with the durable Hermoso url. It beats `dataUri` for anything but a small image: a data: URI spends the whole file as tokens in this conversation. One file per url, and it expires.
urlstringa PUBLIC http(s) URL Hermoso fetches server-side (private/internal addresses are refused, and every redirect hop is re-checked). Works on every surface including the hosted connector, and the bytes never cross this connection — prefer this whenever the file is reachable on the web.
pathstringlocal filesystem path (stdio/CLI only — refused on the hosted connector)
dataUristringbase64 data: URI of the file bytes (data:<mime>;base64,<…>) — bytes travel over this connection, so keep it small
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

WHATSAPP BUSINESS

list_whatsapp_accounts#

WhatsApp Business accounts and numbers read-only

The WhatsApp Business Accounts SHARED WITH THIS BRAND and the phone numbers registered on each — the ids every other WhatsApp tool needs, plus each number’s QUALITY RATING, which is what decides how many messages Meta will let it send. Start here. A WABA with no number cannot send anything and the reply says so: Hermoso does not register or verify numbers, that is WhatsApp Manager. A business portfolio that could not be read is REPORTED rather than dropped — an account missing from this list would read as "the brand has no WhatsApp", which is a claim about their business and not about our read. TWO ACCOUNTS CAN CARRY THE SAME DISPLAY NAME — always quote the `display` field or the id when naming one to a user, never the bare name. ONLY THE ACCOUNTS SHARED WITH THIS BRAND ARE REACHABLE. One Meta login often administers WhatsApp for several businesses, so a human ticks which belong to this brand under Settings ▸ Connectors ▸ Meta ▸ Manage accounts (or set_connector_accounts(provider:"meta")); nothing else here can see or touch the rest. With none ticked every WhatsApp tool refuses and names that as the way out. With exactly ONE ticked, wabaId is optional — pass it only to disambiguate. Read-only, 0 credits (WhatsApp conversations are billed by META to the business directly, never in Hermoso credits). Needs Meta connected.

No parameters.

list_whatsapp_templates#

WhatsApp message templates read-only

The message templates on one WhatsApp Business Account with the REVIEW STATUS Meta actually holds for each. THE STATUS IS THE POINT: an APPROVED template is the only thing WhatsApp accepts outside the 24-hour customer-service window, and Meta’s own refusal for anything else says "the template does not exist in the specified language or the template has not been approved" — two completely different problems in one sentence. Each row also reports its LANGUAGE, because Meta treats en and en_US as different templates and sending against the wrong one is refused as if it were unapproved. Each row also reports the VARIABLES the template carries — the distinct {{1}}, {{2}} placeholders per component, with the total — because send_whatsapp_message must supply EXACTLY that many and Meta refuses a mismatch with 132000. Read the count off that field, never off the one-line body preview, which is capped. Read-only, 0 credits. Needs Meta connected.

ParameterTypeDescription
wabaIdstringthe WhatsApp Business Account id, from list_whatsapp_accounts. Optional when exactly one account is shared with this brand; required when several are, and refused by name for one that is not shared
namestringfilter to one template name (Meta matches it exactly)
languagestringfilter to one language code, e.g. en_US
statusstringfilter to one review status, e.g. APPROVED or PENDING
limitnumberhow many (1–200, default 50)

create_whatsapp_template#

Create a WhatsApp message template

Submit a new WhatsApp message template for Meta’s review. This is the ONLY way a business can start a WhatsApp conversation: outside the 24-hour window that opens when a customer messages first, WhatsApp accepts approved templates and nothing else. CATEGORY IS NOT COSMETIC — MARKETING (anything promotional), UTILITY (a transactional follow-up to something the customer did) or AUTHENTICATION (a one-time passcode) decides both what Meta charges for the conversation and the policy the content is reviewed against, and Meta RE-CATEGORISES a template whose content does not match the claim; the reply reports the category Meta actually assigned, not the one requested. The name is lowercase letters, digits and underscores only. Language is never defaulted: en and en_US are different templates to Meta. Submitting does NOT make it sendable — it comes back PENDING and has to be re-read with list_whatsapp_templates. CAROUSEL TEMPLATES ARE SUPPORTED: add a “CAROUSEL” component whose “cards” array holds 2–10 cards, each card carrying its own HEADER (format IMAGE or VIDEO for a media carousel, PRODUCT for a catalog one), an optional card BODY of at most 160 characters — a different and far shorter limit than the message bubble’s 1024 — and up to 2 buttons. Meta requires every card to carry THE SAME components, and if any card has body text then all of them must. At creation a card is only its “components” array; “card_index” belongs to the send payload, not to this one. A MEDIA HEADER NEEDS AN UPLOADED ASSET: any IMAGE, VIDEO or DOCUMENT header — including every card header in a media carousel — must carry example: { header_handle: [ … ] } holding a handle from Meta’s Resumable Upload API, or Meta refuses the whole template with a bare “Invalid parameter”. Hermoso does not upload template media yet, so it refuses that locally and by name instead. A TEXT header needs no handle, and a PRODUCT card carousel takes its pictures from a connected catalog. 0 credits.

ParameterTypeDescription
wabaIdstringthe WhatsApp Business Account id, from list_whatsapp_accounts. Optional when exactly one account is shared with this brand; required when several are, and refused by name for one that is not shared
namerequiredstringlowercase letters, digits and underscores only, e.g. "order_shipped" (max 512)
languagerequiredstringthe language code, e.g. en_US, es_MX, pt_BR — Meta treats each as its own template
categoryrequired"MARKETING" | "UTILITY" | "AUTHENTICATION"MARKETING = promotional, UTILITY = transactional follow-up, AUTHENTICATION = one-time passcode. It sets the price and the review policy.
componentsrequiredobject[]Meta’s component array. A BODY is the only REQUIRED one: [{ type: "BODY", text: "Your order {{1}} has shipped." }]. Optional HEADER (text max 60), FOOTER (max 60) and BUTTONS (up to 10 in total). Body text max 1024. A carousel is { type: “CAROUSEL”, cards: [{ components: [{ type: “HEADER”, format: “IMAGE” }, { type: “BODY”, text: … }] }, …] } — 2–10 cards, all with the same components, card body max 160, up to 2 buttons each.
parameterFormatstringPOSITIONAL (default, {{1}}) or NAMED

delete_whatsapp_template#

Delete a WhatsApp message template destructive

PERMANENTLY delete a WhatsApp message template. META DELETES EVERY LANGUAGE OF A NAME AT ONCE, which is a wider blast radius than most callers picture, so call it WITHOUT confirm first: nothing is deleted and it reports exactly how many templates and which languages would go. Then show the user that, get an unambiguous yes, and call again with confirm:true AND confirmName set to the template’s exact name — confirming that you meant to delete SOMETHING does not prove you aimed at the right template. Any message sent against a deleted template afterwards is refused. The answer is a RE-READ from Meta, never the 200: if the template still comes back it is reported as not deleted. 0 credits.

ParameterTypeDescription
wabaIdstringthe WhatsApp Business Account id. Optional when exactly one is shared with this brand; an account that is not shared is refused by name
namerequiredstringthe template name (Meta matches it exactly)
confirmbooleanREQUIRED true to actually delete — deletion is permanent and covers every language
confirmNamestringthe template’s exact name, echoed back — required alongside confirm

send_whatsapp_message#

Send a WhatsApp message

Send a WhatsApp message from one of the brand’s registered numbers to a customer. TWO KINDS, and which one is legal depends on time: pass `template` (an APPROVED template name) to START a conversation — the only thing WhatsApp accepts outside the 24-hour customer-service window — or `text` for a free-form reply INSIDE that window, which opens only when the customer messages the business first. Hermoso does not receive WhatsApp webhooks yet, so it CANNOT see whether that window is open: a free-form message is sent optimistically and Meta refuses it with a re-engagement error if it is closed, which the reply explains rather than reporting as a broken connection. THIS REACHES A REAL PERSON’S PHONE AND META BILLS THE BUSINESS for the conversation, so it is confirm-gated: show the user the exact message and the recipient, get an explicit yes, then call again with confirm:true. WhatsApp policy REQUIRES the business to hold the recipient’s opt-in first. An unapproved template is refused BEFORE the send, against the status Meta holds. ACCEPTED IS NOT DELIVERED — the reply reports what Meta accepted, and delivery arrives on a webhook we do not receive, so never tell the user it was delivered. THE VARIABLE COUNT IS CHECKED BEFORE ANYTHING IS DISPATCHED: Hermoso reads the template’s own components back from Meta and refuses a payload that supplies the wrong number of values, naming exactly what is missing — for free, before a real phone is involved, instead of Meta answering 132000 after the attempt. It refuses only what it can prove; anything it cannot determine is sent for Meta to judge. 0 Hermoso credits.

ParameterTypeDescription
phoneNumberIdrequiredstringWHICH NUMBER IT SENDS FROM — from list_whatsapp_accounts (a WABA can have several)
torequiredstringthe recipient in full international form, e.g. +14165550142
templatestringan APPROVED template name — required to start a conversation outside the 24-hour window
languagestringwhich language of that template, e.g. en_US. Omitted, Hermoso uses the first one it finds and reports which.
wabaIdstringthe account the template’s review status is read from. Optional — it defaults to the account that owns phoneNumberId, and a wabaId naming a DIFFERENT account is refused rather than silently preferred
componentsobject[]the template’s variable values, in Meta’s component form. The count must match the {{1}}, {{2}} placeholders exactly. Hermoso checks this against the template’s real components before dispatching and refuses a provable mismatch by name. A media header needs its own component holding the link even though it carries no {{n}}, and a carousel needs one entry per card.
textstringa free-form message — only lands INSIDE the 24-hour window
confirmbooleanREQUIRED true — this sends a real message to a real phone and Meta bills the business for it

send_whatsapp_broadcast#

Broadcast a WhatsApp template to many people

Send ONE approved WhatsApp template to MANY recipients — a customer list, a segment, a launch announcement. TEMPLATES ONLY: WhatsApp accepts free-form text only inside the 24-hour customer-service window that opens when THAT person messages the business, so a free-form broadcast is a separate bet on a separate window per person and Meta refuses each closed one. META HAS NO BULK ENDPOINT, so this is a paced fan-out — one message per recipient, about 10 a second — which is why it runs as a QUEUED JOB and returns a job id rather than a result: nothing has been sent when the call returns. Poll it by calling this tool again with `jobId` (or use get_job), and read the PER-RECIPIENT outcome; a partial failure is reported as one, never as success. PER-RECIPIENT VARIABLES ARE THE POINT: pass recipients as objects { to, components } so each person gets their own {{1}}, {{2}} values in a single broadcast. Duplicates are removed (the same number twice is a second charge for one person and trips Meta’s 6-second per-recipient limit) and unusable numbers are REFUSED BY NAME rather than silently dropped — pass skipInvalid:true to send to the rest, and they are still named in the result. RUN IT WITH dryRun:true FIRST: that costs nothing, sends nothing, and reports the exact recipient count, the template’s real category, what Meta bills for it, and whether the list fits inside the business portfolio’s moving 24-hour messaging limit. ⚠ META DOES NOT DELIVER MARKETING MESSAGES TO WHATSAPP USERS IN THE UNITED STATES AT ALL (error 131049, platform-wide since 1 April 2025) — the sends are still ACCEPTED and given message ids, so a US marketing broadcast looks like a success and is not one; the dry run says how many recipients are on +1. META BILLS THE BUSINESS, not Hermoso credits: per message delivered, at Meta’s own per-country rates, marketing always charged and utility/authentication free inside an open service window. WhatsApp policy REQUIRES the recipient’s opt-in first and nothing in the API enforces it — Meta punishes it afterwards through quality rating. Confirm-gated in code: show the user the message, the template and the recipient count, get an explicit yes, then call again with confirm:true AND confirmCount set to the exact number of distinct recipients. ACCEPTED IS NOT DELIVERED — never report a broadcast as delivered. 0 Hermoso credits.

ParameterTypeDescription
jobIdstringREAD MODE — pass a broadcast’s job id (and nothing else) to get its status and per-recipient outcome instead of starting a new one
phoneNumberIdstringWHICH NUMBER IT SENDS FROM — from list_whatsapp_accounts (a WABA can have several). Required to SEND; omit only when reading a broadcast back with jobId
templatestringan APPROVED template name. Required to SEND — a broadcast cannot be free-form text; omit only when reading a broadcast back with jobId
languagestringwhich language of that template, e.g. en_US. Omitted, Hermoso uses the first one it finds and reports which.
wabaIdstringthe account the template is read from. Optional — it defaults to the account that owns phoneNumberId, and one naming a DIFFERENT account is refused
recipientsobject[]the list: either phone numbers in full international form ["+14165550142", …], or objects { to, components } when each person gets different template values. Duplicates are removed and counted; invalid numbers are refused by name unless skipInvalid is true
componentsobject[]template values SHARED by every recipient, in Meta’s component form. A per-recipient `components` overrides it. The count must match the template’s {{1}}, {{2}} placeholders — Hermoso checks that against the real template and refuses a provable mismatch before anything is dispatched
skipInvalidbooleandrop unusable numbers instead of refusing the whole list. They are still named in the result — nothing is ever silently dropped
dryRunbooleancost nothing, send nothing, queue nothing: report exactly who would be messaged, what Meta charges for it, and whether it fits the 24-hour messaging limit
confirmbooleanREQUIRED true — this sends real messages to real phones and Meta bills the business for every one
confirmCountnumberREQUIRED — the exact number of DISTINCT valid recipients, echoed back. confirm:true alone cannot tell a 12-person test from a 2,000-person send

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. INSTAGRAM COLLAB: pass `collaborators` (up to 3 usernames) to invite other accounts to CO-AUTHOR the post — it then shows on their profile too once they accept, which is the reach play behind every creator partnership. 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
brandstringWHICH BRAND this post belongs to — the id or exact name from list_brands (a workspace shared with you: its profile id). Use it whenever the account has more than one brand and you are not certain which one this connection is pinned to: it beats the pin for THIS CALL ONLY and changes nothing about the connection. A name that matches no brand, or two brands, is REFUSED and nothing is posted — never resolved to the pin, which is the account you were guarding against.
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
productTagsobject[]INSTAGRAM SHOPPING — make the post SHOPPABLE by tagging products from the brand’s own catalog; tapping a tag opens the product’s price sheet inside Instagram. Instagram only. ON A PHOTO each tag is {product_id, x, y}, where x and y are FRACTIONS of the image from 0.0 (left/top) to 1.0 (right/bottom) — 0.5,0.5 is the middle — and BOTH are required, max 20. ON A REEL it is {product_id} ALONE with no coordinates, max 30. ON A CAROUSEL it is an array PER SLIDE ([[{…}], [], [{…}]]) because Instagram tags each slide’s own container, max 5 per slide and 20 across the post. Ids come from search_instagram_shopping_products; call list_instagram_shopping_catalogs FIRST, because tagging needs an APPROVED Instagram Shop and without one this fails after the media is already uploaded. A tag whose product is not “approved” is stored and shown to nobody.
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)
collaboratorsstring[]INSTAGRAM COLLAB — up to 3 Instagram usernames invited to CO-AUTHOR this post. Once one accepts, the post appears on THEIR profile too, with both handles in the header and the likes and comments shared — it is how a brand reaches a creator’s audience without paying for placement, and it is the single most-asked thing a scheduler normally cannot do. Pass handles only ("hermosoai"), not profile links; a leading @ is fine. INSTAGRAM ONLY — Facebook and Threads have no collab post at all and are refused BY NAME rather than silently dropping the co-authors — and never on a Story. AN INVITE IS NOT A CO-POST: publishing SENDS a request the other account must accept in their Instagram notifications, and until they do the post is on this brand’s profile ALONE; they may also decline, and Instagram sends no notification either way. So never report the post as live on both accounts — read the invite status back out of the reply, and use instagram_collaborators later to find out whether they accepted.
target"facebook" | "instagram" | "threads"default facebook; instagram → the Page’s linked IG; threads → the brand’s connected Threads account
accountstringWHICH Instagram account when target is instagram and the brand has several — Page-linked and Instagram Login accounts alike; an @username or id from list_connector_accounts("instagram"). Several and none named is refused by name; omit when there is one.
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.
locationIdstringTAG A PLACE. THREADS: a place id from search_threads_locations. INSTAGRAM: the NUMERIC ID OF A FACEBOOK PAGE associated with that location, not a place name and not coordinates; a non-numeric value is refused rather than sent.
audienceobjectFACEBOOK PAGE POST ONLY — limit who can see this organic post to people in these places and/or over this age (Facebook allows 13, 15, 18, 21 or 25). Not on Instagram, Threads or a Facebook Reel (a vertical clip becomes a Reel): those are refused. Region and city keys come from the Meta ads location search.
placestringFACEBOOK — tag a location on the Page post. The NUMERIC ID OF THE FACEBOOK PAGE for that place, not a place name and not coordinates.
callToAction"BOOK_TRAVEL" | "BUY_NOW" | "CALL_NOW" | "DOWNLOAD" | "GET_DIRECTIONS" | "LEARN_MORE" | "LIKE_PAGE" | "MESSAGE_PAGE" | "NO_BUTTON" | "OPEN_LINK" | "SHOP_NOW" | "SIGN_UP" | "WATCH_MORE"FACEBOOK — a real call-to-action BUTTON on the Page post. The types that open a destination (SHOP_NOW, LEARN_MORE, SIGN_UP, BUY_NOW, DOWNLOAD, OPEN_LINK, WATCH_MORE, BOOK_TRAVEL) need somewhere to go: the post’s own `link`, or `callToActionLink`. CALL_NOW, MESSAGE_PAGE, LIKE_PAGE and GET_DIRECTIONS act on the Page itself and take none.
callToActionLinkstringFACEBOOK — where the button goes, when that is not the post’s own `link`.
linkNamestringFACEBOOK — override the HEADLINE of the link preview. Without it the post shows whatever Open Graph title the destination carries, which on a bare landing page is often nothing. Needs `link`. MEASURED 2026-09-17: Facebook only honours these when the Page’s business has VERIFIED the domain (Business Manager ▸ Brand safety ▸ Domains) — without that Meta refuses the post outright with “Only owners of the URL…”, so leave them out unless the brand owns and has verified that link’s domain.
linkDescriptionstringFACEBOOK — override the DESCRIPTION of the link preview. Needs `link`. MEASURED 2026-09-17: Facebook only honours these when the Page’s business has VERIFIED the domain (Business Manager ▸ Brand safety ▸ Domains) — without that Meta refuses the post outright with “Only owners of the URL…”, so leave them out unless the brand owns and has verified that link’s domain.
linkPicturestringFACEBOOK — override the IMAGE of the link preview: a public http(s) url Facebook fetches itself. Needs `link`. MEASURED 2026-09-17: Facebook only honours these when the Page’s business has VERIFIED the domain (Business Manager ▸ Brand safety ▸ Domains) — without that Meta refuses the post outright with “Only owners of the URL…”, so leave them out unless the brand owns and has verified that link’s domain.
coverUrlstringINSTAGRAM REEL COVER — a public image url Instagram fetches and uses as the cover in the Reels tab. REELS ONLY, and the alternative to `thumbOffset`: passing both is refused, since they are two answers to the same question. Run a local file through upload_file first.
thumbOffsetnumberINSTAGRAM REEL COVER, the other way — which frame becomes the cover, in MILLISECONDS from the start of the video. REELS ONLY. Use it instead of `coverUrl` when the right cover is already a frame of the clip.
shareToFeedbooleanINSTAGRAM REEL — true puts the Reel in the Feed grid as well as the Reels tab. REELS ONLY. Left unset it follows Instagram’s own default; Hermoso does not flip it either way on the user’s behalf.
audioNamestringINSTAGRAM REEL — the name of the Reel’s audio track, which is what viewers tap through to. REELS ONLY.
paidPartnershipbooleanINSTAGRAM — the PAID PARTNERSHIP label. A COMPLIANCE DECLARATION, the same kind Hermoso already carries for TikTok and X: set it whenever the post is sponsored, gifted or otherwise paid for. Opt-in and never inferred — it is the poster’s own statement about their commercial relationship.
brandedContentSponsorIdsstring[]INSTAGRAM — the numeric Instagram USER IDS of the brands behind that label (at most 2, and ids rather than @handles). Naming sponsors IS asking for the label, so setting these with `paidPartnership:false` is refused instead of publishing brand credits with no disclosure.
trialReel"MANUAL" | "SS_PERFORMANCE"INSTAGRAM TRIAL REEL — publish this Reel to NON-FOLLOWERS ONLY at first (Instagram allows trials only on accounts above its follower threshold — about 1,000 followers; an ineligible account is refused by name and nothing is posted), so a hook can be tested on a cold audience without spending it on the people who already follow the brand. Instagram then shows it to followers only if it graduates. MANUAL = the creator graduates it by hand in the Instagram app; SS_PERFORMANCE = Instagram graduates it automatically if it performs well. REELS ONLY and INSTAGRAM ONLY: an image, a carousel, a Facebook post or a Threads post is REFUSED BY NAME rather than quietly published as an ordinary post — a trial that silently goes to every follower is the exact opposite of what was asked for. Omit it for a normal Reel.
storybooleanINSTAGRAM STORY — publish this as a 24-hour Story instead of a feed post. One image OR one video: Instagram has no carousel story, so a carousel is REFUSED BY NAME rather than quietly posted to the feed. A Story carries NO CAPTION (there is nowhere to show one), no collaborators, no product tags and no trialReel — passing any of those is refused by name and nothing is posted, because a story that silently drops the words is worse than one that never went. INSTAGRAM ONLY: a Facebook Page story is a different upload and is not built, so a Facebook channel with `story` set is refused rather than published to the feed.
aiGeneratedbooleanINSTAGRAM / FACEBOOK REEL — Meta’s is_ai_generated self-disclosure. OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, media that came through upload_file or from an external URL (the user’s own photographs or footage) is NOT — a real photo must never carry Instagram’s “AI info” label. Pass true or false only to override: false strips the label from something Hermoso would otherwise declare, true declares a render the user uploaded themselves.
altTextstring | string[]ACCESSIBILITY — the description screen readers announce, and what the platform otherwise auto-generates badly or not at all. Describe what is actually IN the picture, never the caption. ONE STRING describes the picture; on a CAROUSEL it describes EVERY slide. Pass an ARRAY of strings instead to describe each slide separately, aligned to the slide order — that is strictly better on a multi-slide post, because one sentence read out over six different pictures is wrong for five of them. More descriptions than pictures is refused rather than dropped. WHERE IT LANDS, per Meta’s own docs: INSTAGRAM image posts and the IMAGE slides of an Instagram carousel (up to 1000 characters; dropped rather than sent on a Reel or a video slide, which Meta do not support); FACEBOOK Page photos including every photo of an album, via alt_text_custom (Meta publish no length for it, so nothing is truncated); THREADS on a single-image or single-video post ONLY — Meta document no way to attach alt text to a Threads CAROUSEL slide, so a Threads carousel publishes undescribed and the reply says so rather than risking the whole post on a guess. (Meta’s AI disclosure defaults from PROVENANCE: a Hermoso render is declared is_ai_generated; media that came through upload_file or an external URL, i.e. the user’s own photos or footage, is NOT. Pass `aiGenerated` to force it either way.)
pageIdstringtarget Page id (from list_meta_pages); omit = first Page
linkAttachmentstringTHREADS TEXT POSTS ONLY — a full http(s) URL rendered as a clickable link card. This is how a Threads post carries a destination at all; without it a link is just text. Meta allows at most 5 links per post and refuses a link attachment on a post that also carries an image or video, so Hermoso refuses that combination up front rather than letting Meta silently drop it.
replyControl"everyone" | "accounts_you_follow" | "mentioned_only" | "parent_post_author_only" | "followers_only"THREADS ONLY — who may reply. Default is everyone.
quotePostIdstringTHREADS ONLY — the id of a post to QUOTE. A quote post is a distinct post type, not a formatting option.
topicTagstringTHREADS ONLY — one topic tag for discovery, 1–50 characters. A leading # is stripped for you; Threads refuses "." and "&".
countryCodesstring[]THREADS ONLY — restrict the post to these ISO 3166-1 alpha-2 countries. ⚠ This is an ALLOWLIST, so the post becomes INVISIBLE in every country not named — it narrows reach, it does not target.
crossreshareToIgbooleanTHREADS ONLY — ALSO share this Threads post to the linked Instagram account AS A STORY (not a feed post), in the same publish. NOT available on a Threads CAROUSEL, which is refused by name rather than silently dropped. THERE IS NO CONFIRMATION: Threads returns no field saying whether the Story was created, so report it as REQUESTED and tell the user to check their Instagram Stories — never that it is live.
crossreshareDarkModebooleanTHREADS ONLY — render that Instagram Story in dark mode. Only meaningful alongside crossreshareToIg; on its own it is refused rather than silently ignored, because a parameter that never reaches the wire must not look accepted.

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, bluesky, telegram — TEN channels, and every one of them is live. google_business is accepted by the schema too but is currently HELD BACK: Google allowlists that API per project and ours reads 0 QPM, so it is refused at enqueue rather than failing hours later). A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order — never schedule just its first slide. 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. YOUTUBE: `description` (≤5000 chars) is the box under the video for the links and CTA, and the caption stands in when it is omitted; `tags` up to 30; `thumbnailUrl` sets the custom thumbnail. 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`. INSTAGRAM takes `collaborators` — up to 3 usernames invited to CO-AUTHOR the post, which puts it on their profile too once they accept (the invite is sent when the post fires, and is pending until then). 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
brandstringWHICH BRAND this post belongs to — the id or exact name from list_brands (a workspace shared with you: its profile id). Use it whenever the account has more than one brand and you are not certain which one this connection is pinned to: it beats the pin for THIS CALL ONLY and changes nothing about the connection. A name that matches no brand, or two brands, is REFUSED and nothing is posted — never resolved to the pin, which is the account you were guarding against.
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" | "bluesky" | "telegram"[]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
accountsobjectWHICH accounts of a multi-account channel to post to, e.g. { "tiktok": ["@a", "@b"] } or { "tiktok": "all" } — one row per account at fire time, each with its own result. Omit for channels with one account (several and none named is refused by name).
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, carrying the links, the CTA and what YouTube search reads. Omit it and the caption is used.
tagsstring[]YOUTUBE — up to 30 search tags for the video (plain words, no #).
thumbnailUrlstringYOUTUBE: the custom thumbnail, a Hermoso-hosted image (make_thumbnail, or upload_file for the user’s own). Omit it and a frame of the video is used; "auto" keeps YouTube’s pick.
replyControl"everyone" | "accounts_you_follow" | "mentioned_only" | "parent_post_author_only" | "followers_only"THREADS ONLY — who may reply. Omit for Threads' own default (everyone).
quotePostIdstringTHREADS ONLY — the id of the Threads post this one quotes.
linkAttachmentstringTHREADS ONLY — a full http(s) URL rendered as a link card. This is the ONLY way a Threads post carries a destination, and Threads attaches it to TEXT-ONLY posts (a post with media cannot also carry a card).
topicTagstringTHREADS ONLY — one topic tag for the post, without the leading #.
countryCodesstring[]THREADS ONLY — two-letter country codes to limit who can see the post. Omit to show it everywhere.
crossreshareToIgbooleanTHREADS ONLY — when this fires, ALSO share it to the linked Instagram account as a STORY. Refused on a Threads carousel. No confirmation exists that the Story was created, so the result says it was requested.
crossreshareDarkModebooleanTHREADS ONLY — render that Instagram Story in dark mode. Needs crossreshareToIg.
slideTextobject[]PINTEREST CAROUSEL ONLY — per-slide title / description / link, aligned to slide order. Every other platform takes ONE caption for the whole carousel.
altTextstring | string[]ACCESSIBILITY — the screen-reader description of the attached image. ONE STRING describes the picture; on a CAROUSEL it describes EVERY slide. Pass an ARRAY of strings instead to describe each slide separately, aligned to the slide order — that is strictly better on a multi-slide post, because one sentence read out over six different pictures is wrong for five of them. More descriptions than pictures is refused rather than dropped. 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. CARRIED BY: X (max 1000, one per media), Pinterest (max 500 — PIN-LEVEL only, since its API has no per-item alt text, so slide 1’s description is used for the whole Pin and the result says the others were not sent), LinkedIn COMPANY PAGES (max 4086, one per slide), INSTAGRAM image posts and image slides (max 1000), FACEBOOK photos and albums, and BLUESKY, whose lexicon makes it REQUIRED on every image. The schedule is REFUSED if the LONGEST description exceeds the tightest of the channels on it, rather than truncated on the way out. NOT CARRIED, and none of these is a refusal — the post still publishes, just undescribed there, and the per-channel result says which: TikTok (its photo post has no alt field at any level), a THREADS CAROUSEL, an INSTAGRAM Reel or video slide, and a LinkedIn PERSONAL-profile post.
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.
privacyLevel"PUBLIC_TO_EVERYONE" | "MUTUAL_FOLLOW_FRIENDS" | "FOLLOWER_OF_CREATOR" | "SELF_ONLY"TIKTOK — WHICH PRIVACY LEVEL the post goes out at, in TikTok’s own vocabulary. TikTok requires the USER to choose this from the levels their own account allows: call tiktok_creator_info, show them the real options, and pass back the one they picked — never a default and never a guess, which TikTok refuses at init. Omit it and the post falls back to the coarse `visibility` (private → SELF_ONLY, otherwise PUBLIC_TO_EVERYONE), which cannot express MUTUAL_FOLLOW_FRIENDS or FOLLOWER_OF_CREATOR at all. It must agree with the TikTok visibility (SELF_ONLY is the private one) and it is refused on a TikTok DRAFT, which carries no post info.
commercialContentbooleanTIKTOK — the COMMERCIAL CONTENT DISCLOSURE toggle: true when the post promotes a brand, product or service at all. TikTok requires at least one of yourBrand / brandedContent alongside it, and a post declaring itself commercial without naming which kind is refused. Setting either disclosure already implies this, so it is only needed to be explicit.
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). 280 characters per part without X Premium, up to 25,000 with it; nothing is truncated. 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.
xQuotePostIdstringX — the numeric id of an X post this one QUOTES: the last part of its URL. NAMED xQuotePostId, NOT quotePostId, because `quotePostId` on this same schedule belongs to THREADS. Billed at X’s higher LINK rate.
communityIdstringX — publish into an X COMMUNITY instead of the main timeline: the number in the community’s own URL (x.com/i/communities/<id>). The connected account must be a MEMBER of it.
paidPartnershipbooleanINSTAGRAM AND X — the PAID PARTNERSHIP label, a compliance declaration: set it when the post is sponsored, gifted or otherwise paid for. OPT-IN ONLY, never assume it on the user’s behalf. On Instagram, brandedContentSponsorIds names the brands behind it.
xArticleobjectX: publish the X item as a long-form X ARTICLE. title is its headline, the X text (message or captions.x) its markdown body, the image its cover. Refused now if the markdown has formatting X cannot hold. X allows about 5 Articles a day; one that fires into that cap fails with the reset time and can be retried.
collaboratorsstring[]INSTAGRAM — a COLLAB post: up to 3 Instagram usernames invited to CO-AUTHOR it, so it appears on their profile too once they accept, with both handles in the header and the engagement shared. Handles only ("hermosoai"); a leading @ is fine. Instagram must be one of the `channels` — asking for collaborators on a schedule Instagram is not on is REFUSED now rather than discovered when it fires, and the other channels in a mixed schedule simply publish without co-authors. THE INVITE IS SENT WHEN THE POST FIRES, not when you schedule it, and it is PENDING until the other account accepts in their notifications; check with instagram_collaborators afterwards rather than telling the user it is live on both profiles.
audienceobjectFACEBOOK PAGE POST ONLY — limit who can see this organic post to people in these places and/or over this age (Facebook allows 13, 15, 18, 21 or 25). Not on Instagram, Threads or a Facebook Reel (a vertical clip becomes a Reel): those are refused. Region and city keys come from the Meta ads location search.
targetAudienceobjectLINKEDIN COMPANY PAGE POST ONLY — show the post only to Page followers matching these facets (URNs or bare numeric ids; search_linkedin_ads_targeting finds them). LinkedIn requires the matching audience to be over 300 followers and refuses a smaller one. Personal-profile posts cannot be targeted.
placestringFACEBOOK — tag a location on the Page post. The NUMERIC ID OF THE FACEBOOK PAGE for that place, not a place name and not coordinates.
callToAction"BOOK_TRAVEL" | "BUY_NOW" | "CALL_NOW" | "DOWNLOAD" | "GET_DIRECTIONS" | "LEARN_MORE" | "LIKE_PAGE" | "MESSAGE_PAGE" | "NO_BUTTON" | "OPEN_LINK" | "SHOP_NOW" | "SIGN_UP" | "WATCH_MORE"FACEBOOK — a real call-to-action BUTTON on the Page post. The types that open a destination (SHOP_NOW, LEARN_MORE, SIGN_UP, BUY_NOW, DOWNLOAD, OPEN_LINK, WATCH_MORE, BOOK_TRAVEL) need somewhere to go: the post’s own `link`, or `callToActionLink`. CALL_NOW, MESSAGE_PAGE, LIKE_PAGE and GET_DIRECTIONS act on the Page itself and take none.
callToActionLinkstringFACEBOOK — where the button goes, when that is not the post’s own `link`.
linkNamestringFACEBOOK — override the HEADLINE of the link preview. Without it the post shows whatever Open Graph title the destination carries, which on a bare landing page is often nothing. Needs `link`. MEASURED 2026-09-17: Facebook only honours these when the Page’s business has VERIFIED the domain (Business Manager ▸ Brand safety ▸ Domains) — without that Meta refuses the post outright with “Only owners of the URL…”, so leave them out unless the brand owns and has verified that link’s domain.
linkDescriptionstringFACEBOOK — override the DESCRIPTION of the link preview. Needs `link`. MEASURED 2026-09-17: Facebook only honours these when the Page’s business has VERIFIED the domain (Business Manager ▸ Brand safety ▸ Domains) — without that Meta refuses the post outright with “Only owners of the URL…”, so leave them out unless the brand owns and has verified that link’s domain.
linkPicturestringFACEBOOK — override the IMAGE of the link preview: a public http(s) url Facebook fetches itself. Needs `link`. MEASURED 2026-09-17: Facebook only honours these when the Page’s business has VERIFIED the domain (Business Manager ▸ Brand safety ▸ Domains) — without that Meta refuses the post outright with “Only owners of the URL…”, so leave them out unless the brand owns and has verified that link’s domain.
coverUrlstringINSTAGRAM REEL COVER — a public image url Instagram fetches and uses as the cover in the Reels tab. REELS ONLY, and the alternative to `thumbOffset`: passing both is refused, since they are two answers to the same question. Run a local file through upload_file first.
thumbOffsetnumberINSTAGRAM REEL COVER, the other way — which frame becomes the cover, in MILLISECONDS from the start of the video. REELS ONLY. Use it instead of `coverUrl` when the right cover is already a frame of the clip.
shareToFeedbooleanINSTAGRAM REEL — true puts the Reel in the Feed grid as well as the Reels tab. REELS ONLY. Left unset it follows Instagram’s own default; Hermoso does not flip it either way on the user’s behalf.
audioNamestringINSTAGRAM REEL — the name of the Reel’s audio track, which is what viewers tap through to. REELS ONLY.
instagramLocationIdstringINSTAGRAM — tag a place (called locationId on post_to_meta; locationId here is the Google Business listing). It is the NUMERIC ID OF A FACEBOOK PAGE associated with that location, not a place name and not coordinates; a non-numeric value is refused rather than sent.
brandedContentSponsorIdsstring[]INSTAGRAM — the numeric Instagram USER IDS of the brands behind that label (at most 2, and ids rather than @handles). Naming sponsors IS asking for the label, so setting these with `paidPartnership:false` is refused instead of publishing brand credits with no disclosure.
trialReel"MANUAL" | "SS_PERFORMANCE"INSTAGRAM TRIAL REEL — publish this Reel to NON-FOLLOWERS ONLY at first (Instagram allows trials only on accounts above its follower threshold — about 1,000 followers; an ineligible account is refused by name and nothing is posted), so a hook can be tested on a cold audience without spending it on the people who already follow the brand; Instagram shows it to followers only if it graduates. MANUAL = the creator graduates it by hand in the Instagram app; SS_PERFORMANCE = Instagram graduates it automatically if it performs. REELS ONLY and INSTAGRAM ONLY: an image, a carousel, or a Facebook/Threads channel is REFUSED BY NAME rather than quietly published as an ordinary post — a trial that silently goes to every follower is the exact opposite of what was asked for, so Instagram must be one of the `channels` and the item must carry a video. Omit it for a normal Reel.
storybooleanINSTAGRAM STORY — publish this as a 24-hour Story instead of a feed post. One image OR one video: Instagram has no carousel story, so a carousel is REFUSED BY NAME rather than quietly posted to the feed. A Story carries NO CAPTION (there is nowhere to show one), no collaborators, no product tags and no trialReel — passing any of those is refused by name and nothing is posted, because a story that silently drops the words is worse than one that never went. INSTAGRAM ONLY: a Facebook Page story is a different upload and is not built, so a Facebook channel with `story` set is refused rather than published to the feed.
aiGeneratedbooleanINSTAGRAM / FACEBOOK REEL — Meta’s is_ai_generated self-disclosure. OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, media that came through upload_file or from an external URL (the user’s own photographs or footage) is NOT — a real photo must never carry Instagram’s “AI info” label. Pass true or false only to override: false strips the label from something Hermoso would otherwise declare, true declares a render the user uploaded themselves.
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.
chatIdstringTELEGRAM — REQUIRED whenever telegram is a channel: WHICH chat, group or channel the bot posts to. A public channel’s @username (@hermosoai) or the numeric id (a group is negative; a supergroup or channel starts with -100). There is no default and there cannot be one — the Telegram Bot API publishes no method that lists a bot’s chats — so scheduling telegram without one is refused up front. list_telegram_chats finds ids for chats that have messaged the bot in the last 24 hours.
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
optimizeCopybooleanRECOMMENDED when one caption goes to several channels: fit the shared caption to each channel’s own rules when you schedule it (the fitted caption is stored on the scheduled post, so what you scheduled is what publishes) wherever no per-channel caption was written. The angle and every claim stay the author’s; a channel with its own caption is left exactly as written. Off by default so nobody’s words are rewritten unasked.

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. Past items are rebuilt from published-post records, one row per channel; they are not job runs (use list_jobs for those). THE LIST IS COMPACT so it fits in one reply: the next 25 queued and the last 15 fired, captions shortened. Pass `id` for ONE post in full (every caption and setting, which you need before reschedule_post replaces a caption map), `channel` to filter, or `upcoming` / `fired` for more rows. Read-only, 0 credits.

ParameterTypeDescription
brandstringWHICH BRAND to list — the id or exact name from list_brands. Needed when the post lives in a brand this connection is not pinned to: a post you can CREATE in a brand must be manageable there too, without switching the whole connection. A name that matches no brand, or two, is REFUSED.
idstringone post id from this list: returns that post in full, every caption and setting included
channelstringonly posts that include this channel, e.g. "pinterest" or "x"
upcomingnumberhow many queued posts to list, soonest first (default 25, max 200)
firednumberhow many already-fired posts to list, most recent last (default 15, max 200)

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
brandstringWHICH BRAND this post is in — the id or exact name from list_brands. Needed when the post lives in a brand this connection is not pinned to: a post you can CREATE in a brand must be manageable there too, without switching the whole connection. A name that matches no brand, or two, is REFUSED.
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
optimizeCopybooleanfit the shared caption to each channel’s own rules when you schedule it (the fitted caption is stored on the scheduled post, so what you scheduled is what publishes) wherever no per-channel caption was written; a channel with its own caption is left exactly as written. Send false to switch it off on this item.
channels"facebook" | "instagram" | "threads" | "tiktok" | "youtube" | "linkedin" | "x" | "pinterest" | "google_business" | "bluesky" | "telegram"[]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 and the caption is used.
tagsstring[]YOUTUBE — replaces the WHOLE tag list; an empty array [] clears the tags.
thumbnailUrlstringYOUTUBE: replace the custom thumbnail; "" goes back to a frame of the video, "auto" to YouTube’s pick.
replyControl"everyone" | "accounts_you_follow" | "mentioned_only" | "parent_post_author_only" | "followers_only"THREADS ONLY — who may reply.
quotePostIdstringTHREADS ONLY — the id of the Threads post this one quotes.
linkAttachmentstringTHREADS ONLY — a full http(s) URL rendered as a link card on a TEXT-ONLY post. The only way a Threads post carries a destination.
topicTagstringTHREADS ONLY — one topic tag, without the leading #.
countryCodesstring[]THREADS ONLY — two-letter country codes limiting who can see the post.
crossreshareToIgbooleanTHREADS ONLY — also share to Instagram as a Story when it fires; false turns it off. Refused on a carousel.
crossreshareDarkModebooleanTHREADS ONLY — dark-mode that Instagram Story. Needs crossreshareToIg.
slideTextobject[]PINTEREST CAROUSEL ONLY — per-slide title / description / link, aligned to slide order.
privacyLevel"PUBLIC_TO_EVERYONE" | "MUTUAL_FOLLOW_FRIENDS" | "FOLLOWER_OF_CREATOR" | "SELF_ONLY"TIKTOK — WHICH PRIVACY LEVEL the post goes out at, in TikTok’s own vocabulary. TikTok requires the USER to choose this from the levels their own account allows: call tiktok_creator_info, show them the real options, and pass back the one they picked — never a default and never a guess, which TikTok refuses at init. Omit it and the post falls back to the coarse `visibility` (private → SELF_ONLY, otherwise PUBLIC_TO_EVERYONE), which cannot express MUTUAL_FOLLOW_FRIENDS or FOLLOWER_OF_CREATOR at all. It must agree with the TikTok visibility (SELF_ONLY is the private one) and it is refused on a TikTok DRAFT, which carries no post info.
altTextstring | string[]ACCESSIBILITY — replace the screen-reader description(s). A STRING describes every slide; an ARRAY describes them one at a time in slide order and REPLACES the whole list. "" clears it.
linkstring
brandedContentbooleanTIKTOK — the paid-partnership disclosure; false turns it off.
yourBrandbooleanTIKTOK — the own-brand disclosure; false turns it off.
commercialContentbooleanTIKTOK — the COMMERCIAL CONTENT DISCLOSURE toggle: true when this post promotes a brand, product or service at all. TikTok requires at least one of yourBrand / brandedContent once it is on, and refuses a post that declares itself commercial without naming which kind. Either disclosure already implies it.
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.
xQuotePostIdstringX — the post this one QUOTES; an empty string removes the quote. Named apart from the Threads `quotePostId` on this same schedule.
communityIdstringX — the community to publish into; an empty string goes back to the main timeline.
paidPartnershipbooleanINSTAGRAM AND X — the paid-partnership label; false turns it off.
xArticleobjectX: replaces the X Article (title, headings); {} makes it an ordinary X post again.
collaboratorsstring[]INSTAGRAM — replaces the WHOLE collab list (up to 3 usernames); an explicit [] removes the co-authors and the post goes out as an ordinary single-author post. Only takes effect if the post has not fired yet — an invite already sent cannot be withdrawn from here.
trialReel"MANUAL" | "SS_PERFORMANCE" | ""INSTAGRAM — replaces the trial-reel setting on a queued Reel (MANUAL or SS_PERFORMANCE); an explicit "" turns the trial off and it goes out as an ordinary Reel. Only takes effect while the post is still queued — a Reel already published cannot be converted into a trial.
storybooleanINSTAGRAM — true makes it a 24-hour Story, false an ordinary feed post. One image or one video, no carousel.
coverUrlstringINSTAGRAM REEL — replaces the cover image url; an empty string removes it.
thumbOffsetnumberINSTAGRAM REEL — replaces the cover frame, in milliseconds; 0 removes it. Never together with coverUrl.
shareToFeedbooleanINSTAGRAM REEL — whether the Reel also shows in the Feed grid.
audioNamestringINSTAGRAM REEL — replaces the audio track name; an empty string removes it.
instagramLocationIdstringINSTAGRAM — replaces the tagged place (the numeric id of its Facebook Page); an empty string removes it. Not locationId, which is the Google Business listing.
brandedContentSponsorIdsstring[]INSTAGRAM — replaces the sponsor user ids behind the paid-partnership label (at most 2); [] removes them.
placestringFACEBOOK — replaces the tagged place (the numeric id of its Facebook Page); an empty string removes it.
callToAction"BOOK_TRAVEL" | "BUY_NOW" | "CALL_NOW" | "DOWNLOAD" | "GET_DIRECTIONS" | "LEARN_MORE" | "LIKE_PAGE" | "MESSAGE_PAGE" | "NO_BUTTON" | "OPEN_LINK" | "SHOP_NOW" | "SIGN_UP" | "WATCH_MORE" | ""FACEBOOK — replaces the button on the Page post; "" removes it.
callToActionLinkstringFACEBOOK — replaces where the button goes; an empty string falls back to the post link.
linkNamestringFACEBOOK — replaces the link preview headline; an empty string removes the override.
linkDescriptionstringFACEBOOK — replaces the link preview description; an empty string removes the override.
linkPicturestringFACEBOOK — replaces the link preview image url; an empty string removes the override.
audienceobjectFACEBOOK — replaces who can see the Page post {countries, regions, cities, minAge}; {} removes the limit.
targetAudienceobjectLINKEDIN COMPANY PAGE — replaces who sees the post; {} removes the limit. The matching audience must be over 300 followers.
aiGeneratedbooleanINSTAGRAM / FACEBOOK REEL — Meta’s is_ai_generated self-disclosure. OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, media that came through upload_file or from an external URL (the user’s own photographs or footage) is NOT — a real photo must never carry Instagram’s “AI info” label. Pass true or false only to override: false strips the label from something Hermoso would otherwise declare, true declares a render the user uploaded themselves.
boardIdstringPINTEREST — move the Pin to a different board (list_pinterest_boards)
chatIdstringTELEGRAM — send it to a different chat, group or channel (@username or numeric id). It can be changed but never cleared: telegram cannot publish without one.
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
brandstringWHICH BRAND this post is in — the id or exact name from list_brands. Needed when the post lives in a brand this connection is not pinned to: a post you can CREATE in a brand must be manageable there too, without switching the whole connection. A name that matches no brand, or two, is REFUSED.

RETRY + DUPLICATE

retry_scheduled#

Retry a failed scheduled post

Send a post that FAILED again. A scheduled post fans out across its channels INDEPENDENTLY, so a failure is usually PARTIAL — LinkedIn 401s while Instagram published fine — and this re-fires ONLY the channels that did not succeed by default (list_scheduled reports them as `retryable`). It re-queues the same content as a NEW post that goes out RIGHT AWAY — the queue picks it up on its next pass, within seconds — and the original keeps its failure record so the history still shows what went wrong. Naming a channel that already published is REFUSED rather than quietly posting a second time. Two independent belts stop a double-post: a channel that genuinely published can only REPLAY (nothing is posted), and a channel whose outcome is UNRESOLVED — the platform timed out and may be holding the post — refuses with that reason instead of guessing. Retry after fixing the cause — and you can fix it IN THIS CALL: pass `boardId`, `pageId`, `linkedinOrganizationId`, `locationId`, `message` or `captions` to correct the value that failed, and the corrected post is re-validated exactly like a fresh schedule. That matters because the commonest cause is a field, not an outage: a Pin aimed at the wrong board fails identically however many times it is re-sent. Anything you do not name is copied from the original. To send the same thing again ON PURPOSE, use duplicate_scheduled.

ParameterTypeDescription
brandstringWHICH BRAND this post is in — the id or exact name from list_brands. Needed when the post lives in a brand this connection is not pinned to: a post you can CREATE in a brand must be manageable there too, without switching the whole connection. A name that matches no brand, or two, is REFUSED.
idrequiredstringthe scheduled post id from list_scheduled
channels"facebook" | "instagram" | "threads" | "tiktok" | "youtube" | "linkedin" | "x" | "pinterest" | "google_business" | "bluesky" | "telegram"[]retry only these channels (default: every channel that did not publish)
atstringhold the retry until a later time — ISO timestamp or epoch milliseconds. Leave it out to retry immediately, which is almost always what you want. A time you name here must be at least a minute from now, exactly like any other scheduled post.
allowDuplicatebooleanONLY for a channel you have checked by hand and confirmed the post is genuinely NOT there. It bypasses the double-post protection and can publish a second public copy, so never set it to work around a refusal you have not investigated.
boardIdstringCORRECT THE BOARD on retry — the Pinterest board the Pin goes on, from list_pinterest_boards. A Pin aimed at a board Pinterest refuses fails the same way on every retry until this is changed.
chatIdstringCORRECT THE TELEGRAM DESTINATION on retry — the @username or numeric id of the chat. A post aimed at a chat the bot is not in fails every time it is retried until this changes.
pageIdstringCORRECT THE PAGE on retry — which connected Facebook Page (and its linked Instagram/Threads) publishes, from list_meta_pages.
linkedinOrganizationIdstringCORRECT THE LINKEDIN AUTHOR on retry — the company Page id from list_linkedin_pages. Set it to an empty string to fall back to the personal profile.
locationIdstringCORRECT THE LISTING on retry — which Google Business Profile location, e.g. "locations/123" from list_business_locations.
messagestringCORRECT THE CAPTION on retry — use this when the original was refused for length or content. Anything not named here is copied from the original post.
captionsobjectCORRECT ONE CHANNEL’S CAPTION on retry, e.g. { "x": "..." } when only that channel refused the text.

duplicate_scheduled#

Duplicate a scheduled post

Copy an existing scheduled or already-published post into a NEW queued post — the way to run a creative again, reuse a post that worked as the starting point for the next one, or re-send something after it went out. It copies the caption, media, per-channel captions, title, description, tags and the target board / Page / company Page / listing, and ANY of those can be overridden in the same call. Give a new time in `at`, or useQueue:true to drop it into the brand’s next free posting slot. The copy is INDEPENDENT — editing or cancelling it never touches the original — and it is a genuinely new post rather than a re-send, so it publishes even where the original already did. To re-fire only the channels that FAILED, use retry_scheduled instead.

ParameterTypeDescription
brandstringWHICH BRAND this post is in — the id or exact name from list_brands. Needed when the post lives in a brand this connection is not pinned to: a post you can CREATE in a brand must be manageable there too, without switching the whole connection. A name that matches no brand, or two, is REFUSED.
idrequiredstringthe post to copy, from list_scheduled
atstringwhen the copy goes out — ISO timestamp or epoch milliseconds (default: an hour from now)
useQueuebooleaninstead of naming a time, take the brand’s next free posting slot
timezonestringIANA zone for the queue, e.g. "America/New_York"
channels"facebook" | "instagram" | "threads" | "tiktok" | "youtube" | "linkedin" | "x" | "pinterest" | "google_business" | "bluesky" | "telegram"[]post the copy to these channels instead of the original’s
messagestringa different caption for the copy
captionsobjectper-channel caption overrides for the copy
imageUrlstring
videoUrlstring
imageUrlsstring[]CAROUSEL — an ORDERED list of image URLs published as ONE swipeable post
titlestring
linkstring
boardIdstringPINTEREST — the board for the copy (list_pinterest_boards)
chatIdstringTELEGRAM — which chat, group or channel the copy goes to (@username or numeric id)
linkedinOrganizationIdstringLINKEDIN — publish the copy as this company Page (list_linkedin_pages)
pageIdstringFACEBOOK / INSTAGRAM / THREADS — which connected Page (list_meta_pages)
locationIdstringGOOGLE BUSINESS — which listing (list_business_locations)
visibility"public" | "unlisted" | "private" | "draft"

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" | "google_business" | "bluesky" | "telegram"[]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.
chatIdstringTELEGRAM — which chat, group or channel posts go to (@username or numeric id). Without one, telegram is skipped: there is no default chat and posting to the wrong one is a public mistake.
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
brandstringWHICH BRAND this post belongs to — the id or exact name from list_brands (a workspace shared with you: its profile id). Use it whenever the account has more than one brand and you are not certain which one this connection is pinned to: it beats the pin for THIS CALL ONLY and changes nothing about the connection. A name that matches no brand, or two brands, is REFUSED and nothing is posted — never resolved to the pin, which is the account you were guarding against.
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. LENGTH IS THE POSTING ACCOUNT’S, NOT A FLAT 280: 280 characters on an ordinary account, but UP TO 25,000 if that account has X Premium. Hermoso reads the account’s own subscription from X and sends the post rather than refusing something it may be entitled to; if X declines it on length, the reply says so and names the subscription X reported. Over 25,000 is refused for free — that is X’s own ceiling on every account. Text is NEVER truncated. AND A LONG POST IS CHEAPER THAN A THREAD: it is ONE billed X call where the same words split across five posts are five, so prefer one long post over threading when the account has Premium. Only the first ~280 characters show in the timeline; the rest sits behind “Show more”. 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. Each brand also has a rolling 24-hour ceiling on what it can spend at X, and a request that would cross it is refused WHOLE before anything publishes, so a batch of posts is bounded rather than open-ended. THIS TOOL POSTS ORGANICALLY — it does not create an ad campaign. X ads are built with create_x_ads_campaign → create_x_ads_line_item → create_x_ads_promoted_tweet, and a promoted post needs a post id, so publish here first and promote that post. Needs X connected (Settings ▸ Connectors ▸ X).

ParameterTypeDescription
accountstringWHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the brand has more than one x account connected (several and none named is refused by name, never guessed); omit when there is one.
brandstringWHICH BRAND this post belongs to — the id or exact name from list_brands (a workspace shared with you: its profile id). Use it whenever the account has more than one brand and you are not certain which one this connection is pinned to: it beats the pin for THIS CALL ONLY and changes nothing about the connection. A name that matches no brand, or two brands, is REFUSED and nothing is posted — never resolved to the pin, which is the account you were guarding against.
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 without X Premium, up to 25,000 with it — write the full thing, it is never truncated. Use this OR thread, not both.
threadstring[]a thread: each string is one post, published in order, each replying to the previous. Max 25. Each part follows the same length rule as `text`, and on an X Premium account ONE long post is usually both better reading and cheaper than a thread.
mediaUrlstringa Hermoso render (image or video) to attach to the first post — pass its served URL, or an upload_file url for external media
videoUrlstringalias of mediaUrl for a VIDEO — same as passing it as mediaUrl
imageUrlstringalias of mediaUrl for an IMAGE — same as passing it as mediaUrl
mediaUrlsstring[]UP TO FOUR Hermoso-hosted media attached to ONE post — X’s own schema caps media_ids at 4. X renders them as a GRID: every image visible at once, nothing to swipe to. That is NOT a carousel, and a numbered "1/6 · SWIPE" slide deck must still not be sent here — it would publish as a grid and the "swipe" instruction would make no sense. Order decides the layout. On a thread the media rides the FIRST post; give each later part its own post to attach more. Mutually exclusive with mediaUrl, and more than 4 is refused before anything is uploaded.
altTextstring | string[]accessibility description of the attached media, max 1000 characters — write one whenever you attach a render. Costs a small extra amount: X bills one metadata write PER media. With SEVERAL media, pass an ARRAY aligned to their order — X attaches alt text per media id, and a single string describes only the FIRST one (X renders up to four media as a GRID, not a carousel, so one sentence would be wrong for the other three).
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. X ONLY ACCEPTS AN API REPLY TO A POST THAT MENTIONS THIS ACCOUNT OR THAT THIS ACCOUNT WROTE (X policy since Feb 2026, every tier below Enterprise): a cold reply under a stranger's post is refused by X with "You can only reply to or quote posts where you are mentioned or are the author" and nothing is posted. Reply to those from x.com itself; use this for replies in your own threads and to people who tagged you.
quotePostIdstringnumeric id of a post to QUOTE — X renders the quoted post inside yours. This is NOT replyToId: a reply sits under the original in its thread, a quote stands alone on your own timeline with the original embedded, which is the one you want for commentary. X makes a quote mutually exclusive with media and with a poll (their own schema), and a quote is billed at the higher LINK rate because X appends the quoted post’s t.co URL whatever your text says. Same X rule as replyToId: a quote of a post that does not mention this account is refused by X.
communityIdstringpublish into an X COMMUNITY instead of the main timeline — the number in the community’s own URL (x.com/i/communities/<id>). The connected account must be a MEMBER of it; X answers a non-member and a non-existent id with the same refusal and does not separate them.
paidPartnershipbooleanlabel the post a PAID PARTNERSHIP on X — the same disclosure Hermoso already ships for TikTok. OPT-IN ONLY: set it when the post is sponsored, gifted or otherwise paid for, and never assume it on the user’s behalf.

post_x_article#

Publish a long-form Article to X

Publish a long-form ARTICLE to the user’s connected X (Twitter) account — X’s own long-form format, which is a different thing from a long POST. Give it a `title` and a `body` written in MARKDOWN (or plain prose) and Hermoso converts it into the DraftJS `content_state` structure X requires: headings, paragraphs, bulleted and numbered lists, blockquotes, bold / italic / strikethrough, links, horizontal rules, fenced code blocks and pipe tables all carry across. FORMATTING IS NEVER SILENTLY DROPPED — anything X Articles cannot represent (inline `code`, an inline image) REFUSES the article for free and names exactly what and why, and `allowLossy: true` is the explicit way to publish it as plain text anyway. THE HARD LIMIT TO PLAN AROUND: X allows only about 10 Article DRAFTS and 5 Article PUBLISHES per account per DAY, it publishes that cap nowhere, and it counts a FAILED attempt against them — so never iterate on an article by republishing it, and use `publish: false` to save a draft for the user to read in X’s own composer when they want to review before it goes out. This PUBLISHES immediately and PUBLICLY: show the user the full text and get an explicit yes first, because a published Article can NEVER be edited (X’s own rule — edit_x_post will refuse it) and the only remedy is to delete and republish, which costs another of the five. An Article appears in the timeline as a title card, not as body text; readers open it. Costs credits (X bills per API request, and this is three of them). Needs X connected (Settings ▸ Connectors ▸ X).

ParameterTypeDescription
brandstringWHICH BRAND this post belongs to — the id or exact name from list_brands (a workspace shared with you: its profile id). Use it whenever the account has more than one brand and you are not certain which one this connection is pinned to: it beats the pin for THIS CALL ONLY and changes nothing about the connection. A name that matches no brand, or two brands, is REFUSED and nothing is posted — never resolved to the pin, which is the account you were guarding against.
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.
titlerequiredstringthe Article title — X requires one and refuses a draft without it. This is what shows on the timeline card.
bodyrequiredstringthe article body, as markdown or plain prose. Markdown headings, lists, quotes, links, emphasis, ``` code fences and | pipe | tables | are all converted to X’s own Article structure.
coverImageUrlstringoptional cover picture for the Article — a Hermoso render URL or an upload_file url. Must be a STILL image; X Article covers are not videos.
headings"blocks" | "text"how headings are rendered. “blocks” (default) uses X’s own heading block types for # and ## headings; ### and deeper become bold lines, because X Articles refuse a third-level heading (the reply says so). “text” renders every heading as a BOLD standalone paragraph instead: the words survive and the heading structure does not, and the reply says so.
allowLossybooleanpublish even though part of the source cannot be represented on X, rendering those parts as plain text. OFF by default and it should usually stay off — silently publishing a user’s copy with formatting missing is worse than refusing and telling them.
publishbooleandefault true. Pass false to save it as a DRAFT in the account’s X Articles composer instead — nothing becomes public, the user can review and publish it from X, and it does not spend one of the five daily publishes.

edit_x_post#

Edit a post on X

EDIT the text of one of the connected account’s own posts on X. Three things about X’s edit model change how you must use this and none is guessable: (1) X REPLACES THE WHOLE TEXT — there is no partial patch, so pass the complete new post; (2) an edit MINTS A NEW POST ID, and the old id keeps resolving and keeps showing the OLD text, so always hand the user the NEW url afterwards or they will circulate a link to the version they just corrected; (3) X’s window is ONE HOUR from the ORIGINAL post and DOES NOT RESTART when a post is edited, and each post has a limited number of edits. Pass whichever id the user has — Hermoso reads X’s edit chain and aims at the newest id, which is the only one X accepts (an edit aimed at the id the user was originally given is refused by X once the post has been edited once). A published ARTICLE can never be edited whatever the subscription, and this says so rather than trying. Editing needs X Premium on the POSTING account; Hermoso attempts it and reports X’s own refusal rather than pre-refusing on a guess about the plan. Everything knowable for free — window closed, edits used up, post ineligible — is refused before anything is billed. Costs credits. Needs X connected.

ParameterTypeDescription
postIdrequiredstringthe numeric X post id — the last part of the post URL. Any id in the post’s edit chain works; Hermoso resolves the newest one.
textrequiredstringthe FULL new text of the post. It replaces the old text entirely. Same length rule as a new post: 280 characters, or up to 25,000 on an X Premium account.

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

THE X ANALYTICS TOOL THAT WORKS — impressions, likes, reposts, replies, quotes and bookmarks for any post, PLUS the advertiser numbers (link clicks, profile clicks, engagements) for YOUR OWN posts published in the last 30 days. X serves those private metrics on this same lookup with the user-context connection you already have; that is X's own design, not a workaround. Prefer this over x_post_insights, whose endpoint family X has retired. If a post is deleted, protected or suspended, X answers with no data at all and this says so — that is MISSING DATA, never zero engagement, and must never be reported as a measured zero. 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
publishedAtnumberepoch ms the post went out, if known — lets the private owned-post metrics be requested only inside X's 30-day window instead of costing a refused call

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

X DIRECT MESSAGES

list_x_dms#

Read your X direct messages read-only

Read the direct messages sent to the connected X account and group them into conversations, newest first — who is waiting on a reply, in their own words. Use it for 'any new DMs on X', 'what did they say', 'anything I need to answer'. Reply with send_x_dm. COSTS CREDITS PER MESSAGE RETURNED plus one account lookup, so keep maxResults small (default 10) and tell the user the cost before pulling a big page. X KEEPS 30 DAYS of direct-message history and offers no date filter, so a short list can mean an old conversation rather than a quiet inbox — say so rather than reporting silence. There is no conversation-list endpoint at X: this builds the thread list by reading events and grouping them, so a conversation whose last message is older than the page you pulled will not appear. Needs X connected, AND an X account connected before direct messages shipped must be reconnected once (Settings ▸ Connectors ▸ X) — X only grants permissions at authorize time.

ParameterTypeDescription
maxResultsnumberhow many messages to pull, 1-100 (default 10) — EVERY ONE IS BILLED, and omitting it would make X return 100
conversationIdstringread just this conversation (from a previous call). One-to-one ids look like "12-345"; group ids are one long number.
participantIdstringread just the conversation with this NUMERIC X account id (not an @handle)
paginationTokenstringnextToken from a previous call, to page further back. There is no since/until filter at X — this is the only way to walk history.
eventTypesstring[]MessageCreate (default), ParticipantsJoin, ParticipantsLeave. The join/leave events carry no message and are billed like any other, so the default is messages only.

send_x_dm#

Send an X direct message

Send ONE direct message from the connected X account, either into an existing conversation (conversationId) or to a named account (participantId — the NUMERIC id, not an @handle; sending to someone with no existing thread starts one). THIS IS A PRIVATE MESSAGE TO A REAL PERSON AND IT CANNOT BE TAKEN BACK: show the user the exact wording and the exact recipient and get an explicit yes BEFORE calling. Never send the same message to several people — send one, to one named recipient. X may refuse a message to someone whose settings do not accept DMs from accounts they do not follow; that refusal comes from X, not from Hermoso. Costs credits per send. Needs X connected with direct-message access — an X account connected before DMs shipped must be reconnected once (Settings ▸ Connectors ▸ X).

ParameterTypeDescription
textrequiredstringthe message, exactly as it should be sent
conversationIdstringreply into an existing conversation (from list_x_dms)
participantIdstringthe NUMERIC X account id to message — starts a conversation if there is none. list_x_dms returns it beside every message.
idempotencyKeystringpass the same key to make a retry safe — a timeout that already sent would otherwise send twice

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
accountstringWHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the brand has more than one pinterest account connected (several and none named is refused by name, never guessed); omit when there is one.
brandstringWHICH BRAND this post belongs to — the id or exact name from list_brands (a workspace shared with you: its profile id). Use it whenever the account has more than one brand and you are not certain which one this connection is pinned to: it beats the pin for THIS CALL ONLY and changes nothing about the connection. A name that matches no brand, or two brands, is REFUSED and nothing is posted — never resolved to the pin, which is the account you were guarding against.
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
altTextstring | string[]accessibility alt text, max 500 characters. PIN-LEVEL: Pinterest’s API has no per-item alt text at all, so on a CAROUSEL the FIRST description is used for the whole Pin and the reply states that the others were not sent.
slideTextobject[]PINTEREST CAROUSEL ONLY — per-slide title, description and destination LINK, one object per slide in slide order. This is the ONLY genuine per-slide caption on any channel Hermoso publishes to: slide 4 can send people to the product ON slide 4, where every other platform gives a carousel one shared caption. Omit any field to leave it unset; the Pin’s own title/description/link still describe the Pin as a whole. Pinterest publishes no length limit on these, so nothing is truncated. More entries than slides is refused rather than dropped.
coverImageUrlstringvideo Pins only — a render to use as the cover frame
boardSectionIdstringoptional section within the board

OPERATING A PIN AND A BOARD AFTER THEY EXIST

list_pinterest_pins#

List Pins on a Pinterest board read-only

The Pins on one of the account’s boards — or, with no boardId, the account’s own Pins across all of them. Each row carries the Pin id, title, description, destination link, alt text, board, creation date, and whether it HAS BEEN PROMOTED in an ad. THIS IS WHERE THE pinId EVERY OTHER PIN TOOL NEEDS COMES FROM: post_to_pinterest returns an id only at the instant it pins, so an agent that did not itself just pin had no way to name a Pin. Prefer passing a boardId — Pinterest’s own spec warns the account-wide listing has known timeouts. Read-only, 0 credits. Needs Pinterest connected.

ParameterTypeDescription
boardIdstringnumeric board id from list_pinterest_boards — omit for the account’s own Pins across all boards
limitnumber1–100, default 25
cursorstringthe cursor a previous call returned

update_pinterest_pin#

Edit a published Pin

Edit a published Pin — its title, description, destination link, alt text, or which board it sits on. Only send the fields that should change. TWO LIMITS TO STATE BEFORE OFFERING THIS. (1) Pinterest marks its Update Pin endpoint "currently in beta and not available to all apps" in its own API description, so it may be refused outright whatever the account’s scopes or access tier — reconnecting cannot change that. If it is refused, save_pinterest_pin gets the Pin onto another board (generally available) and changing the wording means deleting and re-pinning. (2) A published Pin’s IMAGE or VIDEO can never be changed by anyone: Pinterest’s update model has no media field at all, so swapping the creative means delete and re-pin, which loses the Pin’s accumulated saves. The values reported back are what Pinterest STORED, not what was sent. 0 credits. Needs Pinterest connected.

ParameterTypeDescription
pinIdrequiredstringnumeric Pin id from list_pinterest_pins
titlestringmax 100 characters
descriptionstringmax 800 characters — the text Pinterest search reads
linkstringdestination URL, max 2048
altTextstringaccessibility alt text, max 500
boardIdstringmove the Pin to this board
boardSectionIdstringsection within the board

save_pinterest_pin#

Save a Pin to another board

Save an existing Pin onto another of the account’s boards. This is the GENERALLY AVAILABLE way to get a Pin onto the right board — unlike update_pinterest_pin, which Pinterest keeps in a limited beta — so reach for it first when a Pin is on the wrong board. It COPIES rather than moves: Pinterest’s save endpoint creates a new Pin and the original stays where it is, so delete that one with delete_pinterest_pin if it should not be in two places. Let the USER pick the destination board (list_pinterest_boards) — a Pin on the wrong board is a public mistake. 0 credits. Needs Pinterest connected.

ParameterTypeDescription
pinIdrequiredstringnumeric Pin id
boardIdrequiredstringthe board to save it to, from list_pinterest_boards — the user picks, never guess
boardSectionIdstring

delete_pinterest_pin#

Delete a Pin destructive

PERMANENTLY delete a Pin. Pinterest has no undelete and no archive for one. Call it WITHOUT confirm first: nothing is deleted, and it answers with the Pin’s real title, its lifetime saves and impressions, and whether it HAS BEEN PROMOTED in an ad — all read back from Pinterest. Show the user that, get an unambiguous yes, then call again with confirm:true plus confirmName (its exact title) once it has saves or has been promoted, because confirming that you meant to delete SOMETHING does not prove you aimed at the right Pin. DELETING A PIN THAT AN AD PROMOTES pulls the creative out from under that ad, so check the promoted flag before agreeing. The verdict is read back from Pinterest, never taken from its 2xx. 0 credits. Needs Pinterest connected.

ParameterTypeDescription
pinIdrequiredstringnumeric Pin id from list_pinterest_pins
confirmbooleanREQUIRED true — deletion is permanent
confirmNamestringthe Pin’s EXACT title as the unconfirmed call reported it — required once it has saves or has been promoted

update_pinterest_board#

Rename or re-privacy a Pinterest board

Rename a board, rewrite its description, or change its privacy. ⚠️ SETTING A BOARD TO SECRET HIDES EVERY PIN ON IT from everyone but this account — nothing errors and nothing is deleted, the Pins simply stop being public, which is the Pinterest flavour of a post that looks published and is not. Say so and get a yes before doing it; it IS reversible (set PUBLIC again), and the read-back reports how many Pins were hidden. Pinterest accepts only PUBLIC or SECRET on an update: PROTECTED can be chosen when a board is created and can never be set afterwards, so that is refused by name rather than sent and rejected. 0 credits. Needs Pinterest connected.

ParameterTypeDescription
boardIdrequiredstringnumeric board id from list_pinterest_boards
namestring
descriptionstringmax 500 characters
privacy"PUBLIC" | "SECRET"SECRET hides every Pin on the board from everyone but this account

delete_pinterest_board#

Delete a Pinterest board destructive

PERMANENTLY delete a board AND EVERY PIN ON IT. This is the heaviest thing that can be done to a Pinterest account and there is no undelete. Call it WITHOUT confirm first: nothing is deleted, and it answers with the board’s real name, how many Pins are on it, how many people FOLLOW it and how many collaborators lose access — all read back from Pinterest. Show the user that, then call again with confirm:true plus confirmName (its exact name) and confirmChildren (the Pin count it reported); those echoes exist because a caller who has not looked at the board cannot supply them, and confirming intent alone does not prove aim. IF THEY ONLY WANT IT OUT OF PUBLIC VIEW: do NOT reach for update_pinterest_board(privacy:"SECRET") — making a board secret is a SEPARATE Pinterest permission (boards:write_secret) that Hermoso does not request, so that call is refused for every user and steering someone onto it turns a survivable delete into a dead end. The reversible option lives in Pinterest’s own app. The unconfirmed refusal says which of the two applies to this connection, read off the live scope set — relay it verbatim rather than guessing. 0 credits. Needs Pinterest connected.

ParameterTypeDescription
boardIdrequiredstringnumeric board id from list_pinterest_boards
confirmbooleanREQUIRED true — the board and its Pins are gone for good
confirmNamestringthe board’s EXACT name as the unconfirmed call reported it
confirmChildrennumberthe number of Pins the unconfirmed call reported on 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
brandstringWHICH BRAND this post belongs to — the id or exact name from list_brands (a workspace shared with you: its profile id). Use it whenever the account has more than one brand and you are not certain which one this connection is pinned to: it beats the pin for THIS CALL ONLY and changes nothing about the connection. A name that matches no brand, or two brands, is REFUSED and nothing is posted — never resolved to the pin, which is the account you were guarding against.
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. SCHEDULE it with publishAt, FILE it under the right categoryId (the default 22 "People & Blogs" is wrong for most ads), SUBSCRIBER NOTIFICATIONS FOLLOW PRIVACY — a public publish announces the video to the channel’s subscribers (YouTube’s own default), while unlisted/private uploads stay quiet; pass notifySubscribers explicitly to override either way. THUMBNAIL: pass thumbnailUrl, or a frame of the video is set for free; a thumbnail YouTube refuses never fails the upload, and thumbnailNote says why. Needs a connected YouTube channel (Settings ▸ Connectors ▸ YouTube).

ParameterTypeDescription
accountstringWHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the brand has more than one youtube account connected (several and none named is refused by name, never guessed); omit when there is one.
brandstringWHICH BRAND this post belongs to — the id or exact name from list_brands (a workspace shared with you: its profile id). Use it whenever the account has more than one brand and you are not certain which one this connection is pinned to: it beats the pin for THIS CALL ONLY and changes nothing about the connection. A name that matches no brand, or two brands, is REFUSED and nothing is posted — never resolved to the pin, which is the account you were guarding against.
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
titlestringREQUIRED in practice — the headline every search result and the Shorts feed shows (≤100 chars); an upload with no title is refused
descriptionstringREQUIRED in practice — what the video shows, a line about the brand, a link and a few #hashtags (≤5000 chars); YouTube search, suggested videos and the Shorts feed rank on it, so an upload with no description (and no caption) is refused
tagsstring[]up to 30 tags
thumbnailUrlstringthe custom thumbnail: a Hermoso-hosted image (make_thumbnail, or upload_file for the user’s own). Omit it and a representative frame of the video is set, free; "auto" keeps YouTube’s pick. Custom thumbnails need a verified channel.
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)
categoryIdstringYouTube category id, NUMERIC — default "22" (People & Blogs), which is wrong for most ads. 1 Film & Animation · 2 Autos & Vehicles · 10 Music · 15 Pets & Animals · 17 Sports · 19 Travel & Events · 20 Gaming · 22 People & Blogs · 23 Comedy · 24 Entertainment · 25 News & Politics · 26 Howto & Style · 27 Education · 28 Science & Technology · 29 Nonprofits & Activism. The assignable set is region-specific, so the value is forwarded as given and YouTube has the last word.
publishAtstringSCHEDULE the publish — ISO 8601, e.g. "2026-09-01T15:00:00Z", and it must be in the future. YouTube only allows this on a PRIVATE video and makes it PUBLIC at that moment, so pass privacy:"private" (or leave privacy unset) — asking for a scheduled "unlisted" or "public" post is refused rather than half-honoured.
notifySubscribersbooleanTHE DEFAULT FOLLOWS PRIVACY. privacy:"public" NOTIFIES the channel's subscribers — that is YouTube's own default and normally what someone publishing publicly wants. privacy:"unlisted" and "private" do NOT: the video is not on the channel, so announcing it is nonsense, and a blast to somebody's whole subscriber list cannot be undone. A scheduled publish (publishAt) is PRIVATE at upload, so it does not notify either — pass true to announce one. An explicit value ALWAYS wins in both directions: true announces an unlisted/private upload, false publishes publicly and quietly. The reply reports which way it went and why.
aiGeneratedbooleanYouTube’s “altered or synthetic content” declaration (containsSyntheticMedia). OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, a video the user uploaded through upload_file or from an external URL is NOT — real footage must not carry the label. true/false overrides.

youtube_channel#

Get the connected YouTube channel read-only

Read the brand’s connected YouTube channel — title + subscriber / view / video counts (for reporting), and whether CUSTOM THUMBNAILS are on (customThumbnails: enabled | needs_verification | unavailable | unknown). When they are not on, the reply carries YouTube’s own step-by-step to turn them on (a phone verification in YouTube Studio), so check this before promising a custom thumbnail. 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

YOUTUBE CHANNEL BRANDING

update_youtube_channel#

Apply the brand to the YouTube channel itself

APPLY THE BRAND TO THE CHANNEL ITSELF — banner art, description, keywords, country and the trailer non-subscribers see. Every other YouTube tool brands the videos; this brands the page they sit on. Under the hood channels.update is a PUT, so the CURRENT settings are read and merged first — otherwise setting a description would silently wipe the channel's keywords, country and trailer. AND YOUTUBE SILENTLY IGNORES SOME FIELDS, channel title above all (usually only changeable in YouTube Studio): the result DIFFS what YouTube actually stored against what was asked for and reports anything that did not stick in `notApplied`. DO NOT REPORT THOSE AS CHANGED — a 200 is what YouTube accepted, not what it stored. A banner must be a Hermoso render (jpeg or png, under 6MB); YouTube re-crops it per device, so the safe area is the middle 1235x338 of a 2048x1152 image. Public and immediate — show the user what is going on the channel first. 0 credits. Needs YouTube connected.

ParameterTypeDescription
descriptionstringthe channel description — the About text
keywordsstringchannel keywords, COMMA-SEPARATED (Google's wire format is one string, not a list)
countrystringtwo-letter country code for the channel
defaultLanguagestringthe channel's default language
unsubscribedTrailerstringvideo id of the trailer shown to people who are not subscribed
titlestringthe channel title — YouTube often accepts and ignores this; the result says whether it stuck
bannerImageUrlstringany public https image URL to upload as the channel banner — a Hermoso render, or ANY file of your own brought in with upload_file

set_youtube_watermark#

Set or remove the YouTube branding watermark

Set (or remove) the BRANDING WATERMARK — the small subscribe badge overlaid on EVERY video on the channel, including ones uploaded later. One generated asset brands the whole channel at once, which is why it is worth doing before a batch of uploads rather than after. YouTube wants a SQUARE image, at least 150x150, under 10MB, and it renders SMALL: a full logo lockup with text will not read at that size. By default it shows for the whole video; timingType offsetFromStart/offsetFromEnd with offsetMs and durationMs narrows it. THE DATA API PUBLISHES NO WAY TO READ A WATERMARK BACK — there is only set and unset — so this reports 'accepted', never 'confirmed', and says so rather than claiming a verification it did not get. 0 credits. Needs YouTube connected.

ParameterTypeDescription
action"set" | "unset"defaults to 'set' when an imageUrl is given
imageUrlstringany public https image URL — a Hermoso render, or ANY file of your own brought in with upload_file. Square, at least 150x150
timingType"offsetFromStart" | "offsetFromEnd"leave off for a watermark that shows for the whole video
offsetMsnumberwhen the watermark appears, relative to timingType
durationMsnumberhow long it stays on screen

YOUTUBE BATCH STATS + TRAINABILITY

list_youtube_video_stats#

Batch YouTube video stats read-only

Views, likes and comment counts for up to 50 YouTube videos IN ONE CALL, which is how to answer "how are my last twenty uploads doing" without one youtube_video_insights per video. Pass videoIds from list_youtube_videos. IT CARRIES NO TITLES, and that is the resource rather than a bug: VideoStatsSnippet publishes only publishTime, so join on videoId with list_youtube_videos when a name is needed. YouTube calls this endpoint "intentionally not atomic", so a short answer is normal: a video that is private, deleted, or not visible to the connected account simply does not come back, and this tool names the missing ids. Never report a missing id as zero views. Read-only, free.

ParameterTypeDescription
videoIdsrequiredstring[]up to 50 video ids, from list_youtube_videos
part"snippet" | "statistics" | "contentDetails"[]defaults to snippet + statistics. An unknown part 400s the whole call, so it is refused here

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

YOUTUBE CHANNEL MANAGEMENT

list_youtube_playlists#

List the channel’s playlists (and what is in one) read-only

List the playlists on the connected YouTube channel — id, title, description, privacy and video count. Pass a playlistId to get that ONE playlist plus its entries in order. IMPORTANT: each entry carries BOTH a `videoId` and an `itemId`; `itemId` is the playlist-ENTRY id and is what manage_youtube_playlist_items needs to remove or re-order a row, because one video can appear in a playlist more than once so a videoId does not identify the entry. Read-only, 0 credits. Needs a connected YouTube channel.

ParameterTypeDescription
playlistIdstringone playlist to open, with its entries. Omit to list the channel’s playlists.
limitnumbermax rows, default 25, cap 50 (YouTube’s own maximum)
pageTokenstringnextPageToken from a previous call

manage_youtube_playlist#

Create, rename or delete a YouTube playlist destructive

Create, update or delete a playlist on the connected channel. CREATE makes it PRIVATE unless you pass privacy — a public playlist appears on the channel immediately, so publishing is always a deliberate choice. UPDATE merges: an omitted title or description is LEFT ALONE rather than erased. DELETE is confirm-gated and does NOT delete the videos — what dies is the playlist, its ordering and every link or embed pointing at its URL; a playlist that is public or has videos in it additionally needs confirmTitle echoed back exactly, because confirm alone proves you meant to delete something and not that you aimed at the right playlist. To take a playlist out of public view WITHOUT destroying it use action="update" with privacy="private" — that is reversible and delete is not. 0 credits. Needs a connected YouTube channel.

ParameterTypeDescription
actionrequired"create" | "update" | "delete"create | update | delete
playlistIdstringrequired for update and delete
titlestringrequired on create; on update, omit to leave unchanged
descriptionstringon update, omit to leave unchanged
privacy"public" | "unlisted" | "private"default private on create
confirmbooleanrequired to delete
confirmTitlestringthe playlist’s EXACT title — required to delete one that is public or non-empty

manage_youtube_playlist_items#

Add, remove or re-order a video in a playlist

Add a video to a playlist, remove an entry, or move an entry to a new position. ADD takes playlistId + videoId and returns the new entry’s `itemId`. REMOVE and MOVE take that `itemId` — NOT the videoId — because one video can sit in a playlist more than once, so a videoId does not identify which row to touch; list_youtube_playlists with a playlistId returns the itemId of every entry. Removing an entry does NOT delete the video, only its place in the playlist. Positions are zero-based. 0 credits. Needs a connected YouTube channel.

ParameterTypeDescription
actionrequired"add" | "remove" | "move"add | remove | move
playlistIdstringrequired for add
videoIdstringrequired for add
itemIdstringthe playlist-ENTRY id — required for remove and move (from list_youtube_playlists)
positionnumberzero-based position; required for move, optional on add (default: appended)

YOUTUBE CHANNEL SECTIONS + PLAYLIST IMAGES

manage_youtube_channel_section#

Channel homepage sections destructive

THE SHELVES ON THE CHANNEL HOMEPAGE — what a visitor sees first, and the only place a chosen playlist can be put above YouTube’s own default layout. action:'list' reads them in the order they appear, 'create' adds one, 'update' replaces one, 'delete' removes one (confirm-gated, and there is no undo — the layout has to be rebuilt by hand). EVERY WRITE IS PUBLIC IMMEDIATELY: a channel homepage is not a draft. `type` decides what the shelf holds — singlePlaylist and multiplePlaylists take playlist ids, multipleChannels takes channel ids and those two plus multiplePlaylists take a title you choose, while popularUploads, recentUploads, subscriptions and the rest are filled by YouTube and take neither. `position` is zero-based and is what re-orders the page. DO NOT TREAT A LIST AS A READ-BACK: YouTube's own section list lags a write by a few seconds in BOTH directions — measured, it returned nothing right after a create and still returned a deleted section right after a delete — so a write reports what YouTube returned and a delete reports as ACCEPTED, and re-listing straight away can show the old layout. Free.

ParameterTypeDescription
action"list" | "create" | "update" | "delete"defaults to 'list'
typestringsinglePlaylist | multiplePlaylists | popularUploads | recentUploads | likes | allPlaylists | likedPlaylists | recentPosts | recentActivity | liveEvents | upcomingEvents | completedEvents | multipleChannels | postedVideos | postedPlaylists | subscriptions. Required to create or update
titlestringthe heading, and only multiplePlaylists and multipleChannels take one — YouTube writes the heading for every other type
playlistsstring[]playlist ids, from list_youtube_playlists. Required for singlePlaylist (exactly one) and multiplePlaylists
channelsstring[]channel ids to feature. Required for multipleChannels
positionnumberzero-based position on the homepage. Leave it off and YouTube places the section
style"horizontalRow" | "verticalList"leave it off to let YouTube choose
sectionIdstringfor update and delete — from action:'list'
hlstringlanguage for the returned titles, e.g. "en"
confirmbooleanmust be true to actually delete

manage_youtube_playlist_image#

Custom playlist cover image

PUT A CUSTOM COVER ON A PLAYLIST — the last call in a chain that already existed, since make_thumbnail renders the artwork and the playlist tools own the playlist. Without one YouTube shows the first video's thumbnail. action:'list' reads what is on a playlist, 'set' uploads a cover, 'delete' removes it. imageUrl must be a Hermoso-hosted URL, up to 50MB — a Hermoso render, a make_thumbnail result, or the user’s OWN artwork brought in with upload_file, which turns any local or external file into a URL this accepts. Nothing else is fetched server-side. YOUTUBE ANSWERS EVERY FAILURE HERE AS AN HTTP 500 "Internal error encountered" with the real reason buried inside it, so a plain relay would report a missing playlist id as a Hermoso outage — the refusals here are the real ones. If it comes back refused, the first thing to check is CHANNEL VERIFICATION: custom imagery needs a verified YouTube channel (add a phone number at youtube.com/verify), and on an unverified channel the sibling call that sets a custom video thumbnail is refused in the same way. Free.

ParameterTypeDescription
playlistIdrequiredstringfrom list_youtube_playlists
action"list" | "set" | "delete"defaults to 'list'
imageUrlstringfor action:'set' — a Hermoso render or make_thumbnail URL
imageIdstringfor action:'delete' — from action:'list'

list_youtube_captions#

List (and read) a video’s caption tracks read-only

List the caption/subtitle tracks on one of the connected channel’s videos, and optionally DOWNLOAD one as text. A caption TRACK is not the same thing as burned-in captions: a track is what YouTube indexes the video by, what a viewer toggles on, and what accessibility depends on. Each row says whether YouTube generated it automatically (`isAutoGenerated`, trackKind ASR) — those are read-only and cannot be edited or deleted. Downloading is also the fastest way to get an existing video’s full script back for repurposing. Read-only, 0 credits. Needs a connected YouTube channel.

ParameterTypeDescription
videoIdrequiredstringthe YouTube video id — captions are listed per video
downloadstringa captionId or a language code to also download as text
format"srt" | "vtt" | "sbv"download format, default srt

manage_youtube_caption#

Upload, replace or delete a caption track destructive

Attach a real subtitle track to one of the connected channel’s videos, replace one, or delete one. Pass the caption file BODY itself as `text` (SRT or WebVTT), not a URL. `language` is required and may not be guessed — YouTube uses it to decide which viewers see the track. A track YouTube generated itself (trackKind ASR) is refused by name: YouTube owns it and it cannot be edited or deleted through any API; to replace it, upload your own track in the same language and YouTube prefers yours. Delete is confirm-gated — viewers with captions on lose them immediately and YouTube stops indexing the video by that text, though it can be re-uploaded if you still have the file. 0 credits. Needs a connected YouTube channel.

ParameterTypeDescription
actionrequired"upload" | "update" | "delete"upload | update | delete
videoIdstringrequired for upload; strongly recommended for update and delete so the track can be resolved and read back
captionIdstringrequired for update and delete (from list_youtube_captions)
textstringthe caption file body — SRT or WebVTT. Required for upload.
languagestringBCP-47 code such as en, es, pt-BR. Required for upload.
namestringa label shown in YouTube’s caption picker
draftbooleantrue keeps the track unpublished while you review it
confirmbooleanrequired to delete

moderate_youtube_comment#

Hide, reject, spam-report or delete a YouTube comment destructive

Moderate a comment on the connected channel — the YouTube twin of moderate_meta_comment. ACTIONS: publish (make it visible again) · hold (hide it pending review) · reject (hide it as unfit for display) · spam (report it to YouTube — a REPORT, not a removal, so do not tell the user it is gone) · delete (permanent; YouTube has no undelete for a comment). hold, reject and publish are all reversible with each other, so only delete is confirm-gated. If the goal is just to take a comment out of public view, use reject rather than delete. banAuthor is valid ONLY with action="reject" — that is YouTube’s own restriction — and auto-rejects every future comment that author leaves on the channel. Get commentId from list_youtube_comments. 0 credits. Needs a connected YouTube channel.

ParameterTypeDescription
actionrequired"publish" | "hold" | "reject" | "spam" | "delete"publish | hold | reject | spam | delete
commentIdrequiredstringid of the comment (from list_youtube_comments)
banAuthorbooleanreject only — also auto-reject every future comment from this author
confirmbooleanrequired for delete

list_youtube_categories#

List the video categories you can publish under read-only

List the video categories YouTube will accept on an upload in a given country. post_to_youtube takes a `categoryId` and this is the only way to discover a valid one — the id set AND the names differ by country, which is why regionCode is required rather than defaulted. Categories YouTube marks not-assignable are filtered out by default because an upload using one is refused. Read-only, 0 credits. Needs a connected YouTube channel.

ParameterTypeDescription
regionCoderequiredstringISO 3166-1 alpha-2 country code — US, GB, DE. YouTube’s category ids differ by country, so this cannot be guessed.
includeUnassignablebooleanalso return categories YouTube will refuse on upload (default false)

google_business_search_keywords#

What people searched to find this business read-only

The actual search terms people typed on Google Search and Maps before this business listing appeared — the only keyword data a local business gets for free, and the direct input to their Google Ads keyword set, page titles and profile description. google_business_insights answers HOW MANY people found the listing; this answers WHICH WORDS they used. ⚠ LOW-VOLUME TERMS ARE SUPPRESSED: Google withholds an exact count for them and returns only an upper bound, so those rows come back with impressions=null and below=<threshold>. Report those as “fewer than N” — NEVER as zero and never as the threshold itself, both of which are numbers a marketer would act on and neither is true. Counts are UNIQUE USERS per month summed across the window, not impressions; the two are not comparable. Google keeps roughly 12 months of history. Read-only, 0 credits. Needs Google Business Profile connected.

ParameterTypeDescription
locationIdstringwhich listing, when the brand has more than one (list_business_locations)
monthsnumberhow many whole months back, default 3, cap 12. The current month is excluded because it is always partial.
limitnumbermax terms, default 100, which is also Google’s maximum
pageTokenstringnextPageToken from a previous call

list_bluesky_posts#

List the brand's own Bluesky posts read-only

The brand's OWN recent Bluesky posts, newest first — and THIS is where the at:// AT-URI every other Bluesky tool needs comes from. bluesky_post_metrics and delete_bluesky_post both address a post by AT-URI, so without this the only way to hold one was to have just published it in the same conversation; an agent reviewing past work had no way to name anything. Each row carries the text, when it went out, its web URL, its live like/repost/reply/quote/bookmark counts, and whether it is a REPOST of someone else's post or a reply — a repost is not the brand's own creative and must not be reported as its performance. Optional filter: posts_no_replies, posts_with_media, posts_with_replies, posts_and_author_threads (an unknown one is refused by name). Bluesky publishes NO impression or view count in any lexicon, so these are counts with no denominator and no engagement rate can be computed from them. Read-only, 0 credits.

ParameterTypeDescription
limitnumberhow many posts, 1–100 (default 25). 100 is Bluesky's own maximum.
cursorstringnextCursor from a previous call — a short page is NOT end-of-feed
filterstringposts_no_replies | posts_with_media | posts_with_replies | posts_and_author_threads

search_x#

Search X (Twitter) read-only

Keyword search across the PUBLIC X index — the last 7 days. This is the X half of ad-spy research, alongside search_tiktok / search_instagram / search_reddit / search_threads / search_youtube. X’s own search OPERATORS work and are the point: `from:nike`, `-is:retweet`, `has:media`, `has:links`, `lang:en`, `"exact phrase"`, `(a OR b)`. Every post comes back with impressions, likes, reposts, replies, quotes and bookmarks, so you can rank what actually landed rather than what merely exists. ONLY THE LAST 7 DAYS: the full archive needs a tier we do not hold, and an older window is refused by name rather than silently truncated. Costs a small number of credits (X bills per post read, and this bills the ACTUAL count returned, never the number asked for). Needs X connected.

ParameterTypeDescription
queryrequiredstringthe search, X operators included — e.g. `(protein OR creatine) -is:retweet has:media lang:en`
maxResultsnumber10–100, default 25
sortOrder"recency" | "relevancy"default relevancy
startTimestringISO 8601, no earlier than 7 days ago
endTimestring
nextTokenstringfrom a previous page

x_search_counts#

How often a query is posted on X read-only

The VOLUME of a query on X over time — counts per day, hour or minute for the last 7 days, plus the total. Use it to size a topic BEFORE spending behind it, or to tell a real spike from a flat line. Cheaper than search_x because it returns numbers rather than posts: one read regardless of volume. Needs X connected.

ParameterTypeDescription
queryrequiredstringsame operator syntax as search_x
granularity"minute" | "hour" | "day"default day

x_follows#

Who follows an X account (or who it follows) read-only

The follower or following LIST for an X account — handles, names, bios, locations and each one’s own follower/following/post counts. The COUNTS are already free on x_account and x_user; this is WHO, which is what audience analysis and creator vetting actually need. With no username it reads the CONNECTED account (“who follows us”); pass a handle to read anyone public (“who follows our competitor”). ⚠ NEEDS THE follows.read PERMISSION, added 2026-08-31: an X connection authorized before that date does NOT carry it and cannot have it added — the tool says so and asks for a reconnect rather than surfacing X’s raw refusal. Capped at 100 per call because X bills per user returned; page with nextToken. Needs X connected.

ParameterTypeDescription
usernamestringhandle with or without the @ — omit to read the connected account
direction"followers" | "following"default followers
maxResultsnumber1–100, default 50
paginationTokenstring

list_x_blocks#

Who the connected X account has blocked read-only

The accounts the connected X account has BLOCKED, with handle, name, bio and follower count. READ-ONLY: X refuses the block and unblock WRITE on the access tier Hermoso's app runs on (measured 2026-09-15: X's own "attached to a Project" refusal with the scope granted), so blocking itself is done on x.com and there is no block tool here. Needs the block.read permission: an X connection made before 2026-09-15 must be reconnected once under Settings ▸ Connectors ▸ X. Costs credits (X bills per API call).

ParameterTypeDescription
maxResultsnumber1–1000, default 100
paginationTokenstring

x_user#

Look up any public X account read-only

ACCOUNT-LEVEL numbers for ANY public X account by handle — followers, following, posts, listed and media counts, plus bio, location, verified status and account age. The research twin of `x_account` (which reads YOUR connected account): use this to size a competitor or vet a creator before working with them. Returns the identical shape as x_account, so the two are never described differently, and a counter X does not return is reported as unknown rather than zero. Needs X connected.

ParameterTypeDescription
usernamerequiredstringthe handle, with or without the @

x_account#

Read the connected X account read-only

ACCOUNT-LEVEL numbers for the connected X account — FOLLOWERS, following, total posts, listed count, media count — plus display name, bio, location, verified status and when the account was created. This is the tool for “how many followers do we have on X”, “is our X growing”, or to confirm whose account is linked before posting; for a single post use x_post_metrics or x_post_insights instead. A counter X does not return comes back under `absent` with the reason and is NEVER reported as zero. Read-only. Rides the account lookup Hermoso already makes when it posts — X bills per request and not per field — so it costs at most one User: Read and is free on a cached lookup. Needs X connected (Settings ▸ Connectors ▸ X).

No parameters.

edit_telegram_message#

Edit a Telegram post

Change the TEXT or CAPTION of a message already posted to a Telegram channel, group or chat — the fix for a typo that would otherwise cost the post AND its accumulated views, since deleting and reposting starts from zero. Pass `text` for a plain post or `caption` for one carrying media; Telegram refuses the wrong one and its error names the other. Free, reversible, and 0 credits — use this instead of delete_telegram_message wherever the post can be salvaged. Needs Telegram connected.

ParameterTypeDescription
chatIdrequiredstringthe channel’s @username or numeric chat id
messageIdrequirednumberfrom post_to_telegram, or the number at the end of a t.me link
textstringthe new text, for a post with no media
captionstringthe new caption, for a post carrying a photo/video/album
linkPreviewbooleanfalse to suppress the link preview

pin_telegram_message#

Pin or unpin a Telegram message

Pin a message to the top of a Telegram channel or group, or unpin one. A channel’s pinned message is its most valuable real estate and its de-facto call to action. Pinning is SILENT by default (no member notification) — pass notify:true to ping. To unpin, pass unpin:true; omitting messageId then unpins the most recently pinned message, which is what “unpin” usually means. The bot must be an administrator with pin rights. Free, 0 credits. Needs Telegram connected.

ParameterTypeDescription
chatIdrequiredstringthe channel’s @username or numeric chat id
messageIdnumberrequired to PIN; optional when unpinning
unpinbooleantrue to unpin instead of pin
notifybooleantrue to notify members; default silent

create_linkedin_event#

Create a LinkedIn event

Create a LinkedIn LIVE event on a company Page the brand administers. ⚠ THIS REQUIRES LINKEDIN LIVE ACCESS on the member’s account: `liveVideo` is the only online format LinkedIn documents for event creation, and a member without Live access is refused with “The member does not have LINKEDIN_LIVE creation access” (measured live 2026-08-31). Check the account has it before offering this. ⚠ CREATED BUT NOT ANNOUNCED: LinkedIn requires a SEPARATE post to make an event publicly visible, so creating one puts nothing in anyone’s feed and the brand decides when to tell people. That separation is LinkedIn’s own and it is why an agent cannot accidentally broadcast to a company’s followers. ONLINE events only for now — an in-person event needs a full address object and a custom background image needs a three-step upload, and neither is offered rather than failing at LinkedIn. Needs the Events permission (administrator flag) and a reconnect, the same two conditions as list_linkedin_events. 0 credits.

ParameterTypeDescription
namerequiredstringthe event title
startsAtrequiredstringISO 8601, must be in the future — LinkedIn rejects an event that has already begun
endsAtstringISO 8601, after startsAt; defaults to one hour after the start
descriptionstring
organizationIdstringthe Page — omit if only one is shared with this brand

list_linkedin_events#

List a LinkedIn Page’s events read-only

The events a LinkedIn company Page is organizing — name, link, start and end times, online or in-person, visibility and lifecycle state. LinkedIn is the one channel where an event is a first-class object rather than a post about one, so this is how you see what a B2B brand actually has scheduled (webinars, launches, live sessions) and pair it with the posts promoting it. Only Pages the user has SHARED with this brand are readable. ⚠ Needs the Events permission: an administrator switches it on for the deployment, and because LinkedIn grants permissions at authorise time and never retroactively, every existing LinkedIn connection must RECONNECT once before it works. The tool tells you which of those two applies rather than failing generically. Read-only, 0 credits. Needs LinkedIn connected.

ParameterTypeDescription
organizationIdstringthe Page to read — omit if only one Page is shared with this brand; list_linkedin_pages shows them
startnumber
countnumbermax 50, default 10

search_linkedin_ad_library#

Search LinkedIn’s official Ad Library read-only

Search the ads running on LinkedIn, from LinkedIn’s OWN Ad Library, and get three things no ad scrape can give you: the advertiser’s actual TARGETING (location, language, audience, company, job, age, gender, interests, traits — both the segments they INCLUDED and the ones they EXCLUDED), estimated impressions as a range plus the percentage split BY COUNTRY, and, for ads LinkedIn REMOVED, the advertising policy that was violated and how it was caught. That last one is the cheapest way to learn what not to write. It is GLOBAL, not an EU-only transparency feed. COVERAGE IS BOUNDED and the reply always says so: nothing created before 2023-06-01, and an ad is dropped one year after its last impression, so an empty result usually means the window rather than the market. 25 ads per page. Needs LinkedIn connected — no extra permission, any LinkedIn connection works. 0 credits.

ParameterTypeDescription
keywordstringsearched in the ad’s content; several words are a logical AND, not a phrase
advertiserstringadvertiser name, e.g. Notion
payerNamestringwho PAID for the ad, which is often a different entity from the advertiser
countriesstringcomma-separated ISO codes the ad was served in, e.g. US,GB
dateFromstringYYYY-MM-DD, inclusive — must be given together with dateTo
dateTostringYYYY-MM-DD, EXCLUSIVE — must be given together with dateFrom
includeFacetsstringonly ads whose targeting INCLUDES these facet categories: LANGUAGE, LOCATION, AUDIENCE, AGE, GENDER, COMPANY, EDUCATION, JOB, INTERESTS, TRAITS
excludeFacetsstringonly ads whose targeting EXCLUDES these facet categories
impressionsFromnumberlower bound of the estimated impressions range; pass both bounds or neither
impressionsTonumberupper bound; pass both bounds or neither
sort"DESCENDING" | "ASCENDING"by creation time; default DESCENDING (newest first)
startnumber
countnumbermax 25

search_linkedin_brand_partnerships#

Find sponsored creator posts on LinkedIn read-only

Search ORGANIC LinkedIn posts that creators have themselves disclosed as brand partnerships — paid influencer content, not ads bought through Campaign Manager. Use it to see which creators a competitor is paying, what a sponsored post in your category actually looks like, or to vet a creator’s existing brand relationships before approaching them. This is a different dataset from search_linkedin_ad_library and from search_linkedin_ads: those are paid placements, these are disclosed endorsements in the feed. DIFFERENT CONTRACT from the ad search, so do not carry parameters across: `keyword` is REQUIRED here, `dateRange` is an ENUM (PAST_24H / PAST_WEEK / PAST_MONTH) rather than a date range, and the reply is POST URLS ONLY — no advertiser, no targeting, no impressions. Posts are queryable for one year after publication. 25 per page. Needs LinkedIn connected, no extra permission. 0 credits.

ParameterTypeDescription
keywordrequiredstringREQUIRED on this finder, unlike the ad search — searched in the post content; several words are a logical AND
dateRange"PAST_24H" | "PAST_WEEK" | "PAST_MONTH"an ENUM, not a date range; omit to search everything LinkedIn still holds
startnumber
countnumbermax 25

list_business_categories#

Google Business categories for a country read-only

Google’s CATEGORY vocabulary for a country — the primary category is a required field on a Business Profile and one of the strongest LOCAL RANKING signals, and Google accepts only its own stable ids (`categories/gcid:coffee_shop`), never a display name. So this is the lookup you run BEFORE setting a category, and `filter` searches display names (“pizza”, “dentist”). regionCode is REQUIRED because Google’s list genuinely differs by country — there is no worldwide answer. view:"FULL" additionally returns the service types and the attributes valid for each category, which is how you join this to list_business_attributes. Read-only, 0 credits. Needs Google Business Profile connected.

ParameterTypeDescription
regionCoderequiredstringISO 3166-1 alpha-2 country code — US, GB, CA, AU
languageCodestringBCP 47, default en
view"BASIC" | "FULL"FULL adds service types and valid attributes
filterstringsearch the display names, e.g. “coffee”
pageSizenumber
pageTokenstring

list_business_attributes#

Google Business attributes available for a listing read-only

The ATTRIBUTES a Business Profile can set — accessibility, amenities, service options, payment types — with the valid values for each. These are what local searchers FILTER on, so a listing with none set is invisible to those filters. Scope it exactly ONE of three ways and no more: locationName (`locations/123`) for what THAT listing may set, categoryName (`categories/gcid:…`, from list_business_categories) for a category, or regionCode for a country. Passing more than one is refused by name, because Google silently ignores the ones it does not use and would answer about a different scope than you asked for. Read-only, 0 credits. Needs Google Business Profile connected.

ParameterTypeDescription
locationNamestringlocations/{id} — from list_business_locations
categoryNamestringcategories/gcid:… — from list_business_categories
regionCodestringISO 3166-1 alpha-2 country code
languageCodestring
showAllboolean
pageSizenumber
pageTokenstring

business_google_updated#

Edits Google is proposing to a listing read-only

What GOOGLE thinks a Business Profile should say — the pending edits it has inferred from the web, from user submissions and from its own sources, with `changedFields` naming exactly which fields it would change. THESE CAN GO LIVE IF NOBODY LOOKS, which is why a business needs to see them. An empty result is a real and good answer (Google is proposing nothing) and is reported as such, never as a failure. Read-only — accepting or rejecting an edit is a write this tool does not do. 0 credits. Needs Google Business Profile connected.

ParameterTypeDescription
locationNamerequiredstringlocations/{id} — from list_business_locations

telegram_account#

Read a Telegram channel or group read-only

ACCOUNT-LEVEL numbers for a Telegram channel, group or chat — its SUBSCRIBER / MEMBER COUNT — plus title, @username, description, invite link and linked discussion group. This is the answer to “how many subscribers does our Telegram channel have”. WHICH CHAT IS ALWAYS ASKED FOR: pass chatId (a public channel’s @username, or the numeric id) — the Bot API publishes no method that lists the chats a bot is in, so there is no default and guessing would report another channel’s numbers as this one’s; list_telegram_chats reports any chat that messaged the bot in the last 24 hours. ⚠ TELEGRAM HAS NO PER-POST ANALYTICS AT ANY PERMISSION LEVEL: its Message object carries no `views` field and the Bot API has no history method, so post views and a post list are genuinely unavailable to a bot — both come back under `absent` with the reason, and neither is ever reported as zero. The bot must be a member of the chat. Read-only, 0 credits. Needs Telegram connected.

ParameterTypeDescription
chatIdrequiredstringthe channel’s @username or numeric chat id — required; there is no default

bluesky_account#

Read a Bluesky account read-only

ACCOUNT-LEVEL numbers for a Bluesky account — FOLLOWERS, following and total posts — plus display name, bio, avatar and when it was created. With no argument it reads the CONNECTED account, which is the answer to “how many followers do we have on Bluesky”; pass `actor` (a handle or DID) to read any other account, which is free and is how you size a competitor there. The reply says which one it read: `self` is true only for the connected account, compared on the DID because a Bluesky handle can change. A counter Bluesky does not return comes back under `absent` with the reason and is NEVER reported as zero — all three are optional in the lexicon. Read-only, 0 credits, no extra permission. Needs Bluesky connected (Settings ▸ Connectors ▸ Bluesky, or connect_connector).

ParameterTypeDescription
actorstringa Bluesky handle (hermoso.ai or @hermoso.ai) or a did:plc:… — omit it to read the connected account

bluesky_post_metrics#

Read likes, reposts, replies and quotes on your Bluesky posts read-only

Read live engagement for up to 25 of the connected account’s Bluesky posts — likes, reposts, replies, quotes and bookmarks. Address a post by its AT-URI (the `at://…` value post_to_bluesky returns), not its web URL. Bluesky publishes NO impression or view count in any AT Protocol lexicon, so these are COUNTS with no denominator and no engagement rate can be computed from them — do not present one. A uri Bluesky returns nothing for is reported as MISSING (deleted, or not on the connected account), never as zero engagement. Read-only, 0 credits. Needs Bluesky connected.

ParameterTypeDescription
urisrequiredstring[]AT-URIs of the posts, at most 25 (Bluesky’s own maximum for one call)

list_bluesky_convos#

List Bluesky direct-message conversations read-only

Read the connected Bluesky account’s DM conversations — who each one is with, how many messages are unread, and whether it is a REQUEST (someone the account does not follow, which Bluesky holds separately, the same idea as a message request). Free, no credits, no vendor approval: AT Protocol app passwords are not scoped. It DOES need a PRIVILEGED app password — one created with direct-message access ticked — and says so precisely if the saved one cannot chat, which is a property of the password and NOT a broken connection. Filters: readState "unread", status "request" or "accepted", kind "direct" or "group". An unknown filter value is refused by name, never silently dropped.

ParameterTypeDescription
limitnumberhow many conversations, 1–100 (default 25)
cursorstringwalk further back — pass the cursor from a previous call
readState"unread"only conversations with unread messages
status"request" | "accepted""request" = from someone this account does not follow
kind"direct" | "group"

read_bluesky_dm#

Read the messages in one Bluesky conversation read-only

Read a Bluesky DM conversation in CHRONOLOGICAL order (Bluesky returns newest-first on the wire; this reverses it so the conversation reads top to bottom). Each message says whether it was sent by this account. A message with no text is reported as DELETED rather than as an empty message — “they unsent it” and “they sent nothing” are different facts. Get a convoId from list_bluesky_convos.

ParameterTypeDescription
convoIdrequiredstringfrom list_bluesky_convos
limitnumberhow many messages, 1–100 (default 30)
cursorstringwalk further back through older messages

send_bluesky_dm#

Send a Bluesky direct message

Send ONE direct message on the connected Bluesky account, either into an existing conversation (convoId) or to a named handle (a conversation is opened for you — the handle is resolved to a DID first, because Bluesky identifies chat members by DID). A DM allows 1000 characters, NOT the 300 a post allows. Refusals name the real situation rather than a generic failure: the recipient has DMs off, only accepts DMs from people they follow, has blocked this account, is suspended, or does not exist. There is deliberately no broadcast form — a recipient is always named.

ParameterTypeDescription
convoIdstringreply into an existing conversation (from list_bluesky_convos). Omit to start one with `to`.
tostringthe recipient’s Bluesky handle, e.g. alice.bsky.social. Ignored when convoId is given.
textrequiredstringthe message, up to 1000 characters
replyToMessageIdstringreply to a specific message in the conversation
dryRunbooleanCHECK FIRST, SEND NOTHING. Asks Bluesky whether a DM to `to` would be delivered at all and returns canChat plus the reason when it would not — that account has DMs off, only accepts them from people it follows, has blocked this one, is suspended, or does not exist. Free, and the message is NOT sent. Use it before writing a long DM to someone who has never been messaged, so a refusal costs nothing instead of arriving after the words were written. `text` is still validated for length so a dry run cannot pass on a message the real send would refuse.

mark_bluesky_convo_read#

Mark Bluesky conversations as read

Clear the unread count on Bluesky DMs — ONE conversation, or the WHOLE ACCOUNT when convoId is omitted (Bluesky’s own mark-all, narrowable with status to just the message requests or just the accepted threads). Useful after triaging an inbox so the next list_bluesky_convos does not surface the same threads again. Reports the unread count — or the number of conversations — Bluesky reads back, not the one requested. Free.

ParameterTypeDescription
convoIdstringfrom list_bluesky_convos. OMIT to mark EVERY conversation on the account read.
messageIdstringmark read only up to this message; omit to clear the whole conversation
status"request" | "accepted"mark-all only: narrow it to just the message requests, or just the accepted conversations. Omit to clear both. An unknown value is refused rather than dropped — a dropped one would clear everything when you asked to clear only the requests.

manage_bluesky_convo#

Accept, mute, lock, leave or prune a Bluesky DM conversation destructive

Act on one Bluesky DM conversation. ACCEPT a message request — Bluesky holds DMs from people the account does not follow in a separate requests folder, and until one is accepted it stays there. MUTE / UNMUTE it. LOCK / UNLOCK it (no new messages). LEAVE it. Or DELETE one message from THIS account’s view. TWO THINGS TO SAY OUT LOUD BEFORE USING THEM: deleting is FOR SELF ONLY — the other person still sees the message, because Bluesky offers no delete-for-everyone in chat — and leaving a conversation cannot be undone from here. Accepting a request that was already accepted is reported as such rather than as a change, because Bluesky says so by returning no revision. Free, and it needs the same PRIVILEGED app password as every other Bluesky DM tool. Get a convoId from list_bluesky_convos.

ParameterTypeDescription
actionrequired"accept" | "mute" | "unmute" | "lock" | "unlock" | "leave" | "deleteMessage"what to do to the conversation
convoIdrequiredstringfrom list_bluesky_convos
messageIdstringrequired for deleteMessage — from read_bluesky_dm

react_to_bluesky_dm#

React to a Bluesky direct message

Add or remove an emoji reaction on one message in a Bluesky DM — the light acknowledgement that does not need a written reply, and the thing to reach for when someone says “thanks” and a paragraph back would be worse than a thumbs-up. A REACTION IS EXACTLY ONE EMOJI: that is Bluesky’s own rule, so a word or a phrase is refused here rather than by the service (where it comes back as an opaque ReactionInvalidValue). Reports the reactions Bluesky reads the message back carrying, not the one requested. Free.

ParameterTypeDescription
convoIdrequiredstringfrom list_bluesky_convos
messageIdrequiredstringfrom read_bluesky_dm
valuerequiredstringexactly one emoji, e.g. 👍
removebooleantrue to take the reaction off instead of putting it on

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
accountstringWHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the brand has more than one tiktok account connected (several and none named is refused by name, never guessed); omit when there is one.
brandstringWHICH BRAND this post belongs to — the id or exact name from list_brands (a workspace shared with you: its profile id). Use it whenever the account has more than one brand and you are not certain which one this connection is pinned to: it beats the pin for THIS CALL ONLY and changes nothing about the connection. A name that matches no brand, or two brands, is REFUSED and nothing is posted — never resolved to the pin, which is the account you were guarding against.
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
aiGeneratedbooleanTikTok’s is_aigc AI-generated-content label (video posts). OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, a video that came through upload_file or an external URL (the user’s own footage) is NOT. true/false overrides.
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

The connected account’s own TikTok posts with per-video stats — views, likes, comments, shares, duration, cover image and link. TWO WAYS TO ASK: with no arguments it lists the most recent (newest first, up to 20 a page); with videoIds it reads THOSE posts directly however old they are, which is how you answer "how did that specific video do" without paging back through the account. Any id TikTok does not return comes back under `unresolved` — meaning it is not on this account or no longer exists, which TikTok does not distinguish — never as a zero. Only ever the connected user’s OWN videos. ⚠️ TIKTOK OFFERS NO WAY TO DELETE OR EDIT A PUBLISHED POST through its API — not the caption, not the privacy level, not the comment/duet/stitch settings, not the cover. Every one of those is fixed at the moment of publishing. If the user wants a TikTok changed or taken down, tell them plainly that it has to be done in the TikTok app; do not look for a tool for it. Read-only, 0 credits. Needs TikTok connected.

ParameterTypeDescription
limitnumber1-20, default 10 (ignored when videoIds is given)
videoIdsstring[]read these specific TikTok video ids instead of listing recent ones — up to 20 per call

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. CATALOG / ADVANTAGE+ CATALOG ADS (also called DPA or dynamic product ads) ARE SUPPORTED: pass productCatalogId here and productSetId on the ad set, and the campaign is catalog-backed. Say YES when a user asks for catalog, Advantage+ catalog, DPA or feed-driven ads.

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
productCatalogIdstringCATALOG (dynamic product) ADS — the PRODUCT CATALOG this campaign advertises, from list_meta_catalogs. This is the CAMPAIGN-level half of a catalog ad; the AD SET takes a productSetId and the creative takes the same set. Passing a product SET id here is refused by name with the level it belongs on. The binding is READ BACK from Meta before it is reported, so a 200 is never narrated as “bound”.

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

MESSENGER + INSTAGRAM DIRECT

list_meta_conversations#

The brand’s Messenger and Instagram DM threads read-only

The DM threads on the brand’s Facebook Page and its Instagram account — who wrote, when they last wrote, how many messages and how many are unread. THIS IS WHERE THE BUYING QUESTION GETS ASKED. IT READS THE INBOX AND THE MESSAGE REQUESTS BY DEFAULT (Meta’s "other" folder), which is where a DM from someone who does NOT follow the account lands — every thread says which of the two it came from, because a message request is not an accepted conversation and whoever answers it should know that first. Omit `platform` to read BOTH surfaces (one question to a person, two edges to Meta); name one to narrow. AN EMPTY LIST IS NEVER "nobody has messaged you" — the reply says so and names the MEASURED causes, including one the account holder cannot fix: Instagram Direct on this edge needs an app-level Instagram messaging capability, and without it Meta answers with an empty list rather than an error. Never tell anyone to go and switch something on inside the Instagram app — there is no such setting on a professional account. Read one thread with read_meta_conversation. Read-only, 0 credits.

ParameterTypeDescription
platform"MESSENGER" | "INSTAGRAM"omit to read both — Messenger and Instagram Direct are separate inboxes
folder"inbox" | "other" | "page_done" | "spam" | "pending" | "archived"narrow to ONE folder. Omit to read inbox + other, which is almost always what you want. "other" IS the message-requests folder. A folder Meta does not recognise is REFUSED rather than forwarded, because Meta answers an unknown folder with the DEFAULT inbox — so a plausible-looking spelling like "requests" would hand back the ordinary inbox and be reported as "no message requests".
limitnumberthreads per surface (1–100, default 25)
accountstringwhich Instagram account — an @handle or id from list_connector_accounts("instagram"). The way a DIRECT (Instagram Login) account is reached; omit for the Page-linked one.
pageIdstringFacebook Page id — omit when only one Page is connected

read_meta_conversation#

Read one Messenger / Instagram DM thread read-only

The messages in one DM thread, oldest to newest, each marked inbound or outbound — plus WHETHER A REPLY IS ALLOWED RIGHT NOW. Meta only permits a reply within 24 hours of the person acting, and the window is computed from their LAST MESSAGE rather than from the thread’s updated time (which includes our own replies and would report the window open for a day after WE spoke). Read this before drafting: "can I answer this?" is the question a person actually has, and finding out by being refused is the worst way to learn it. It also hands back `replyTo`, the id a reply goes to, so nobody has to guess which participant is the customer. ON INSTAGRAM Meta serves only the 20 most recent messages — a truncated thread is unreachable history, not a short conversation, and the reply says so. Read-only, 0 credits.

ParameterTypeDescription
conversationIdrequiredstringfrom list_meta_conversations
platform"MESSENGER" | "INSTAGRAM"so the Instagram 20-message ceiling can be stated when it applies
limitnumbermessages (1–100, default 25)
accountstringwhich Instagram account — an @handle or id from list_connector_accounts("instagram"). The way a DIRECT (Instagram Login) account is reached; omit for the Page-linked one.
pageIdstring

reply_to_meta_message#

Reply to a Messenger / Instagram DM

Send a text reply to someone who has messaged the brand. THIS REACHES A REAL PERSON — show the user the exact text and who it goes to, get a yes, then send. Pass `conversationId` as well as `recipientId` and Hermoso checks Meta’s 24-hour window for free BEFORE sending, and refuses with the real reason instead of letting Meta refuse it; without one it sends and discloses that the window could not be checked. HERMOSO SENDS REPLIES ONLY: messaging_type is always RESPONSE, and proactive messages and message tags are not offered at all — that is a deliberate product boundary, not a gap. THE ONE PROACTIVE THING MESSENGER ALLOWS IS A MARKETING-MESSAGES SUBSCRIPTION: to ask a person in the inbox to SUBSCRIBE to marketing messages, call request_messenger_optin (Meta’s own opt-in template, in the ads group — enable_tools(["ads"]) or find_tools if it is not in your list). Never answer that with a plain-text reply asking them to say YES, and never say the capability does not exist. And a closed window is a RULE that only the person writing again reopens. `recipientId` is a PAGE-SCOPED ID on Messenger and an INSTAGRAM-SCOPED ID on Instagram (read_meta_conversation returns it as `replyTo`); a username or a handle is not one and cannot be turned into one. ACCEPTED IS NOT DELIVERED — never report it as delivered or read. 0 credits.

ParameterTypeDescription
recipientIdrequiredstringthe PSID (Messenger) or IGSID (Instagram) to reply to — `replyTo` from read_meta_conversation
textrequiredstringthe reply
conversationIdstringstrongly recommended: it is what lets the 24-hour window be checked BEFORE sending rather than discovered by a refusal
platform"MESSENGER" | "INSTAGRAM"
accountstringwhich Instagram account — an @handle or id from list_connector_accounts("instagram"). The way a DIRECT (Instagram Login) account is reached; omit for the Page-linked one.
pageIdstring

META PAGE WEBHOOKS

subscribe_meta_webhooks#

Subscribe a Facebook Page to real-time events

Have Meta PUSH events to Hermoso instead of Hermoso polling for them. Subscribe a Page to `feed` (comments, posts, likes and shares — this is what makes a new comment ARRIVE rather than be polled for), `mention` (someone mentions the Page), `leadgen` (a lead-ad form submission delivered the moment it happens, which turns "read the leads when asked" into "the leads arrive"), and `messages` (an inbound Messenger or Instagram DM — THE ONLY WAY to be told about one, and Meta’s 24-hour reply window starts the moment it arrives, so being told rather than polling is time on the clock). Read what has arrived with list_meta_webhook_events. THE ANSWER IS READ BACK FROM META, never the 200: Meta can accept a subscription and record FEWER fields than you asked for, and a caller told "subscribed to leadgen" whose leads never arrive has no way to discover why — so any field Meta did not record is named. Fields Hermoso does not consume (message_reactions, messaging_postbacks, message_echoes) are refused by name with the reason, so an agent gets a fact rather than "unsupported". 0 credits.

ParameterTypeDescription
fieldsrequired"feed" | "mention" | "leadgen" | "messages"[]what to be told about. At least one — Meta marks it required, and defaulting it would decide on the brand’s behalf what they hear about.
pageIdstringFacebook Page id — omit when only one Page is connected

meta_webhook_status#

What a Page is subscribed to read-only

What Meta ACTUALLY has recorded for this app on a Page — the fields it will push, read straight back from Meta rather than from anything we stored. CHECK THIS BEFORE CONCLUDING THAT WEBHOOKS ARE NOT WORKING: an unsubscribed Page is completely silent and looks identical to a quiet one, so an empty event feed is not evidence of anything until you have seen the subscription. Read-only, 0 credits.

ParameterTypeDescription
pageIdstringFacebook Page id — omit when only one Page is connected

unsubscribe_meta_webhooks#

Stop Meta pushing events for a Page destructive

Remove this app’s webhook subscription from a Page, so Meta stops pushing its events. It removes the WHOLE subscription — Meta’s DELETE takes no field list — so it is all fields or none; to keep some, call subscribe_meta_webhooks again with just those. Nothing published or posted is affected; only the notifications stop. THE ANSWER IS THE READ-BACK: a subscription still present when read back means the unsubscribe did NOT take effect, and that is reported rather than the 200. 0 credits.

ParameterTypeDescription
pageIdstringFacebook Page id — omit when only one Page is connected

list_meta_webhook_events#

What Meta has pushed to Hermoso read-only

The Page events Meta has PUSHED to Hermoso — new comments, mentions, lead-form submissions and inbound Messenger/Instagram DMs — each with the moment it arrived. This is the difference between a brand learning about a comment when someone thinks to look and learning about it when it happens. Scoped to the Pages this brand can actually see, so it never shows another account’s activity. AN EMPTY FEED IS AMBIGUOUS AND THE REPLY SAYS SO: nothing has happened, or the Page is not subscribed (check meta_webhook_status — an unsubscribed Page is silent and looks exactly like a quiet one), or Hermoso restarted and rebuilt the feed from its durable mirror, which keeps recent deliveries rather than all history. Read-only, 0 credits.

ParameterTypeDescription
pageIdstringnarrow to one Page — only within the Pages this brand already administers; naming an id you cannot see does not widen the answer
fieldstringnarrow to one kind: feed, mention, leadgen or messages
limitnumberhow many, newest first (default 50)

INSTAGRAM BUSINESS DISCOVERY

instagram_profile#

Look up an Instagram account by handle read-only

Turn an Instagram @handle into the NUMERIC Instagram account id, straight from Meta — with the account’s real name, bio, follower count, post count, website and profile picture. Two jobs: it answers "what is @<handle>’s Instagram id?", and it sizes up any brand or creator on Instagram before you work with them (followers and post count are Meta’s own numbers, not a scraper’s). CALL IT BEFORE manage_meta_partnership_creator: that tool’s `allowTagging` list takes NUMERIC IDS ONLY, and an id that did not come from Meta is rejected there with “An unknown error has occurred.” — an error carrying no cause at all, so a scraped or guessed id looks like an outage instead of a bad argument. PROFESSIONAL ACCOUNTS ONLY: Meta reads Instagram Business and Creator accounts here and nothing else. A personal account, a private account, an age-gated account and a handle that does not exist all come back as the SAME refusal, because Meta returns one error code for all four — so never tell a user which of them it was. Read-only, 0 credits, no new permission, no reconnect.

ParameterTypeDescription
handlerequiredstringthe Instagram handle — "fellowproducts", "@fellowproducts" or an instagram.com profile link all work. Meta matches it EXACTLY and does not strip a leading @, so Hermoso normalises it first.
pageIdstringFacebook Page id — omit when only one Page is connected. Meta requires the lookup to be made AS a professional account, so the Page must have an Instagram account linked.

list_instagram_shopping_catalogs#

What Instagram Shopping can tag read-only

Whether this Instagram account can tag products at all, and which catalogs its SHOP can tag from. CALL THIS FIRST: product tagging needs an APPROVED INSTAGRAM SHOP, and if the account does not have one, tagging fails AFTER the photo is already uploaded. The reply says which of three things is true — eligible, not eligible (a Commerce Manager approval nothing in Hermoso can grant, and not a sign anything is broken), or "could not tell", which is NOT the same as not eligible. AN EMPTY CATALOG LIST IS NOT AN EMPTY CATALOG: Instagram reaches a catalog through the account's SHOP, while list_meta_catalogs reads the business PORTFOLIO — a merchant can have a full catalog there and nothing available here until the shop is approved. Read-only, 0 credits.

ParameterTypeDescription
pageIdstringFacebook Page id — omit when only one Page is connected. Its linked Instagram account is the one that gets tagged.

search_instagram_shopping_products#

Find products to tag on Instagram read-only

The products in one catalog that can actually be TAGGED on an Instagram post — this is where the product_id for a product tag comes from. Omit `q` to see everything tag-eligible; pass a product name or SKU to narrow it. THIS IS A SMALLER SET THAN THE CATALOG HOLDS: a product can be in the catalog, counted by list_meta_catalog_products, and still not be taggable, so an empty answer here is never evidence the catalog is empty. Meta only SHOWS a tag whose product review_status is "approved" — an unapproved one is accepted, stored and shown to nobody, so the reply flags them. Read-only, 0 credits.

ParameterTypeDescription
catalogIdrequiredstringfrom list_instagram_shopping_catalogs. Meta REQUIRES it — there is no search-every-catalog form.
qstringproduct name or SKU. Omit it to list every tag-eligible product, which is a real ask rather than a missing argument.
pageIdstringFacebook Page id — omit when only one Page is connected

manage_instagram_product_tags#

Read or update the product tags on a published Instagram post

READ the product tags on a post the brand has already published, or ADD/MOVE tags on it. Pass `tags` to update: Meta's own behaviour is "updates coordinates if the product is already tagged; otherwise adds new tag" — so it is ADD-OR-MOVE, never replace-all, and it CANNOT be used to take a tag off. THERE IS NO WAY TO REMOVE A PRODUCT TAG: Meta documents Creating, Reading and Updating on this edge and no delete at all, so Hermoso will not guess at one — deleting the post is the only thing that removes its tags, and the reply says so rather than implying otherwise. The answer is always READ BACK from Instagram, and it separates tags that are STORED from tags that will actually be SHOWN — only an "approved" product ever appears on a published post. Read is free; the update costs 0 credits too.

ParameterTypeDescription
mediaIdrequiredstringthe numeric Instagram media id, from list_instagram_media
tagsobject[]ADD or MOVE these tags. x and y are FRACTIONS of the image, 0.0 (left/top) to 1.0 (right/bottom) — 0.5,0.5 is the middle. Omit to just read. Max 20 on a feed post.
pageIdstringFacebook Page id — omit when only one Page is connected

SHOPIFY: the merchant's own storefront

list_shopify_products#

List the Shopify catalog read-only

The merchant's real Shopify products — id, title, description, price, images and storefront URL. This is where the productId for publish_to_shopify_product comes from, and it doubles as ground truth about what the brand actually sells (real titles and real photos, not a guess from the website). Newest-updated first. Only works for accounts created by installing Hermoso from the Shopify App Store. Read-only, free.

ParameterTypeDescription
limitnumberhow many products (1–100, default 24)
cursorstringpageInfo.endCursor from a previous call, to page further

publish_to_shopify_product#

Publish an image onto a Shopify product

Attach a finished image to one of the merchant's Shopify product listings, as product media. Pass productId (from list_shopify_products — the gid://shopify/Product/… form) and a PUBLIC https imageUrl, which is what every Hermoso render returns. Shopify fetches the image server-side and processes it asynchronously, so the media can come back status PROCESSING and appear on the listing a moment later — that is success, not a failure. Only works for accounts created by installing Hermoso from the Shopify App Store. Free — the render was already paid for.

ParameterTypeDescription
productIdrequiredstringgid://shopify/Product/… from list_shopify_products
imageUrlrequiredstringany public https image URL — a Hermoso render works, and so does ANY file of your own brought in with upload_file
altstringalt text for accessibility and SEO; defaults to a generic credit

merchant_report#

Merchant Center competitive visibility, best sellers and price benchmarks read-only

COMPETITOR INTELLIGENCE GOOGLE COMPUTES FOR FREE, for any retail brand with a Merchant Center. Ten report views, read with a SQL-like MCQL query. The three worth reaching for first: `competitive_visibility_competitor_view` (WHICH other domains appear beside this merchant, their rank, page-overlap and higher-position rates — i.e. who is actually beating them), `best_sellers_product_cluster_view` (what is SELLING on Google in a category right now, with an inventory_status saying whether this merchant even stocks it) and `price_insights_product_view` (Google's own suggested price plus the predicted click and conversion change). Also: competitive_visibility_benchmark_view, competitive_visibility_top_merchant_view, best_sellers_brand_view, price_competitiveness_product_view, product_view, product_performance_view, non_product_performance_view. MCQL IS NOT SQL: no OR, no subqueries, no GROUP BY, no aggregates, no JOIN, and ORDER BY may only name fields already in SELECT. Date filters use `WHERE date BETWEEN '2026-01-01' AND '2026-01-31'` or `WHERE date DURING LAST_30_DAYS`. Several views REQUIRE specific fields in SELECT and in WHERE — competitive visibility needs report_category_id + report_country_code + traffic_source, and top_merchant uniquely REQUIRES a date condition while FORBIDDING date in SELECT. An unknown view is refused by name with the list. NOT AVAILABLE ON MULTI-CLIENT (MCA) ACCOUNTS — pass a subaccount id. An empty result is often a normal state (price insights are only produced where Google predicts a substantial gain), and the note says which kind of empty it is. Read-only, free, no new permission.

ParameterTypeDescription
merchantCenterIdrequiredstringfrom list_merchant_accounts — a STANDALONE account or a SUBACCOUNT, never a multi-client (MCA) account
queryrequiredstringthe MCQL query, e.g. "SELECT id, title, price, suggested_price, effectiveness FROM price_insights_product_view"
pageSizenumberrows per response, 1–100000 (default 1000). 100,000 is GOOGLE's ceiling, not ours.
pageTokenstringnextPageToken from a previous call — resend the IDENTICAL query and pageSize with it, which Google requires

list_merchant_issues#

List Merchant Center account issues read-only

Read the account-level issues Google reports on a Merchant Center — the answer to "why is this product not showing?", which Google Ads reporting CANNOT give you, because a disapproved product has no impressions to report on. Needs merchantCenterId from list_merchant_accounts. Read-only and free.

ParameterTypeDescription
merchantCenterIdrequiredstringfrom list_merchant_accounts

HubSpot

list_hubspot_contacts#

List or search HubSpot contacts read-only

Contacts from the brand's OWN HubSpot CRM, newest first — email, name, lifecycle stage, lead status, created date, original source — or a search by email / first name / last name (whole-token match, wildcards allowed). Read-only, free. Needs HubSpot connected (Settings ▸ Connectors ▸ HubSpot).

ParameterTypeDescription
searchstringemail or name to search for
limitnumber1–100, default 25
afterstringpagination cursor from the previous page

create_hubspot_contact#

Create or update a HubSpot contact

Create a contact in the brand's HubSpot by email, or UPDATE the contact that already has that email — HubSpot's duplicate answer carries the existing id, so this is an UPSERT and never makes a twin. Optional firstname, lastname, phone, company, website, lifecyclestage, hs_lead_status, plus any other writable contact property in `properties` (by internal name). The contact is READ BACK after the write; the reply says created or updated. Free. Needs HubSpot connected.

ParameterTypeDescription
emailrequiredstringthe contact email — the key HubSpot de-duplicates on
firstnamestring
lastnamestring
phonestring
companystring
websitestring
lifecyclestagestring
hs_lead_statusstring
propertiesobjectany other writable contact properties, by internal name

sync_leads_to_hubspot#

Push lead-form leads into HubSpot

Push the leads collected by the brand's Meta and LinkedIn LEAD FORMS into HubSpot as contacts. Each lead is upserted by email with lead status NEW and the platform + form name written into the contact's Message property (a default HubSpot property, so no schema scope is needed). Synced lead ids are remembered per workspace, so a re-run, a retry or a second agent never creates twins. Reports created / updated / already-synced / no-email per platform; a platform that is not connected is REPORTED, not fatal. dryRun:true lists what would be synced and writes nothing. Free. Needs HubSpot connected, plus Meta and/or LinkedIn for the leads.

ParameterTypeDescription
sincestringISO date; default 7 days ago
dryRunbooleanlist what would be synced and write nothing

hubspot_deals_report#

HubSpot deals report read-only

Deals from the brand's HubSpot created in the window (default the last 90 days): count and amount by stage (labels resolved through the deal pipelines, closed-won recognised from the stage's own metadata), by original source, a monthly created / closed-won series, and totals. Capped at 2,000 deals and says so. Read-only, free. Needs HubSpot connected.

ParameterTypeDescription
sincestringYYYY-MM-DD, default 90 days ago
untilstringYYYY-MM-DD, default today

TIKTOK ACCOUNT

tiktok_account_status#

Check the TikTok account authorization read-only

Report whether this brand holds the TikTok ACCOUNT-HOLDER authorization — the SECOND, separate consent on the same TikTok app that the TikTok Ads connection uses. TikTok issues two different grants: the ADVERTISER one (campaigns, budgets, reporting — that is `tiktok_ads`) and this ACCOUNT one, which is what lets Hermoso read and manage the comments on the brand's own TikTok posts and mint Spark-Ads authorization codes for them. Holding one does NOT give you the other, so a workspace can be fully connected for ads and still answer 'not connected' here — that is a real third state, not a broken session. Reports the state, the TikTok business id every other tool in this family uses, the scopes the grant actually carries, and any scope MISSING from it (TikTok binds scopes at authorize time and never retroactively, so a grant made before a scope was added simply does not have it and only a reconnect fixes that). Connecting is the one step that needs a browser — the reply carries the exact URL to send the user to. Read-only, free.

No parameters.

list_tiktok_comments#

List comments on one of the brand’s TikTok posts read-only

Read the comments on a TikTok post the AUTHORIZED ACCOUNT OWNS — this is TikTok's answer to list_meta_comments and list_youtube_comments. It sees BOTH public and hidden comments, and by default returns both: TikTok's `status` defaults to ALL, so the list mixes comments the owner hid with comments TikTok's own moderation, privacy or spam filters hid, and those are not the same thing (the second kind may refuse to unhide). Pass status:'PUBLIC' for only what the public sees. A row carrying parentCommentId IS A REPLY, not a top-level comment — that is how TikTok distinguishes them. include_replies attaches at most THREE replies per comment; use list_tiktok_comment_replies for all of them. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION, which is a separate consent from the TikTok Ads advertiser connection — tiktok_account_status says whether this brand has it. Read-only, free.

ParameterTypeDescription
videoIdrequiredstringthe TikTok post id (`item_id`) — the last path segment of a tiktok.com/@handle/video/<id> URL
commentIdsstring[]filter to specific comment ids; TikTok caps this at 30
includeRepliesbooleanattach up to THREE replies per comment — not all of them
status"PUBLIC" | "ALL"default ALL, which INCLUDES hidden comments
sortField"likes" | "replies" | "create_time"
sortOrder"asc" | "desc"
cursornumber
maxCountnumber

list_tiktok_comment_replies#

List every reply to one TikTok comment read-only

All replies to a single comment on a post the authorized TikTok account owns — the complete list, where list_tiktok_comments only ever attaches three. TIKTOK DOES NOT RETURN REPLIES TO A HIDDEN COMMENT AT ALL, so an empty list against a hidden parent means 'cannot read', never 'no replies' — check the parent's status first if the answer looks wrong. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION (see tiktok_account_status). Read-only, free.

ParameterTypeDescription
videoIdrequiredstringTikTok requires the post id alongside the comment id
commentIdrequiredstringfrom list_tiktok_comments
status"PUBLIC" | "ALL"
sortField"likes" | "replies" | "create_time"
sortOrder"asc" | "desc"
cursornumber
maxCountnumber

comment_on_tiktok_video#

Post a new comment on the brand’s own TikTok post

Write a NEW top-level comment on a TikTok post the authorized account owns. Text (≤1,200 characters, UTF-8) or an image, and TikTok requires at least one of the two. AN IMAGE HERE MUST BE UPLOADED FIRST — a raw URL is refused on a new comment; call upload_tiktok_comment_image and pass back imageUri + imageWidth + imageHeight together (a reply is the one place TikTok accepts a plain URL). TIKTOK SILENTLY HIDES COMMENTS IT FLAGS AS SPAM and sends no signal when it does, so avoid posting many near-identical comments in a short window, and read the comment back with list_tiktok_comments(status:'PUBLIC') if it matters that it is visible. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION (see tiktok_account_status).

ParameterTypeDescription
videoIdrequiredstringthe TikTok post id
textstring≤1,200 characters (UTF-8). Either this or an image is required.
imageUristringfrom upload_tiktok_comment_image — a raw URL is NOT accepted on a new comment
imageWidthnumberrequired with imageUri
imageHeightnumberrequired with imageUri

reply_to_tiktok_comment#

Reply to a comment on the brand’s TikTok post

Reply to an existing comment on a TikTok post the authorized account owns — TikTok's twin of reply_to_meta_comment and reply_to_youtube_comment. Text (≤1,200 characters) or an image; at least one is required. A REPLY IS THE ONE PLACE TIKTOK ACCEPTS A PLAIN IMAGE URL (pass imageUrl); an uploaded imageUri + width + height also works. TikTok silently hides replies it flags as spam, so read it back with list_tiktok_comment_replies if visibility matters. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION (see tiktok_account_status).

ParameterTypeDescription
videoIdrequiredstringthe post the comment sits on — TikTok requires it
commentIdrequiredstringfrom list_tiktok_comments
textstring≤1,200 characters (UTF-8)
imageUrlstringa public image URL — TikTok accepts this on a REPLY only, never on a new comment
imageUristringfrom upload_tiktok_comment_image
imageWidthnumber
imageHeightnumber

moderate_tiktok_comment#

Like, hide or delete a TikTok comment destructive

Moderate one comment on a TikTok post the authorized account owns: LIKE, UNLIKE, HIDE, UNHIDE or DELETE. TWO RULES TIKTOK ENFORCES AND THIS TOOL STATES UP FRONT. (1) YOU CAN ONLY DELETE A COMMENT THIS ACCOUNT WROTE — anyone else's can be hidden but never deleted, so HIDE is the tool for an unwanted comment from a stranger, and it is reversible. DELETE is permanent and confirm-gated. (2) UNHIDE IS NOT GUARANTEED: TikTok says a comment may stay hidden because ITS OWN moderation, privacy or spam filters are what hid it, in which case accepting the request changes nothing — so pass videoId and the reply reads the comment back and reports its real status rather than claiming success from a 200. HIDE and UNHIDE require videoId; LIKE, UNLIKE and DELETE do not. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION (see tiktok_account_status).

ParameterTypeDescription
commentIdrequiredstringfrom list_tiktok_comments
actionrequired"LIKE" | "UNLIKE" | "HIDE" | "UNHIDE" | "DELETE"
videoIdstringREQUIRED for HIDE and UNHIDE; pass it on the others too and the reply reads the comment back to prove the change landed
confirmbooleanREQUIRED true for DELETE, which is permanent — call without it to see exactly what would happen

upload_tiktok_comment_image#

Upload an image for a TikTok comment

Turn a public image URL into the imageUri that comment_on_tiktok_video needs — TikTok will not take a raw URL on a new comment, only on a reply. Returns imageUri, imageWidth and imageHeight, and ALL THREE must be passed back together: TikTok rejects dimensions that do not match what it stored. Limits are TikTok's own — at most 5 MB, JPG/JPEG/PNG/WebP, between 360x360 and 1080x1920 (or 1920x1080). upload_file turns a local file into a URL this accepts. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION (see tiktok_account_status).

ParameterTypeDescription
imageUrlrequiredstringa public URL to the image — upload_file turns a local file into one

set_tiktok_post_ad_authorization#

Turn Spark-Ads authorization on or off for the brand’s own post

Turn TikTok's 'Ad authorization' setting ON or OFF for a post the authorized account owns — THIS IS WHERE A SPARK ADS AUTHORIZATION CODE COMES FROM. Until now the only way to get one was a human opening the TikTok app and copying a string; that is still true for somebody ELSE's post, and no longer true for the brand's own. Turning it on mints the code, which you then hand to authorize_tiktok_ads_spark_post so an ad account may promote the post. IT IS CONFIRM-GATED ON THE WAY ON, and not because it spends: TikTok changes the post's privacy to 'Available for Ads', sends it to their ad review team, says the post 'may also appear as an ad on third party platforms', and treats the call as accepting their Advertising Content Terms on the owner's behalf. Turning it OFF needs no confirm — but TikTok REFUSES to turn it off while an active Spark Ad is using the post, so pause those campaigns first. authorizationDays must be one of 7, 30, 60, 180, 365. THE ANSWER IS THE READ-BACK: the reply carries the post's real authorization status and its code, because TikTok's own response body is empty. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION (see tiktok_account_status).

ParameterTypeDescription
itemIdrequiredstringthe TikTok post id
enabledrequiredbooleantrue turns Ad authorization ON (and mints the Spark Ads code); false turns it off
authorizationDaysnumber7 | 30 | 60 | 180 | 365 — TikTok publishes exactly those five; default 30
confirmbooleanREQUIRED true when enabling — it makes the post publicly promotable and accepts TikTok’s advertising terms on the owner’s behalf

get_tiktok_post_ad_authorization#

Read a post’s Spark-Ads authorization status read-only

Read the Spark-Ads authorization status of a post the authorized account owns — whether it is promotable, its authorization CODE, and the window the authorization runs for. A SPARK AD CANNOT OUTLIVE ITS AUTHORIZATION, so check the end time before building a campaign around a post. TIKTOK ERRORS RATHER THAN ANSWERING when a post has no authorization code at all, so a not-found style refusal from here almost always means Ad authorization was never turned on (or the code was deleted) — turn it on with set_tiktok_post_ad_authorization — and not that anything is broken. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION (see tiktok_account_status). Read-only, free.

ParameterTypeDescription
itemIdrequiredstringthe TikTok post id

extend_tiktok_post_ad_authorization#

Extend a post’s Spark-Ads authorization

Extend how long a post the authorized account owns stays promotable as a Spark Ad — and REGENERATE its code if it was deleted. THE DAYS ARE ADDED, NOT SET: TikTok's own example is that a post with 180 days remaining, extended by 180, ends up at 360 — so passing '365' to a post that already has time left does not mean 'expires in a year'. Must be one of 7, 30, 60, 180, 365. Ad authorization has to be ON already (set_tiktok_post_ad_authorization) or TikTok refuses. The reply reads the new window back, because TikTok's own response body is empty. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION (see tiktok_account_status).

ParameterTypeDescription
itemIdrequiredstringthe TikTok post id
authorizationDaysnumber7 | 30 | 60 | 180 | 365 — ADDED to whatever is left, not set as an absolute. Default 30.

delete_tiktok_post_ad_authorization#

Delete a post’s Spark-Ads authorization code destructive

Delete the Spark-Ads authorization code for a post the authorized account owns. CONFIRM-GATED, and not because it spends: ads already built on the post keep running, but no NEW ad can be built against it until a fresh code exists, and TikTok REFUSES this outright while any campaign or ad using the post is still ACTIVE — pause those first. Calling it without confirm changes nothing and returns the sentence describing exactly what would happen. THE READ-BACK INVERTS: proof of success is that TikTok no longer reports a code for the post, and a code that is still there is reported as not confirmed rather than done. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION (see tiktok_account_status).

ParameterTypeDescription
itemIdrequiredstringthe TikTok post id
confirmbooleanREQUIRED true — call without it first to see exactly what would change

TIKTOK BRAND MENTIONS: social listening on the brand's own h…

list_tiktok_mentions#

Posts that mention the brand on TikTok read-only

Who is talking about the brand on TikTok: every public post whose CAPTION @-mentions the connected account's handle. This is TikTok's answer to x_mentions and list_threads_mentions, and it is the brand-monitoring read the product had for every other channel and not for TikTok. FOUR THINGS TIKTOK ENFORCES THAT MAKE AN EMPTY RESULT AMBIGUOUS, so read them before reporting silence: the mentioning post must be PUBLIC, it must be under 90 days old, its author must not be underage or of unknown age, and THE WHOLE FAMILY ONLY WORKS FOR A TIKTOK BUSINESS ACCOUNT, so a personal account returns nothing at all (tiktok_account_insights reports which one this is). Capped at the top 1,000 mentioning posts however many exist. VIEWS AND REACH COME BACK NULL, NOT ZERO, on any post under 1,000 of either. That is TikTok withholding a number, never a measurement of nothing. Thumbnail URLs expire after 48 hours. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION with the brand-insights permission (tiktok_account_status says whether this brand has it). Read-only, free.

ParameterTypeDescription
daysnumberlook-back window, 1 to 90 (TikTok indexes nothing older). Default 90
regionsstring[]two-letter codes to filter the MENTIONING author's registration country, e.g. ['US','GB']. TikTok publishes 162 of them and answers an unknown one with an empty result, so an unpublished code is refused here instead
sortField"CREATE_TIME" | "LIKES" | "COMMENTS" | "SHARES"
sortOrder"ASC" | "DESC"
fieldsstring[]defaults to every field TikTok publishes; item_id, create_time, video_link, caption, likes, comments, shares, thumbnail_url, views, reach, creator_handle_name
cursornumber
maxCountnumber1 to 100

get_tiktok_mention#

Read one mention from the TikTok mentions webhook read-only

Read the full detail of a SINGLE mention: a mentioning post, or a mentioning comment if you pass commentId as well. This exists for the TikTok mentions WEBHOOK: the event carries only ids, and this turns one into the caption, the creator, the engagement and the thumbnail. TIKTOK ONLY GUARANTEES THE DATA FOR 48 HOURS after the event fires, so a queue that retries tomorrow gets nothing. For anything older read it out of list_tiktok_mentions or list_tiktok_mention_comments instead. itemId is required either way, comment or not. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION with the brand-insights permission. Read-only, free.

ParameterTypeDescription
itemIdrequiredstringthe post id. It is `video_id` in the webhook event content, or itemId from list_tiktok_mentions. REQUIRED even when reading a comment
commentIdstringpass this to read a mentioning COMMENT rather than a mentioning post
fieldsstring[]defaults to every field TikTok publishes for that kind

list_tiktok_mention_comments#

Comments that mention the brand on TikTok read-only

Comments and replies anywhere on TikTok whose TEXT @-mentions the connected account's handle. This is the conversational half of brand monitoring, where list_tiktok_mentions covers post captions. Same four preconditions as that tool (public post, 90 days, adult author, BUSINESS account), so an empty list is not the same as silence. Capped at the top 1,000 by comment likes, and TIKTOK DELIBERATELY DEPRIORITISES a comment that is nothing but the @-mention with no other text, so bare tags may not appear at all. Sorts on different keys from the post list: VIDEO_LIKES, COMMENT_CREATE_TIME or COMMENT_LIKES. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION with the brand-insights and comment-list permissions. Read-only, free.

ParameterTypeDescription
daysnumber1 to 90. Default 90
regionsstring[]two-letter codes; filters on the commenting author's registration country
sortField"VIDEO_LIKES" | "COMMENT_CREATE_TIME" | "COMMENT_LIKES"
sortOrder"ASC" | "DESC"
fieldsstring[]
cursornumber
maxCountnumber1 to 100; TikTok defaults this one to 10

tiktok_mention_top_terms#

The words and hashtags inside the brand’s TikTok mentions read-only

The top 20 KEYWORDS and the top 20 HASHTAGS appearing in the captions of the posts that mention this brand. It is what people say when they talk about it, rather than which posts they said it in. Two TikTok endpoints behind one tool because they take identical parameters and answer the same question at two granularities; kind:'KEYWORDS' or kind:'HASHTAGS' calls only one. Counted across the top 1,000 mentioning posts of the last 90 days, so this is the language of the mentions and not of TikTok at large. Same BUSINESS-account precondition as every mentions tool. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION with the brand-insights permission. Read-only, free.

ParameterTypeDescription
kind"KEYWORDS" | "HASHTAGS" | "BOTH"default BOTH, which makes two calls
regionsstring[]two-letter codes to narrow which mentioning posts are counted

list_tiktok_brand_hashtags#

The brand hashtags TikTok tracks for this account read-only

The brand hashtags this account curates, in two flavours. kind:'ENABLED' is what TikTok is currently counting (with the date each was turned on, and whether it is old enough to remove). kind:'AVAILABLE' is what TikTok will ACCEPT. A tag qualifies once it has appeared in at least three post captions, either from this account or from a post mentioning its handle. A tag containing the account handle as a substring can also be enabled even when it is not on the available list. AT MOST 50 CAN BE ENABLED PER BRAND. TikTok's own caveat on the available list: test and newly created accounts often return nothing and should not be used to judge whether this works. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION with the brand-insights permission. Read-only, free.

ParameterTypeDescription
kind"ENABLED" | "AVAILABLE"default ENABLED
usernamestringnormally resolved from the authorization itself. Pass the @handle (without the @) only if that read is refused

manage_tiktok_brand_hashtags#

Turn a brand hashtag on or off for TikTok tracking

Enable or disable the hashtags TikTok counts as this brand's. ADD takes a LIST of up to 10 per call (50 enabled per brand in total); REMOVE takes exactly ONE, because TikTok publishes no bulk removal and a list would silently drop all but one. TWO TIMING RULES THAT ARE EASY TO TRIP: a newly enabled hashtag is not counted for 24 HOURS, so list_tiktok_brand_hashtag_posts shows nothing for it until then; and it CANNOT BE REMOVED FOR 7 DAYS after being enabled, so this tool reads the enable date first and reports the exact wait rather than relaying TikTok's undated refusal. A tag must either be on the AVAILABLE list or contain the account handle as a substring, or TikTok rejects it. THE ANSWER IS THE READ-BACK, not the 200: TikTok's remove response is an empty body, so success means the tag has left the enabled list. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION with the brand-insights permission.

ParameterTypeDescription
actionrequired"ADD" | "REMOVE"
hashtagsstring[]ADD only. Up to 10, with or without the leading #
hashtagstringREMOVE only. Exactly one
usernamestringnormally resolved from the authorization itself

list_tiktok_brand_hashtag_posts#

Posts carrying the brand’s hashtags on TikTok read-only

Public posts whose captions carry one of the brand hashtags this account has enabled. It is the hashtag half of brand monitoring, where list_tiktok_mentions covers @-mentions. Omit `hashtag` for the top posts across every enabled tag; pass one to narrow to it. TWO TIKTOK BEHAVIOURS THAT READ AS BUGS IF NOBODY SAYS THEM: the hashtag filter is CASE-SENSITIVE and must exactly match an enabled tag, and filtering to one tag makes matched_hashtags come back EMPTY on every row. NOTHING IS RETURNED UNTIL HASHTAGS ARE ENABLED. That is a setup step rather than a result: use list_tiktok_brand_hashtags and manage_tiktok_brand_hashtags first, and allow 24 hours after enabling. Capped at the top 1,000 posts of the last 90 days. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION with the brand-insights permission. Read-only, free.

ParameterTypeDescription
hashtagstringone ENABLED tag, spelled exactly as enabled. The match is case-sensitive
daysnumber1 to 90. Default 90
regionsstring[]
sortField"CREATE_TIME" | "LIKES" | "COMMENTS" | "SHARES"
sortOrder"ASC" | "DESC"
fieldsstring[]
cursornumber
maxCountnumber1 to 100; TikTok defaults this one to 10

TIKTOK AUDIENCE INSIGHTS AND CATEGORY BENCHMARKS

tiktok_account_insights#

TikTok follower demographics and daily performance read-only

The connected TikTok account's OWN analytics: follower demographics broken down by AGE, GENDER, COUNTRY and CITY, the daily series (video views, profile views, likes, comments, shares, reached audience, engaged audience, follower gained/lost/net, and the profile-button clicks a verified Business account collects), and the lifetime counters. This is TikTok's twin of instagram_insights and youtube_channel_report. TWO PRECONDITIONS TIKTOK ENFORCES ON THE DEMOGRAPHICS, and this tool reports which one is in the way instead of returning an empty breakdown: the account must be a BUSINESS account, and it must have at least 100 FOLLOWERS. Below that TikTok withholds the distributions for privacy, which is not the same as an audience it could not measure. The look-back is capped at 60 DAYS, which is SHORTER than the 90 days the brand-mentions tools cover, and daily numbers lag by up to 48 hours. The bio, verified badge and profile link are deliberately not readable here. That needs a TikTok permission this authorization does not request, and tiktok_account on the TikTok posting connector already returns all three. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION with the audience-insights permission (tiktok_account_status says whether this brand has it). Read-only, free.

ParameterTypeDescription
startDatestringYYYY-MM-DD (UTC). Default is 7 days ago; TikTok keeps at most 60 days
endDatestringYYYY-MM-DD (UTC). Default is yesterday
fieldsstring[]defaults to everything this authorization can read. Demographics are audience_ages, audience_genders, audience_countries, audience_cities

tiktok_category_benchmark#

TikTok industry averages for a business category read-only

What an average TikTok Business account in a given industry looks like: mean likes, comments, shares, video count, follower count, 30-day follower growth, engagement rate and video views. Pair it with tiktok_account_insights to answer 'are we ahead of our category or behind it', which neither number answers alone. These are TikTok's own cross-account averages, not this brand's numbers. businessCategory must be one of TikTok's twenty-five published values. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION. Read-only, free.

ParameterTypeDescription
businessCategoryrequired"ART_AND_CRAFTS" | "AUTOMOTIVE_AND_TRANSPORTATION" | "BABY" | "BEAUTY" | "CLOTHING_AND_ACCESSORIES" | "EDUCATION_AND_TRAINING" | "ELECTRONICS" | "FINANCE_AND_INVESTING" | "FOOD_AND_BEVERAGE" | "GAMING" | "HEALTH_AND_WELLNESS" | "HOME_FURNITURE_AND_APPLIANCES" | "MACHINERY_AND_EQUIPMENT" | "MEDIA_AND_ENTERTAINMENT" | "PERSONAL_BLOG" | "PETS" | "PROFESSIONAL_SERVICES" | "PUBLIC_ADMINISTRATION" | "REAL_ESTATE" | "RESTAURANTS_AND_BARS" | "SHOPPING_AND_RETAIL" | "SOFTWARE_AND_APPS" | "SPORTS_FITNESS_AND_OUTDOORS" | "TRAVEL_AND_TOURISM" | "OTHERS"

search_tiktok_ads_targeting#

Resolve TikTok locations / interests / hashtags / languages read-only

Look up the exact ids TikTok ad-group targeting expects, so none of them has to be invented. kind:"location" resolves TikTok’s targetable regions — an ad group CANNOT be created without location ids, TikTok refuses it in its own words ("‘location_ids’ or ‘zipcode_ids’ must be specified"). kind:"interest" resolves the interest categories and kind:"interest_keyword" the additional interest KEYWORDS (both attach to an ad group). kind:"hashtag" resolves real targeting HASHTAGS — until 2026-08-11 this kind pointed at TikTok’s interest-keyword endpoint and quietly returned interest categories instead; note that hashtag ids feed TikTok’s actions[] field, which Hermoso does not send yet, so treat hashtag results as RESEARCH rather than targeting you can apply. kind:"language" the language codes. A made-up id either fails the create or, worse, targets somebody else and spends money silently, so always resolve here first and never guess. Read-only, free.

ParameterTypeDescription
advertiserIdstringfrom list_tiktok_ads_accounts — TikTok scopes these lookups to an advertiser
kind"location" | "interest" | "interest_keyword" | "hashtag" | "language"default location. interest_keyword and hashtag both REQUIRE keyword.
keywordstringnarrows the lookup, e.g. "Canada", "Beauty", "skincare"

tiktok_ads_report#

TikTok ads performance report read-only

Performance for a TikTok advertiser account at campaign, ad group, ad or whole-advertiser level — spend, impressions, clicks, CTR, CPC, CPM and conversions by default, or whichever of TikTok’s metrics and grouping dimensions you name. `reportType` picks WHICH REPORT: BASIC is spend and performance (the default); AUDIENCE is the same money broken down by WHO SAW IT — pass an audience dimension such as age, gender, country_code or interest_category, which BASIC refuses outright. A report with ZERO rows genuinely means nothing delivered in that window — say exactly that, and never present zeros as measured performance. Read-only, free, spends nothing. NOTE: everything Hermoso creates on TikTok is created PAUSED, so a brand-new build reports nothing at all until somebody activates it with set_tiktok_ads_status(confirm:true) — check the statuses with list_tiktok_ads_campaigns before reading an empty report as bad performance.

ParameterTypeDescription
advertiserIdstringfrom list_tiktok_ads_accounts
level"campaign" | "adgroup" | "ad" | "advertiser"the reporting level — default campaign. "advertiser" is the whole account, which is the level an audience breakdown is normally run at.
reportType"BASIC" | "AUDIENCE" | "PLAYABLE_MATERIAL" | "CATALOG"BASIC (default) = spend + performance. AUDIENCE = the audience breakdown; its dimensions are audience ATTRIBUTES (age, gender, country_code, interest_category …) and BASIC rejects those, so name them. PLAYABLE_MATERIAL = playable ads, CATALOG = Dynamic Showcase Ads. TikTok also documents BC (Business Center) and TT_SHOP (GMV max) — both are refused here by name with the reason.
startDatestringYYYY-MM-DD
endDatestringYYYY-MM-DD
metricsstring[]TikTok metric names — default ["spend","impressions","clicks","ctr","cpc","cpm","conversion"]
dimensionsstring[]TikTok dimensions to group by — default the level’s own id, e.g. ["campaign_id"]. Add "stat_time_day" for a daily breakdown. On an AUDIENCE report these are audience attributes instead, e.g. ["age"].
pagenumber1-based page (default 1). A report LONGER than one page comes back with hasMore:true and a truncationNote — totals computed from one page are WRONG.
pageSizenumberrows per page, 1–1000 (default 100). Adding "stat_time_day" overflows a page fast: 30 days x 4 campaigns is already 120 rows.
filteringobject[]TikTok’s own filter array, e.g. [{"field_name":"campaign_ids","filter_type":"IN","filter_value":"['123']"}]. Narrows the report AT TIKTOK rather than after truncation.

COMMUNITY MANAGEMENT

list_linkedin_comments#

Read the comments on a LinkedIn Page post read-only

Read the comments people have left on one of your LinkedIn company Page's posts. Community Management surface, and it needs its OWN connection: LinkedIn grants the comment scopes on a different app from the one that posts, so connecting LinkedIn does NOT connect this — the card is “LinkedIn Community Management” in Settings ▸ Connectors. Pass the post's urn, which is what post_to_linkedin_page returns. A post nobody has commented on comes back as an EMPTY LIST with a note, never an error: LinkedIn answers 404 for a post with no social activity, and that means “no comments yet”, not “no such post”. Each comment carries its text, author, time, like and reply counts, and `isOurs` so your Page's own replies are distinguishable from other people's comments; `fromOthers` counts the ones actually awaiting an answer. The connected member must hold an ADMINISTRATOR role on the Page.

ParameterTypeDescription
postUrnrequiredstringthe post to read — urn:li:share:…, urn:li:ugcPost:… or urn:li:activity:…
limitnumberhow many to fetch (default 50; clamped below LinkedIn’s own 200 timeout bound rather than refused)

reply_to_linkedin_comment#

Comment or reply on a LinkedIn Page post

Speak AS your LinkedIn company Page: comment on one of its posts, or reply to somebody else's comment on it. Community Management surface — same separate connection as list_linkedin_comments. `organizationId` is WHO IS SPEAKING (the Page, from list_linkedin_pages) and `postUrn` is WHAT IS BEING COMMENTED ON; they are different things and both are required. To reply to a particular comment rather than the post itself, pass that comment's `commentUrn` from list_linkedin_comments as `parentComment`. LinkedIn's API does not support images in comments. It also throttles comment creation per member, and a throttle is reported as a throttle so you wait rather than retrying into it.

ParameterTypeDescription
organizationIdstringthe company Page commenting — OPTIONAL: defaults to the Page shared with this brand on the LinkedIn connector, and is only needed when several are shared
postUrnrequiredstringthe post being commented on
textrequiredstringthe comment text
parentCommentstringto REPLY to an existing comment, its commentUrn — omit for a top-level comment

delete_linkedin_comment#

Delete a comment from a LinkedIn Page post destructive

Remove a comment from one of your LinkedIn company Page's posts — your own Page's comment, or somebody else's on your Page. Community Management surface, same separate connection. CONFIRM-GATED: call once without `confirm` to be refused and shown what would go, then again with confirm:true. Deleting is immediate and public. `organizationId` is required because LinkedIn refuses a Page-level delete that does not name the acting organization. The delete is idempotent, so a repeat reports success rather than an error.

ParameterTypeDescription
postUrnrequiredstringthe post the comment sits on
commentIdrequiredstringthe comment id from list_linkedin_comments (its `id`, not the composite commentUrn)
organizationIdstringthe company Page doing the deleting — OPTIONAL: LinkedIn requires an acting organization, but it defaults to the Page shared with this brand
confirmbooleanREQUIRED true to actually delete

manage_linkedin_post#

Edit or delete a LinkedIn post destructive

Edit or delete a published LinkedIn post — personal profile or company Page. Pass postUrn, the full urn returned when it was published. action:"edit" changes ONLY THE COPY: LinkedIn does not allow the image or video of a published post to be replaced, so a new visual means a NEW post — tell the user that instead of promising a swap. action:"delete" is immediate and public and requires confirm:true.

ParameterTypeDescription
postUrnrequiredstringthe full LinkedIn post urn returned by publishing
actionrequired"edit" | "delete"
textstringthe new copy, for action:"edit"
confirmbooleanREQUIRED true to delete

linkedin_page_analytics#

Organic performance of a LinkedIn company Page read-only

ORGANIC performance for one of the brand’s LinkedIn COMPANY PAGES: total followers, followers gained (organic vs paid) across the window, Page views (all / unique / desktop / mobile), and the impressions, unique impressions, clicks, likes, comments, shares and engagement rate of the Page’s posts. This is what answers “is our LinkedIn actually working” and “did that post land”. It is NOT linkedin_ads_report — that covers PAID campaigns; LinkedIn excludes sponsored activity from these figures entirely. Pass postUrns (the urn:li:share:… / urn:li:ugcPost:… that post_to_linkedin_page returned) for PER-POST numbers; LinkedIn forbids a date range together with named posts, so that switches to lifetime-per-post. Only Pages the user ticked in Manage accounts (or call list_connector_accounts with provider linkedin, then set_connector_accounts) are readable — a Page the account merely administers is refused, by design. LinkedIn keeps 12 months, follower figures run about 2 days behind, and it OMITS posts with no recorded activity rather than returning zeros: report an absent post or an unavailable section as MISSING data, never as zero. Read-only, 0 credits. Needs LinkedIn connected with the organization scopes.

ParameterTypeDescription
organizationIdstringnumeric Page id from list_linkedin_pages — omit only when exactly one Page is shared with this brand
startDatestringYYYY-MM-DD, default 28 days ago (LinkedIn keeps 12 months)
endDatestringYYYY-MM-DD, default today
postUrnsstring[]urn:li:share:… / urn:li:ugcPost:… — switches to per-post lifetime numbers instead of the Page total

list_linkedin_ads_campaigns#

List LinkedIn ad accounts / campaigns read-only

Read the LinkedIn ad accounts this connection can reach, and — with adAccountId — that account’s campaign groups and campaigns: name, status, objective, budgets, and LinkedIn’s own servingStatuses, which explain WHY something is not delivering (billing hold, start-date hold, parent-status hold). LinkedIn’s Advertising API is an approval-gated product, and on its Development tier each ad account must ALSO be mapped to the app in LinkedIn’s Developer Portal — so if nothing is reachable, say that rather than implying the user has no ad account. Read-only, free.

ParameterTypeDescription
adAccountIdstringLinkedIn ad account id — omit to list the reachable accounts
campaignIdstringalso return the CREATIVES (the actual ads) under this campaign, each with its intendedStatus, whether it isServing, and LinkedIn’s own servingHoldReasons
campaignIdsstring[]same, for several campaigns at once
statuses"ACTIVE" | "PAUSED" | "ARCHIVED" | "DRAFT"[]

linkedin_ads_report#

LinkedIn ads performance report read-only

LinkedIn ad performance — impressions, clicks, cost, website conversions, leads and social actions — pivoted by CAMPAIGN (default), CAMPAIGN_GROUP, CREATIVE, ACCOUNT, CONVERSION, PLACEMENT_NAME, IMPRESSION_DEVICE_TYPE, SERVING_LOCATION… or by AUDIENCE DEMOGRAPHICS: MEMBER_COMPANY_SIZE, MEMBER_INDUSTRY, MEMBER_SENIORITY, MEMBER_JOB_TITLE, MEMBER_JOB_FUNCTION, MEMBER_COUNTRY_V2, MEMBER_REGION_V2, MEMBER_COMPANY. The MEMBER_* pivots are what LinkedIn is uniquely good at — job title, seniority and company size are targeting dimensions no other platform reports — and LinkedIn allows exactly ONE pivot per report, so ask for them one at a time and join the answers yourself. An unknown pivot or granularity is refused BY NAME rather than forwarded. On a demographic pivot LinkedIn returns only the top 100 values, DROPS any value under 3 events (so the rows will not sum to the campaign total) and lags 12–24 hours behind the performance numbers — the note says so, every time. Window via since/until (YYYY-MM-DD). ZERO rows genuinely means no delivery in that window; say exactly that and never present zeros as measured performance. A LinkedIn TEST ad account NEVER returns analytics, and the note says so when that is what you are looking at. Read-only, free.

ParameterTypeDescription
adAccountIdstring
campaignIdsstring[]
sincestringYYYY-MM-DD, default 30 days ago
untilstringYYYY-MM-DD
pivotstringCAMPAIGN (default), CAMPAIGN_GROUP, CREATIVE, ACCOUNT, CONVERSION, PLACEMENT_NAME, IMPRESSION_DEVICE_TYPE, SERVING_LOCATION, or one MEMBER_* demographic pivot — an unknown value is refused with the full list
granularity"ALL" | "DAILY" | "MONTHLY" | "YEARLY"default ALL
fieldsstring[]metric names — omit for the standard set (LinkedIn returns ONLY impressions and clicks if none are named)

LINKEDIN LEAD SYNC

list_linkedin_lead_forms#

List LinkedIn lead gen forms read-only

The LEAD GEN FORMS a LinkedIn company Page or ad account owns — id, name, state, version and the fields each one asks for (firstName, email, company …). Forms are created in Campaign Manager or on the Page; this API reads them and cannot create one. If it answers that the connection must be reconnected, say exactly that: the lead-sync permission is granted at authorise time. Read-only, free.

ParameterTypeDescription
pageIdstringthe company Page that owns the form — from list_linkedin_pages; omit when one Page is shared
adAccountIdstringread forms owned by an AD ACCOUNT instead of a Page

list_linkedin_leads#

List LinkedIn leads (form responses) read-only

The LEADS a LinkedIn lead gen form collected — every response with its answers keyed by field (firstName, lastName, email, company, …), the campaign and creative that produced it, the consents ticked, and whether it was a test lead. Newest first. Filter by formId, a since/until window (ISO date or epoch ms — LinkedIn takes epoch), or testLeadsOnly. THIS IS PERSONAL DATA: show it to the user, hand it to the CRM they name, never repeat it into a post or an unrelated tool. Pass start for the next page. Read-only, free.

ParameterTypeDescription
pageIdstringthe company Page that owns the form — from list_linkedin_pages; omit when one Page is shared
adAccountIdstringread forms owned by an AD ACCOUNT instead of a Page
formIdstringonly this form (from list_linkedin_lead_forms)
formVersionnumberdefault 1
sincestringISO date or epoch milliseconds
untilstring
testLeadsOnlybooleantrue returns ONLY test submissions
leadType"SPONSORED" | "COMPANY" | "EVENT" | "ORGANIZATION_PRODUCT"defaults by owner: SPONSORED for an ad account, COMPANY (organic Page form) for a Page; EVENT for event forms. LinkedIn refuses SPONSORED on a Page owner
limitnumberper page, max 100
startnumberoffset for the next page

get_linkedin_lead#

Read one LinkedIn lead read-only

One LinkedIn lead by id (from list_linkedin_leads), with every answer named by field. Personal data — show, never republish. Read-only, free.

ParameterTypeDescription
leadIdrequiredstring
pageIdstringthe company Page that owns the form — from list_linkedin_pages; omit when one Page is shared
adAccountIdstringread forms owned by an AD ACCOUNT instead of a Page

subscribe_linkedin_leads#

Turn on real-time LinkedIn lead delivery

Have LinkedIn push every new lead to Hermoso the moment it is submitted, and optionally relay each event on to the user’s own CRM. THE WEBHOOK LINKEDIN VALIDATES IS ALWAYS HERMOSO’S OWN: LinkedIn challenges it with our app secret (and re-challenges every ~2 hours), which no CRM, Zapier or Make endpoint can answer — so never promise a customer URL as the LinkedIn webhook. Pass forwardTo (public HTTPS) to have Hermoso relay each lead event there; leave it off to keep events in Hermoso only (list_linkedin_lead_events). The reply is read back from LinkedIn, not from the 201. Leads stay readable with list_linkedin_leads either way. Free.

ParameterTypeDescription
forwardTostringoptional public HTTPS URL Hermoso relays each lead event to (a CRM, Zapier, Make)
pageIdstringthe company Page that owns the form — from list_linkedin_pages; omit when one Page is shared
adAccountIdstringread forms owned by an AD ACCOUNT instead of a Page
leadType"SPONSORED" | "COMPANY" | "EVENT" | "ORGANIZATION_PRODUCT"defaults by owner: SPONSORED for an ad account, COMPANY for a Page

list_linkedin_lead_events#

LinkedIn lead events received in real time read-only

The lead events LinkedIn has PUSHED to Hermoso for this brand (new lead / deleted lead, with the form and the lead id), newest first. Empty means none have arrived, not that none exist — list_linkedin_leads reads every lead regardless, and subscribe_linkedin_leads is what starts delivery. Read a lead’s answers with get_linkedin_lead. Read-only, free.

ParameterTypeDescription
limitnumber

list_linkedin_lead_subscriptions#

List LinkedIn lead webhooks read-only

The lead notification webhooks registered on a LinkedIn Page or ad account, with the id delete_linkedin_lead_subscription takes. Read-only, free.

ParameterTypeDescription
pageIdstringthe company Page that owns the form — from list_linkedin_pages; omit when one Page is shared
adAccountIdstringread forms owned by an AD ACCOUNT instead of a Page
leadTypestring

delete_linkedin_lead_subscription#

Stop a LinkedIn lead webhook destructive

Remove a lead notification webhook (subscriptionId from list_linkedin_lead_subscriptions). Leads themselves are unaffected and stay readable; only the real-time delivery stops. Read back from LinkedIn. Free.

ParameterTypeDescription
subscriptionIdrequiredstring
pageIdstringthe company Page that owns the form — from list_linkedin_pages; omit when one Page is shared
adAccountIdstringread forms owned by an AD ACCOUNT instead of a Page

delete_meta_object#

Delete a Meta campaign / ad set / ad destructive

PERMANENTLY delete a campaign, ad set, or ad. Pass objectId (from list_meta_ads) + adAccountId. DELETING A CAMPAIGN ALSO DELETES EVERY AD SET AND AD UNDER IT, and deleting an ad set deletes its ads — one id, the whole tree. Call it WITHOUT confirm first: it reports what the object is, its name, and how many children go with it. Show the user that, get an unambiguous yes, then call again with confirm:true plus confirmChildren set to the number it reported (only needed when there is at least one child). To stop delivery without deleting anything, use update_meta_object(status:"PAUSED") instead — that is reversible and this is not.

ParameterTypeDescription
objectIdrequiredstringthe campaign / ad set / ad id to delete
adAccountIdrequiredstringad account id (for auth + scope)
confirmbooleanREQUIRED true — deletion is permanent
confirmChildrennumberthe number of child ad sets + ads this delete also destroys, as reported by the unconfirmed call — required whenever that is above zero

manage_meta_post#

Edit or delete a published post destructive

Edit the text of, or delete, a published post. target:"facebook" → edit the message (action:"edit", message:…) OR delete (action:"delete"); target:"threads" → delete only (Threads has no edit API); target:"instagram" → DELETE ONLY — Meta lets you change nothing on a published Instagram post except whether comments are enabled, so a caption cannot be fixed; deleting covers ordinary posts, Stories, Reels and ENTIRE carousel albums (Instagram cannot remove one card out of an album — pass the album’s own media id, from list_instagram_media). Deleting is permanent. FOR INSTAGRAM, CALL IT WITHOUT confirm FIRST: nothing is deleted and you get back the post’s real caption, its likes and comments and how many carousel cards go with it — show the user exactly that, then call again with confirm:true plus confirmName (and confirmChildren for an album) if the refusal asks for them. A post nobody has liked or commented on yet stays a one-call delete. INSTAGRAM DELETE NEEDS A RECONNECT ON AN OLD CONNECTION: the `instagram_manage_contents` permission joined Hermoso’s Meta grant on 2026-08-05, so any Meta connection made before then must be reconnected (Settings ▸ Connectors ▸ Meta) before Instagram will accept a delete. Call the tool rather than pre-refusing — every refusal it can raise names the one thing that fixes it.

ParameterTypeDescription
postIdrequiredstringthe post id returned by post_to_meta — for Instagram, the media id from list_instagram_media
actionrequired"edit" | "delete"edit the text (FB only) or delete the post
target"facebook" | "threads" | "instagram"default facebook
messagestringthe new post text (action:"edit" on facebook)
pageIdstringwhich Page to use — needed when the post id has no page prefix, or when the brand has several Pages and you are deleting an Instagram post
confirmbooleanREQUIRED true to delete (permanent)
confirmNamestringInstagram only: the post’s exact caption line, exactly as the unconfirmed call reported it — required once the post has any likes or comments
confirmChildrennumberInstagram only: how many carousel cards the delete also destroys, as the unconfirmed call reported

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
accountstringwhich Instagram account — an @handle or id from list_connector_accounts("instagram"). Needed when several are linked, and the way an Instagram Login (standalone) account is reached; omit for the one Page-linked account.
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 our 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, format (recipe), channel, media format or posting hour, reports the engagement RATE within each channel, and ranks the best and worst POSTS in each channel. Describe a post by the creative it carried (what it shows, its format, its link), not by its caption — the caption is the least important part of a post. 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" | "recipe" | "channel" | "media" | "hour"what to group by — default hook; recipe = the format of the creative
channelstringrestrict to one channel

diagnose_posts#

What to fix next, post by post read-only

WHAT TO FIX NEXT, post by post — and the tool that fixes it. post_performance tells you which hook is AHEAD; this tells you what is WRONG with a given post and where the next edit goes. Under-distributed is A HOOK PROBLEM (change the opening: mine_angles, then list_hooks, then plan_variations). Seen but not held is A RETENTION PROBLEM (plan_variations to rebuild the middle against the same hook). Seen, held, and still not converting is AN OFFER PROBLEM. FOUR REFUSALS, AND YOU SHOULD REPEAT THEM RATHER THAN PAPER OVER THEM: (1) a post younger than ~24h is TOO EARLY and is never called a failure — it has not had its run; (2) a metric the platform does not publish is UNMEASURED, never zero — Facebook has published no post reach since 2026-06-15, Reddit publishes no impressions, and Google Business publishes nothing per-post at all; (3) below 5 measured posts on a channel there is no baseline of the brand's own, and the ONLY fallback is a published short-video hook floor that is NOT our measured number and does not transfer off TikTok/Instagram/YouTube — it is attributed in the output and you should attribute it too; (4) it does not always find a problem, and 'nothing here needs fixing' is a real answer rather than a failure to look. Hermoso cannot see conversions for an organic post — no channel reports installs or purchases against a post id — so the offer rung runs ONLY when the user tells you they are not converting and you pass converting:false. Print `summary` verbatim. Read-only, 0 credits.

ParameterTypeDescription
channelstringrestrict to one channel: facebook, instagram, threads, x, linkedin, youtube, tiktok, reddit, pinterest
limitnumberhow many recent posts to diagnose (default 25, max 200). The baseline is always built from EVERY post recorded for the brand, never only these, so a bad month can never become its own definition of normal.
convertingbooleanpass false ONLY when the user has told you these posts are getting seen and are not converting — it re-reads the ones that are earning their reach as an offer problem instead of a win. Omit when you do not know; we cannot measure it.

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)
remeasurebooleanALSO re-read posts older than 7 days whose every reading came back empty or failed — use after post_performance reports posts "read but empty", or once a channel's reader has been fixed. Otherwise those windows stay closed.

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, pinterest and bluesky; the others say plainly why they cannot (LinkedIn and Reddit have no enumerate-my-posts endpoint on our grant, X bills per read so it is excluded from bulk import, Google Business has had no per-post insights since 2023, and Telegram's Bot API cannot read a chat's past messages at all — nothing published before Hermoso is recoverable through a bot token). 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" | "bluesky"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