hermosodocs

Publishing & scheduling

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

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

Saving and exporting what you make

  • File types. Video is MP4, voice and music are MP3, subtitles are SRT (from add_subtitles and clip_video), and stills are PNG, JPG or WebP.
  • Shapes. The aspect ratio is set where the file is made: 9:16, 1:1, 16:9 and the other ratios each model lists, and reframe_video or edit_timeline change it afterwards.
  • Where it goes. Every result is a URL you can download. save_to_drive and save_to_onedrive save it to Google Drive or OneDrive, and the tools below publish it now or on a schedule. upload_file brings in a file made anywhere else.

The editing tools themselves (trim, join, timeline, captions, reframe, upscale) are on Creative generation.

THE INBOX

post_to_bluesky#

Post to Bluesky destructive

Use this when the user wants to post to Bluesky. Publishing costs no credits. 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. @MENTIONS written as the full handle (@alice.bsky.social) are looked up and tagged as Bluesky mentions; one that does not resolve stays plain text and the reply names it. MEDIA: either up to 10 images (imageUrls + altText; 5–10 post as a Bluesky gallery, more than 10 is refused, never cut; a picture over Bluesky's 2 MB limit is re-encoded to fit and the reply says so) 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.

ParameterTypeDescription
accountstringWHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the profile has more than one bluesky account connected (several and none named is refused by name, never guessed); omit when there is one.
brandstringWHICH PROFILE this post belongs to — id or exact name from list_brands (a shared workspace: its profile id). Beats the connection's pin for THIS CALL ONLY; a name that matches no profile, or two, is REFUSED and nothing is posted.
hookstringthe post's angle — a list_hooks id or your own wording, reused exactly
subjectstringwhat the post is about
recipestringthe post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format
ideaIdstringshort id of the content-plan idea this post came from
idempotencyKeystringany stable string: a repeat within 24h returns the original post instead of posting again
allowDuplicatebooleanpost it even though an identical post was just made
textrequiredstringThe post, up to 300 characters / 3000 UTF-8 bytes.
imageUrlsstring[]Up to 10 public image URLs to attach (1–4 a standard image post, 5–10 a Bluesky gallery; more than 10 is refused, never cut). 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.
platformCoverbooleanVIDEO COVER. Bluesky has no cover setting and shows the video’s FIRST frame, so by default Hermoso checks it and, only when that frame is blank (a template ad’s empty opening card), sends Bluesky a copy with the first frame replaced by the video’s best frame. Your Library file is never changed. true = send the file exactly as it is.

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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
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.
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

TELEGRAM

post_to_telegram#

Post to Telegram destructive

Use this when the user wants to post to a Telegram channel or group. Publishing costs no credits. 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. @yourchannel) 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.

ParameterTypeDescription
accountstringWHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the profile has more than one telegram account connected (several and none named is refused by name, never guessed); omit when there is one.
brandstringWHICH PROFILE this post belongs to — id or exact name from list_brands (a shared workspace: its profile id). Beats the connection's pin for THIS CALL ONLY; a name that matches no profile, or two, is REFUSED and nothing is posted.
hookstringthe post's angle — a list_hooks id or your own wording, reused exactly
subjectstringwhat the post is about
recipestringthe post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format
ideaIdstringshort id of the content-plan idea this post came from
idempotencyKeystringany stable string: a repeat within 24h returns the original post instead of posting again
allowDuplicatebooleanpost it even though an identical post was just made
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.
buttonsobject | object[][]URL BUTTONS under the post, Telegram's call to action: [{text:'Shop now', url:'https://…'}] one per row, or rows [[a,b],[c]]. URL buttons only (https://, http:// or tg://), optional style danger|success|primary. A single photo, a video or a text post can carry them; an ALBUM cannot (Telegram's sendMediaGroup takes no buttons), so that combination is refused.
parseMode"HTML"'HTML' to format the text with Telegram's HTML: <b>, <i>, <u>, <s>, <a href="…">, <code>, <pre>, <blockquote>, <tg-spoiler>. Omit for plain text (the default). In HTML mode a literal <, > or & in the copy must be written &lt; &gt; &amp;, which is why it is never turned on by itself. Markdown (**bold**) is NOT parsed; convert it to HTML tags.
platformCoverbooleanVIDEO COVER. Omit it (the default) and Hermoso sets the video’s best frame — the same frame as its Library thumbnail — as the cover (Telegram’s in-chat video cover). true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins.
coverAtMsnumberTHE VIDEO COVER in the chat, as ONE frame: milliseconds from the start (7000 = the frame at 7s). Sent as Telegram’s cover image; beats platformCover.

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)
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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. Warning: 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.
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

WORDPRESS

post_to_wordpress#

Publish a WordPress blog post destructive

Use this when the user wants to publish, draft or schedule a BLOG POST on their WordPress site. Publishing costs no credits. A title is REQUIRED and the text becomes the body: plain text or light markdown (## headings, - lists, **bold**, [links](url)) is turned into WordPress blocks, and HTML is sent as written. status: publish (default), draft, pending (submit for review), private (logged-in editors only) or future with a date — a future `date` makes WordPress schedule it natively. The FIRST image becomes the FEATURED image, every other image is uploaded and placed inline, and a video is uploaded and embedded (if the site's host refuses the file as too large, the post plays it from its own link and the reply says so). categories and tags take names or ids; a name the site lacks is CREATED and the reply says which. An over-long title is refused, never cut. WordPress has NO per-post views in its API: never quote a view count for a WordPress post. Self-hosted WordPress only — a WordPress.com site needs its own OAuth sign-in and cannot be connected. Not connected: the user connects it under Workspace > Connectors > WordPress, or you call connect_connector with provider wordpress (siteUrl, username, appPassword).

ParameterTypeDescription
brandstringWHICH PROFILE this post belongs to — id or exact name from list_brands (a shared workspace: its profile id). Beats the connection's pin for THIS CALL ONLY; a name that matches no profile, or two, is REFUSED and nothing is posted.
hookstringthe post's angle — a list_hooks id or your own wording, reused exactly
subjectstringwhat the post is about
recipestringthe post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format
ideaIdstringshort id of the content-plan idea this post came from
idempotencyKeystringany stable string: a repeat within 24h returns the original post instead of posting again
allowDuplicatebooleanpost it even though an identical post was just made
titlerequiredstringREQUIRED — the post title
textstringthe body: plain text, light markdown or HTML
status"publish" | "draft" | "pending" | "private" | "future"default publish
datestringISO time to publish at; a future date schedules it on WordPress itself
excerptstringthe summary under the title in lists and search
slugstringthe end of the post URL
categoriesstring[]category names or ids (a missing name is created)
tagsstring[]tag names or ids (a missing name is created)
imageUrlstringone image — becomes the featured image
imageUrlsstring[]several images: the first is featured, the rest go inline
videoUrlstringone video, embedded in the post
altTextstringalt text for the images

update_wordpress_post#

Edit a WordPress post destructive

Change a WordPress post that already exists: title, body, excerpt, slug, status (publish a draft, unpublish to draft, make private, or schedule with a future date), categories, tags or the featured image. Only the fields passed change; the reply is WordPress's own copy of the post. Free.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
postIdrequirednumberthe post id post_to_wordpress or list_wordpress_posts returned
titlestring
textstringthe new body — replaces the old one
excerptstring
slugstring
status"publish" | "draft" | "pending" | "private" | "future"
datestring
categoriesstring[]
tagsstring[]
featuredImageUrlstringa new featured image

delete_wordpress_post#

Delete a WordPress post destructive

Delete a WordPress post. Call it WITHOUT confirm first: nothing is deleted and you get a sentence naming the post to show the user. By default it goes to WordPress's TRASH (restorable from Posts > Trash); permanent:true skips the trash and cannot be undone. Free.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
postIdrequirednumber
permanentbooleanskip the trash — cannot be undone
confirmbooleantrue only once the user agreed to the sentence the first call returned

list_wordpress_posts#

List WordPress posts read-only

List the posts on the brand's WordPress site, newest first — published, scheduled, drafts, pending and private — each with its id, status, title and live URL. Free. WordPress publishes no per-post view counts, so this carries none.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
statusstringone status or a comma list: publish, future, draft, pending, private (default all)
searchstring
pagenumber
perPagenumber1–100, default 20

get_wordpress_post#

Read a WordPress post read-only

Read one WordPress post back — WordPress's own title, status, live URL, excerpt and body. Use it to confirm a post really published. Free.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
postIdrequirednumber

list_wordpress_terms#

List WordPress categories or tags read-only

List the WordPress site's categories or tags (kind), most-used first, each with its id. Free.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
kindrequired"categories" | "tags"
searchstring

create_wordpress_term#

Create a WordPress category or tag

Create a category or tag on the WordPress site. (post_to_wordpress also creates any category or tag NAME it is given that the site lacks.) Free.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
kindrequired"categories" | "tags"
namerequiredstring
parentnumbera parent category id (categories only)
descriptionstring

DISCORD + SLACK ANNOUNCEMENTS

post_to_discord#

Post a Discord announcement destructive

Post to the user's Discord server. Free. With the Hermoso bot added it posts as the bot to channel (or the brand's default channel) with attachments up to 25 MB a message, replyTo, threadId (inside a thread), startThread (a thread under the post) and threadName (a forum channel); the messageId it returns ("<channel>/<message>") is what edit, delete and get_discord_message take. Without the bot it posts through the brand's announcement webhook (never the alert one): ≤2000 characters, files up to 20 MB attached (up to 10), larger ones as links. Mentions never ping unless mentions:true. Reactions are the only per-post number (get_discord_message). Not connected: connect_connector with provider discord and no fields returns the install link.

ParameterTypeDescription
brandstringWHICH PROFILE this post belongs to — id or exact name from list_brands (a shared workspace: its profile id). Beats the connection's pin for THIS CALL ONLY; a name that matches no profile, or two, is REFUSED and nothing is posted.
hookstringthe post's angle — a list_hooks id or your own wording, reused exactly
subjectstringwhat the post is about
recipestringthe post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format
ideaIdstringshort id of the content-plan idea this post came from
idempotencyKeystringany stable string: a repeat within 24h returns the original post instead of posting again
allowDuplicatebooleanpost it even though an identical post was just made
textstringthe announcement, ≤2000 characters
imageUrlstring
imageUrlsstring[]up to 10 files in one message
videoUrlstring
mentionsbooleanlet @everyone / @here / role mentions ping people (off by default)
silentbooleanpost without a notification
threadNamestringrequired when the channel is a FORUM channel: the new thread title
usernamestringwebhook posts only: a different display name than the webhook's own
discordServerstringHERMOSO BOT: which Discord server (id or name) when the bot is in several
channelstringHERMOSO BOT: channel id or #name (list_discord_channels); unset = the default channel
replyTostringHERMOSO BOT: reply to this message id
threadIdstringHERMOSO BOT: post inside this existing thread
startThreadstringHERMOSO BOT: start a thread under the new post with this title

edit_discord_message#

Edit a Discord announcement destructive

Change the text of a Discord message Hermoso posted (≤2000 characters) — the bot's or the announcement webhook's. Pass messageId exactly as post_to_discord returned it. Mentions never ping on an edit. Free.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
messageIdrequiredstringthe message id post_to_discord returned (a bot post's is "<channel>/<message>")
channelstringthe channel, when messageId is a bare id of a BOT post
textrequiredstring

delete_discord_message#

Delete a Discord announcement destructive

PERMANENTLY delete a Discord message Hermoso posted (the bot's or the announcement webhook's). Call it WITHOUT confirm first: nothing is deleted and you get a sentence quoting the message to show the user. Free.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
messageIdrequiredstring
channelstring
confirmbooleantrue only once the user agreed

get_discord_message#

Read a Discord announcement read-only

Read back a Discord message Hermoso posted: its text, link and REACTIONS — the only engagement Discord exposes (there is no view count). Free.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
messageIdrequiredstring
channelstring

post_to_slack#

Post a Slack announcement destructive

Post to the user's Slack. Free. With the Hermoso app installed it posts as the app to channel (or the brand's default channel), uploads images and video, replies in a thread (threadTs) or schedules in Slack (postAt, text only, ≤120 days); the messageId it returns ("<channel>/<ts>") is what edit_slack_message, delete_slack_message and get_slack_message take. Without the app it posts through the brand's announcement webhook (never the alert one): text ≤40,000 characters (refused, never cut), a title line, png/jpg/gif images by link, video as a link; a webhook post has no id, so it cannot be edited, deleted or measured. Not connected: connect_connector with provider slack and no fields returns the install link.

ParameterTypeDescription
brandstringWHICH PROFILE this post belongs to — id or exact name from list_brands (a shared workspace: its profile id). Beats the connection's pin for THIS CALL ONLY; a name that matches no profile, or two, is REFUSED and nothing is posted.
hookstringthe post's angle — a list_hooks id or your own wording, reused exactly
subjectstringwhat the post is about
recipestringthe post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format
ideaIdstringshort id of the content-plan idea this post came from
idempotencyKeystringany stable string: a repeat within 24h returns the original post instead of posting again
allowDuplicatebooleanpost it even though an identical post was just made
textstring
titlestringa header line above the text
imageUrlstring
imageUrlsstring[]
videoUrlstring
altTextstringalt text for the images
slackWorkspacestringHERMOSO APP: which Slack workspace (id or name) when the app is in several
channelstringHERMOSO APP: channel id or #name (list_slack_channels); unset = the default channel
threadTsstringHERMOSO APP: reply in this thread (the parent message ts)
replyBroadcastbooleanHERMOSO APP: also show the thread reply in the channel
postAtstringHERMOSO APP: schedule in Slack itself for this ISO time (≤120 days, text only; use schedule_post for files)
unfurlLinksbooleanfalse = no link previews

THE HERMOSO SLACK + DISCORD APPS

list_slack_channels#

List Slack channels read-only

The Slack workspaces this brand added the Hermoso app to, and the channels in one of them (public, plus private ones the app was invited to with /invite @Hermoso), with the default channel marked. Use it before posting when the user has not named a channel, and to show them the choice. Free.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
slackWorkspacestringwhich workspace (id or name) when there are several
querystringfilter channel names

set_slack_channel#

Set the default Slack channel

Choose the DEFAULT Slack channel the Hermoso app posts to: post_to_slack, scheduled Slack posts and Autopilot use it whenever a post names no channel. Shared with everyone on the brand. Ask the user which channel; never pick one for them. Free.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
slackWorkspacestring
channelrequiredstringchannel id or #name from list_slack_channels

read_slack_channel#

Read a Slack channel read-only

Read a Slack channel's recent messages, newest first, for context — what the team is discussing before you write. A public channel is joined first if needed; a private one needs /invite @Hermoso. Free.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
slackWorkspacestring
channelstringchannel id or #name; unset = the default channel
limitnumberhow many, 1–200 (default 20)
oldeststringonly messages after this ts

list_discord_channels#

List Discord channels read-only

The Discord servers this brand added the Hermoso app to, and the channels in one of them (text, announcement and forum channels), with the default channel marked. Use it before posting when the user has not named a channel, and to show them the choice. Free.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
discordServerstringwhich server (id or name) when there are several
querystringfilter channel names

set_discord_channel#

Set the default Discord channel

Choose the DEFAULT Discord channel the Hermoso app posts to: post_to_discord, scheduled Discord posts and Autopilot use it whenever a post names no channel. Shared with everyone on the brand. Ask the user which channel; never pick one for them. Free.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
discordServerstring
channelrequiredstringchannel id or #name from list_discord_channels

read_discord_channel#

Read a Discord channel read-only

Read a Discord channel's recent messages, newest first, for context — what members are saying before you write. Other people’s text needs Discord’s Message Content intent; a message Discord withholds is marked contentHidden. Free.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
discordServerstring
channelstringchannel id or #name; unset = the default channel
limitnumberhow many, 1–100 (default 20)
beforestringpage back from this message id

edit_slack_message#

Edit a Slack message destructive

Change the text of a Slack message the Hermoso app posted (≤4,000 characters, Slack's edit limit). messageId is what post_to_slack returned ("<channel>/<ts>"). A message posted through a pasted webhook has no id and cannot be edited. Free.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
messageIdrequiredstring
textrequiredstring
slackWorkspacestring

delete_slack_message#

Delete a Slack message destructive

PERMANENTLY delete a Slack message the Hermoso app posted. Call it WITHOUT confirm first: nothing is deleted and you get a sentence quoting the message to show the user. Free.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
messageIdrequiredstring
slackWorkspacestring
confirmbooleantrue only once the user agreed

get_slack_message#

Read a Slack message read-only

Read back a Slack message the Hermoso app posted: text, link, REACTIONS and thread REPLIES — the engagement Slack exposes (there is no view count). Free.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
messageIdrequiredstring
slackWorkspacestring

list_slack_scheduled#

List messages scheduled in Slack read-only

Messages the Hermoso app scheduled IN SLACK (post_to_slack with postAt) that have not posted yet. Posts queued with schedule_post are in list_scheduled instead. Free.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
slackWorkspacestring
channelstring

cancel_slack_scheduled#

Cancel a message scheduled in Slack destructive

Cancel a message scheduled in Slack (from list_slack_scheduled). Slack refuses once it is within 60 seconds of posting. Free.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
slackWorkspacestring
channelrequiredstring
scheduledMessageIdrequiredstring

WHOP

post_to_whop#

Post to Whop destructive

Use this when the user wants to post into their WHOP (the community they sell on Whop). Publishing costs no credits. A FORUM post takes a title, Markdown text, images and video, pinning, members-only or public visibility, a paywall and a poll; a CHAT message takes text and files. It posts as the Hermoso app the whop installed. experienceId picks the forum or chat (list_whop_destinations names them); with one forum or chat it is used, with several and none named the call is refused and lists them. Mentions are OFF unless mention:true (it notifies every member). Returns the post id for edit_whop_post / delete_whop_post / get_whop_post. Not connected: the seller connects the whop from Hermoso (Connectors ▸ Whop) or presses Open Hermoso in the whop's dashboard; nothing is billed through Whop.

ParameterTypeDescription
brandstringWHICH PROFILE this post belongs to — id or exact name from list_brands (a shared workspace: its profile id). Beats the connection's pin for THIS CALL ONLY; a name that matches no profile, or two, is REFUSED and nothing is posted.
hookstringthe post's angle — a list_hooks id or your own wording, reused exactly
subjectstringwhat the post is about
recipestringthe post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format
ideaIdstringshort id of the content-plan idea this post came from
idempotencyKeystringany stable string: a repeat within 24h returns the original post instead of posting again
allowDuplicatebooleanpost it even though an identical post was just made
experienceIdstringthe forum or chat (exp_…) from list_whop_destinations
textstringthe post or message, Markdown
titlestringforum posts: the headline (required with a paywall)
imageUrlstring
imageUrlsstring[]up to 10 files
videoUrlstring
pinnedbooleanforum posts: pin to the top
mentionbooleanforum posts: notify every member (off by default)
visibility"members_only" | "globally_visible"forum posts: who can see it
paywallAmountnumberforum posts: price to unlock, e.g. 5 for $5 (needs a title)
paywallCurrencystringpaywall currency, default usd
pollstring[]2 to 10 poll options
replyTostringreply to this post or message id

list_whop_destinations#

List Whop forums and chats read-only

List the forums and chats in the brand's whop that Hermoso can post into, each with its experienceId and kind (forum or chat). Free.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.

list_whop_products#

Read Whop products read-only

Read the brand's Whop products as Hermoso sees them: names, prices and product photos, plus the brand description Hermoso drafts from them (draft_brand with that description sets the brand up). Free.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.

edit_whop_post#

Edit a Whop post destructive

Change a Whop forum post (text, title, pinned, visibility) or chat message (text, pinned) that Hermoso posted. Free.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
idrequiredstringthe id post_to_whop returned
kindrequired"forum" | "chat"
textstring
titlestring
pinnedboolean
visibility"members_only" | "globally_visible"

delete_whop_post#

Delete a Whop chat message destructive

PERMANENTLY delete a Whop CHAT message Hermoso posted. Call it WITHOUT confirm first: nothing is deleted and you get a sentence quoting the message to show the user. Whop's API cannot delete a FORUM post; asked to, it says so and points to Whop. Free.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
idrequiredstring
kindrequired"forum" | "chat"
confirmbooleantrue only once the user agreed

get_whop_post#

Read a Whop post read-only

Read back a Whop forum post or chat message Hermoso posted: its text, the numbers Whop reports (views, likes and comments on a forum post; views and reactions on a chat message), and whether it is still up — state live, deleted or unconfirmed. Whop's read also returns a DELETED chat message, so pass the chat's experienceId (from list_whop_destinations) to confirm a message is still in the chat; one deleted through Hermoso always reads deleted. A count Whop does not report is listed as unavailable, never zero. Free.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
idrequiredstring
kindrequired"forum" | "chat"
experienceIdstringthe chat it was posted in: confirms a chat message is still up

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 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. Support tickets too: the open conversations in the brand's Intercom inbox. 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. Each DM conversation folds to ONE item (the newest thing the other person said). Messenger and Instagram DMs need no argument; each has a messageKind (text, story_mention, story_reply, share, image, video...), a spoken summary, and unavailable:true when Meta withholds the content. Bluesky and X DMs need conversationId (list_bluesky_convos / list_x_dms). 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, google_business, x, bluesky, bluesky_mention, bluesky_dm, x_dm, telegram_dm, intercom. 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). Required for Bluesky and X DMs; on X reading one costs credits.
pageIdstringFacebook Page id (list_meta_pages) when the brand has several Pages connected: reads that Page's Messenger and Instagram DMs and comments only. Omit to read every connected Page.
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 DM sources and on Intercom (it goes to the customer from the brand's support inbox) — 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#

Hermoso models, options and credit costs read-only

Probe what this Hermoso account can do RIGHT NOW: available image/video model ids + their credit costs (estimates: a render is charged its actual cost), aspect ratios, video durations, the recipe ids, and the canEdit/canAvatar flags. Call it when you need a specific model id, a credit 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 credit balance, recent priced calls with their credits, 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 cost no credits on any plan (X is the single per-call exception; xCost gives its exact credits per post, with and without a link). Check before kicking off paid generation; answer "does posting cost credits?" with NO. HOW MANY posts is set by the plan, not credits: the Free plan includes 10 posts a month on up to 3 channels, and a post counts once however many channels it goes to (`posting` says how many are used); every paid plan posts without a limit and runs autopilot (Basic $9.99/mo: unlimited posting and Autopilot, no monthly credits; it includes the same 250+ free credits as the Free plan, monthly credits start on Starter, and Autopilot spends credits). Starter, Pro and Studio: media models at cost + 10% (memberRate); Basic and Free pay standard prices.

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 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 profile 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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
postIdrequiredstringpost/media id returned by post_to_meta
target"facebook" | "instagram"which metric set to ask for; an Instagram media id is detected and read as Instagram either way (default: Facebook for a Facebook id)
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 this_month = the last 30 days). engaged_audience_demographics takes ONLY this_month or this_week — Meta retired the other four for it from API v20; follower_demographics takes all six
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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
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

list_meta_stories#

List the Stories live right now read-only

The Stories live right now on the connected Instagram accounts and Facebook Pages: id, caption (Instagram), posted time, hours left and permalink, and on Instagram `insights`: reach, views, replies, link clicks and navigation (forward, back, next story, exit). Use it to check that a post or reel really went to Stories, or to match a story id against what is live. includeEnded also returns `ended`, the Stories that have ended with the numbers kept from before they ended. Meta marks nothing on a Story to say it is a reshared reel, so `origin` appears only when the share went through Hermoso, and says how it was found. Meta serves a Story for 24 hours and leaves live video and reshared stories out of this list. A source that could not be read comes back in `unreadable`, never as an empty list. Read-only, 0 credits.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
channel"instagram" | "facebook" | "all"default all
accountstringone Instagram account: an @handle or id
pageIdstringone Facebook Page, and its linked Instagram
limitnumberrows, newest first (1-100, default 25)
includeEndedbooleanalso list Stories that ended, with their kept numbers
daysnumberhow far back includeEnded goes (1-400, default 30)

instagram_story_insights#

An Instagram Story’s numbers, kept past its 24 hours read-only

An Instagram Story’s numbers: reach, views, replies, shares, link clicks, profile visits, follows, and navigation (forward, back, next story, exit). Meta keeps them for 24 hours, so Hermoso reads every live Story on a schedule, including ones posted from the phone, and they stay readable here after the Story ends. Pass storyId for one, or nothing for the recent ones. `state` says what is known: numbers, below_minimum (Meta withholds them under 5 viewers), not_captured (it ended before a read), or why a read failed; none of those means zero. Meta returns 0 replies for Stories created in Europe and Japan, and the answer says so. Link clicks need a Facebook-login connection. Read-only, 0 credits.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
storyIdstringone Story id, from list_meta_stories
accountstringone Instagram account: an @handle or id
pageIdstringone Facebook Page, for its linked Instagram
daysnumberhow far back (1-400, default 30)
limitnumberrows, newest first (1-100, default 25)

get_meta_story#

Look up one Story by id read-only

What can be known about one story id, usually from a story_reply or story_mention item in list_inbox: its caption, posted time and permalink while it is live, or that it has expired. `state` is live, readable, not_a_story, expired, not_found or unknown. Meta serves a Story for 24 hours and answers an expired one with the same error as a wrong id, so pass repliedAt (the item’s `at`) and the url so an expiry can be stated and a match found. unknown means a read failed, not that the Story is gone. Read-only, 0 credits.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
storyIdstringthe story id from the inbox item, or a media id
urlstringthe story url from the same inbox item
repliedAtstringwhen the reply arrived, the item’s `at`; a reply older than 24 hours means the Story has expired
accountstringone Instagram account: an @handle or id
pageIdstringone Facebook Page

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. Instagram offers this library only to an account linked to a Facebook Page and connected through Meta; an account connected through Instagram Login alone has none, and this says so. TO USE ONE, pass its `id` as `audioId` on post_to_meta or schedule_post (target instagram, a video): Instagram puts the track under the Reel itself. 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 destructive

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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
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 or Instagram post

Leave a NEW top-level comment on one of the brand’s own posts — a Facebook Page post OR an Instagram post/Reel — 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, and an Instagram caption link is not clickable), 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 (target:"instagram" for Instagram media ids). INSTAGRAM: text only (Meta documents no attachment on that edge, so attachmentUrl is refused by name), not on live videos; pass account for an Instagram-Login account or one of several. Free.

ParameterTypeDescription
postIdrequiredstringthe Facebook Page post id or Instagram media id — from list_meta_posts, or returned by post_to_meta
messagestringthe comment text. Required unless attachmentUrl is given.
attachmentUrlstringFACEBOOK ONLY — optional public image URL to post as the comment’s attachment (refused on Instagram)
pageIdstringPage id — omit when only one Page is connected
accountstringINSTAGRAM — which account, an @handle or id from list_connector_accounts("instagram"); needed for an Instagram Login account or when several are linked

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
pageIdstringFacebook Page id when the brand's Instagram is linked to a Page; from list_meta_pages
accountstringdirect Instagram account (@handle or id) when several are connected; from list_connector_accounts

INSTAGRAM LIKE / UNLIKE

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. With private:true it instead sends ONE private message to the commenter (Messenger / Instagram Direct) — once per comment, within 7 days.

ParameterTypeDescription
privatebooleantrue = send ONE PRIVATE message to the person who left the comment (Messenger / Instagram Direct) instead of a public reply — the way to answer "price?" or "DM me". Meta allows one per comment, within 7 days of it.
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. Hide is the default and can be undone. 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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
limitnumberhow many posts (1–50, default 15)
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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)
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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)
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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)
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

hide_thread_reply#

Hide or unhide a Threads reply destructive

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)
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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.

ParameterTypeDescription
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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)
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
searchMode"KEYWORD" | "TAG"KEYWORD (default) or TAG — TAG searches a TOPIC TAG (what is being said under #skincare)
mediaType"TEXT" | "IMAGE" | "VIDEO"TEXT, IMAGE or VIDEO — only that kind of post
sincestringonly posts on/after this date (YYYY-MM-DD, ISO or unix seconds; Threads search reaches back to 2023-07-05)
untilstringonly posts on/before this date
limitintegerhow many results (1–100, default 25)
authorUsernamestringonly posts by this Threads account (exact username, no @) — 'what has @competitor said about X'
qrequiredstringkeyword or phrase
searchType"TOP" | "RECENT"TOP (default) or RECENT
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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. Kept in the Library. THIS IS THE BRING-YOUR-OWN-CREATIVE PATH: it is for files that have NOTHING to do with a Hermoso render (media on the user's desktop, an agency's finished ad, a photo they shot), and it means you can publish, schedule and run ads through Hermoso without generating anything here. Provide exactly ONE source — passing two is an error, never a silent preference: `url` (ANY public http(s) link — Hermoso fetches it server-side, so nothing crosses this connection and there is no practical size limit; THIS IS THE ONE THAT ALWAYS WORKS, including on the hosted connector), `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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

WHATSAPP BUSINESS

list_whatsapp_accounts#

WhatsApp Business accounts and numbers read-only

The WhatsApp Business Accounts SHARED WITH THIS PROFILE 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 PROFILE ARE REACHABLE. One Meta login often administers WhatsApp for several businesses, so a human ticks which belong to this profile 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 profile; 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”. Pass `mediaUrl` on that header (a public JPEG/PNG for IMAGE, MP4 for VIDEO, PDF for DOCUMENT) and Hermoso uploads it through Meta's Resumable Upload API and fills the handle; a header with neither is refused locally and by name. 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 profile; 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. AUTHENTICATION templates have their OWN shape (Meta presets the text): [{ type: "BODY", add_security_recommendation: true }, { type: "FOOTER", code_expiration_minutes: 10 }, { type: "BUTTONS", buttons: [{ type: "OTP", otp_type: "COPY_CODE", text: "Copy code" }] }] — no body `text` (refused, never dropped), expiry 1–90 minutes, otp_type COPY_CODE or ONE_TAP (ONE_TAP also needs supported_apps: [{ package_name, signature_hash }], up to 5).
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 profile; 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 — or, for someone who reached the business by WhatsApp username and shared no number, their business-scoped user id exactly as received (e.g. US.13491208655302741918). Authentication templates need the phone number.
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 destructive

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. Warning: 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 business-scoped user ids, e.g. US.13491208655302741918, for people who reached you by username), 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 destructive

Use this when the user wants to post now 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, an ad made here or the user’s own 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. 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 PROFILE this post belongs to — id or exact name from list_brands (a shared workspace: its profile id). Beats the connection's pin for THIS CALL ONLY; a name that matches no profile, or two, is REFUSED and nothing is posted.
hookstringWHAT ANGLE this post is built on, recorded only at publish time. post_performance ranks hooks on it (a winner needs 5 posts sharing ONE hook), so pass a list_hooks id (e.g. "direct_callout", "before_after") or reuse your own wording EXACTLY across a campaign. Omit it and this post never votes on which hook works.
subjectstringWHAT THIS POST IS ABOUT — product, feature, offer or theme (e.g. "winter coat", "free trial"). post_performance's second grouping axis: reuse the exact wording, as with hook.
recipestringthe post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format
ideaIdstringshort id of the content-plan idea this post came from
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–20 (images, videos or a mix), Threads 2–20 (mix allowed), Facebook 2+ (Meta publishes no documented maximum; Hermoso caps the upload fan-out at 30 and says so), LinkedIn company Pages 2–20 (images only), Pinterest 2–5 (images only), TikTok up to 35. One url here is simply an ordinary single post. Anything a channel cannot do is REFUSED with the real reason — nothing is ever quietly downgraded to one slide.
idempotencyKeystringSAFE RETRIES — any stable string: a repeat of the SAME publish within 24h returns the ORIGINAL post id instead of posting again (an identical publish is also auto-recognised for 10 minutes). On a timeout or an ambiguous error, CALL AGAIN WITH THE SAME KEY: it reports the original post or publishes it once. It never posts twice.
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. 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.
userTagsstring | object[]INSTAGRAM USER TAGS — tag people (public accounts) on the post; each is notified. Usernames, or { username, x, y } with x/y from 0 to 1 = where the tag sits on the IMAGE (a tag without a position sits at the centre). A Reel tag takes no position; a story position is optional. Not on a carousel (refused by name).
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 pageId from search_meta_places, 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 pageId from search_meta_places: the NUMERIC ID OF THE FACEBOOK PAGE for that place, not a place name and not coordinates.
placeNamestringthe name the user saw for that place; a different business is refused
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.
coverAtMsnumberTHE VIDEO COVER for every target of this post, as ONE frame: milliseconds from the start (7000 = the frame at 7s). Instagram gets it as thumb_offset, Facebook as its uploaded cover (a Reel’s preferred thumbnail). Instagram’s own thumbOffset, or a Hermoso-hosted coverUrl, also becomes the Facebook cover.
instagramPollstring[]INSTAGRAM — attach a POLL: 2 to 4 answers of 1–25 characters each, and the caption IS the question (so a caption is required). Feed photos, carousels and Reels only (never a story), one caption add-on per post (not with instagramCommentPrompt), and only on an account connected through Meta (a Facebook Page with a linked Instagram). WRITE-ONCE: Instagram cannot add, change or remove it after publishing, so show the user the answers first.
instagramPollExtendedbooleanINSTAGRAM — give that poll Instagram’s extended voting duration instead of its default 3 days (Instagram’s changelog says it then stays open indefinitely). Only with instagramPoll.
instagramCommentPromptbooleanINSTAGRAM — make the caption a COMMENT PROMPT that people answer in the comments. Same limits as instagramPoll, and never together with it.
firstCommentstringFIRST COMMENT — posted on the new post as the same account right after it publishes (Facebook or Instagram; not Threads, not a story, not an unpublished post). Up to 2,000 characters, refused rather than cut. If the comment fails the post stays live and firstComment in the result says why.
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.
audioIdstringINSTAGRAM REEL — put one of Instagram’s OWN licensed music tracks under the Reel: the `id` search_instagram_audio returns. Instagram mixes it in when the Reel is published; nothing is downloaded or re-rendered. REELS ONLY, and only on an Instagram account connected through Meta (a Facebook Page with a linked Instagram), which is Instagram’s own rule — the Instagram connector cannot take it and is refused by name.
audioVolumeintegerINSTAGRAM REEL — how loud that track plays, 0–100 (Instagram’s default 100). Needs audioId.
videoVolumeintegerINSTAGRAM REEL — how loud the video’s own sound plays under the track, 0–100 (Instagram’s default 100; 0 = the track alone). Needs audioId.
paidPartnershipbooleanINSTAGRAM — the PAID PARTNERSHIP label. A COMPLIANCE DECLARATION: set it whenever the post is sponsored, gifted or otherwise paid for. Opt-in and never inferred — it is the poster’s own statement about their commercial relationship.
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 OR FACEBOOK PAGE 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. Link stickers are not available on a Story posted here. An Instagram Story keeps its caption: people see the start under the Story in the mobile app and tap for the rest. It takes NO collaborators, product tags or trialReel — any of those is refused by name and nothing is posted. FACEBOOK PAGE STORIES WORK TOO (target facebook): one image, or one vertical 9:16 video of 3–90 seconds, no caption (a message is refused by name), not schedulable natively — use schedule_post to queue one; the reply reads the story back from the Page.
aiGeneratedbooleanINSTAGRAM / FACEBOOK REEL / THREADS — Meta’s is_ai_generated self-disclosure (Threads: is_made_with_ai, the AI info label; a text-only Threads post is declared only when you pass true). 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 — use that on a multi-slide post, because one sentence read out over six different pictures is wrong for five of them. More descriptions than pictures is refused rather than dropped. WHERE IT LANDS, per Meta’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 when the brand has one Page. With several and none named the call refuses and lists them by name and id: ask the user which Page, never pick one yourself.
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.
threadsLongTextstringTHREADS ONLY — LONG-FORM text, up to 10,000 characters, attached under a TEXT-only Threads post (Threads' text attachment). The way to publish more than Threads' 500-character post: keep a short lead in the message and put the full text here. Not with an image, video or carousel.
threadsGhostPostbooleanTHREADS ONLY — a GHOST post: text only, Threads archives it automatically after 24 hours (a flash offer, a 24h-only note). No other Threads option can ride it.
threadsGifstringTHREADS ONLY — a GIPHY GIF id (the id in a giphy.com/gifs/… link) attached to a TEXT Threads post. GIPHY is the only provider Threads takes.
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. Warning: 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.
platformCoverbooleanVIDEO COVER. Omit it (the default) and Hermoso sets the video’s best frame — the same frame as its Library thumbnail — as the cover (Instagram Reel: thumb_offset; Facebook video/Reel: an uploaded cover image) — and on Threads, which has no cover setting, a blank first frame is replaced on a copy sent to Threads only. true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins.

SCHEDULING

schedule_post#

Schedule a post for later destructive

Use this when the user wants to schedule a social media post for later, on one channel or several at once. Scheduling and publishing cost no credits on any Hermoso plan (X alone bills per call: hermoso_credits gives the exact credits per X post). THE FREE PLAN INCLUDES 10 POSTS A MONTH ON UP TO 3 CHANNELS (a post counts once, however many channels it goes to; counted in the UTC month the post goes out); every paid plan posts without a limit. A post over that allowance is refused here and nothing is queued; the refusal says how many are used and when the count resets. Relay it as written, never retry it and never split the post to fit. billing_status and list_scheduled show how many are used. Queue a post for a future time on one or more connected channels at once: facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, bluesky, telegram, wordpress, discord, slack — every one live. whop works where its connector is offered (list_connectors says) and is held back elsewhere: there a whop post is refused, not queued. A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order — never schedule just its first slide. Name the time in `at`, or pass `useQueue:true` for the brand’s next free POSTING SLOT (what “just queue it” means). imageUrl/videoUrl take a Hermoso render URL or an upload_file URL. `captions` gives a channel its own wording; the rest use `message`. PINTEREST AND YOUTUBE SHOW A TITLE: `title` (max 100 chars), derived from the caption when omitted; YOUTUBE also takes `description`, `tags`, `thumbnailUrl`. Every PER-CHANNEL SETTING is a parameter below, carried straight to the real publisher — TikTok’s `brandedContent` / `yourBrand` disclosures (set them whenever the post is commercial), Google Business `topicType` / `event` / `offer` / `actionType`, an X `thread` / `poll`, Instagram `collaborators`, and the rest. Channels are attempted INDEPENDENTLY: one failing never blocks the others. SOME CHANNELS MUST BE TOLD WHICH ACCOUNT, and Hermoso never guesses: Pinterest needs `boardId` (list_pinterest_boards) or is refused; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages) or it goes to the person’s own profile. Several Facebook Pages need `pageId` (list_meta_pages): a Facebook post on a brand with several Pages and none named is refused when you schedule it, with the Pages listed by name and id, so ask the user which one. Several Google Business listings need `locationId` (list_business_locations), and without it that post is refused when it fires. Resolve those FIRST and let the user pick. A scheduled post GOES LIVE PUBLICLY by default on every channel, never quietly downgraded. Only if the user asks, set `visibility` (or `visibilityByChannel`): ‘unlisted’ (YouTube) · ‘private’ (YouTube, or TikTok SELF_ONLY) · ‘draft’ (TikTok, or an unpublished Facebook Page post). A visibility a channel cannot do is REFUSED now, never posted weaker later.

ParameterTypeDescription
brandstringWHICH PROFILE this post belongs to — id or exact name from list_brands (a shared workspace: its profile id). Beats the connection's pin for THIS CALL ONLY; a name that matches no profile, or two, is REFUSED and nothing is posted.
hookstringWHAT ANGLE this post is built on, recorded only at publish time. post_performance ranks hooks on it (a winner needs 5 posts sharing ONE hook), so pass a list_hooks id (e.g. "direct_callout", "before_after") or reuse your own wording EXACTLY across a campaign. Omit it and this post never votes on which hook works.
subjectstringWHAT THIS POST IS ABOUT — product, feature, offer or theme (e.g. "winter coat", "free trial"). post_performance's second grouping axis: reuse the exact wording, as with hook.
recipestringthe post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format
ideaIdstringshort id of the content-plan idea this post came from
channelsrequired"facebook" | "instagram" | "threads" | "tiktok" | "youtube" | "linkedin" | "x" | "pinterest" | "google_business" | "bluesky" | "telegram" | "wordpress" | "discord" | "slack" | "whop"[]one or more channels to post to at that time
atstringwhen to post: local wall clock, no offset (2026-10-30T10:00), read in `timezone` or the brand’s zone ON THAT DATE so a series survives a clock change; or UTC (…Z) / epoch ms. 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 profile 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 (e.g. "Africa/Cairo") a wall-clock `at` or the queue is read in; overrides the brand’s saved zone for this one post. Without either, 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–20, 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.
paidProductPlacementbooleanYOUTUBE: paid product placement (shows the disclosure)
brandPartnerstringYOUTUBE: sponsoring brand channel (UC… or @handle)
replyControl"everyone" | "accounts_you_follow" | "mentioned_only" | "parent_post_author_only" | "followers_only"THREADS ONLY — who may reply. Omit for Threads' own default (everyone).
threadsLongTextstringTHREADS ONLY — LONG-FORM text, up to 10,000 characters, attached under a TEXT-only Threads post (Threads' text attachment). The way to publish more than Threads' 500-character post: keep a short lead in the message and put the full text here. Not with an image, video or carousel.
threadsGhostPostbooleanTHREADS ONLY — a GHOST post: text only, Threads archives it automatically after 24 hours (a flash offer, a 24h-only note). No other Threads option can ride it.
threadsGifstringTHREADS ONLY — a GIPHY GIF id (the id in a giphy.com/gifs/… link) attached to a TEXT Threads post. GIPHY is the only provider Threads takes.
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 — use that on a multi-slide post, because one sentence read out over six different pictures is wrong for five of them. More descriptions than pictures is refused rather than dropped. 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 Hermoso uses the video’s best frame (platformCover:true leaves it to TikTok, which 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.
userTagsstring | object[]INSTAGRAM USER TAGS — tag people (public accounts) on the post; each is notified. Usernames, or { username, x, y } with x/y from 0 to 1 = where the tag sits on the IMAGE (a tag without a position sits at the centre). A Reel tag takes no position; a story position is optional. Not on a carousel (refused by name).
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 pageId from search_meta_places: the NUMERIC ID OF THE FACEBOOK PAGE for that place, not a place name and not coordinates.
placeNamestringthe name the user saw for that place; a different business is refused
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.
coverAtMsnumberTHE VIDEO COVER on EVERY channel of this post, as ONE frame: milliseconds from the start (7000 = the frame at 7s). Instagram, TikTok, Facebook, LinkedIn Page, Pinterest, Telegram and YouTube all get that frame; X, Threads and Bluesky have no cover setting. A channel’s own field (thumbOffset, coverTimestampMs, coverUrl) wins there and otherwise counts as this.
coverImageUrlstringTHE VIDEO COVER as a picture instead of a frame — a Hermoso-hosted image (upload_file). Every channel that takes a cover image gets it (Instagram, Facebook, LinkedIn Page, Pinterest, Telegram, YouTube); TikTok takes only a frame. Never together with coverAtMs.
instagramPollstring[]INSTAGRAM — attach a POLL: 2 to 4 answers of 1–25 characters each, and the caption IS the question (so a caption is required). Feed photos, carousels and Reels only (never a story), one caption add-on per post (not with instagramCommentPrompt), and only on an account connected through Meta (a Facebook Page with a linked Instagram). WRITE-ONCE: Instagram cannot add, change or remove it after publishing, so show the user the answers first.
instagramPollExtendedbooleanINSTAGRAM — give that poll Instagram’s extended voting duration instead of its default 3 days (Instagram’s changelog says it then stays open indefinitely). Only with instagramPoll.
instagramCommentPromptbooleanINSTAGRAM — make the caption a COMMENT PROMPT that people answer in the comments. Same limits as instagramPoll, and never together with it.
firstCommentstringFIRST COMMENT — posted on each new post as the same account right after it publishes, on Facebook, Instagram and LinkedIn company Pages (LinkedIn needs its comments authorization, provider linkedin_community). Other channels publish without it and their result row says so. Up to 2,000 characters, refused rather than cut; never on a story. A failed comment never undoes the post.
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.
audioIdstringINSTAGRAM REEL — put one of Instagram’s OWN licensed music tracks under the Reel: the `id` search_instagram_audio returns. Instagram mixes it in when the Reel is published; nothing is downloaded or re-rendered. REELS ONLY, and only on an Instagram account connected through Meta (a Facebook Page with a linked Instagram), which is Instagram’s own rule — the Instagram connector cannot take it and is refused by name.
audioVolumeintegerINSTAGRAM REEL — how loud that track plays, 0–100 (Instagram’s default 100). Needs audioId.
videoVolumeintegerINSTAGRAM REEL — how loud the video’s own sound plays under the track, 0–100 (Instagram’s default 100; 0 = the track alone). Needs audioId.
instagramLocationIdstringINSTAGRAM — tag a place (called locationId on post_to_meta; locationId here is the Google Business listing). It is the pageId from search_meta_places, 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 OR FACEBOOK PAGE 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. Link stickers are not available on a Story posted here. An Instagram Story keeps its caption: people see the start under the Story in the mobile app and tap for the rest. It takes NO collaborators, product tags or trialReel — any of those is refused by name and nothing is posted. FACEBOOK PAGE STORIES WORK TOO (target facebook): one image, or one vertical 9:16 video of 3–90 seconds, no caption (a message is refused by name), not schedulable natively — use schedule_post to queue one; the reply reads the story back from the Page.
aiGeneratedbooleanAI-CONTENT DISCLOSURE (Instagram / Facebook Reel is_ai_generated, TikTok is_aigc, YouTube containsSyntheticMedia, Pinterest ai_disclosures, Threads is_made_with_ai). OMIT IT and Hermoso decides from provenance: a Hermoso render is declared AI-generated, media that came through upload_file or from an external URL (the user’s own photos or footage) is NOT. Pass true or false only to override.
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 (@yourchannel) 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.
whopExperienceIdstringWHOP — which forum or chat in the whop (exp_…, from list_whop_destinations). Omit it when the whop has only one; with several and none named the post is refused at fire time, never guessed.
whopPinnedbooleanWHOP FORUM POSTS ONLY — pin it to the top of the forum.
whopMentionbooleanWHOP FORUM POSTS ONLY — notify every member (off by default).
whopVisibility"members_only" | "globally_visible"WHOP FORUM POSTS ONLY — who can see it; unset is the forum’s own default.
whopPaywallCentsintegerWHOP FORUM POSTS ONLY — a paywall in cents (500 = $5.00): readers pay to see the body; needs a title.
whopPaywallCurrencystringWHOP — the paywall currency, a 3-letter code (default usd).
whopPollstring[]WHOP FORUM POSTS ONLY — a poll: 2 to 10 options, in order.
buttonsobject | object[][]TELEGRAM ONLY — URL buttons under the post, as post_to_telegram takes them; never on an album.
parseMode"HTML"TELEGRAM ONLY — 'HTML' for Telegram's HTML tags; omit for plain text.
silentbooleanTELEGRAM / DISCORD — no notification.
disablePreviewbooleanTELEGRAM ONLY — no link preview.
slackChannelstringSLACK app: channel id or #name (default: the brand's)
slackWorkspacestringSLACK app: which workspace
slackThreadTsstringSLACK app: reply in this thread (parent ts)
discordChannelstringDISCORD bot: channel id or #name (default: the brand's)
discordServerstringDISCORD bot: which server
discordReplyTostringDISCORD bot: reply to this message id
discordStartThreadstringDISCORD bot: open a thread under the post, this title
discordMentionsbooleanDISCORD: let mentions ping
threadNamestringDISCORD: forum thread title
wpCategoriesstring[]WORDPRESS — categories (names or ids)
wpTagsstring[]WORDPRESS — tags (names or ids)
excerptstringWORDPRESS — the excerpt under the title
wpSlugstringWORDPRESS — the post slug
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 profile has more than one Page connected: a Facebook post with several and no id is refused WHEN YOU SCHEDULE IT, with the Pages listed by name and id, so ask the user which Page and schedule again with its id. It is never sent from a guessed Page.
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.
platformCoverbooleanVIDEO COVER. Omit it (the default) and Hermoso sets the video’s best frame — the same frame as its Library thumbnail — as the cover on every channel that allows one (Instagram, Facebook, TikTok direct posts, LinkedIn Pages, Pinterest, Telegram, YouTube) — and on X, Threads and Bluesky, which have none, a blank first frame is replaced on a copy sent there only. true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins.

WHICH ACCOUNT

list_scheduled#

List scheduled and past posts read-only

Use this when the user asks what is scheduled, what already posted, or whether a post went out. 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 PROFILE to list — the id or exact name from list_brands. Needed when the post lives in a profile this connection is not pinned to: a post you can CREATE in a profile must be manageable there too, without switching the whole connection. A name that matches no profile, 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 destructive

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 PROFILE this post is in — id or exact name from list_brands; needed when it is not the profile this connection is pinned to. A name that matches no profile, or two, is REFUSED.
recipestringthe post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format
ideaIdstringshort id of the content-plan idea this post came from
idrequiredstringthe scheduled post id from list_scheduled
atstringthe new time: local wall clock (2026-10-30T10:00, read in `timezone` or the brand’s zone on that date), UTC (…Z) or epoch ms. Future, at most 365 days out.
timezonestringIANA zone for a wall-clock `at`
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" | "wordpress" | "discord" | "slack" | "whop"[]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.
paidProductPlacementbooleanYOUTUBE: paid product placement (shows the disclosure) — false removes it
brandPartnerstringYOUTUBE: sponsoring brand channel (UC… or @handle); "" removes it
replyControl"everyone" | "accounts_you_follow" | "mentioned_only" | "parent_post_author_only" | "followers_only"THREADS ONLY — who may reply.
threadsLongTextstringTHREADS ONLY — LONG-FORM text, up to 10,000 characters, attached under a TEXT-only Threads post (Threads' text attachment). The way to publish more than Threads' 500-character post: keep a short lead in the message and put the full text here. Not with an image, video or carousel.
threadsGhostPostbooleanTHREADS ONLY — a GHOST post: text only, Threads archives it automatically after 24 hours (a flash offer, a 24h-only note). No other Threads option can ride it.
threadsGifstringTHREADS ONLY — a GIPHY GIF id (the id in a giphy.com/gifs/… link) attached to a TEXT Threads post. GIPHY is the only provider Threads takes.
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.
linkstringdestination URL for a Pinterest Pin, Facebook text post or Google Business post
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.
userTagsstring | object[]INSTAGRAM USER TAGS — tag people (public accounts) on the post; each is notified. Usernames, or { username, x, y } with x/y from 0 to 1 = where the tag sits on the IMAGE (a tag without a position sits at the centre). A Reel tag takes no position; a story position is optional. Not on a carousel (refused by name).
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.
coverAtMsnumberTHE VIDEO COVER on every channel, as one frame in milliseconds (see schedule_post).
coverImageUrlstringTHE VIDEO COVER on every channel, as a Hermoso-hosted picture (see schedule_post).
instagramPollstring[]INSTAGRAM — attach a POLL: 2 to 4 answers of 1–25 characters each, and the caption IS the question (so a caption is required). Feed photos, carousels and Reels only (never a story), one caption add-on per post (not with instagramCommentPrompt), and only on an account connected through Meta (a Facebook Page with a linked Instagram). WRITE-ONCE: Instagram cannot add, change or remove it after publishing, so show the user the answers first.
instagramPollExtendedbooleanINSTAGRAM — give that poll Instagram’s extended voting duration instead of its default 3 days (Instagram’s changelog says it then stays open indefinitely). Only with instagramPoll.
instagramCommentPromptbooleanINSTAGRAM — make the caption a COMMENT PROMPT that people answer in the comments. Same limits as instagramPoll, and never together with it.
firstCommentstringFIRST COMMENT — posted on each new post as the same account right after it publishes, on Facebook, Instagram and LinkedIn company Pages (LinkedIn needs its comments authorization, provider linkedin_community). Other channels publish without it and their result row says so. Up to 2,000 characters, refused rather than cut; never on a story. A failed comment never undoes the post.
shareToFeedbooleanINSTAGRAM REEL — whether the Reel also shows in the Feed grid.
audioNamestringINSTAGRAM REEL — replaces the audio track name; an empty string removes it.
audioIdstringINSTAGRAM REEL — put one of Instagram’s OWN licensed music tracks under the Reel: the `id` search_instagram_audio returns. Instagram mixes it in when the Reel is published; nothing is downloaded or re-rendered. REELS ONLY; an empty string removes it, and only on an Instagram account connected through Meta (a Facebook Page with a linked Instagram), which is Instagram’s own rule — the Instagram connector cannot take it and is refused by name.
audioVolumeintegerINSTAGRAM REEL — how loud that track plays, 0–100 (Instagram’s default 100). Needs audioId.
videoVolumeintegerINSTAGRAM REEL — how loud the video’s own sound plays under the track, 0–100 (Instagram’s default 100; 0 = the track alone). Needs audioId.
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.
placeNamestringthe name the user saw for that place; a different business is refused
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.
aiGeneratedbooleanAI-CONTENT DISCLOSURE (Instagram / Facebook Reel is_ai_generated, TikTok is_aigc, YouTube containsSyntheticMedia, Pinterest ai_disclosures, Threads is_made_with_ai). OMIT IT and the value already on the post stays; a post that never had one is decided from provenance at publish: a Hermoso render is declared AI-generated, media that came through upload_file or from an external URL (the user’s own photos or footage) is NOT. Pass true or false only to override.
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.
whopExperienceIdstringWHOP — move it to a different forum or chat (exp_…, from list_whop_destinations).
buttonsobject | object[][]TELEGRAM ONLY — replace the URL buttons ([] removes them).
parseMode"HTML"TELEGRAM ONLY — 'HTML' for Telegram's HTML tags.
silentbooleanTELEGRAM / DISCORD — no notification.
disablePreviewbooleanTELEGRAM ONLY — no link preview.
slackChannelstringSLACK app: channel id or #name (default: the brand's)
slackWorkspacestringSLACK app: which workspace
slackThreadTsstringSLACK app: reply in this thread (parent ts)
discordChannelstringDISCORD bot: channel id or #name (default: the brand's)
discordServerstringDISCORD bot: which server
discordReplyTostringDISCORD bot: reply to this message id
discordStartThreadstringDISCORD bot: open a thread under the post, this title
discordMentionsbooleanDISCORD: let mentions ping
threadNamestringDISCORD: forum thread title
whopPinnedbooleanWHOP: pin the forum post
whopMentionbooleanWHOP: notify every member
whopVisibility"members_only" | "globally_visible"WHOP: who can see the forum post
whopPaywallCentsintegerWHOP: forum-post paywall in cents (needs a title)
whopPaywallCurrencystringWHOP: paywall currency
whopPollstring[]WHOP: poll options, 2 to 10
wpCategoriesstring[]WORDPRESS — categories (names or ids)
wpTagsstring[]WORDPRESS — tags (names or ids)
excerptstringWORDPRESS — the excerpt under the title
wpSlugstringWORDPRESS — the post slug
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
visibilityByChannelobjectper-channel visibility override, e.g. {tiktok: draft}; values public, unlisted, private or draft
platformCoverbooleanVIDEO COVER. Omit it (the default) and Hermoso sets the video’s best frame — the same frame as its Library thumbnail — as the cover on every channel that allows one — and on X, Threads and Bluesky, which have none, a blank first frame is replaced on a copy sent there only. true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins.

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 PROFILE this post is in — id or exact name from list_brands; needed when it is not the profile this connection is pinned to. A name that matches no profile, or two, is REFUSED.

RETRY + DUPLICATE

retry_scheduled#

Retry a failed scheduled post destructive

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 PROFILE this post is in — id or exact name from list_brands; needed when it is not the profile this connection is pinned to. A name that matches no profile, or two, is REFUSED.
idrequiredstringthe scheduled post id from list_scheduled
channels"facebook" | "instagram" | "threads" | "tiktok" | "youtube" | "linkedin" | "x" | "pinterest" | "google_business" | "bluesky" | "telegram" | "wordpress" | "discord" | "slack" | "whop"[]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.
whopExperienceIdstringWHOP — correct the forum or chat on retry (exp_…, from list_whop_destinations).
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 destructive

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 PROFILE this post is in — id or exact name from list_brands; needed when it is not the profile this connection is pinned to. A name that matches no profile, or two, is REFUSED.
idrequiredstringthe post to copy, from list_scheduled
recipestringthe post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format
ideaIdstringshort id of the content-plan idea this post came from
atstringwhen the copy goes out: local wall clock (2026-10-30T10:00), UTC or epoch ms (default: an hour from now)
useQueuebooleaninstead of naming a time, take the brand’s next free posting slot
timezonestringIANA zone for a wall-clock `at` or the queue
channels"facebook" | "instagram" | "threads" | "tiktok" | "youtube" | "linkedin" | "x" | "pinterest" | "google_business" | "bluesky" | "telegram" | "wordpress" | "discord" | "slack" | "whop"[]post the copy to these channels instead of the original’s
messagestringa different caption for the copy
captionsobjectper-channel caption overrides for the copy
imageUrlstringreplacement image for the copy: a Hermoso render URL or an upload_file URL
videoUrlstringreplacement video for the copy: a Hermoso render URL or an upload_file URL
imageUrlsstring[]CAROUSEL — an ORDERED list of image URLs published as ONE swipeable post
titlestringPinterest or YouTube headline for the copy, max 100 characters
linkstringdestination URL for the copy (Pinterest, Facebook text post or Google Business post)
boardIdstringPINTEREST — the board for the copy (list_pinterest_boards)
chatIdstringTELEGRAM — which chat, group or channel the copy goes to (@username or numeric id)
whopExperienceIdstringWHOP — which forum or chat the copy goes to (exp_…, from list_whop_destinations).
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"public (default), unlisted or private (YouTube), or draft (TikTok or an unpublished Facebook Page post)

THE POSTING REFILL / AUTOPOSTING

get_post_refill#

Autopilot posting status read-only

Show AUTOPILOT POSTING (autoposting, the posting refill) for this profile — "is autopilot on?", "what is waiting for review?": whether it is on, its mode (review = each batch of fresh posts waits as drafts for approval; auto = fresh posts are scheduled straight away), WHICH CHANNELS it posts to with each one’s posts per day and posting times, roughly what it costs a day, when it next runs, how many posts are queued, the DRAFTS waiting for approval (id, time, channels, caption, the new image or video), and the notes it learned from reviews. It also names the channels that CANNOT be posted to and why. On the Free plan, paidOnly is true and note explains the status. Read-only, free.

No parameters.

set_post_refill#

Set up, change or pause autopilot posting destructive

Turn AUTOPILOT POSTING (autoposting, the posting refill) on, change it or pause it. Available on paid plans: on the Free plan switching it on is refused, so relay that and do not retry. Examples: "post 2 times a day on Instagram and Bluesky about …, let me review first", "make it 1 a day", "switch to auto-publish", "pause autopilot". PASS ONLY WHAT CHANGES. It always makes NEW posts (a fresh image, carousel or video for each) and never re-posts the Library. WHERE: every connected channel by default (channels narrows it); HOW OFTEN: the brand’s posting times, and channelPostsPerDay sets fewer on a channel (e.g. {"x":1}). There are no credit limits to set — get_post_refill shows roughly what it costs a day. Switching it on is refused when no channel is connected or nothing a day would be made. SWITCHING IT ON ASKS THE BRIEF ONCE: a goal (followers / sales / launch / other) is required; content pillars (get_post_refill suggests some from the brand), formats (mix / video / carousel / image) and an avoid list are optional. Posts are written from the brief, the brand profile, what has worked on each channel (compared only within a channel), the brand’s skills and playbooks and the ads it saved. Ask the user for anything missing (goal, mode, channels) rather than guessing. `mode` picks what happens to each batch: "review" (the default) keeps the fresh posts as drafts until a human approves them (run_post_refill approve), "auto" schedules them straight away. `enabled:false` is the PAUSE — it removes the recurring job outright, and posts already queued are left alone (cancel those with cancel_scheduled). Three posting times means up to three posts a day; postsPerDay sets fewer, and to post MORE per day pass postingTimes (one time per post a day) — e.g. "2 a day" on a brand with 3 posting times is postsPerDay:2, "4 a day" needs four postingTimes.

ParameterTypeDescription
enabledbooleanon/off. true needs a connected channel and a goal (refused otherwise). false PAUSES it: the recurring job is deleted and nothing new is made. Already-queued posts are untouched.
mode"review" | "auto""review" (default): each batch of fresh posts waits as drafts for approval. "auto": fresh posts are scheduled to publish automatically.
daysAheadnumberhow far ahead to keep the queue full, 1–30 (default 7)
postsPerDaynumberposts a day, up to the number of posting times ("2 times a day" = 2). 0 (default) = one per posting time. To post MORE per day than there are posting times, also pass postingTimes.
postingTimesstring[]the brand's daily posting times, 24-hour "HH:MM" (e.g. ["09:00","13:00","18:00"]); REPLACES the list. Autopilot makes one post per posting time, and scheduled posts use the same times. get_post_refill shows the current ones.
timezonestringIANA timezone the posting times are in, e.g. "America/New_York" (default: the brand's, else UTC)
channelPostsPerDayobjectper channel, how many posts a day (the first N of the posting times), e.g. {"x":1,"instagram":3}. 0 turns a channel off; null resets it to every posting time. Merged into what is saved.
goal"followers" | "sales" | "launch" | "other"THE BRIEF: what the posts are for — grow followers, drive sales, promote a launch, or other (say what in goalNote). Required to switch it on.
goalNotestringTHE BRIEF: a line about the goal (e.g. the launch date and what is launching).
pillarsstring[]THE BRIEF: content pillars / topics, up to 8 short lines. Every post belongs to one. get_post_refill suggests some from the brand profile.
formats"mix" | "video" | "carousel" | "image"THE BRIEF: what to make — mix (images with a short video now and then, the default), video (short videos only), carousel (3 new images per post), image.
avoidstring[]THE BRIEF: never mention or show these — topics, claims, competitors, faces.
learningsstring[]notes learned from reviews; REPLACES the list
forgetCaptionEditsbooleantrue = forget the caption edits learned from past reviews
channels"facebook" | "instagram" | "threads" | "tiktok" | "youtube" | "linkedin" | "x" | "pinterest" | "google_business" | "bluesky" | "telegram" | "wordpress" | "discord" | "slack" | "whop"[] | stringREPLACES the list; a channel connected later stays out until added. "all" = every channel, later ones too, only if the user asks
addChannels"facebook" | "instagram" | "threads" | "tiktok" | "youtube" | "linkedin" | "x" | "pinterest" | "google_business" | "bluesky" | "telegram" | "wordpress" | "discord" | "slack" | "whop"[]add these
removeChannels"facebook" | "instagram" | "threads" | "tiktok" | "youtube" | "linkedin" | "x" | "pinterest" | "google_business" | "bluesky" | "telegram" | "wordpress" | "discord" | "slack" | "whop"[]stop posting here
excludeChannels"facebook" | "instagram" | "threads" | "tiktok" | "youtube" | "linkedin" | "x" | "pinterest" | "google_business" | "bluesky" | "telegram" | "wordpress" | "discord" | "slack" | "whop"[]never these channels, even ones connected later; [] clears
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.
whopExperienceIdstringWHOP — which forum or chat posts go to (exp_…, from list_whop_destinations). Without one, the whop’s only forum or chat is used; with several, whop is skipped rather than guessed.
linkedinOrganizationIdstringLINKEDIN — which company Page to post as (list_linkedin_pages). A single shared Page is used automatically; a Page that is not shared with this profile 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.
accountsobjectaccounts to post to on a channel holding several: {"tiktok":["@a","@b"]} or "all"; null clears. A new setup must pick; an older one posts to the primary until it does.

run_post_refill#

Run autopilot now / approve its drafts destructive

Run AUTOPILOT POSTING (autoposting, paid plans only) NOW instead of waiting for its daily turn, or review the drafts it made: schedule them, delete one, change a caption, or save a note such as "too salesy". By default it is a preview: it returns the posts it WOULD make — each new picture’s scene, the caption, the channels and the price — rendering nothing and queueing nothing. The preview still WRITES the scenes and copy with a model, which bills its model cost (the reply says exactly how many credits). dryRun:false actually renders a fresh image or video for each post within the budget (credits are spent) and then, by mode, schedules them ("auto") or keeps them as drafts ("review"). It never re-posts the Library. REVIEWING A BATCH: pass approve (draft ids, or ["all"]) to schedule drafts, discard to delete them, edit ([{id, message?, captions?, title?, at?, channels?}]) to change one first (a new `at` reschedules it); get_post_refill lists them. learn saves notes future batches follow (free). redo remakes ONE draft from a note and is paid; without confirm:true it returns only the price. A scheduled post can still be pulled before it goes out with cancel_scheduled. Every caption is screened against the voice rules and a failing one is dropped, so a plan can come back shorter than the cadence — the reason is in the notes.

ParameterTypeDescription
dryRunbooleandefault TRUE (a preview: nothing rendered, nothing queued; the copy it writes bills its model cost). false renders fresh creative and schedules or drafts it per the mode.
forcebooleanplan a preview even while autoposting is switched off — useful for showing someone what it would do before they turn it on.
approvestring[]REVIEW: draft ids to schedule, or ["all"]. A draft whose time has passed moves to the next free posting slot.
discardstring[]REVIEW: draft ids to delete, or ["all"]. Nothing is posted for them.
editobject[]REVIEW: change drafts before approving them. Edited copy is screened by the voice rules.
learnstring[]notes for future batches, e.g. "too salesy". Free.
redoobjectremake one draft from feedback; paid, so without confirm:true it returns only the price

post_to_linkedin#

Publish to LinkedIn destructive

Use this when the user wants to post to their personal LinkedIn profile. Publishing costs no credits. Publish a post to the user’s connected LinkedIn profile — text, and optionally an image (imageUrl), a VIDEO (videoUrl, with captionsSrt and a videoThumbnailUrl cover), a 2–20 image MULTI-IMAGE post (imageUrls), a LINK post with a real preview card (linkUrl + linkTitle / linkDescription / linkThumbnailUrl — LinkedIn never scrapes the page), a DOCUMENT post (documentUrl: a PDF / PPTX readers swipe through), and @mentions of companies (mentions). The image does NOT have to be something Hermoso generated: LinkedIn is served the bytes from us, so the URL must be Hermoso-hosted, and upload_file turns ANY file the user already has into exactly that. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Needs a connected LinkedIn account (Settings > Connectors > LinkedIn).

ParameterTypeDescription
brandstringWHICH PROFILE this post belongs to — id or exact name from list_brands (a shared workspace: its profile id). Beats the connection's pin for THIS CALL ONLY; a name that matches no profile, or two, is REFUSED and nothing is posted.
hookstringWHAT ANGLE this post is built on, recorded only at publish time. post_performance ranks hooks on it (a winner needs 5 posts sharing ONE hook), so pass a list_hooks id (e.g. "direct_callout", "before_after") or reuse your own wording EXACTLY across a campaign. Omit it and this post never votes on which hook works.
subjectstringWHAT THIS POST IS ABOUT — product, feature, offer or theme (e.g. "winter coat", "free trial"). post_performance's second grouping axis: reuse the exact wording, as with hook.
recipestringthe post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format
ideaIdstringshort id of the content-plan idea this post came from
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 MULTI-IMAGE post: 2–20 Hermoso-hosted images in order, one post the reader swipes through (LinkedIn’s MultiImage API takes a member author on w_member_social). Images only.
videoUrlstringa Hermoso-hosted video (a render, or the user’s footage via upload_file). LinkedIn processes it before publishing, which takes a minute.
captionsSrtstringSRT caption CONTENT for a videoUrl post (English only, attached during upload)
videoThumbnailUrlstringcover image for a videoUrl post (Hermoso-hosted)
titlestringvideo title
altTextstring | string[]image alt text — a string for every image, or an array per slide
linkUrlstringa LINK post with a real preview card. Not with media.
linkTitlestringthe card headline — LinkedIn never scrapes the page, so without it the card is unlabelled
linkDescriptionstringlink card description; LinkedIn does not read the page, so set it here
linkThumbnailUrlstringHermoso-hosted image for the card
documentUrlstringa DOCUMENT post — a Hermoso-hosted PDF / PPTX / DOCX (upload_file first) that readers swipe through page by page in the feed; LinkedIn takes up to 100MB and 300 pages. One kind of media per post: not with an image, video, slides or linkUrl.
documentTitlestringthe title shown on the document (defaults to the file name)
mentionsobject[]TAG COMPANIES: write "@Name" in the text where each tag goes and list each company here — its vanity name (the part after linkedin.com/company/), its company page URL, or {organizationId, name}. Hermoso resolves it on LinkedIn and LinkedIn renders a real clickable mention; a company with no "@" spot in the text is refused rather than appended.
idempotencyKeystringSAFE RETRIES — any stable string: a repeat of the SAME publish within 24h returns the ORIGINAL post id instead of posting again (an identical publish is also auto-recognised for 10 minutes). On a timeout or an ambiguous error, CALL AGAIN WITH THE SAME KEY: it reports the original post or publishes it once. It never posts twice.
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) destructive

Use this when the user wants to post to X (Twitter) now. 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 at X’s per-post price, every post in a thread billed: a post that contains a link (any web address, and every quote post) is X’s link rate, far higher than one without (hermoso_credits gives both figures). When the user asks what a post will cost, call this with dryRun:true: it posts NOTHING and returns that exact post’s costCredits. Each profile 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 the X Ads campaign tools (campaign, then line item, then promoted post), 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 profile has more than one x account connected (several and none named is refused by name, never guessed); omit when there is one.
brandstringWHICH PROFILE this post belongs to — id or exact name from list_brands (a shared workspace: its profile id). Beats the connection's pin for THIS CALL ONLY; a name that matches no profile, or two, is REFUSED and nothing is posted.
hookstringWHAT ANGLE this post is built on, recorded only at publish time. post_performance ranks hooks on it (a winner needs 5 posts sharing ONE hook), so pass a list_hooks id (e.g. "direct_callout", "before_after") or reuse your own wording EXACTLY across a campaign. Omit it and this post never votes on which hook works.
subjectstringWHAT THIS POST IS ABOUT — product, feature, offer or theme (e.g. "winter coat", "free trial"). post_performance's second grouping axis: reuse the exact wording, as with hook.
recipestringthe post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format
ideaIdstringshort id of the content-plan idea this post came from
textstringthe post text. 280 characters without X Premium, up to 25,000 with it — write the full thing, it is never truncated. With `thread` also set, this is the FIRST post and the thread entries follow it (a thread whose first entry is already this text is not posted twice).
threadstring[]a thread: each string is one post, published in order, each replying to the previous (if `text` is also set it is posted first and these follow). Max 25 including `text`. 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.
dryRunbooleantrue = post NOTHING and return what this exact post would cost (costCredits). Free.
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.
videoTitlestringVIDEO posts only — a headline under the video, max 70 (measured: X drops it on an image post, so it is refused there)
videoDescriptionstringVIDEO posts only — a line under the title, max 200
videoCta"visit_site" | "watch_now"VIDEO posts only — a button on the video; needs videoCtaUrl (https)
videoCtaUrlstringhttps link the video button opens; used with videoCta
taggedUserIdsstring[]tag up to 10 people (numeric X user ids, from x_user) in the post’s media
dmDeepLinkstring | booleana "Message us" button: true for the connected account, or https://x.com/messages/compose?recipient_id=<id>. TEXT posts only — X refuses it alongside media.
platformCoverbooleanVIDEO COVER. X has no cover setting and shows the video’s FIRST frame, so by default Hermoso checks it and, only when that frame is blank (a template ad’s empty opening card), sends X a copy with the first frame replaced by the video’s best frame. Your Library file is never changed. true = send the file exactly as it is.

post_x_article#

Publish a long-form Article to X destructive

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 PROFILE this post belongs to — id or exact name from list_brands (a shared workspace: its profile id). Beats the connection's pin for THIS CALL ONLY; a name that matches no profile, or two, is REFUSED and nothing is posted.
hookstringWHAT ANGLE this post is built on, recorded only at publish time. post_performance ranks hooks on it (a winner needs 5 posts sharing ONE hook), so pass a list_hooks id (e.g. "direct_callout", "before_after") or reuse your own wording EXACTLY across a campaign. Omit it and this post never votes on which hook works.
subjectstringWHAT THIS POST IS ABOUT — product, feature, offer or theme (e.g. "winter coat", "free trial"). post_performance's second grouping axis: reuse the exact wording, as with hook.
recipestringthe post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format
ideaIdstringshort id of the content-plan idea this post came from
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.
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

edit_x_post#

Edit a post on X destructive

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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
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.
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
idrequiredstringthe numeric X post id — the last part of the post URL
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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. Use this rather than 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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
idsrequiredstring[]numeric X post ids (max 25) — the last part of each post URL
granularity"Total" | "Daily" | "Hourly" | "Weekly"default Total
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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 and per distinct author X returns with them, 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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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. Messages sent in X Chat are end-to-end encrypted and are NOT in this read, so never say someone did not write from it alone: list_x_chats shows which conversations are unread. 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.
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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; paid Pinterest ads have their own report. 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"app type to report on: ALL, MOBILE, TABLET or WEB
splitFieldstringaccount: NO_SPLIT | APP_TYPE | OWNED_CONTENT | SOURCE | PIN_FORMAT
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

list_pinterest_catalogs#

Read the Pinterest product catalog read-only

Read the Pinterest product CATALOG side of the connected account — what a Shopping (CATALOG_SALES) campaign serves from. what: catalogs (default), feeds, feed (feedId), processing_results (feedId — did Pinterest ingest the feed, product counts, errors), item_issues (processingResultId — per-product problems), product_groups (optionally feedId / catalogId), product_counts or products (productGroupId), items_batch (batchId). Pinterest catalogs need a BUSINESS account with a claimed website. Read-only, 0 credits.

ParameterTypeDescription
what"catalogs" | "feeds" | "feed" | "processing_results" | "item_issues" | "product_groups" | "product_counts" | "products" | "items_batch"which view to read; default catalogs
catalogIdstringcatalog id from what:catalogs, used with feeds and product groups
feedIdstringfeed id from what:feeds, used with feed, processing_results and product_groups
processingResultIdstringprocessing result id from what:processing_results, used with item_issues
productGroupIdstringproduct group id from what:product_groups, used with products and product_counts
batchIdstringbatch id returned by update_pinterest_catalog_items, used with items_batch
adAccountIdstringoptional — act as this ad account’s owner via Pinterest Business Access (must be shared with this profile)
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

create_pinterest_catalog#

Create a Pinterest catalog

Create a Pinterest product CATALOG (RETAIL for products, or HOTEL) — the container feeds and items fill. Read back from Pinterest. Most merchants can skip it: a feed created without catalogId lands in a default retail catalog. 0 credits.

ParameterTypeDescription
namerequiredstringcatalog name
catalogType"RETAIL" | "HOTEL"RETAIL (products) or HOTEL
adAccountIdstringoptional — act as this ad account’s owner via Pinterest Business Access (must be shared with this profile)
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

manage_pinterest_catalog_feed#

Create, edit or delete a Pinterest product feed destructive

Create, edit or delete a Pinterest PRODUCT FEED — the CSV / TSV / XML file of your products Pinterest downloads on a schedule to fill the catalog Shopping ads serve from. action:"create" needs name, location (the feed URL), defaultCountry and defaultLocale (Pinterest refuses a feed without them), optionally format, defaultCurrency, defaultAvailability, catalogId, schedule {time HH:MM, timezone}, credentials. action:"update" changes those on feedId. action:"delete" removes feedId and needs confirm:true — without it nothing changes and the reply names what would go. Read back from Pinterest every time. 0 credits.

ParameterTypeDescription
action"create" | "update" | "delete"default create
feedIdstringfeed to update or delete, from list_pinterest_catalogs what:feeds
namestringfeed name; required on create
locationstringthe URL Pinterest downloads the feed from
format"CSV" | "TSV" | "XML" | "INTEGRATION"feed file format: CSV, TSV, XML or INTEGRATION
defaultCountrystringtwo-letter, e.g. US — required on create
defaultLocalestringe.g. en-US — required on create
defaultCurrencystringcurrency for items that omit one, e.g. USD
defaultAvailability"IN_STOCK" | "OUT_OF_STOCK" | "PREORDER"availability for items that omit it: IN_STOCK, OUT_OF_STOCK or PREORDER
catalogIdstringcatalog to attach the feed to, from list_pinterest_catalogs
status"ACTIVE" | "INACTIVE"ACTIVE or INACTIVE
scheduleobjectwhen Pinterest downloads the feed: {time HH:MM, timezone}
credentialsobjectonly if the feed URL needs a login
confirmbooleanrequired true for delete
adAccountIdstringoptional — act as this ad account’s owner via Pinterest Business Access (must be shared with this profile)
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

update_pinterest_catalog_items#

Add, change or remove Pinterest catalog products destructive

Add, change or remove PRODUCTS in a Pinterest catalog directly — no feed file needed. items: up to 1,000 of {itemId (your SKU), operation UPSERT (default) / CREATE / UPDATE / DELETE, attributes {title, description, link, image_link, price e.g. "19.99 USD", availability e.g. "in stock", brand, sale_price, …}}. A create or upsert must carry title, link, image_link and price (refused here otherwise). Pinterest processes the batch asynchronously; the batch is read back and its status and every per-item error reported. 0 credits.

ParameterTypeDescription
itemsrequiredobject[]products to add, change or remove; up to 1,000 per call
countrystringdefault US
languagestringdefault en-US
catalogIdstringcatalog to write to, from list_pinterest_catalogs
adAccountIdstringoptional — act as this ad account’s owner via Pinterest Business Access (must be shared with this profile)
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

manage_pinterest_product_group#

Create, edit or delete a Pinterest product group destructive

Create, edit or delete a Pinterest PRODUCT GROUP — the slice of a catalog a Shopping ad group promotes (one brand, a price band, a custom label). action:"create" needs name, feedId and filters; "update" changes name / description / filters on productGroupId; "delete" needs confirm:true. Read back from Pinterest. 0 credits.

ParameterTypeDescription
action"create" | "update" | "delete"default create
productGroupIdstringproduct group to update or delete
namestringproduct group name; required on create
descriptionstringproduct group description
feedIdstringfeed the group draws products from; required on create
filtersobjectPinterest product-group filters: {"all_of":[{"BRAND":{"values":["Acme"]}},{"MIN_PRICE":{"values":10}}]} or {"any_of":[…]}. Keys: AVAILABILITY, BRAND, CONDITION, CURRENCY, CUSTOM_LABEL_0-4, CUSTOM_NUMBER_0-4, GENDER, GOOGLE_PRODUCT_CATEGORY_0-6, ITEM_GROUP_ID, ITEM_ID, MAX_PRICE, MEDIA_TYPE, MIN_PRICE, PINTEREST_PRODUCT_CATEGORIES, PRODUCT_GROUP, PRODUCT_TYPE_0-4, TITLE_KEYWORDS — an unknown key is refused by name
isFeaturedbooleantrue = mark the product group as featured
confirmbooleanrequired true for delete
adAccountIdstringoptional — act as this ad account’s owner via Pinterest Business Access (must be shared with this profile)
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

list_pinterest_board_sections#

List Pinterest board sections read-only

List the SECTIONS of a Pinterest board with their ids (the boardSectionId post_to_pinterest and save_pinterest_pin take), or with sectionId the Pins in one section. Read-only, 0 credits.

ParameterTypeDescription
boardIdrequiredstringfrom list_pinterest_boards
sectionIdstringread the Pins in this section
bookmarkstringthe next page, from the last reply
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

manage_pinterest_board_section#

Create, rename or delete a Pinterest board section destructive

Create, rename or delete a section of a Pinterest board. action:"create" needs name; "rename" needs sectionId and name; "delete" needs sectionId and confirm:true (without confirm it reports what would go). Read back from Pinterest. 0 credits.

ParameterTypeDescription
boardIdrequiredstringfrom list_pinterest_boards
action"create" | "rename" | "delete"default create
sectionIdstringrename, delete: from list_pinterest_board_sections
namestringcreate, rename: up to 180 characters
confirmbooleandelete: required true
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

pinterest_account_lists#

Pinterest followers, following and websites read-only

Read lists on the connected Pinterest account. what: followers, following, following_boards, websites (each site and whether it is claimed; a site that is not claimed explains a refused link) or website_verification (the code to put on a site to claim it). Claiming itself happens in Pinterest. Read-only, 0 credits.

ParameterTypeDescription
whatrequired"followers" | "following" | "following_boards" | "websites" | "website_verification"which list
limitnumberup to 250 per page
bookmarkstringthe next page, from the last reply
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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.
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

post_to_pinterest#

Create a Pin destructive

Use this when the user wants to publish a Pin to Pinterest. Publishing costs no credits. 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 profile has more than one pinterest account connected (several and none named is refused by name, never guessed); omit when there is one.
brandstringWHICH PROFILE this post belongs to — id or exact name from list_brands (a shared workspace: its profile id). Beats the connection's pin for THIS CALL ONLY; a name that matches no profile, or two, is REFUSED and nothing is posted.
hookstringWHAT ANGLE this post is built on, recorded only at publish time. post_performance ranks hooks on it (a winner needs 5 posts sharing ONE hook), so pass a list_hooks id (e.g. "direct_callout", "before_after") or reuse your own wording EXACTLY across a campaign. Omit it and this post never votes on which hook works.
subjectstringWHAT THIS POST IS ABOUT — product, feature, offer or theme (e.g. "winter coat", "free trial"). post_performance's second grouping axis: reuse the exact wording, as with hook.
recipestringthe post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format
ideaIdstringshort id of the content-plan idea this post came from
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–20 (images, videos or a mix), Threads 2–20 (mix allowed), Facebook 2+ (Meta publishes no documented maximum; Hermoso caps the upload fan-out at 30 and says so), LinkedIn company Pages 2–20 (images only), Pinterest 2–5 (images only), TikTok up to 35. One url here is simply an ordinary single post. Anything a channel cannot do is REFUSED with the real reason — nothing is ever quietly downgraded to one slide.
idempotencyKeystringSAFE RETRIES — any stable string: a repeat of the SAME publish within 24h returns the ORIGINAL post id instead of posting again (an identical publish is also auto-recognised for 10 minutes). On a timeout or an ambiguous error, CALL AGAIN WITH THE SAME KEY: it reports the original post or publishes it once. It never posts twice.
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
coverAtMsnumberTHE VIDEO COVER of a video Pin, as ONE frame: milliseconds from the start (7000 = the frame at 7s). That frame is cut and sent as the Pin cover image. coverImageUrl wins.
boardSectionIdstringoptional section within the board
aiGeneratedbooleanPinterest’s AI disclosure (ai_disclosures AI_MODIFIED). OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, an image or video that came through upload_file or an external URL (the user’s own) is NOT. Pass true or false only to override.
syntheticPerformerbooleanALSO declare SYNTHETIC_PERFORMER — the Pin shows a realistic AI-generated person. Only with an AI disclosure; never guessed, so pass true only when the user says the person is AI-made.
platformCoverbooleanVIDEO COVER. Omit it (the default) and Hermoso sets the video’s best frame — the same frame as its Library thumbnail — as the cover (on Pinterest the frame rides as the cover image). true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins.

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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

update_pinterest_pin#

Edit a published Pin destructive

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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
boardSectionIdstringoptional section id within the destination board
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

update_pinterest_board#

Rename or re-privacy a Pinterest board destructive

Rename a board, rewrite its description, or change its privacy. Warning: 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
namestringnew board name
descriptionstringmax 500 characters
privacy"PUBLIC" | "SECRET"SECRET hides every Pin on the board from everyone but this account
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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: making the board SECRET with update_pinterest_board is reversible, but it needs a Pinterest permission (boards:write_secret) that an older connection may not hold. The unconfirmed refusal says which applies to this connection, read off the live scope set, so 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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

GOOGLE BUSINESS PROFILE

list_business_locations#

List Google business listings read-only

List the Google Business Profile listings SHARED WITH THIS PROFILE — 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 profile. 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 destructive

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 PROFILE this post belongs to — id or exact name from list_brands (a shared workspace: its profile id). Beats the connection's pin for THIS CALL ONLY; a name that matches no profile, or two, is REFUSED and nothing is posted.
hookstringWHAT ANGLE this post is built on, recorded only at publish time. post_performance ranks hooks on it (a winner needs 5 posts sharing ONE hook), so pass a list_hooks id (e.g. "direct_callout", "before_after") or reuse your own wording EXACTLY across a campaign. Omit it and this post never votes on which hook works.
subjectstringWHAT THIS POST IS ABOUT — product, feature, offer or theme (e.g. "winter coat", "free trial"). post_performance's second grouping axis: reuse the exact wording, as with hook.
recipestringthe post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format
ideaIdstringshort id of the content-plan idea this post came from
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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
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 profile
limitnumber1–50, default 20
orderBy"updateTime desc" | "updateTime" | "rating" | "rating desc"default newest first
pageTokenstringnext page token from the previous reply

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
locationIdstringlisting id from list_business_locations; omit when only one is shared
limitnumber1–20, default 10
pageTokenstringnext page token from the previous reply

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
locationIdstringlisting id from list_business_locations; omit when only one is shared
deletebooleantrue removes the existing answer instead of posting one; needs confirm
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_DIRECTION_REQUESTS, CALL_CLICKS, WEBSITE_CLICKS, MESSAGING_CLICKS, BUSINESS_BOOKINGS, BUSINESS_FOOD_MENU_CLICKS; Google-deprecated and askable only by name: BUSINESS_CONVERSATIONS, BUSINESS_FOOD_ORDERS). Omit for every current one. 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 profile

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 profile
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 profile — 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 profile

post_to_youtube#

Post a video to YouTube destructive

Use this when the user wants to upload a video or Short to YouTube. Publishing costs no credits. 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). PUBLISHES PUBLICLY BY DEFAULT: a plain "post this to YouTube" puts it ON the channel (confirm the title with the user, as for any publish) and notifies subscribers as YouTube does. Pass the privacy the user states instead: "unlisted" (link-only) or "private" (eyes-only). A video meant to run as a YouTube/Google AD should go up "unlisted" — private videos CANNOT be used as ads. 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. YOUTUBE MUSIC: YouTube’s API takes no music track. Only when the user wants a track from YouTube’s own Audio Library: BEFORE uploading, tell them plainly it will go up UNLISTED (not on their channel, nobody sees it) so they can add the track in YouTube Studio on desktop (Content > the video > Editor > Audio), and that THEY must then switch it to Public there themselves; get their yes, then pass privacy:"unlisted". Never choose unlisted for music on your own. 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 profile has more than one youtube account connected (several and none named is refused by name, never guessed); omit when there is one.
brandstringWHICH PROFILE this post belongs to — id or exact name from list_brands (a shared workspace: its profile id). Beats the connection's pin for THIS CALL ONLY; a name that matches no profile, or two, is REFUSED and nothing is posted.
hookstringWHAT ANGLE this post is built on, recorded only at publish time. post_performance ranks hooks on it (a winner needs 5 posts sharing ONE hook), so pass a list_hooks id (e.g. "direct_callout", "before_after") or reuse your own wording EXACTLY across a campaign. Omit it and this post never votes on which hook works.
subjectstringWHAT THIS POST IS ABOUT — product, feature, offer or theme (e.g. "winter coat", "free trial"). post_performance's second grouping axis: reuse the exact wording, as with hook.
recipestringthe post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format
ideaIdstringshort id of the content-plan idea this post came from
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 public (live + searchable on the channel); unlisted = link-only (the ad-ready setting, or to add YouTube music in Studio first — only when the user asks); 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.
platformCoverbooleanVIDEO COVER. Omit it (the default) and Hermoso sets the video’s best frame — the same frame as its Library thumbnail — as the cover (YouTube custom thumbnail; the same as thumbnailUrl:"auto" when true). true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins.
coverAtMsnumberTHE VIDEO COVER (the custom thumbnail), as ONE frame: milliseconds from the start (7000 = the frame at 7s). That frame is cut from the upload and set with thumbnails.set. thumbnailUrl wins; this beats platformCover.
paidProductPlacementbooleantrue = contains PAID product placement (sponsored) — YouTube shows the disclosure; legally required for sponsored content
brandPartnerstringsponsoring brand's channel id (UC…) or @handle — gives that brand access to run the video as a partnership ad; "" unlinks (update only)
defaultLanguagestringlanguage of title/description, e.g. "en"
defaultAudioLanguagestringspoken language, e.g. "es"
localizationsobjecttranslated title/description per language, e.g. {"es":{"title":"…"}}; needs defaultLanguage
license"youtube" | "creativeCommon"YouTube licence: youtube (standard) or creativeCommon
embeddablebooleantrue = allow embedding on other sites
publicStatsViewablebooleantrue = show public view and like counts
recordingDatestringYYYY-MM-DD

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.

ParameterTypeDescription
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
limitnumberhow many recent uploads to return (default 25, max 50)
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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) · contentType (SHORTS vs long-form vs live; contentType:"shorts" narrows most reports to Shorts, and engagedViews — views past the first frame — is the honest Shorts number). 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" | "contentType"which report (default day); contentType = Shorts vs long-form vs live
contentType"shorts" | "videoOnDemand" | "liveStream" | "story"keep only this type (videoOnDemand = long-form) — any report except topVideos, the *Detail reports and audienceRetention
splitByContentTypebooleanadd a content-type column instead of filtering
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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

YOUTUBE REPORTING API

youtube_bulk_report#

YouTube bulk report (thumbnail CTR, cards, end screens)

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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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.

ParameterTypeDescription
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

update_youtube_video#

Update a YouTube video’s details destructive

Edit an existing video on the connected channel: title, description, tags, privacy (unlisted | public | private), category, schedule (publishAt; "" clears it), madeForKids, aiGenerated, licence, embedding, stats visibility, languages + per-language translations, paid-placement disclosure and brand partner. THIS IS HOW YOU FLIP AN UNLISTED UPLOAD PUBLIC. 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; every change is read back from YouTube and any field it did not apply is named. Needs a connected YouTube channel.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
videoIdrequiredstringthe YouTube video id
titlestring≤100 chars
descriptionstring≤5000 chars
tagsstring[]video tags, up to 30; omit to leave the current tags
privacy"unlisted" | "public" | "private"public = live on the channel; confirm with the user first
categoryIdstringnumeric YouTube category id
publishAtstringISO date-time to go public; keeps it private until then; "" clears a schedule
madeForKidsbooleantrue = made for kids; false = not made for kids
aiGeneratedbooleanYouTube’s “altered or synthetic content” label. OMIT IT and the value already on the post stays; a post that never had one is decided from provenance (a Hermoso render is declared, real footage from upload_file or an external URL is not). true/false overrides.
paidProductPlacementbooleantrue = contains PAID product placement (sponsored) — YouTube shows the disclosure; legally required for sponsored content
brandPartnerstringsponsoring brand's channel id (UC…) or @handle — gives that brand access to run the video as a partnership ad; "" unlinks (update only)
defaultLanguagestringlanguage of title/description, e.g. "en"
defaultAudioLanguagestringspoken language, e.g. "es"
localizationsobjecttranslated title/description per language, e.g. {"es":{"title":"…"}}; needs defaultLanguage
license"youtube" | "creativeCommon"YouTube licence: youtube (standard) or creativeCommon
embeddablebooleantrue = allow embedding on other sites
publicStatsViewablebooleantrue = show public view and like counts
recordingDatestringYYYY-MM-DD
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

YOUTUBE CHANNEL BRANDING

update_youtube_channel#

Apply the brand to the YouTube channel itself destructive

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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

set_youtube_watermark#

Set or remove the YouTube branding watermark destructive

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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

set_youtube_thumbnail#

Set the custom thumbnail on a YouTube video destructive

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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
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.
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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, reply count and comment id, newest first — plus `imageUrl` when a viewer attached an image or GIF to the comment (a signed YouTube link that expires six hours after the read, so download it then if it needs keeping). Read-only, 0 credits. Needs a connected YouTube channel.

ParameterTypeDescription
videoIdrequiredstringthe YouTube video id
limitnumbermax comments, default 25, cap 100
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

list_youtube_comment_replies#

Read the replies under one YouTube comment read-only

Every reply under ONE comment on the connected channel, newest pages first via cursor: check that a reply the brand posted landed, or whether someone already answered a viewer before replying. Pass the top-level comment id from list_youtube_comments (each comment line shows its id and reply count); a REPLY id is recognised and its whole thread is returned, with `note` and `requestedId` saying so. Read-only, free.

ParameterTypeDescription
commentIdrequiredstringthe top-level comment id from list_youtube_comments
limitnumbermax replies per page, default 25, cap 100
cursorstringnextCursor from the previous page
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

manage_youtube_playlist_items#

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

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)
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

manage_youtube_playlist_image#

Custom playlist cover image destructive

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'
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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)
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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. Warning: 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 profile 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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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 per distinct author returned with them, 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
endTimestringISO 8601 end of the search window; within the last 7 days
nextTokenstringfrom a previous page
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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”). Warning: 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. Default 20, max 100 per call (X bills per account 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 20
paginationTokenstringnext page token from the previous reply
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

hide_x_reply#

Hide or unhide a reply on X destructive

Hide a reply under the brand’s own X post (hidden:false to unhide) — spam and abuse moderation. A hidden reply is not deleted; it moves behind "View hidden replies". Only works on replies in a conversation the connected account started. Needs the tweet.moderate.write permission: an X connection made before 2026-10-27 must be reconnected once under Settings > Connectors > X (the tool says so without calling X). Costs credits (X bills per API call).

ParameterTypeDescription
replyIdrequiredstringthe REPLY’s id, or its x.com link
hiddenbooleandefault true; false unhides
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
paginationTokenstringnext page token from the previous reply
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

list_x_broadcasts#

List X live broadcasts read-only

The connected X account's LIVE VIDEO broadcasts: past and current ones (title, state, share URL, live and total viewers, start/end) and SCHEDULED ones (start, end, whether it goes live automatically or waits for go_live). kind 'all' (default) reads both, 'live' or 'scheduled' one; pass id for a single broadcast. Read-only. Needs X connected with the broadcast permissions added 2026-09-21: an X connection made before then must be reconnected once under Settings > Connectors > X (the tool says so without calling X). Costs credits (X bills per call).

ParameterTypeDescription
kind"all" | "live" | "scheduled"all (default), live or scheduled
idstringone broadcast id (alphanumeric, up to 24 characters)
idsstringcomma-separated broadcast ids (live kind, up to 100)
maxResultsnumber1-100, default 25
paginationTokenstringnext page token from the previous reply
oldestStartstringscheduled kind: earliest start, ISO 8601 or epoch ms
newestStartstringscheduled kind: latest start, ISO 8601 or epoch ms
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

manage_x_broadcast#

Schedule, update, go live on or cancel an X broadcast destructive

Schedule, update, go live on, or cancel an X LIVE VIDEO broadcast on the connected account. action 'schedule' needs sourceId (from manage_x_stream_source), startAt, and manualPublish, which is REQUIRED with no default: true = nothing airs until action 'go_live'; false = X puts the brand ON AIR automatically at startAt (needs confirm:true). 'update' changes only the fields you pass (Hermoso re-sends the rest, because X's update replaces the whole schedule). 'go_live' airs a manualPublish schedule NOW, publicly, as the brand (confirm:true). 'cancel' deletes a schedule and cannot be undone (confirm:true). X retired recurring broadcasts: schedule each occurrence on its own. WITHOUT A SCHEDULE: 'create' makes a broadcast on sourceId that posts nothing, 'publish' puts it on air (confirm:true; skipPost:true skips the announcing post), 'end' stops one for everyone (confirm:true), 'delete' removes one and its replay (confirm:true; end it first). X limits broadcasting by account (Premium or 1,000 followers) and says so. Confirm the exact broadcast, time and stream with the user before passing confirm:true; a refusal is free. Needs X connected with the broadcast permissions added 2026-09-21: an X connection made before then must be reconnected once under Settings > Connectors > X (the tool says so without calling X). Costs credits (X bills per call).

ParameterTypeDescription
actionrequired"schedule" | "update" | "go_live" | "cancel" | "create" | "publish" | "end" | "delete"schedule, update, go_live, cancel, create, publish, end or delete
idstringbroadcast id, for every action but schedule and create
sourceIdstringstream source id from manage_x_stream_source (schedule, create)
startAtstringISO 8601 or epoch ms
endAtstringISO 8601 or epoch ms; X requires one on update
manualPublishbooleanrequired on schedule: true waits for go_live, false auto-publishes at startAt
titlestringbroadcast title
descriptionstringbroadcast description
localestringbroadcast language locale, e.g. en
availableForReplaybooleantrue = keep the broadcast available as a replay
isLockedbooleantrue = lock the broadcast (X's isLocked flag)
chatOptionstringX chat permission option (numeric string)
thumbnailMediaIdstringpre-live slate media id
skipPostbooleanpublish: true skips the post X would make announcing it
confirmbooleantrue only after the user approved this exact public or irreversible action
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

manage_x_stream_source#

Manage X stream sources destructive

The connected X account's STREAM SOURCES, the RTMP ingest points a live broadcast plays from. action 'list' (default) and 'get' (id) show each source's id, stream key, RTMPS URL, region and whether video is arriving; 'create' (name, optional region) makes one; 'rename' (id, name); 'delete' (id, confirm:true: it kills the stream key); 'region' reads the nearest ingest region. Pass a source's id as sourceId to manage_x_broadcast. The stream key is a secret, so never post it. Needs X connected with the broadcast permissions added 2026-09-21: an X connection made before then must be reconnected once under Settings > Connectors > X (the tool says so without calling X). Costs credits (X bills per call).

ParameterTypeDescription
action"list" | "get" | "create" | "rename" | "delete" | "region"default 'list'
idstringsource id, for get, rename and delete
namestringlabel, for create and rename
regionstringcreate only: 'atla' or 'pdxa', or an AWS region X maps onto one
confirmbooleantrue only after the user approved deleting this source
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

read_x_broadcast_chat#

Read an X broadcast chat read-only

The chat on one of the connected X account's live broadcasts, newest first: message id, text, author handle, time and reply-to. Use it to answer viewers or find a message to moderate. Read-only. Needs X connected with the broadcast permissions added 2026-09-21: an X connection made before then must be reconnected once under Settings > Connectors > X (the tool says so without calling X). Costs credits (X bills per call).

ParameterTypeDescription
broadcastIdrequiredstringbroadcast id from list_x_broadcasts
maxResultsnumber1-200, default 100
paginationTokenstringnext page token from the previous reply
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

send_x_broadcast_chat#

Send a message to an X broadcast chat

Post ONE chat message, as the brand, into a RUNNING live broadcast on the connected X account (max 140 characters; a longer one is refused, never cut). It is PUBLIC in front of everyone watching: show the user the exact wording, get a yes, then pass confirm:true. replyTo answers one specific message. Needs X connected with the broadcast permissions added 2026-09-21: an X connection made before then must be reconnected once under Settings > Connectors > X (the tool says so without calling X). Costs credits (X bills per call).

ParameterTypeDescription
broadcastIdrequiredstringrunning broadcast id from list_x_broadcasts
textrequiredstringchat message, max 140 characters; a longer one is refused
replyTostringchat message id to reply to
confirmbooleantrue only after the user approved this exact message; it is posted publicly
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

moderate_x_broadcast#

Moderate an X broadcast chat destructive

Moderate the connected X account's live broadcast chat. action 'remove_message' (broadcastId + messageId; cannot be undone, confirm:true), 'mute' (broadcastId + userId or username; until = when a timeout ends, omit to mute indefinitely; messageId removes that message while muting), 'unmute', 'list_mutes' (everyone muted across the brand's chats), 'mute_everywhere' / 'unmute_everywhere' (userId or username, every chat of the brand; when the brand moderates another host, broadcastId scopes it to that host), and the account's PERSISTENT chat moderators, which apply to every current and future broadcast: 'list_moderators', 'add_moderator', 'remove_moderator' (userId or username). Only the host or a moderator can moderate, and X says so if not. A username costs one extra lookup. Needs X connected with the broadcast permissions added 2026-09-21: an X connection made before then must be reconnected once under Settings > Connectors > X (the tool says so without calling X). Costs credits (X bills per call).

ParameterTypeDescription
actionrequired"remove_message" | "mute" | "unmute" | "list_moderators" | "add_moderator" | "remove_moderator" | "list_mutes" | "mute_everywhere" | "unmute_everywhere"remove_message, mute, unmute, list_moderators, add_moderator, remove_moderator, list_mutes, mute_everywhere or unmute_everywhere
broadcastIdstringbroadcast id; for remove_message, mute and unmute
messageIdstringchat message id to remove; with mute, also removes that message
userIdstringnumeric X account id
usernamestringX handle, resolved to the id for you
untilstringmute end, ISO 8601 or epoch ms
confirmbooleanrequired for remove_message
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

x_chat_settings#

Read the X Chat privacy settings read-only

The connected X account's X Chat (direct message) PRIVACY SETTINGS: who can message the brand (allowDmsFrom: all / verified / following), whether subscribers can always message it, the low-quality request filter, read receipts, and audio/video call permissions. Use it before changing them with update_x_chat_settings, or to answer 'can customers DM us on X'. Read-only. Needs X connected with direct-message access: an X connection made before 2026-08-25 must be reconnected once under Workspace > Connectors > X (the tool says so without calling X). Costs credits (X bills per call).

ParameterTypeDescription
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

update_x_chat_settings#

Change the X Chat privacy settings destructive

Change the connected X account's X Chat privacy settings: the account-wide answer to 'open our DMs to everyone' or 'turn off read receipts'. Pass only what changes. allowDmsFrom: 'all' (anyone), 'verified' (verified accounts only) or 'following' (accounts the brand follows); narrowing it stops customers the brand does not follow from messaging it, so say that before changing it. Call settings are merged onto the stored ones (X does not promise a partial call setting keeps the rest). Returns the settings X stored, not what was sent. Reversible. Needs X connected with direct-message access: an X connection made before 2026-08-25 must be reconnected once under Workspace > Connectors > X (the tool says so without calling X). Costs credits (X bills per call).

ParameterTypeDescription
allowDmsFrom"all" | "verified" | "following"who may message the brand: all, verified or following
alwaysAllowSubscribersbooleansubscribers can always message the brand, whatever allowDmsFrom says
qualityFilterbooleantrue = low-quality message requests go to a separate folder
readReceiptsbooleansend and receive read receipts
callsEnabledbooleanaudio and video calls on or off
enhancedCallPrivacybooleantrue = enhanced privacy for calls
acceptCallsFromobjectwho may call the brand: flags for each group below
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

list_x_chats#

List X Chat conversations read-only

The connected X account's X CHAT conversations: id (group ids start with g), type, muted, disappearing-message timer, screenshot controls, admins, members, PENDING join requests, which actions are admin-only, and the active invite link. X Chat is END-TO-END ENCRYPTED, so message text and group names cannot be read here (list_x_dms reads the plain direct-message history). A filtered page can be short or empty while more remain: keep paging with the same filters. view 'admin_requests' (id = a group the brand admins) lists who asked to become an admin; view 'invite' (inviteLink, or id + inviteToken) previews an invite link: group name, member count, whether the brand is in it. view 'message_requests' lists the MESSAGE REQUESTS waiting outside the inbox (inbox 'priority', the default, or 'low_quality' for X's filtered tab): who sent each one (@handle, name, followers, verified), which accounts the brand follows also follow them, and whether they changed their name or picture recently (X's impersonation hint). A message sent in X Chat is encrypted and cannot be read; one sent as a plain direct message can, with list_x_dms participantId = the sender. Show that to the user before manage_x_chat accept_request. withMembers:true resolves member @handles, and each user X returns is billed. Read-only. Billed per conversation or request returned, plus one user lookup per request sender, so keep maxResults small. Needs X connected with direct-message access: an X connection made before 2026-08-25 must be reconnected once under Workspace > Connectors > X (the tool says so without calling X). Costs credits (X bills per call).

ParameterTypeDescription
view"conversations" | "admin_requests" | "invite" | "message_requests"default 'conversations'
inbox"priority" | "low_quality"message_requests: X's Priority tab (default) or the filtered low-quality one
withSendersbooleanmessage_requests: resolve who sent each request (default true, each sender billed)
idstringone conversation; for admin_requests, the group (g + digits)
maxResultsnumber1-100 (message_requests 1-50), default 10; every conversation or request returned is billed
paginationTokenstringnextToken from a previous call
withMembersbooleanalso resolve member/admin @handles (each one billed)
unreadOnlybooleanonly unread conversations
type"direct" | "group"only 1:1 or only group conversations
isArchivedbooleantrue only archived, false only not archived
isMutedbooleantrue only muted, false only not muted
participantIdsstring1-10 numeric X account ids, comma-separated, all in the chat
participantQuerystringname or @username prefix, 1-50 characters
startTimestringlatest message at or after this ISO 8601 time
endTimestringlatest message before this ISO 8601 time
inviteLinkstringan x.com/i/chat/group_join/... link, for view invite
inviteTokenstringthe 10-character token, with id, for view invite
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

manage_x_chat#

Manage an X Chat conversation destructive

Manage one of the connected X account's X Chat conversations (id from list_x_chats). For the brand only: 'mute' / 'unmute'; 'delete' removes it from the brand's inbox for good while the others keep theirs (confirm:true). For everyone in it: 'set_disappearing' (disappearAfter like '24h' or '7d'), 'clear_disappearing', 'screen_capture' (blockScreenshots OR notifyOnScreenshot, one per call). GROUPS (id starts with g; the brand must be an admin): 'admin_settings' (adminOnly {addMember, editGroupInfo, sendMessage, startCall, editMessageTtl, blockScreenCapture}; sendMessage:true makes it announcement-only), 'add_admins' / 'remove_admins' (userIds), 'remove_members' (userIds, confirm:true), 'leave' (confirm:true), 'approve_admin_request' / 'reject_admin_request' (userId), 'reject_join_requests' (userIds; APPROVING a join re-keys the encrypted group, so it is done in the X app), 'enable_invite' (optional title, avatarUrl, welcomeMessage, expiresAt; X stores that preview UNENCRYPTED and shows it to anyone with the link), 'disable_invite' (the shared link dies for good: confirm:true). INBOX, for the brand only: 'archive' / 'unarchive', 'mark_read' (may send the other person a read receipt). MESSAGE REQUESTS: 'accept_request' (id = the sender's numeric id, or the request's conversationId from list_x_chats view message_requests) moves the conversation into the inbox; show the user who sent it first (list_x_dms with participantId reads the message only if it was not sent encrypted). Confirm the exact conversation with the user before passing confirm:true; a refusal is free. The answer is X's own yes or no, never an echo. Needs X connected with direct-message access: an X connection made before 2026-08-25 must be reconnected once under Workspace > Connectors > X (the tool says so without calling X). Costs credits (X bills per call).

ParameterTypeDescription
actionrequired"mute" | "unmute" | "delete" | "set_disappearing" | "clear_disappearing" | "screen_capture" | "admin_settings" | "add_admins" | "remove_admins" | "remove_members" | "leave" | "approve_admin_request" | "reject_admin_request" | "reject_join_requests" | "enable_invite" | "disable_invite" | "accept_request" | "archive" | "unarchive" | "mark_read"conversation action; the tool description lists each one and what it needs
idrequiredstringconversation id from list_x_chats: g + digits for a group, or the other person's numeric id for a 1:1 (accept_request: the sender's numeric id)
userIdsstring[]NUMERIC X account ids (add_admins, remove_admins, remove_members, reject_join_requests)
userIdstringNUMERIC X account id (approve_admin_request, reject_admin_request)
disappearAfterstringset_disappearing: '24h', '7d', '90m' or a number of seconds
blockScreenshotsbooleanscreen_capture: true = block screenshots of the chat
notifyOnScreenshotbooleanscreen_capture: true = notify everyone when someone takes a screenshot
adminOnlyobjectadmin_settings: true = only admins may
titlestringenable_invite: the group name shown on the link preview (stored unencrypted)
avatarUrlstringenable_invite: https image for the preview
welcomeMessagestringenable_invite: welcome text on the invite preview; stored unencrypted
expiresAtstringenable_invite: ISO 8601 or epoch ms; omit for a link that does not expire
confirmbooleantrue only after the user approved this exact delete / leave / removal / link shutdown
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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 @
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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

ParameterTypeDescription
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

edit_telegram_message#

Edit a Telegram post destructive

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. It overwrites the published text, and the previous wording is not kept. Free, 0 credits. Needs Telegram connected.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
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
parseMode"HTML"'HTML' to format the new text with Telegram's HTML tags; omit for plain text
buttonsobject | object[][]URL buttons under the post. An edit REPLACES the buttons with what it sends, so pass them again to keep them; the reply reads back the buttons Telegram now shows.
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

create_linkedin_event#

Create a LinkedIn event

Create an event on a LinkedIn company Page the brand administers: an EXTERNAL online event (a webinar on Zoom, YouTube or your own site — pass url), an IN-PERSON event (pass address), or a LinkedIn Live event (format:"live" — that one needs LinkedIn Live access on the member, and without it LinkedIn refuses with “The member does not have LINKEDIN_LIVE creation access”, measured 2026-08-31). Optionally attach a LinkedIn REGISTRATION FORM (registrationForm:true + privacyPolicyUrl, or your own approved leadFormId); sign-ups arrive as EVENT leads in list_linkedin_leads(leadType:"EVENT"). Read back from LinkedIn. Warning: ANNOUNCING IS A SEPARATE, PUBLIC STEP (announce:true does it in this call): LinkedIn requires a SEPARATE post to make an event publicly visible, so creating one puts nothing in anyone’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. Edit it with update_linkedin_event until it starts. A custom background image is not offered yet. Needs the Events permission (administrator flag) and a reconnect, the same two conditions as list_linkedin_events. 0 credits.

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
descriptionstringevent description shown on the event page
organizationIdstringthe Page — omit if only one is shared with this profile
format"external" | "in_person" | "live"inferred from url / address when omitted; live needs LinkedIn Live access
urlstringexternal: where it streams or where people join; in_person: an info page
addressobjectin_person venue
venueDetailsstringroom, floor, parking…
registrationFormbooleanattach a LinkedIn-built registration form (needs privacyPolicyUrl)
privacyPolicyUrlstringprivacy policy link; required with registrationForm:true
leadFormIdstringor attach your own APPROVED lead form instead of registrationForm
discoveryMode"LISTED" | "URL_ONLY"LISTED (shown in event search) or URL_ONLY (reachable by link only)
announcebooleanALSO publish the Page post that shares the event. LinkedIn makes an event reachable only once a post shares it (an unannounced one stopped being readable within a minute, measured 2026-10-27). PUBLIC — show the user the copy and get a yes first
announceTextstringthe copy of the announcing post

update_linkedin_event#

Edit a LinkedIn event destructive

Edit a LinkedIn event the Page organizes: name, description, start / end time, the join or info url, the in-person address or venue details, or discoveryMode (LISTED / URL_ONLY). LinkedIn’s own rules, quoted back when it refuses: an event can be edited only BEFORE it starts (never once cancelled), and online vs in-person never changes. Read back from LinkedIn. 0 credits.

ParameterTypeDescription
eventIdrequiredstringfrom list_linkedin_events or create_linkedin_event
organizationIdstringPage id that organizes the event; omit when only one is shared
namestringnew event title
descriptionstringnew event description
startsAtstringISO 8601
endsAtstringISO 8601
urlstringjoin or info link for an online or external event
addressobjectin-person venue address: {line1, city, region, postalCode, country}
venueDetailsstringroom, floor or parking notes for an in-person event
discoveryMode"LISTED" | "URL_ONLY"LISTED or URL_ONLY (see create_linkedin_event)

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 profile are readable. Warning: Needs the Events permission: an administrator switches it on for the deployment, and because LinkedIn grants permissions at authorise time and never retroactively, every existing LinkedIn connection must RECONNECT once before it works. The tool tells you which of those two applies rather than failing generically. Read-only, 0 credits. Needs LinkedIn connected.

ParameterTypeDescription
organizationIdstringthe Page to read — omit if only one Page is shared with this profile; list_linkedin_pages shows them
startnumberpagination offset; default 0
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 LinkedIn publishes for each ad: 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. Coverage is global. 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)
startnumberpagination offset; default 0
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
startnumberpagination offset; default 0
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”
pageSizenumbercategories per page
pageTokenstringnext page token from the previous reply

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
languageCodestringBCP 47 language code; default en
showAllbooleantrue = send Google's showAll flag for the full attribute list
pageSizenumberattributes per page; default 200
pageTokenstringnext page token from the previous reply

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. Warning: 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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
urisrequiredstring[]AT-URIs of the posts, at most 25 (Bluesky’s own maximum for one call)
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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"direct or group conversations only
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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.
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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.
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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 character, e.g. the thumbs-up emoji
removebooleantrue to take the reaction off instead of putting it on
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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

ParameterTypeDescription
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

post_to_tiktok#

Post a video or photo post to TikTok destructive

Use this when the user wants to post a video or photo post to TikTok now. Publishing costs no credits. 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. destination:"post" (THE DEFAULT) publishes it LIVE on their profile: TikTok requires the user to CHOOSE the privacy themselves (no default is allowed), so call tiktok_creator_info, show them their real privacy options, and get their choice and an explicit yes before calling. destination:"draft" is ONLY for when the user asks for a draft, or wants to add a TikTok sound or trending audio (TikTok’s API takes no sound for a VIDEO): BEFORE sending, tell them plainly it lands in their TikTok inbox as a DRAFT, that they add the sound in TikTok’s editor, and that THEY must publish it from the TikTok app — nothing goes live until they do. Never pick draft on your own. A photo post published with destination:"post" gets a TikTok-recommended track automatically (autoAddMusic, default on) that they can change in 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 profile has more than one tiktok account connected (several and none named is refused by name, never guessed); omit when there is one.
brandstringWHICH PROFILE this post belongs to — id or exact name from list_brands (a shared workspace: its profile id). Beats the connection's pin for THIS CALL ONLY; a name that matches no profile, or two, is REFUSED and nothing is posted.
hookstringWHAT ANGLE this post is built on, recorded only at publish time. post_performance ranks hooks on it (a winner needs 5 posts sharing ONE hook), so pass a list_hooks id (e.g. "direct_callout", "before_after") or reuse your own wording EXACTLY across a campaign. Omit it and this post never votes on which hook works.
subjectstringWHAT THIS POST IS ABOUT — product, feature, offer or theme (e.g. "winter coat", "free trial"). post_performance's second grouping axis: reuse the exact wording, as with hook.
recipestringthe post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format
ideaIdstringshort id of the content-plan idea this post came from
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" (default) = live on the profile now (needs the privacy the user chose + their explicit yes); "draft" = to their TikTok inbox for them to finish and publish in the app — only when they ask for a draft or want to add a TikTok sound, and only after telling them so.
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.
disableCommentbooleantrue = turn comments off on this post
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
platformCoverbooleanVIDEO COVER. Omit it (the default) and Hermoso sets the video’s best frame — the same frame as its Library thumbnail — as the cover (TikTok video_cover_timestamp_ms, on a direct post — a draft takes no cover, you pick it in the TikTok app). true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins.

tiktok_post_status#

Check whether a TikTok post finished publishing read-only

Follow a TikTok post that post_to_tiktok sent but TikTok was still processing (it came back pending:true). Pass the publishId it returned. Answers one of three states: done (PUBLISH_COMPLETE, with the public `url` once TikTok’s moderation has named it; `urlStatus` is ready, pending, private or draft), failed (with TikTok’s reason), or still processing, which is normal for a few minutes and is NOT a failure. Never post the video again while it is processing. Free, read-only. Needs TikTok connected (Settings > Connectors > TikTok).

ParameterTypeDescription
publishIdrequiredstringthe publishId post_to_tiktok returned
accountstringwhich connected TikTok account the post went out on (@handle or id from list_connector_accounts) when the profile has more than one

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

ParameterTypeDescription
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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. Warning: 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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
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
accountstringwhich connected account: its @handle or id from list_connector_accounts; needed only when several are connected

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

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
bidStrategy"LOWEST_COST_WITHOUT_CAP" | "LOWEST_COST_WITH_BID_CAP" | "COST_CAP" | "LOWEST_COST_WITH_MIN_ROAS"with dailyBudgetUsd: how the campaign bids across its ad sets — LOWEST_COST_WITHOUT_CAP (default: highest volume), LOWEST_COST_WITH_BID_CAP / COST_CAP (each ad set then needs bidAmountUsd), LOWEST_COST_WITH_MIN_ROAS (each ad set needs minRoas)
dailyBudgetUsdnumberoptional campaign daily budget in USD (1–10000); real spend once ACTIVE. With it, the campaign holds the budget (Advantage+ campaign budget) and every ad set under it carries NONE — create_meta_adset / create_meta_ad then send no ad-set budget
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 destructive

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. EVERY MESSAGE SAYS WHAT IT IS: `kind` (text, story_mention, story_reply, share, image, video, audio, file, sticker, gif, template, unsupported, empty), a one-line `summary` to read out, and `attachments` / `story` / `share` / `inReplyTo` / `reactions`; content Meta withholds (unsupported, or a "use the latest app" placeholder) is `unavailable: true` with `unavailableReason`, never blank text. 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.
pageIdstringFacebook Page that owns the DM thread; omit when only one Page is connected

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
humanAgentbooleanHUMAN AGENT — true when a PERSON on the team is personally answering a customer's question more than 24 hours after they last wrote (up to 7 days): Meta's HUMAN_AGENT tag. Never for automated, promotional or unrelated messages. Meta grants the tag only after approving Hermoso's Human Agent feature (App Review, pending); until then Meta refuses it and the reply says so.
conversationIdstringstrongly recommended: it is what lets the 24-hour window be checked BEFORE sending rather than discovered by a refusal
platform"MESSENGER" | "INSTAGRAM"MESSENGER or INSTAGRAM; the channel the conversation is on
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 that owns the conversation; omit when only one Page is connected

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" | "marketing_messages" | "marketing_message_echoes" | "marketing_message_deliveries" | "marketing_message_reads" | "marketing_message_clicks" | "marketing_message_delivery_failed"[]what to be told about. `marketing_messages` is shorthand for the five marketing_message_* outcome fields (sent / delivered / read / clicked / failed of a paid Messenger marketing message, read with get_messenger_marketing_message_status). Pass every field the Page should keep: Meta does not document whether a subscribe merges or replaces the list, and the read-back shows what it recorded. DM automation’s own fields (messaging_postbacks, messaging_referrals) are kept when already present, so subscribing never breaks it. 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 profile 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 profile 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 DM AUTOMATION

list_instagram_dm_automations#

Instagram DM automations read-only

The brand's Instagram DM automations (comment keyword -> DM, DM keyword, story reply, ig.me ref), each with its stats, plus the recent send log and the connected Instagram accounts. Pass `id` for one rule's log. Free.

ParameterTypeDescription
idstringone rule's id; shows only that rule's send log
limitnumberlog rows, newest first (default 30)

save_instagram_dm_automation#

Create or edit an Instagram DM automation destructive

Create (no id) or edit (id) an automation that DMs people automatically: a comment with a keyword on one post / the next post / any post gets a private reply, or a DM / story reply / ig.me link gets an answer. Optional link buttons, public comment replies, follow gate and dry run. Once per person per post; Meta allows private replies within 7 days of the comment. It DMs real people, so confirm the text first. Free, no AI on the send path.

ParameterTypeDescription
idstringrule id to edit; omit to create a new automation
namestringname of the automation, shown in the list
accountstring@handle or id of the Instagram account; omit when only one is connected
triggerobjectwhat starts the rule: {type, keywords, match, scope, ...}; keywords are case-insensitive
messageobjectwhat the person receives: {text, buttons}; up to 3 link buttons
publicRepliesstring[]comment rules: public replies under the comment, rotated (e.g. "Sent! Check your DMs")
followGateobjectsend the content only to followers: they tap a button first
cooldownHoursnumberDM/story/ref rules: answer one person at most once per this many hours (default 24)
enabledbooleanfalse pauses the automation without deleting it
dryRunbooleanlog what would be sent without sending

test_instagram_dm_automation#

Test an Instagram DM automation read-only

Check whether a sample comment or DM would trigger a saved rule and exactly what it would send. Sends nothing. Free.

ParameterTypeDescription
idrequiredstringsaved rule id to test against
textrequiredstringthe sample comment or DM

delete_instagram_dm_automation#

Delete an Instagram DM automation destructive

Delete one automation by id. Nothing further is sent by it; DMs already sent stay. To pause instead, save it with enabled:false. Free.

ParameterTypeDescription
idrequiredstringrule id to delete; messages already sent stay sent

DM AUTOMATIONS ON EVERY CHANNEL

list_dm_automations#

DM automations read-only

The brand's DM automations on Instagram, Facebook (Page comments, ads, Messenger) and X, each with its stats, the recent send log and the connected accounts per channel. Pass `channel` to narrow, `id` for one rule's log. Free.

ParameterTypeDescription
channel"instagram" | "facebook" | "x"instagram, facebook or x; omit for all channels
idstringone rule's id; shows only that rule's send log
limitnumberlog rows, newest first (default 30)

save_dm_automation#

Create or edit a DM automation (Instagram, Facebook, X) destructive

Create (no id) or edit (id) an AUTO-DM / AUTO-REPLY automation: "when someone comments GUIDE on my posts, DM them my guide link", "reply to anyone who DMs PRICE with our pricing page", "reply to people who mention us on X". PAUSE one with {id, enabled:false}; PRACTICE MODE (dryRun:true) logs what it would send and sends nothing. Instagram/Facebook: a keyword comment (one post, the next post, any post; on Facebook also ads) gets a private reply in DMs/Messenger, a DM keyword or ig.me/m.me ref link gets a message, with optional link buttons and rotated public replies. X: a keyword @mention or a reply under your post gets a public reply, a DM keyword gets a DM (X allows an automated DM only after the person DMed you). Once per person per post; a DM saying STOP opts that person out. Instagram and Facebook are free; X is billed in credits for every mention, reply or DM X delivers to the account plus each reply or DM sent, and running out stops the X rules. It messages real people, so confirm the text first.

ParameterTypeDescription
idstringrule id to edit; omit to create a new automation
channel"instagram" | "facebook" | "x"instagram (default), facebook (a Facebook Page: comments on its posts and ads, plus Messenger) or x
namestringname of the automation, shown in the list
accountstringthe account on that channel: @handle (Instagram, X) or Page name/id (Facebook); omit when only one is connected
triggerobjectwhat starts the rule: {type, keywords, match, scope, mediaId, ...}; keywords are case-insensitive
messageobjectwhat the person receives: {text, buttons}; on X the text is the public reply, up to 280 characters
publicRepliesstring[]Instagram/Facebook comment rules: public replies under the comment, rotated
followGateobjectInstagram only: followers get the content after tapping a button
cooldownHoursnumberDM/ref rules: answer one person at most once per this many hours (default 24)
enabledbooleanfalse pauses the automation without deleting it
dryRunbooleanlog what would be sent without sending

test_dm_automation#

Test a DM automation read-only

Check whether a sample comment, mention or DM would trigger a rule (saved by id, or unsaved) and exactly what it would send on which channel; on X it also quotes what X charges for the send. Sends nothing. Free.

ParameterTypeDescription
idstringsaved rule id to test; omit to test a rule that is not saved yet
textrequiredstringthe sample comment, mention or DM
channel"instagram" | "facebook" | "x"instagram (default), facebook (a Facebook Page: comments on its posts and ads, plus Messenger) or x
namestringname of the automation, shown in the list
accountstringthe account on that channel: @handle (Instagram, X) or Page name/id (Facebook); omit when only one is connected
triggerobjectwhat starts the rule: {type, keywords, match, scope, mediaId, ...}; keywords are case-insensitive
messageobjectwhat the person receives: {text, buttons}; on X the text is the public reply, up to 280 characters
publicRepliesstring[]Instagram/Facebook comment rules: public replies under the comment, rotated
followGateobjectInstagram only: followers get the content after tapping a button
cooldownHoursnumberDM/ref rules: answer one person at most once per this many hours (default 24)
enabledbooleanfalse pauses the automation without deleting it
dryRunbooleanlog what would be sent without sending

delete_dm_automation#

Delete a DM automation destructive

Delete one automation by id on any channel. Nothing further is sent by it; messages already sent stay; on X the event subscription is removed when no rule needs it. To pause instead, save it with enabled:false. Free.

ParameterTypeDescription
idrequiredstringrule id to delete, on any channel; messages already sent stay sent

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. RECENT POSTS: pass posts (1–50) to also get that account's latest posts with their like, comment and view counts and links, read first-party from Meta for FREE, for a business or creator account's recent Instagram posts (competitor organic research); page with cursor.

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.
postsintegerhow many of the account’s recent posts to read with like / comment / view counts and links (0–50, default 0). First-party and free
cursorstringnext page of posts — the cursor from the previous reply

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. Whether Meta has approved Hermoso's app for the permission this needs is read LIVE from Meta: when it has not, list_connectors says so under Meta and this tool's refusal says Meta hasn't approved Hermoso for it yet. That is Meta's decision about Hermoso, not the user's connection, so never tell them to reconnect for it.

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. Whether Meta has approved Hermoso's app for the permission this needs is read LIVE from Meta: when it has not, list_connectors says so under Meta and this tool's refusal says Meta hasn't approved Hermoso for it yet. That is Meta's decision about Hermoso, not the user's connection, so never tell them to reconnect for it.

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. Whether Meta has approved Hermoso's app for the permission this needs is read LIVE from Meta: when it has not, list_connectors says so under Meta and this tool's refusal says Meta hasn't approved Hermoso for it yet. That is Meta's decision about Hermoso, not the user's connection, so never tell them to reconnect for it.

ParameterTypeDescription
mediaIdrequiredstringthe numeric Instagram media id, from list_instagram_media
tagsobject[]ADD or MOVE these tags. Omit to just read. ON A PHOTO each tag is {product_id, x, y}: x and y are FRACTIONS of the image, 0.0 (left/top) to 1.0 (right/bottom), 0.5,0.5 is the middle, and both are required. ON A REEL OR VIDEO it is {product_id} ALONE: Instagram takes no position on a video tag, so coordinates sent for one are not used and the reply says so. Max 20 on a photo or feed video, 30 on a Reel. A carousel is tagged on its SLIDES: pass the slide’s own media id.
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 or video onto a Shopify product destructive

Attach a finished IMAGE or VIDEO 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 mediaUrl, which is what every Hermoso render returns. Whether it is a video is read from the file (.mp4 / .mov / .webm, or the link's content type); pass kind to say so outright. An image is fetched by Shopify directly; a video is uploaded to Shopify for you (MP4, MOV or WEBM, up to Shopify's 1 GB and 10-minute product-video limits; a file over a limit is refused by name, never cut). Shopify processes media asynchronously: the reply is the status read back from Shopify, and READY means it is live on the listing while PROCESSING means it will appear when Shopify finishes. 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
mediaUrlstringany public https image or video URL: a Hermoso render works, and so does ANY file of your own brought in with upload_file
imageUrlstringolder name for mediaUrl; still accepted
kind"image" | "video"only when the link does not say (no file extension and no content type)
altstringalt text for accessibility and SEO; defaults to a generic credit

record_shopify_marketing_activity#

Record an ad or campaign in Shopify > Marketing

Record an ad or campaign the merchant is running (on Meta, Google, TikTok, email, anywhere) in their Shopify admin under Marketing, so Shopify's own reports attribute storefront visits and sales to it by its UTM. REPORTING ONLY: it publishes nothing and spends nothing. Pass title, remoteUrl (where the ad is managed, e.g. the Ads Manager link), the utm {campaign, source, medium} the ad's links carry, and optionally tactic (AD default, RETARGETING, POST, LINK, NEWSLETTER, …), channel (SOCIAL default, SEARCH, DISPLAY, EMAIL, REFERRAL), status (ACTIVE default, PAUSED, SCHEDULED, INACTIVE, DELETED_EXTERNALLY), budget {type DAILY|LIFETIME, amount, currency}, adSpend {amount, currency}, start / end / scheduledEnd dates and previewImageUrl. remoteId is YOUR stable id for the activity (the ad platform's campaign id is ideal); calling again with the same remoteId UPDATES that one row, and every field you leave out is KEPT from the existing record (Shopify's own upsert would erase it; the reply lists what was kept). Read back from Shopify. Needs the store to have approved Hermoso's marketing permission: if it has not, the reply says to open Hermoso once from the Shopify admin to approve it. Only works for accounts created by installing Hermoso from the Shopify App Store. Free.

ParameterTypeDescription
titlestringwhat the merchant sees in Shopify > Marketing; required on the first recording
remoteUrlstringhttps link where this ad or campaign is managed or viewed; required on the first recording
remoteIdstringyour stable id for this activity (e.g. the ad platform campaign id); derived from platform + campaignId or the utm campaign when omitted
platformstringwhere it runs, e.g. meta, google_ads, tiktok_ads; only used to derive remoteId
campaignIdstringthe ad platform campaign id; only used to derive remoteId
utmobjectthe UTM the ad links carry; Shopify matches visits on all three
tactic"AD" | "RETARGETING" | "POST" | "LINK" | "AFFILIATE" | "MESSAGE" | "NEWSLETTER" | "ABANDONED_CART" | "LOYALTY" | "NOTIFICATION" | "SEO" | "STOREFRONT_APP" | "TRANSACTIONAL"Shopify marketing tactic, e.g. AD, RETARGETING, POST, NEWSLETTER; default AD
channel"SOCIAL" | "SEARCH" | "DISPLAY" | "EMAIL" | "REFERRAL"SOCIAL, SEARCH, DISPLAY, EMAIL or REFERRAL; default SOCIAL
status"ACTIVE" | "PAUSED" | "SCHEDULED" | "INACTIVE" | "DELETED_EXTERNALLY" | "UNDEFINED"ACTIVE, PAUSED, SCHEDULED, INACTIVE, DELETED_EXTERNALLY or UNDEFINED; default ACTIVE
budgetobjectbudget object: {type DAILY or LIFETIME, amount, currency}
adSpendobjectamount spent so far
startstringISO date the ad started
endstringISO date it ended
scheduledEndstringISO date the activity is scheduled to end
previewImageUrlstringhttps image shown next to the activity (the ad creative works)

list_shopify_marketing_activities#

List Shopify marketing activities read-only

The marketing activities recorded in the merchant's Shopify > Marketing by Hermoso (and other apps): title, tactic, channel, status, UTM, budget, ad spend, dates and the remoteId each was recorded under. Filter by remoteId or a Shopify search query (e.g. title:summer). Newest first; pass cursor for more. Read-only, free. Needs the store to have approved Hermoso's marketing permission. Only works for accounts created by installing Hermoso from the Shopify App Store.

ParameterTypeDescription
limitnumber1–100, default 25
cursorstringpageInfo cursor from the previous reply, to fetch the next page
remoteIdstringreturn only the activity recorded under this remoteId
querystringShopify search syntax, e.g. title:summer or tactic:AD

delete_shopify_marketing_activity#

Delete a Shopify marketing activity destructive

Remove a marketing activity Hermoso recorded from the merchant's Shopify > Marketing, by remoteId or id. Shopify stops attributing visits and sales to it and it cannot be undone, so without confirm:true NOTHING is deleted and the reply names exactly what would go: show the user, get a yes, then call again with confirm:true. Read back afterwards. Only works for accounts created by installing Hermoso from the Shopify App Store. Free.

ParameterTypeDescription
remoteIdstringremoteId of the activity to delete; use this or id
idstringgid://shopify/MarketingActivity/…
confirmbooleantrue to delete; without it the reply names what would be removed and deletes nothing

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?". Needs merchantCenterId from list_merchant_accounts. Read-only and free.

ParameterTypeDescription
merchantCenterIdrequiredstringfrom list_merchant_accounts

Vercel

list_vercel_projects#

List Vercel projects read-only

The brand's Vercel projects (its websites): name, id, framework, production URL and state, the latest deployment (URL, state, target, when), the linked git repo, and whether Web Analytics and Speed Insights are on. search filters by project name. Read-only, free. Needs Vercel connected (Settings > Connectors > Vercel, or connect_connector with an access token).

ParameterTypeDescription
searchstringpart of a project name
limitnumber1-50, default 20
cursorstringnextCursor from a previous call

list_vercel_deployments#

List Vercel deployments read-only

Deployments on the brand's Vercel account, newest first: URL, state (READY, BUILDING, QUEUED, ERROR, CANCELED...), target (production or preview), when it was created and became ready, the commit message and Vercel's inspector link. Filter by projectId, target or state, or pass deploymentId (an id or a deployment URL) to read one, e.g. to see whether a page you just deployed is READY. Read-only, free. Needs Vercel connected (Settings > Connectors > Vercel, or connect_connector with an access token).

ParameterTypeDescription
projectIdstringproject id or name
deploymentIdstringread ONE deployment by id or URL
target"production" | "preview" | "staging"filter by environment: production, preview or staging
state"BLOCKED" | "BUILDING" | "CANCELED" | "DELETED" | "ERROR" | "INITIALIZING" | "QUEUED" | "READY"filter to deployments in this state
limitnumber1-50, default 20
untilstringthe nextCursor a previous page returned — the next (older) page

list_vercel_domains#

List Vercel domains read-only

The domains on the brand's Vercel account (verified or not, when they expire), or with projectId the domains assigned to one project (and any redirect or git branch on them). Read-only, free. Needs Vercel connected (Settings > Connectors > Vercel, or connect_connector with an access token).

ParameterTypeDescription
projectIdstringproject id or name, for that project's domains
limitnumber1-50, default 20

vercel_web_analytics#

Vercel Web Analytics read-only

Vercel WEB ANALYTICS for one project: page views and unique visitors over a window (default the last 30 days, production traffic), and with `by` a breakdown by requestPath (which pages), referrerHostname (where visitors came from), country, deviceType, osName, browserName, route, utmSource / utmMedium / utmCampaign / utmContent / utmTerm (which campaigns and ads brought them), or a time step (day, week, month). Use it to see which landing pages and ad campaigns actually bring traffic. Needs Web Analytics turned on for the project in Vercel. Read-only, free. Needs Vercel connected (Settings > Connectors > Vercel, or connect_connector with an access token).

ParameterTypeDescription
projectIdrequiredstringproject id or name (list_vercel_projects)
bystringone or TWO comma-separated dimensions (at most one time step): requestPath, referrerHostname, country, deviceType, osName, browserName, route, utmSource, utmMedium, utmCampaign, utmContent, utmTerm, environment, flags, flags/<key>, hour, day, week, month, year — e.g. 'day,utmCampaign'
daysnumber1-366, default 30
sincestringYYYY-MM-DD (instead of days)
untilstringYYYY-MM-DD (default now)
limitnumberrows in the breakdown, 1-100, default 10
filterstringVercel's OData filter, e.g. utmSource eq 'facebook'

vercel_event_analytics#

Vercel custom events (conversions) read-only

Vercel CUSTOM EVENTS, the CONVERSIONS on a Vercel-hosted site (signups, purchases, any track('Name') from @vercel/analytics): the count and unique visitors over a window, and with `by` a breakdown by eventName, eventData/<property> (e.g. eventData/plan), utmSource / utmMedium / utmCampaign / utmContent / utmTerm (which ad or campaign drove the conversion), requestPath, country and the rest, or a time step. Up to TWO at once, e.g. by 'utmCampaign,eventName' or 'day,utmCampaign'. eventName narrows to one event. This is the conversion half of an ad report for a landing page on Vercel; vercel_web_analytics is the traffic half. Custom events need Web Analytics on and a Vercel Pro or Enterprise plan. Read-only, free. Needs Vercel connected (Settings > Connectors > Vercel, or connect_connector with an access token).

ParameterTypeDescription
projectIdrequiredstringproject id or name (list_vercel_projects)
eventNamestringonly this event, e.g. 'Signup'
bystringone or TWO comma-separated dimensions: eventName, eventData, eventData/<property>, utmSource, utmMedium, utmCampaign, utmContent, utmTerm, requestPath, referrerHostname, country, deviceType, osName, browserName, route, environment, flags, flags/<key>, or one time step hour/day/week/month/year
daysnumber1-366, default 30
sincestringYYYY-MM-DD (instead of days)
untilstringYYYY-MM-DD (default now)
limitnumberrows in the breakdown, 1-100, default 10
filterstringVercel's OData filter, e.g. utmSource eq 'facebook'

deploy_vercel_page#

Deploy a page to Vercel destructive

Publish a STATIC landing page to the brand's Vercel account from HTML (it becomes index.html) or a few files [{file, data, encoding:'utf-8'|'base64'}], up to 50 files and 4 MB. project names the Vercel project (created if it does not exist; it becomes part of the URL). target 'preview' (default) makes a new preview URL and leaves the live site alone; 'production' REPLACES what the project's domains serve. WITHOUT confirm:true nothing is sent and you get the exact preview to show the user; call again with confirm:true only after they say yes. The answer is Vercel's own deployment row (URL and state); a new deployment is QUEUED or BUILDING for a moment, so read it back with list_vercel_deployments deploymentId until READY. Free. Needs Vercel connected (Settings > Connectors > Vercel, or connect_connector with an access token).

ParameterTypeDescription
projectrequiredstringVercel project name, e.g. spring-sale
htmlstringthe full HTML of the page
filesobject[]instead of (or as well as) html: relative paths with their content
target"preview" | "production"default preview
confirmbooleantrue only after the user approved this exact deploy

delete_vercel_deployment#

Delete a Vercel deployment destructive

PERMANENTLY delete one Vercel deployment: its URL stops working and Vercel has no undo. Without confirm:true nothing is deleted and you get the deployment it would delete (URL, target, state) to show the user; a production deployment the domains still serve takes the site down. Call again with confirm:true only after the user says yes to that exact deployment. Free. Needs Vercel connected (Settings > Connectors > Vercel, or connect_connector with an access token).

ParameterTypeDescription
deploymentIdrequiredstringfrom list_vercel_deployments
confirmbooleantrue only after the user approved deleting this exact deployment

Mailchimp

list_mailchimp_audiences#

List Mailchimp audiences read-only

The brand's Mailchimp AUDIENCES (lists): subscribed member count, total contacts, unsubscribes, double opt-in, the default from name and email, and when each last sent. An audience id is what create_mailchimp_campaign and sync_leads_to_mailchimp take. Read-only, free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).

ParameterTypeDescription
limitnumber
offsetnumber

list_mailchimp_segments#

List Mailchimp segments read-only

The SEGMENTS and tags of one Mailchimp audience with their member counts and type (saved, static/tag, fuzzy). A segment id narrows a campaign to part of the audience. Read-only, free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).

ParameterTypeDescription
audienceIdrequiredstring
limitnumber
offsetnumber

list_mailchimp_campaigns#

List Mailchimp campaigns read-only

The brand's Mailchimp CAMPAIGNS, newest first: title, status (save = draft, schedule, sending, sent, paused), subject, preview text, sender, audience and segment, recipient count, send time. Filter by status or audienceId; or pass campaignId to read ONE with Mailchimp's own send checklist (is it ready, and what is missing). For opens, clicks and revenue use mailchimp_report. Read-only, free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).

ParameterTypeDescription
campaignIdstring
status"save" | "paused" | "schedule" | "sending" | "sent"
audienceIdstring
limitnumber
offsetnumber

list_mailchimp_templates#

List Mailchimp templates read-only

The brand's own Mailchimp EMAIL TEMPLATES (name, id, created, edited). A template id is what create_mailchimp_campaign sends. Read-only, free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).

ParameterTypeDescription
limitnumber
offsetnumber

list_mailchimp_files#

List Mailchimp File Manager images read-only

The pictures in the brand's Mailchimp FILE MANAGER: id, name, hosted URL, size and dimensions, newest first. Read-only, free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).

ParameterTypeDescription
limitnumber
offsetnumber

upload_mailchimp_image#

Upload an image to Mailchimp

Put a picture into the brand's Mailchimp FILE MANAGER for use in emails: a Hermoso render or Library id (list_library gives L0, L1...), a Hermoso link, or an upload_file URL (for an outside picture, call upload_file first). JPEG, PNG and GIF go up as they are; another format or a file over 5 MB is converted to a JPEG first; a video is refused. Returns Mailchimp's own file row (id and hosted URL). create_mailchimp_template uploads its image itself, so call this only to stock the File Manager. Free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).

ParameterTypeDescription
imagerequiredstringa Library id (L0), or a Hermoso or upload_file URL
namestring
folderIdnumbera File Manager folder id

create_mailchimp_template#

Create a Mailchimp email template

Make an EMAIL TEMPLATE in Mailchimp from a creative: image (any reference upload_mailchimp_image takes; it goes into the File Manager first) plus copy YOU write in the brand's voice (read get_brand for voice and colours): headline, body (paragraphs split by a blank line), ctaText + ctaUrl for the button, accent (#RRGGBB button colour). It is laid out as a 600px email with the unsubscribe link and mailing address Mailchimp requires. Or pass html for the user's own markup (that footer is added if it has no *|UNSUB|*). Saves the template only, nothing is sent; returns the templateId for create_mailchimp_campaign. Free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).

ParameterTypeDescription
namestring
imagestringa Library id, or a Hermoso or upload_file URL
imageAltstring
headlinestring
bodystring
ctaTextstring
ctaUrlstring
accentstring#RRGGBB
backgroundstring#RRGGBB
htmlstringyour own full HTML instead of the layout

update_mailchimp_template#

Update a Mailchimp template

Replace a Mailchimp template's HTML (Mailchimp's update replaces the whole template, so pass the full html), optionally with a new name. The unsubscribe footer is added if the html has none. Free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).

ParameterTypeDescription
templateIdrequiredstring
htmlrequiredstring
namestring

delete_mailchimp_template#

Delete a Mailchimp template destructive

PERMANENTLY delete a Mailchimp template. Without confirm:true nothing is deleted and you get the template it would delete; call again with confirm:true only after the user says yes. Free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).

ParameterTypeDescription
templateIdrequiredstring
confirmbooleantrue only after the user approved deleting this template

send_mailchimp_test_email#

Send a Mailchimp test email

Send ONE TEST of a Mailchimp campaign draft to up to 5 addresses the user names (never to the audience), so they can see it in a real inbox. Without confirm:true nothing is sent and you get the preview; call again with confirm:true after they say yes. Free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).

ParameterTypeDescription
campaignIdrequiredstring
recipientsrequiredstring[]up to 5 email addresses
confirmboolean

create_mailchimp_campaign#

Create a Mailchimp campaign draft

Create a Mailchimp email CAMPAIGN DRAFT: templateId (create_mailchimp_template), subject, previewText, audienceId and optionally segmentId (part of the audience), optional name, fromName and replyTo (default: the audience's own defaults). It is created as a DRAFT and NOTHING IS SENT; the answer is Mailchimp's own row read back with its send checklist. To send or schedule it use send_mailchimp_campaign, which shows the recipient count first. Free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).

ParameterTypeDescription
templateIdrequiredstring
subjectrequiredstring
audienceIdrequiredstring
previewTextstring
segmentIdstring
namestring
fromNamestring
replyTostring

update_mailchimp_campaign#

Update a Mailchimp campaign draft

Edit a Mailchimp campaign that is still a DRAFT: name, subject, previewText, fromName, replyTo, templateId, or its audience / segment. A scheduled one must be unscheduled first (cancel_mailchimp_campaign). Read back with its send checklist. Free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).

ParameterTypeDescription
campaignIdrequiredstring
namestring
subjectstring
previewTextstring
fromNamestring
replyTostring
templateIdstring
audienceIdstring
segmentIdstring

send_mailchimp_campaign#

Send or schedule a Mailchimp campaign destructive

SEND or SCHEDULE a Mailchimp campaign draft. An email cannot be unsent, so this is gated on how many people it reaches: without confirm:true nothing is sent and you get Mailchimp's own recipient count with the subject, sender, audience, timing and Mailchimp's send checklist. Show the user that number; only after they say yes call again with confirm:true AND confirmRecipients set to that count (a mismatch, a zero, or a campaign Mailchimp says is not ready is refused). sendTime (ISO, in the future, on :00/:15/:30/:45; Mailchimp schedules only on the quarter-hour) schedules instead of sending now; timewarp:true sends at that time in each recipient's own timezone. Free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).

ParameterTypeDescription
campaignIdrequiredstring
sendTimestringISO date-time on the quarter-hour; omit to send now
timewarpboolean
confirmRecipientsnumberthe recipient count the dry run showed the user
confirmbooleantrue only after the user approved this exact send

cancel_mailchimp_campaign#

Stop a Mailchimp campaign

Stop a Mailchimp campaign: action 'unschedule' puts a scheduled one back to a draft; 'cancel' stops one that is sending (emails already delivered stay delivered). Without confirm:true nothing changes and you get what would happen. Free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).

ParameterTypeDescription
campaignIdrequiredstring
action"unschedule" | "cancel"
confirmboolean

delete_mailchimp_campaign#

Delete a Mailchimp campaign destructive

PERMANENTLY delete a Mailchimp campaign (a draft or a sent one; a scheduled one must be unscheduled first). Without confirm:true nothing is deleted and you get the campaign it would delete; call again with confirm:true only after the user says yes. Free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).

ParameterTypeDescription
campaignIdrequiredstring
confirmbooleantrue only after the user approved deleting this campaign

mailchimp_report#

Mailchimp campaign performance read-only

Mailchimp campaign PERFORMANCE: emails sent, unique opens and open rate (and the rate without machine opens), unique clicks and click rate, unsubscribes, bounces, abuse reports and, where a store is connected to Mailchimp, orders and REVENUE, per campaign sent in the last `days` (default 30), or one campaign by campaignId. Rates are fractions (0.25 = 25%). Read-only, free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).

ParameterTypeDescription
campaignIdstring
daysnumber1-3650, default 30
limitnumber
offsetnumber

sync_leads_to_mailchimp#

Sync lead-form leads to a Mailchimp audience

Put the leads the brand's LEAD FORMS collected (Meta, LinkedIn, TikTok, X Ads, Snapchat, ChatGPT Ads) into a Mailchimp AUDIENCE: each lead with an email is added with their name. By default they are NOT subscribed (a non-marketing 'transactional' contact), because a lead form is not consent Hermoso can vouch for; pass consented:true only when the user confirms the form asked for marketing consent. An existing contact's status is never changed, and synced leads are remembered per audience so a re-run never repeats a write. since (default 7 days ago), platforms narrows it, dryRun:true lists what would be synced. Free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).

ParameterTypeDescription
audienceIdrequiredstring
consentedboolean
sincestringISO date; default 7 days ago
platforms"meta" | "linkedin" | "tiktok" | "x" | "snapchat" | "chatgpt" | "reddit"[]
dryRunboolean

Klaviyo

list_klaviyo_lists#

List Klaviyo lists read-only

The brand's Klaviyo LISTS with how many profiles each holds (profile count), single or double opt-in, and when each was created and updated, newest first. Klaviyo pages lists 10 at a time: pass nextCursor for more, or name for one exact list. A list id is a campaign audience (create_klaviyo_campaign listIds) or a lead-sync target. Read-only, free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).

ParameterTypeDescription
namestringan exact list name
limitnumber
cursorstringnextCursor from a previous call

list_klaviyo_segments#

List Klaviyo segments read-only

The brand's Klaviyo SEGMENTS with how many profiles each holds, whether it is active and still processing, newest first, 10 per page (nextCursor for more). A segment id is a campaign audience (create_klaviyo_campaign segmentIds). Read-only, free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).

ParameterTypeDescription
namestringan exact segment name
limitnumber
cursorstringnextCursor from a previous call

list_klaviyo_campaigns#

List Klaviyo campaigns read-only

The brand's Klaviyo CAMPAIGNS, newest first: name, status (draft, scheduled, sending, sent or cancelled, read off its messages), send time, and the list / segment ids it targets. Filter by name (contains), status or archived; or pass campaignId to read ONE in full with its subject line, preview text, sender and template. For opens, clicks and revenue use klaviyo_report. Read-only, free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).

ParameterTypeDescription
campaignIdstringread one campaign in full
namestringpart of a campaign name
status"draft" | "scheduled" | "sending" | "sent" | "cancelled"
archivedboolean
limitnumber
cursorstringnextCursor from a previous call

list_klaviyo_flows#

List Klaviyo flows read-only

The brand's Klaviyo FLOWS (automations such as a welcome series, abandoned cart, post-purchase): name, status (live, manual, draft), trigger type, archived, created and updated, newest first. Filter by status or name. For a flow's opens, clicks and revenue use klaviyo_report with of:'flows'. Read-only, free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).

ParameterTypeDescription
statusstringlive, manual or draft
namestringpart of a flow name
limitnumber
cursorstringnextCursor from a previous call

list_klaviyo_templates#

List Klaviyo templates read-only

The brand's Klaviyo EMAIL TEMPLATES (name, id, editor type, created, updated), newest first, 10 per page. A template id is what create_klaviyo_campaign sends. Read-only, free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).

ParameterTypeDescription
namestringpart of a template name
limitnumber
cursorstringnextCursor from a previous call

list_klaviyo_images#

List Klaviyo images read-only

The pictures in the brand's Klaviyo IMAGE LIBRARY: id, name, hosted URL, format and size, newest first. Read-only, free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).

ParameterTypeDescription
namestringpart of an image name
limitnumber
cursorstringnextCursor from a previous call

upload_klaviyo_image#

Upload an image to Klaviyo

Put a picture into the brand's Klaviyo IMAGE LIBRARY for use in emails: a Hermoso render or Library id (list_library gives L0, L1…), a Hermoso link, an upload_file URL, or a public https image link (Klaviyo fetches an outside link itself). Klaviyo takes JPEG, PNG or GIF up to 5 MB, so another format or a bigger file is converted to a JPEG first; a video is refused. Returns Klaviyo's own image row (id and hosted URL). create_klaviyo_template uploads its image itself, so call this only to stock the library. Free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).

ParameterTypeDescription
imagerequiredstringa Library id (L0), a Hermoso or upload_file URL, or a public https image URL
namestring
hiddenbooleankeep it out of the visible asset library

create_klaviyo_template#

Create a Klaviyo email template

Make an EMAIL TEMPLATE in Klaviyo from a creative: image (any reference upload_klaviyo_image takes; it goes into the image library first) plus copy YOU write in the brand's voice (read get_brand for voice and colours): headline, body (paragraphs split by a blank line), ctaText + ctaUrl for the button, accent (#RRGGBB button colour). It is laid out as a 600px email with the unsubscribe link and sender address Klaviyo requires. Or pass html for the user's own markup (that footer is added if it has no unsubscribe link). Saves the template only, nothing is sent; returns the templateId for create_klaviyo_campaign. Free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).

ParameterTypeDescription
namestring
imagestringa Library id, Hermoso or upload_file URL, or a public image URL
imageAltstring
headlinestring
bodystring
ctaTextstring
ctaUrlstring
accentstring#RRGGBB
backgroundstring#RRGGBB
htmlstringyour own full HTML instead of the layout
textstringplain-text version

update_klaviyo_template#

Update a Klaviyo template destructive

Change a Klaviyo template: a new name, html (the unsubscribe footer is added if it has none) or plain text. Read back. Free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).

ParameterTypeDescription
templateIdrequiredstring
namestring
htmlstring
textstring

delete_klaviyo_template#

Delete a Klaviyo template destructive

PERMANENTLY delete a Klaviyo template. Without confirm:true nothing is deleted and you get the template it would delete; call again with confirm:true only after the user says yes. Free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).

ParameterTypeDescription
templateIdrequiredstring
confirmbooleantrue only after the user approved deleting this template

send_klaviyo_test_email#

Send a Klaviyo test email

Send ONE TEST EMAIL of a Klaviyo template to up to 5 addresses the user names (never to a list), so they can see it in a real inbox. Without confirm:true nothing is sent and you get the preview; call again with confirm:true after they say yes. Klaviyo allows 100 test sends a day. Free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).

ParameterTypeDescription
templateIdrequiredstring
recipientsrequiredstring[]up to 5 email addresses
confirmboolean

create_klaviyo_campaign#

Create a Klaviyo campaign draft

Create a Klaviyo email CAMPAIGN DRAFT: templateId (create_klaviyo_template), subject, previewText, the audience as listIds and/or segmentIds (excludeIds leaves people out), optional name, fromEmail / fromLabel (default: the account's default sender) and replyTo. It is created as a DRAFT and NOTHING IS SENT; the answer is Klaviyo's own row read back. To send or schedule it use send_klaviyo_campaign, which shows the recipient count first. Free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).

ParameterTypeDescription
templateIdrequiredstring
subjectrequiredstring
previewTextstring
listIdsstring[]
segmentIdsstring[]
excludeIdsstring[]
namestring
fromEmailstring
fromLabelstring
replyTostring

update_klaviyo_campaign#

Update a Klaviyo campaign draft destructive

Edit a Klaviyo campaign that is still a DRAFT: name, subject, previewText, fromEmail, fromLabel, replyTo, templateId, or its audience (listIds / segmentIds / excludeIds). A scheduled one must be reverted first (cancel_klaviyo_campaign). Read back. Free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).

ParameterTypeDescription
campaignIdrequiredstring
namestring
subjectstring
previewTextstring
fromEmailstring
fromLabelstring
replyTostring
templateIdstring
listIdsstring[]
segmentIdsstring[]
excludeIdsstring[]

send_klaviyo_campaign#

Send or schedule a Klaviyo campaign destructive

SEND or SCHEDULE a Klaviyo campaign draft. An email cannot be unsent, so this is gated on how many people it reaches: without confirm:true nothing is sent and you get Klaviyo's own count of recipients with the subject, sender, lists and timing. Show the user that number; only after they say yes call again with confirm:true AND confirmRecipients set to that count (a mismatch, a zero, or a count Klaviyo cannot produce is refused). sendTime (ISO, in the future) schedules instead of sending now; strategy 'local_timezone' sends at that time in each recipient's own timezone. Free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).

ParameterTypeDescription
campaignIdrequiredstring
sendTimestringISO date-time in the future; omit to send now
strategy"static" | "local_timezone"
confirmRecipientsnumberthe recipient count the dry run showed the user
confirmbooleantrue only after the user approved this exact send

cancel_klaviyo_campaign#

Stop a Klaviyo campaign destructive

Stop a Klaviyo campaign that is scheduled or sending: action 'revert' puts it back to a draft (only before it goes), 'cancel' cancels it for good. Emails already delivered stay delivered. Without confirm:true nothing changes and you get what would happen. Free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).

ParameterTypeDescription
campaignIdrequiredstring
action"revert" | "cancel"
confirmboolean

delete_klaviyo_campaign#

Delete a Klaviyo campaign destructive

PERMANENTLY delete a Klaviyo campaign (a draft, a sent or a cancelled one; a scheduled one must be reverted first). Without confirm:true nothing is deleted and you get the campaign it would delete; call again with confirm:true only after the user says yes. Free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).

ParameterTypeDescription
campaignIdrequiredstring
confirmbooleantrue only after the user approved deleting this campaign

klaviyo_report#

Klaviyo campaign and flow performance read-only

Klaviyo PERFORMANCE for campaigns (default) or flows (of:'flows'): recipients, delivered, unique opens and open rate, unique clicks and click rate, conversions and conversion rate, REVENUE (conversion_value) and revenue per recipient, unsubscribes, bounces and spam complaints, per campaign (or per flow message), over timeframe (last_7_days, last_30_days default, last_90_days, this_month, last_12_months...) or start + end (at most a year). Conversions and revenue count on conversionMetric (default 'Placed Order'). Narrow with campaignIds / flowIds or channel. Rates are fractions (0.25 = 25%). Klaviyo allows only 2 report queries a minute and 225 a day, so ask once with a wide window; the same question within 10 minutes is answered from the last read unless fresh:true. Read-only, free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).

ParameterTypeDescription
of"campaigns" | "flows"
timeframe"last_12_months" | "last_30_days" | "last_365_days" | "last_3_months" | "last_7_days" | "last_90_days" | "last_month" | "last_week" | "last_year" | "this_month" | "this_week" | "this_year" | "today" | "yesterday"
startstring
endstring
statisticsstring[]Klaviyo statistic names, e.g. opens_unique, click_rate, conversion_value
campaignIdsstring[]
flowIdsstring[]
channel"email" | "sms" | "push-notification" | "whatsapp"
conversionMetricstringmetric name or id; default 'Placed Order'
freshboolean

sync_leads_to_klaviyo#

Sync lead-form leads to a Klaviyo list

Put the leads the brand's LEAD FORMS collected (Meta, LinkedIn, TikTok, X Ads, Snapchat, ChatGPT Ads) into a Klaviyo LIST: each lead with an email becomes a Klaviyo profile (name and the form it came from) and joins listId. Nobody is SUBSCRIBED to marketing: a lead form is not consent Hermoso can vouch for, so their consent status is unchanged. Synced leads are remembered per list, so a re-run never repeats a write. since (default 7 days ago), platforms narrows it, dryRun:true lists what would be synced. Needs profiles:write and lists:write on the key. Free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).

ParameterTypeDescription
listIdrequiredstring
sincestringISO date; default 7 days ago
platforms"meta" | "linkedin" | "tiktok" | "x" | "snapchat" | "chatgpt" | "reddit"[]
dryRunboolean

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 destructive

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, hubspotOwnerId (the sales rep to assign it to — an owner id from list_hubspot_owners), 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
firstnamestringcontact first name
lastnamestringcontact last name
phonestringcontact phone number
companystringcompany name on the contact
websitestringcompany website URL
lifecyclestagestringHubSpot lifecycle stage internal value, e.g. lead or customer
hs_lead_statusstringHubSpot lead status internal value, e.g. NEW
hubspotOwnerIdstringHubSpot owner id (digits) from list_hubspot_owners
ownerIdstringolder name for hubspotOwnerId (MCP only)
propertiesobjectany other writable contact properties, by internal name

sync_leads_to_hubspot#

Push lead-form leads into HubSpot destructive

Push the leads the brand's LEAD FORMS collected into HubSpot as contacts — Meta, LinkedIn, TikTok (every published Instant Form, every lead region), X Ads, Snapchat and ChatGPT Ads (the last two: leads delivered to Hermoso by their lead webhooks). Each lead is upserted by email with lead status NEW and the platform + form + campaign 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 scheduled 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, or cannot be read (Reddit has no API for leads), is REPORTED, not fatal. platforms narrows it. dryRun:true lists what would be synced and writes nothing. set_hubspot_lead_sync runs it automatically. Free. Needs HubSpot connected, plus the platforms whose leads you want.

ParameterTypeDescription
sincestringISO date; default 7 days ago
platforms"meta" | "linkedin" | "tiktok" | "x" | "snapchat" | "chatgpt" | "reddit"[]default every connected one
tiktokRegions"us" | "eu" | "other"[]TikTok stores leads per region; default all three
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, BY CAMPAIGN (source -> platform -> campaign from HubSpot's Original Source drill-downs, so PAID_SOCIAL splits into the actual campaigns), BY OWNER (the sales rep holding each deal, by name), 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

list_hubspot_companies#

List or search HubSpot companies read-only

COMPANIES (accounts) from the brand's OWN HubSpot CRM, newest first: name, domain, website, LinkedIn company page, industry, employees, annual revenue, lifecycle stage, lead status, country/city, owner (rep name), original source + drill-downs, associated contacts and deals, total revenue — plus a de-duplicated `domains` list, which is what LinkedIn company targeting and account-based (ABM) audiences are built from. search matches name / domain / website / phone; filters lifecycleStage (e.g. customer, opportunity, salesqualifiedlead), industry (HubSpot's internal value, e.g. COMPUTER_SOFTWARE), hubspotOwnerId, createdSince (YYYY-MM-DD). Read-only, free. Needs HubSpot connected.

ParameterTypeDescription
searchstringname, domain, website or phone
lifecycleStagestringinternal value, e.g. customer, opportunity, salesqualifiedlead, lead
industrystringHubSpot's internal industry value, e.g. COMPUTER_SOFTWARE
hubspotOwnerIdstringowner id from list_hubspot_owners
ownerIdstringolder name for hubspotOwnerId (MCP only)
createdSincestringYYYY-MM-DD
limitnumber1–100, default 25
afterstringpagination cursor from the previous page

list_hubspot_owners#

List HubSpot owners (sales reps) read-only

The sales reps (OWNERS) in the brand's HubSpot: id, name, email, teams. An owner's id is what create_hubspot_contact's hubspotOwnerId assigns a lead to, and what list_hubspot_companies filters by. search narrows by name or email; archived:true lists DEACTIVATED owners instead. Read-only, free. Needs HubSpot connected.

ParameterTypeDescription
searchstringname or email contains
archivedbooleantrue = deactivated owners only

get_hubspot_lead_sync#

Read the automatic HubSpot lead sync read-only

Whether the brand's lead-form leads are pushed into HubSpot AUTOMATICALLY: on or off, how often (everyMinutes), which platforms, when it runs next, and what the last run did — counts per platform, or why it did not run (HubSpot disconnected, a platform not readable). Read-only, free. Needs HubSpot connected.

No parameters.

set_hubspot_lead_sync#

Turn the automatic HubSpot lead sync on or off

Switch the AUTOMATIC HubSpot lead sync on or off for this brand: enabled, everyMinutes (a whole number from 15 to 1440; default 60 — anything outside that is refused, not clamped), optional platforms (meta, linkedin, tiktok, x, snapchat, chatgpt, reddit; default every connected one). Each run is sync_leads_to_hubspot reading from the last successful run, so a new lead reaches the CRM within minutes and nothing is ever created twice. If HubSpot is disconnected a run records that and the schedule backs off to once a day — no emails. runNow:true runs the first one straight away. Answers with the stored setting and the armed run, read back. Free. Needs HubSpot connected to switch on.

ParameterTypeDescription
enabledrequiredbooleantrue turns the automatic lead sync on; false turns it off
everyMinutesnumber15–1440, default 60
platforms"meta" | "linkedin" | "tiktok" | "x" | "snapchat" | "chatgpt" | "reddit"[]omit for every connected one
runNowbooleantrue = run the first sync immediately

Intercom

list_intercom_conversations#

List Intercom conversations read-only

Support conversations from the brand's OWN Intercom inbox, newest activity first: subject, state, whether the customer is waiting, assignee, customer email, channel, tags and the first message. One Intercom workspace can serve several products, so every filter below combines with the others. view: open (default), unassigned, waiting (customer waiting on a reply), snoozed, closed, recent or all. days bounds it by last update (default 30), or use updatedAfter / updatedBefore / createdAfter / createdBefore (dates). text matches words in the first message; email is the customer's exact email, customer is part of their name or email; contactId; assigneeId (a teammate) or teamId (ids from list_intercom_teammates); tagId or tagName; channel (conversation is the Messenger, or email, whatsapp, sms and so on); priority true or false; unread true. app is the product the customer wrote from (their contact's current_app, case-insensitive, for example Hermoso); contactAttributes and attributes match other custom attributes of the customer or of the conversation. Intercom's search cannot filter on custom attributes or app, so those are matched on the pages read and the answer says how many it read. Pass cursor from the last answer for the next page. Read-only, free. Needs Intercom connected (Settings > Connectors > Intercom, or connect_connector).

ParameterTypeDescription
view"open" | "unassigned" | "waiting" | "snoozed" | "closed" | "recent" | "all"inbox view; default open
daysnumber1-365, default 30: conversations updated in the last N days
updatedAfterstringdate such as 2026-10-01; replaces days
updatedBeforestringdate
createdAfterstringdate
createdBeforestringdate
textstringwords that appear in the first message
emailstringthe customer's exact email
customerstringpart of the customer's name or email
contactIdstringan Intercom contact id
assigneeIdstringthe teammate id it is assigned to (list_intercom_teammates)
teamIdstringthe team id it is assigned to (list_intercom_teammates)
tagIdstringIntercom tag id to filter by, from the Intercom tags list
tagNamestringa tag name, resolved to its id; use instead of tagId
channel"conversation" | "email" | "facebook" | "instagram" | "phone_call" | "phone_switch" | "push" | "sms" | "twitter" | "whatsapp"where the conversation started; conversation is the Messenger
prioritybooleantrue = only priority conversations, false = only non-priority
unreadbooleantrue = only conversations no teammate has read
appstringthe product the customer wrote from: their contact's current_app attribute, for example Hermoso
contactAttributesobject{ name: value } custom attributes of the customer's contact record
attributesobject{ name: value } custom attributes of the conversation
limitnumber1-50, default 20
cursorstringnextCursor from the previous answer

read_intercom_conversation#

Read an Intercom conversation read-only

One Intercom conversation in full: every message in order (customer, teammate, bot, internal note) as plain text, plus its state, assignee and tags. Read it before drafting a reply. Read-only, free. Needs Intercom connected (Settings > Connectors > Intercom, or connect_connector).

ParameterTypeDescription
conversationIdrequiredstringthe conversation id from list_intercom_conversations

reply_to_intercom_conversation#

Reply to an Intercom customer destructive

Send a reply to the CUSTOMER on an Intercom conversation, from the brand's inbox as the teammate who owns the connection. This reaches a real customer and cannot be unsent. Read the conversation first (read_intercom_conversation), draft the reply in the brand's voice, show the user the exact words, and send only when they ask: call with confirm:true. Without confirm it sends nothing and returns the preview. The result is read back from the conversation Intercom returns. Plain text; blank lines become paragraphs. Never leave a placeholder such as [Your name] or [Order number] in it: Intercom already shows the customer which teammate replied, so sign with the brand's name or leave it unsigned, and ask the user for any fact you do not have. Free. Needs Intercom connected (Settings > Connectors > Intercom, or connect_connector).

ParameterTypeDescription
conversationIdrequiredstringIntercom conversation id, from list_intercom_conversations
messagerequiredstringthe exact reply the customer will read, with no [placeholders]
confirmbooleantrue only after the user approved this exact reply

add_intercom_note#

Add an internal Intercom note destructive

Leave an INTERNAL NOTE on an Intercom conversation: only the brand's teammates see it, never the customer (a summary, a suggested reply, a handover). Without confirm:true it adds nothing and returns the preview; call again with confirm:true once the user agrees. Read back from the conversation. Free. Needs Intercom connected (Settings > Connectors > Intercom, or connect_connector).

ParameterTypeDescription
conversationIdrequiredstringIntercom conversation id, from list_intercom_conversations
noterequiredstringthe note text teammates will read
confirmbooleantrue once the user agreed to add this note

manage_intercom_conversation#

Close, snooze, reopen or assign an Intercom conversation destructive

Close, snooze, reopen or assign an Intercom conversation in the brand's shared inbox. action: close | snooze (needs snoozeUntil, a future date-time) | open | assign (assigneeId = a teammate id, or teamId, or assignToMe:true, or unassign:true; ids from list_intercom_teammates). close and assign may carry a message the CUSTOMER sees. Without confirm:true nothing changes and the preview comes back; call again with confirm:true when the user agrees. The new state is read back. Free. Needs Intercom connected (Settings > Connectors > Intercom, or connect_connector).

ParameterTypeDescription
conversationIdrequiredstringIntercom conversation id, from list_intercom_conversations
actionrequired"close" | "snooze" | "open" | "assign"close, snooze (needs snoozeUntil), open or assign
snoozeUntilstringISO date-time in the future, for snooze
assigneeIdstringteammate id to assign to, from list_intercom_teammates
teamIdstringteam id to assign to, from list_intercom_teammates
assignToMebooleantrue = assign to the connected teammate
unassignbooleantrue = remove the current assignee
messagestringoptional, close or assign only: the customer sees it
confirmbooleantrue once the user agreed to this change

list_intercom_tags#

List Intercom tags read-only

The tags in the brand's Intercom workspace (id and name), for tag_intercom_conversation or to filter list_intercom_conversations. Read-only, free. Needs Intercom connected (Settings > Connectors > Intercom, or connect_connector).

No parameters.

tag_intercom_conversation#

Tag an Intercom conversation destructive

Add an Intercom tag to a conversation, or remove it (remove:true). Name it by tagId or exact tagName (list_intercom_tags); a tagName the workspace does not have yet is created only with create:true (ask the user first). Internal to the inbox: the customer never sees tags. Read back. Free. Needs Intercom connected (Settings > Connectors > Intercom, or connect_connector).

ParameterTypeDescription
conversationIdrequiredstringIntercom conversation id to tag
tagIdstringIntercom tag id from list_intercom_tags; use this or tagName
tagNamestringexact tag name, as an alternative to tagId
removebooleantrue = remove the tag from the conversation; omit or false = add it
createbooleantrue = create tagName if the workspace has no such tag

search_intercom_contacts#

Search Intercom contacts read-only

Find people in the brand's Intercom: by exact email, or by part of a name or email (query). Returns id, role (user or lead), name, email, phone, created and last seen. Read-only, free. Needs Intercom connected (Settings > Connectors > Intercom, or connect_connector).

ParameterTypeDescription
emailstringexact email
querystringpart of a name or email
limitnumber1-50, default 10
cursorstringnextCursor from a previous result, to get the next page

list_intercom_teammates#

List Intercom teammates and teams read-only

The brand's Intercom teammates and teams with their ids, for assigning a conversation or filtering the list by assignee. Read-only, free. Needs Intercom connected (Settings > Connectors > Intercom, or connect_connector).

No parameters.

intercom_customer_language#

Customer language from Intercom read-only

What CUSTOMERS wrote in the brand's Intercom: reads recent conversations in full and returns only the customers' own words (no teammate or bot text; emails, phone numbers and links redacted; no names). Use it for real pains, objections, questions and exact phrases for ad copy, hooks, angles and research, and quote their words rather than paraphrasing. days (default 30), conversations to read (default 25, at most 60), optional text or tagId to focus it. Read-only, free. Needs Intercom connected (Settings > Connectors > Intercom, or connect_connector).

ParameterTypeDescription
daysnumberlook-back window in days for conversations
conversationsnumber1-60, default 25
textstringonly read conversations containing this text
tagIdstringonly read conversations with this Intercom tag id (list_intercom_tags)
view"open" | "closed" | "all"conversations to read: open, closed or all

TIKTOK ACCOUNT

tiktok_account_status#

Check the TikTok account authorization read-only

Report whether this profile 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 profile 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"likes, replies or create_time
sortOrder"asc" | "desc"asc or desc
cursornumberpagination cursor from the previous reply's cursor field
maxCountnumbernumber of comments to return in this page

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"PUBLIC or ALL (ALL includes hidden replies)
sortField"likes" | "replies" | "create_time"likes, replies or create_time
sortOrder"asc" | "desc"asc or desc
cursornumberpagination cursor from the previous reply's cursor field
maxCountnumbernumber of replies to return in this page

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
imageWidthnumberpixel width of the image sent as imageUri (from upload_tiktok_comment_image)
imageHeightnumberpixel height of the image sent as imageUri

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"LIKE, UNLIKE, HIDE, UNHIDE or DELETE; DELETE only works on comments this account wrote
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 destructive

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. For somebody ELSE's post the code still comes from that creator, copied out of the TikTok app. 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 profile 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"CREATE_TIME, LIKES, COMMENTS or SHARES
sortOrder"ASC" | "DESC"ASC or 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
cursornumberpagination cursor from the previous reply to fetch the next page
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"VIDEO_LIKES, COMMENT_CREATE_TIME or COMMENT_LIKES
sortOrder"ASC" | "DESC"ASC or DESC
fieldsstring[]fields to return; omit for every field TikTok publishes
cursornumberpagination cursor from the previous reply to fetch the next page
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"ADD (a list of up to 10 tags) or REMOVE (exactly one tag)
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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
hashtagstringone ENABLED tag, spelled exactly as enabled. The match is case-sensitive
daysnumber1 to 90. Default 90
regionsstring[]two-letter country codes matched against the posting author's registration country, e.g. US
sortField"CREATE_TIME" | "LIKES" | "COMMENTS" | "SHARES"CREATE_TIME, LIKES, COMMENTS or SHARES
sortOrder"ASC" | "DESC"ASC or DESC
fieldsstring[]fields to return; omit for every field TikTok publishes
cursornumberpagination cursor from the previous reply to fetch the next page
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 profile 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"TikTok business category, e.g. BEAUTY; one of TikTok's published values

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 by name, e.g. "Canada" returns Canada rather than every place TikTok has; REQUIRED for interest_keyword and hashtag
regionCodestringlocation only: two-letter country code, e.g. CA, to list the places inside one country
level"country" | "province" | "city" | "district"location only: return one level
limitnumberrows to return, default 50, max 200; total always counts every match

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 profile 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 profile
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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
postUrnrequiredstringthe full LinkedIn post urn returned by publishing
actionrequired"edit" | "delete"edit changes the copy only; delete removes the post and needs confirm
textstringthe new copy, for action:"edit"
confirmbooleanREQUIRED true to delete

linkedin_profile_followers#

Followers of a personal LinkedIn profile read-only

Follower count of the PERSONAL LinkedIn profile shared with this brand — the lifetime total and, with startDate / endDate, the new followers per day (LinkedIn’s memberFollowersCount). This is the creator / founder growth number; Company Page followers are in linkedin_page_analytics. It needs a personal profile ticked in Manage accounts (list_connector_accounts provider linkedin, then set_connector_accounts with its id to tick it) and the LinkedIn analytics authorization on the LinkedIn card — if the reply says re-authorize, say exactly that. Read-only, free.

ParameterTypeDescription
startDatestringYYYY-MM-DD — include for a daily series
endDatestringYYYY-MM-DD, exclusive, default today

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 profile
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
demographicsbooleanalso return WHO follows the Page and who visits it (all-time, LinkedIn’s lifetime statistics): the top seniorities, industries, job functions, countries, regions and company sizes, each named — the core B2B "who is our audience" answer

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"[]filter campaigns by ACTIVE, PAUSED, ARCHIVED or 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 report job title, seniority, company size and the other member demographics. 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
adAccountIdstringLinkedIn ad account id from list_linkedin_ads_campaigns
campaignIdsstring[]limit the report to these campaign ids; omit for all
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 …). Make a new one with create_linkedin_lead_form; edit or archive one with update_linkedin_lead_form. 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
untilstringend of the time window, ISO date or epoch milliseconds; omit for now
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
leadIdrequiredstringlead id from list_linkedin_leads
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
limitnumberevents to return, newest first; default 50, max 200

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
leadTypestringSPONSORED for an ad account; COMPANY, EVENT or ORGANIZATION_PRODUCT for a Page; defaults by owner

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
subscriptionIdrequiredstringwebhook id from list_linkedin_lead_subscriptions
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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
postIdrequiredstringthe post id returned by post_to_meta — for Instagram, the media id from list_instagram_media
actionrequired"edit" | "delete" | "cover"edit the text (FB only), delete the post, or cover: replace the cover of a published Facebook video or Reel (postId = its video id)
target"facebook" | "threads" | "instagram"default facebook
coverAtMsnumberaction:"cover" — the new cover frame, in milliseconds from the start (Facebook only; Instagram, LinkedIn and Pinterest cannot change a published cover)
coverImageUrlstringaction:"cover" — a Hermoso-hosted picture as the new cover, instead of a frame (a render, or any file through upload_file)
videoUrlstringaction:"cover" with coverAtMs — the Hermoso file the video was published from, to cut the frame from (else Facebook’s own copy is used)
messagestringthe new post text (action:"edit" on facebook)
pageIdstringwhich Page to use — needed when the post id has no page prefix, or when the profile 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. 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
taggedbooleanINSTAGRAM — list posts OTHER accounts tagged the brand in (UGC, creator posts, partner mentions), with each author’s @handle, instead of the brand’s own posts. Free.
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
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 profile 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, newest first, across all channels — channel, permalink, caption, format, the HOOK and SUBJECT it was written to, and its measured engagement. The WHOLE history, no cap: pass the reply's nextCursor as `cursor` for older posts. Each row says how it was recorded: 'captured' (written at publish time — the hook is what the author intended) or 'backfilled' (reconstructed from the platform afterwards). A dash for engagement means the platform reported no number — NOT zero. Read-only, 0 credits.

ParameterTypeDescription
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
channelstringfilter to one channel: facebook, instagram, threads, x, linkedin, youtube, tiktok, pinterest, bluesky, google_business
limitnumbermax posts (default 50, max 200), newest first
cursorstringnextCursor from a previous reply: the next, older page

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, pinterest, bluesky)
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.

list_templates#

Industry ad templates read-only

Ready-to-run ad BRIEFS by industry: dropshipping, ecommerce, mobile apps, restaurants, real estate and local services (12 trades: plumbers, electricians, hvac, roofers, landscapers, house-cleaners, pest-control, auto-repair, salons, med-spas, dentists, gyms). Compact list by default; pass `id` for one template in full: its brief (filled with the active profile's name, and the trade's specifics when `trade` is given), what the user must supply, the recommended format/aspect/length, and its web deep link. To run one, fill the [square-bracket] slots from what the user gave you, then pass the brief to plan_ad as `product` (with its format and durationSeconds) and render the plan (render_ad for video, generate_image for images). Every part is editable: a template is a shortcut, not a lane, so change or ignore anything that does not fit. These are NOT the native HTML formats of make_template_ad. Read-only, 0 credits.

ParameterTypeDescription
verticalstringdropshipping | ecommerce | mobile-app | restaurants | real-estate | local-services; omit for all
tradestringfor local services: plumbers, electricians, hvac, roofers, landscapers, house-cleaners, pest-control, auto-repair, salons, med-spas, dentists or gyms. Fills the trade specifics into the brief
idstringone template id (from the list) to return in full, with its brief

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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
axis"hook" | "subject" | "recipe" | "channel" | "media" | "hour"what to group by — default hook; recipe = the format of the creative
channelstringrestrict to one channel
daysnumberlook back N days (1-730) over the whole history; omit for the recent posts only

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, pinterest, bluesky
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
brandstringWHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done.
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 has 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 profile has more than one