hermosodocs

Publishing & scheduling

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

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

THE INBOX

post_to_bluesky#

Post to Bluesky

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

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

delete_bluesky_post#

Delete a post from the connected Bluesky account destructive

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

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

TELEGRAM

post_to_telegram#

Post to Telegram

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

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

list_telegram_chats#

Find Telegram chat ids read-only

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

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

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

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

list_inbox#

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

EVERYTHING PEOPLE SAID TO THIS BRAND, across every connected channel, in one list: Facebook and Instagram comments, Threads replies and mentions, YouTube and Reddit comments, Google Business reviews, Bluesky replies and mentions, and X mentions. Use this for 'what do I need to reply to', 'any new comments', '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. Free — it only re-reads what the per-channel tools already read.

ParameterTypeDescription
sourcesstring[]Limit to these sources: facebook, instagram, threads, threads_mention, youtube, reddit, google_business, x, bluesky, bluesky_mention. Omit for every connected channel.
postIdstringRestrict Facebook/Instagram to one post or media id (Meta reads comments per post).
videoIdstringRestrict YouTube to one video.
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 PUBLIC and posted as the brand, so show the user the exact wording and get their go-ahead first. X is the one exception and says so: a reply there is a new post, so it goes through post_to_x with replyTo.

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

hermoso_capabilities#

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

Probe what this Hermoso account can do RIGHT NOW: available image/video model ids + their exact credit costs, aspect ratios, video durations, the recipe ids, and the canEdit/canAvatar flags. Call this FIRST so you generate with valid model ids and known costs. Read-only, free.

No parameters.

hermoso_credits#

Credit balance read-only

Return the account credit balance, the credits this account has spent on the calls listed, and those recent priced calls. Check before kicking off paid generation.

No parameters.

buy_credits#

Buy credits

Out of credits? Top up with a credit PACK. Call with no argument to list the available packs (id · credits · price). If the account has a saved card and you have billing-admin rights, calling with `pack` quotes the exact charge and calling again with confirm:true AND the quote's quote_token charges the saved card instantly (same one-click top-up as the app — no redirect). If there's no saved card yet, you get a Stripe checkout URL to hand your human for the FIRST purchase; their card saves for one-click after that. Packs only; subscriptions are managed by a person in Settings → Billing. To stop running out entirely, turn on auto-reload with set_auto_reload (admin) — low balances then top themselves up from the saved card automatically.

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

META engagement + insights

meta_page_insights#

Facebook Page + Instagram insights read-only

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

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

meta_post_insights#

Insights for one Facebook/Instagram post read-only

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

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

instagram_insights#

Instagram account insights + audience demographics read-only

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

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

list_instagram_media#

List the brand’s Instagram posts read-only

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

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

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

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

reply_to_meta_comment#

Reply to a Facebook/Instagram comment

Post a public reply to a comment on the brand’s Facebook or Instagram post. This is PUBLIC and posted as the brand — show the user the exact wording and get their go-ahead first.

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

moderate_meta_comment#

Hide, unhide or delete a Meta comment destructive

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

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

THREADS read + manage

list_threads_posts#

List your Threads posts read-only

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

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

threads_insights#

Threads insights read-only

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

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

list_threads_replies#

List replies on a Threads post read-only

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

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

reply_to_thread#

Reply on Threads

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

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

hide_thread_reply#

Hide or unhide a Threads reply

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

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

delete_thread#

Delete a Threads post destructive

PERMANENTLY delete one of the brand’s Threads posts. IRREVERSIBLE — Threads has no undelete. Call it WITHOUT confirm first: nothing is deleted, and it answers with the post’s real text plus its views, likes, replies and reposts read back from Threads. Show the user that, get an unambiguous yes, then call again with confirm:true — plus confirmName (the post’s exact text as it was reported) once anyone has engaged with it. confirmName exists because confirming that you meant to delete SOMETHING does not prove you aimed at the right post, and a wrong id must not be confirmable blind. Threads allows only 100 deletions per account per rolling 24 hours; threads_publishing_limit says how many are left, and a quota refusal otherwise reads like a broken connection. Note Meta documents nothing about what a delete does to the replies underneath a post, so do not promise the conversation survives. 0 credits.

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

repost_thread#

Repost a Threads post

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

ParameterTypeDescription
postIdrequiredstringthe Threads post id to repost

threads_publishing_limit#

Threads quota remaining read-only

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

No parameters.

list_threads_mentions#

Threads mentions of the brand read-only

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

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

search_threads_keyword#

Search Threads by keyword read-only

Search PUBLIC Threads posts for a keyword or topic — competitor listening, finding what people say about a product, or sourcing real customer language for ad copy. Distinct from search_threads, which reads a specific profile.

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

META publishing + ads management

list_meta_pages#

List Meta pages & ad accounts read-only

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

No parameters.

upload_file#

Upload a local file → durable public URL

Persist an ARBITRARY user file (image, video, audio, PDF or document, up to 150MB) into Hermoso and get back a durable public URL that EVERY publish, schedule and ad-build tool accepts — post_to_meta / post_to_linkedin / post_to_linkedin_page / post_to_youtube / post_to_tiktok / post_to_pinterest / post_to_x / post_to_reddit / post_to_google_business / schedule_post / upload_meta_asset / upload_google_ads_asset / create_meta_ad / create_linkedin_ads_creative / set_youtube_thumbnail / save_to_drive / save_to_onedrive. THIS IS THE BRING-YOUR-OWN-CREATIVE PATH: it is for files that have NOTHING to do with a Hermoso render (media on the user's desktop, an agency's finished ad, a photo they shot), and it means you can publish, schedule and run ads through Hermoso without generating anything here. Provide exactly ONE source — 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
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

post_to_meta#

Post to Facebook, Instagram or Threads

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

ParameterTypeDescription
hookstringWHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer "which hooks work", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. "direct_callout", "mid_problem", "before_after") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works.
subjectstringWHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. "winter coat", "free trial", "founder story"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group.
messagestringpost text / caption
imageUrlstringpublic https URL, a data: URI, or a Hermoso /generated path (upload_file gives you one for a local file)
videoUrlstringpublic https URL, data: URI, or /generated path — FB video post / IG Reel
imageUrlsstring[]CAROUSEL — an ORDERED list of image (and, where the channel allows, video) URLs published as ONE post the viewer swipes through. THIS IS NOT “post several” — it is a single post with several slides, which is what a multi-slide creative (a listicle, a “1/6 · SWIPE” deck) actually needs; publishing only its first slide tells the viewer to swipe at something that cannot. The ORDER is the product. Limits per channel: Instagram 2–10 (images, videos or a mix), Threads 2–20 (mix allowed), Facebook 2+ (Meta publishes no documented maximum; Hermoso caps the upload fan-out at 30 and says so), LinkedIn company Pages 2–20 (images only), Pinterest 2–5 (images only), TikTok up to 35. One url here is simply an ordinary single post. Anything a channel cannot do is REFUSED with the real reason — nothing is ever quietly downgraded to one slide.
idempotencyKeystringSAFE RETRIES. Publishing can take minutes (a video upload, a carousel of ten slides) and a transport can time out while the post SUCCEEDS — retrying blind is how the same thing gets posted twice. Pass any stable string here and a repeat of the SAME publish returns the ORIGINAL post id instead of posting again (24h). You do not have to: an identical publish is auto-recognised for 10 minutes anyway. If a call times out or errors ambiguously, CALL AGAIN WITH THE SAME KEY — that is the safe move, and it will either report the original post or publish it for the first time. It never posts twice.
allowDuplicatebooleanpost it even though an identical post was just made or attempted. Only pass this when the user genuinely wants the same thing posted twice, or when you have LOOKED at the account and confirmed a timed-out attempt did not land.
asyncbooleanpublish in the BACKGROUND and return a job id to poll with get_job, instead of waiting. USE THIS FOR VIDEO: a Facebook or Instagram video publish routinely outlives an agent transport, and a timeout on the synchronous path leaves you unable to tell whether the post is live. With async:true nothing can time out — the job reports the post id and url when it lands.
linkstringa URL to attach (FB text post only)
collaboratorsstring[]INSTAGRAM COLLAB — up to 3 Instagram usernames invited to CO-AUTHOR this post. Once one accepts, the post appears on THEIR profile too, with both handles in the header and the likes and comments shared — it is how a brand reaches a creator’s audience without paying for placement, and it is the single most-asked thing a scheduler normally cannot do. Pass handles only ("hermosoai"), not profile links; a leading @ is fine. INSTAGRAM ONLY — Facebook and Threads have no collab post at all and are refused BY NAME rather than silently dropping the co-authors — and never on a Story. AN INVITE IS NOT A CO-POST: publishing SENDS a request the other account must accept in their Instagram notifications, and until they do the post is on this brand’s profile ALONE; they may also decline, and Instagram sends no notification either way. So never report the post as live on both accounts — read the invite status back out of the reply, and use instagram_collaborators later to find out whether they accepted.
target"facebook" | "instagram" | "threads"default facebook; instagram → the Page’s linked IG; threads → the brand’s connected Threads account
scheduleAtstringFACEBOOK ONLY — schedule instead of posting now. ISO timestamp (2026-08-01T09:00:00Z) or unix seconds; must be 10 minutes to 30 days ahead. Facebook holds the post and publishes it at that time, so nothing has to stay running on our side. Instagram and Threads have NO scheduling in Meta’s API — passing this for them is refused rather than silently posted immediately.
locationIdstringThreads only — a place id from search_threads_locations, to geotag the post to a physical location (restaurant, storefront)
trialReel"MANUAL" | "SS_PERFORMANCE"INSTAGRAM TRIAL REEL — publish this Reel to NON-FOLLOWERS ONLY at first, 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.
altTextstring | string[]ACCESSIBILITY — the description screen readers announce, and what the platform otherwise auto-generates badly or not at all. Describe what is actually IN the picture, never the caption. ONE STRING describes the picture; on a CAROUSEL it describes EVERY slide. Pass an ARRAY of strings instead to describe each slide separately, aligned to the slide order — that is strictly better on a multi-slide post, because one sentence read out over six different pictures is wrong for five of them. More descriptions than pictures is refused rather than dropped. WHERE IT LANDS, per Meta’s own docs: INSTAGRAM image posts and the IMAGE slides of an Instagram carousel (up to 1000 characters; dropped rather than sent on a Reel or a video slide, which Meta do not support); FACEBOOK Page photos including every photo of an album, via alt_text_custom (Meta publish no length for it, so nothing is truncated); THREADS on a single-image or single-video post ONLY — Meta document no way to attach alt text to a Threads CAROUSEL slide, so a Threads carousel publishes undescribed and the reply says so rather than risking the whole post on a guess. (AI disclosure is separate and automatic — every Instagram post Hermoso publishes is flagged is_ai_generated, which is not a caller setting.)
pageIdstringtarget Page id (from list_meta_pages); omit = first Page
linkAttachmentstringTHREADS TEXT POSTS ONLY — a full http(s) URL rendered as a clickable link card. This is how a Threads post carries a destination at all; without it a link is just text. Meta allows at most 5 links per post and refuses a link attachment on a post that also carries an image or video, so Hermoso refuses that combination up front rather than letting Meta silently drop it.
replyControl"everyone" | "accounts_you_follow" | "mentioned_only" | "parent_post_author_only" | "followers_only"THREADS ONLY — who may reply. Default is everyone.
quotePostIdstringTHREADS ONLY — the id of a post to QUOTE. A quote post is a distinct post type, not a formatting option.
topicTagstringTHREADS ONLY — one topic tag for discovery, 1–50 characters. A leading # is stripped for you; Threads refuses "." and "&".
countryCodesstring[]THREADS ONLY — restrict the post to these ISO 3166-1 alpha-2 countries. ⚠ This is an ALLOWLIST, so the post becomes INVISIBLE in every country not named — it narrows reach, it does not target.

SCHEDULING

schedule_post#

Schedule a post for later

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

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

WHICH ACCOUNT

list_scheduled#

List scheduled and past posts read-only

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

No parameters.

reschedule_post#

Change a scheduled post

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

ParameterTypeDescription
idrequiredstringthe scheduled post id from list_scheduled
atstringthe new time — ISO timestamp (2026-08-05T09:00:00Z) or epoch milliseconds. Must be in the future, at most 365 days out.
messagestringreplace the caption used for every channel that has no override
captionsobjectreplaces the WHOLE per-channel caption map — send every override you want to keep, not just the new one
channels"facebook" | "instagram" | "threads" | "tiktok" | "youtube" | "linkedin" | "x" | "pinterest" | "google_business" | "bluesky" | "telegram"[]replaces the channel list
imageUrlstringswap the image; "" removes it
videoUrlstringswap the video; "" removes it
imageUrlsstring[]replace the CAROUSEL slides, in order; an empty array [] drops the carousel and goes back to a single image. Omit to leave the slides exactly as they are. The edited item is re-checked against the same carousel rules the create passed, so adding a channel that cannot swipe is refused now rather than posting slide 1 later.
titlestringPINTEREST / YOUTUBE — replace the headline; "" clears it and goes back to deriving one from the caption
descriptionstringYOUTUBE — replace the video description; "" clears it. Remember the caption is the TITLE, not the description.
tagsstring[]YOUTUBE — replaces the WHOLE tag list; an empty array [] clears the tags.
replyControl"everyone" | "accounts_you_follow" | "mentioned_only" | "parent_post_author_only" | "followers_only"THREADS ONLY — who may reply.
quotePostIdstringTHREADS ONLY — the id of the Threads post this one quotes.
linkAttachmentstringTHREADS ONLY — a full http(s) URL rendered as a link card on a TEXT-ONLY post. The only way a Threads post carries a destination.
topicTagstringTHREADS ONLY — one topic tag, without the leading #.
countryCodesstring[]THREADS ONLY — two-letter country codes limiting who can see the post.
slideTextobject[]PINTEREST CAROUSEL ONLY — per-slide title / description / link, aligned to slide order.
privacyLevel"PUBLIC_TO_EVERYONE" | "MUTUAL_FOLLOW_FRIENDS" | "FOLLOWER_OF_CREATOR" | "SELF_ONLY"TIKTOK — WHICH PRIVACY LEVEL the post goes out at, in TikTok’s own vocabulary. TikTok requires the USER to choose this from the levels their own account allows: call tiktok_creator_info, show them the real options, and pass back the one they picked — never a default and never a guess, which TikTok refuses at init. Omit it and the post falls back to the coarse `visibility` (private → SELF_ONLY, otherwise PUBLIC_TO_EVERYONE), which cannot express MUTUAL_FOLLOW_FRIENDS or FOLLOWER_OF_CREATOR at all. It must agree with the TikTok visibility (SELF_ONLY is the private one) and it is refused on a TikTok DRAFT, which carries no post info.
altTextstring | string[]ACCESSIBILITY — replace the screen-reader description(s). A STRING describes every slide; an ARRAY describes them one at a time in slide order and REPLACES the whole list. "" clears it.
linkstring
brandedContentbooleanTIKTOK — the paid-partnership disclosure; false turns it off.
yourBrandbooleanTIKTOK — the own-brand disclosure; false turns it off.
commercialContentbooleanTIKTOK — the COMMERCIAL CONTENT DISCLOSURE toggle: true when this post promotes a brand, product or service at all. TikTok requires at least one of yourBrand / brandedContent once it is on, and refuses a post that declares itself commercial without naming which kind. Either disclosure already implies it.
disableCommentbooleanTIKTOK — comments off on this post.
disableDuetbooleanTIKTOK VIDEO ONLY — block Duets.
disableStitchbooleanTIKTOK VIDEO ONLY — block Stitches.
coverTimestampMsnumberTIKTOK VIDEO ONLY — cover frame in milliseconds.
topicType"STANDARD" | "EVENT" | "OFFER" | "ALERT"GOOGLE BUSINESS — the kind of Post; EVENT and OFFER both require `event`.
actionType"BOOK" | "ORDER" | "SHOP" | "LEARN_MORE" | "SIGN_UP" | "CALL"GOOGLE BUSINESS — the call-to-action button; "" clears it.
eventobjectGOOGLE BUSINESS — replaces the whole event record {title, startDate, startTime, endDate, endTime}.
offerobjectGOOGLE BUSINESS — replaces the whole offer record {couponCode, redeemOnlineUrl, termsConditions}.
threadstring[]X — replaces the WHOLE thread; an explicit [] drops back to a single post using the caption.
pollobjectX — replaces the poll; an empty options list removes it.
replySettings"following" | "mentionedUsers" | "subscribers" | "verified"X — who may reply; "" goes back to everyone.
madeWithAibooleanX — the AI-media label; false turns it off.
collaboratorsstring[]INSTAGRAM — replaces the WHOLE collab list (up to 3 usernames); an explicit [] removes the co-authors and the post goes out as an ordinary single-author post. Only takes effect if the post has not fired yet — an invite already sent cannot be withdrawn from here.
trialReel"MANUAL" | "SS_PERFORMANCE" | ""INSTAGRAM — replaces the trial-reel setting on a queued Reel (MANUAL or SS_PERFORMANCE); an explicit "" turns the trial off and it goes out as an ordinary Reel. Only takes effect while the post is still queued — a Reel already published cannot be converted into a trial.
boardIdstringPINTEREST — move the Pin to a different board (list_pinterest_boards)
chatIdstringTELEGRAM — send it to a different chat, group or channel (@username or numeric id). It can be changed but never cleared: telegram cannot publish without one.
linkedinOrganizationIdstringLINKEDIN — target a different company Page, or "" to post as the connected person instead
pageIdstringFACEBOOK / INSTAGRAM / THREADS — publish from a different connected Page (list_meta_pages)
locationIdstringGOOGLE BUSINESS — a different listing (list_business_locations)
visibility"public" | "unlisted" | "private" | "draft"NOTE: changing this without also naming visibilityByChannel clears any per-channel overrides, so "make it all draft" is not a no-op
visibilityByChannelobject

cancel_scheduled#

Cancel a scheduled post destructive

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

ParameterTypeDescription
idrequiredstringthe scheduled post id from list_scheduled

RETRY + DUPLICATE

retry_scheduled#

Retry a failed scheduled post

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

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

duplicate_scheduled#

Duplicate a scheduled post

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

ParameterTypeDescription
idrequiredstringthe post to copy, from list_scheduled
atstringwhen the copy goes out — ISO timestamp or epoch milliseconds (default: an hour from now)
useQueuebooleaninstead of naming a time, take the brand’s next free posting slot
timezonestringIANA zone for the queue, e.g. "America/New_York"
channels"facebook" | "instagram" | "threads" | "tiktok" | "youtube" | "linkedin" | "x" | "pinterest" | "google_business" | "bluesky" | "telegram"[]post the copy to these channels instead of the original’s
messagestringa different caption for the copy
captionsobjectper-channel caption overrides for the copy
imageUrlstring
videoUrlstring
imageUrlsstring[]CAROUSEL — an ORDERED list of image URLs published as ONE swipeable post
titlestring
linkstring
boardIdstringPINTEREST — the board for the copy (list_pinterest_boards)
chatIdstringTELEGRAM — which chat, group or channel the copy goes to (@username or numeric id)
linkedinOrganizationIdstringLINKEDIN — publish the copy as this company Page (list_linkedin_pages)
pageIdstringFACEBOOK / INSTAGRAM / THREADS — which connected Page (list_meta_pages)
locationIdstringGOOGLE BUSINESS — which listing (list_business_locations)
visibility"public" | "unlisted" | "private" | "draft"

THE POSTING REFILL

get_post_refill#

Posting refill status read-only

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

No parameters.

set_post_refill#

Configure the posting refill

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

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

run_post_refill#

Run the posting refill

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

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

post_to_linkedin#

Publish to LinkedIn

Publish a post to the user’s connected LinkedIn profile — text, and optionally an image (pass its served URL as imageUrl). The image does NOT have to be something Hermoso generated: LinkedIn is served the bytes from us, so the URL must be Hermoso-hosted, and upload_file turns ANY file the user already has into exactly that. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Needs a connected LinkedIn account (Settings ▸ Connectors ▸ LinkedIn).

ParameterTypeDescription
hookstringWHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer "which hooks work", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. "direct_callout", "mid_problem", "before_after") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works.
subjectstringWHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. "winter coat", "free trial", "founder story"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group.
textrequiredstringthe post text
imageUrlstringa Hermoso-hosted image URL to attach (≤12MB) — a Hermoso render, or ANY file of the user’s own put through upload_file first. An arbitrary external host is refused (we fetch the bytes ourselves).
imageUrlsstring[]A CAROUSEL IS NOT AVAILABLE ON A PERSONAL PROFILE — LinkedIn's organic multi-image post publishes from a COMPANY PAGE. Passing several here is refused by name rather than posting slide 1; use post_to_linkedin_page instead.
idempotencyKeystringSAFE RETRIES. Publishing can take minutes (a video upload, a carousel of ten slides) and a transport can time out while the post SUCCEEDS — retrying blind is how the same thing gets posted twice. Pass any stable string here and a repeat of the SAME publish returns the ORIGINAL post id instead of posting again (24h). You do not have to: an identical publish is auto-recognised for 10 minutes anyway. If a call times out or errors ambiguously, CALL AGAIN WITH THE SAME KEY — that is the safe move, and it will either report the original post or publish it for the first time. It never posts twice.
allowDuplicatebooleanpost it even though an identical post was just made or attempted. Only pass this when the user genuinely wants the same thing posted twice, or when you have LOOKED at the account and confirmed a timed-out attempt did not land.
visibility"PUBLIC" | "CONNECTIONS"default PUBLIC

X / TWITTER

post_to_x#

Publish a post to X (Twitter)

Publish to the user’s connected X (Twitter) account — a single post, a post with an image or video render attached, a reply to an existing post, or a whole THREAD (pass `thread` as an array and each part is posted as a reply to the one before). This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Can also run a POLL (2-4 options) instead of media, and restrict who may reply. Each post must be 280 characters or fewer; longer text is REFUSED, never truncated — split it into a thread instead. Write altText whenever you attach a render. COSTS CREDITS: X charges per API request, so every post in a thread is billed, and a post containing a LINK costs roughly 13× one without — mention the cost before publishing a long thread. Each brand also has a rolling 24-hour ceiling on what it can spend at X, and a request that would cross it is refused WHOLE before anything publishes, so a batch of posts is bounded rather than open-ended. THIS TOOL POSTS ORGANICALLY — it does not create an ad campaign. X ads are built with create_x_ads_campaign → create_x_ads_line_item → create_x_ads_promoted_tweet, and a promoted post needs a post id, so publish here first and promote that post. Needs X connected (Settings ▸ Connectors ▸ X).

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

delete_x_post#

Delete a post on X destructive

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

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

x_post_metrics#

Read performance of a post on X read-only

THE X ANALYTICS TOOL THAT WORKS — impressions, likes, reposts, replies, quotes and bookmarks for any post, PLUS the advertiser numbers (link clicks, profile clicks, engagements) for YOUR OWN posts published in the last 30 days. X serves those private metrics on this same lookup with the user-context connection you already have; that is X's own design, not a workaround. Prefer this over x_post_insights, whose endpoint family X has retired. If a post is deleted, protected or suspended, X answers with no data at all and this says so — that is MISSING DATA, never zero engagement, and must never be reported as a measured zero. Costs a small number of credits (X bills per API read). Needs X connected.

ParameterTypeDescription
idrequiredstringthe numeric X post id — the last part of the post URL
publishedAtnumberepoch ms the post went out, if known — lets the private owned-post metrics be requested only inside X's 30-day window instead of costing a refused call

x_post_insights#

Advertiser analytics for your own posts on X read-only

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

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

x_post_insights_historical#

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

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

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

x_mentions#

Read who is mentioning you on X read-only

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

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

REDDIT

post_to_reddit#

Post to a subreddit

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

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

reddit_post_stats#

How a Reddit post did read-only

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

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

OPERATING A REDDIT POST AFTER IT IS SUBMITTED

list_reddit_posts#

The connected Reddit account’s own posts read-only

The connected Reddit account’s OWN submissions — id, title, subreddit, score, comment count, whether the subreddit removed it, and whether its body can be edited at all. THIS IS WHERE THE postId EVERY OTHER REDDIT TOOL NEEDS COMES FROM: post_to_reddit returns an id only at the instant it publishes, so an agent that did not itself just post had no way to name a post and had to ask the user for a link. Read-only, 0 credits. Needs Reddit connected.

ParameterTypeDescription
limitnumber1–100, default 25
sort"new" | "hot" | "top" | "controversial"default new
cursorstringthe cursor a previous call returned

edit_reddit_post#

Edit a Reddit text post’s body

Rewrite the BODY of one of the connected account’s Reddit TEXT posts — the fix for a dead link, a wrong price or a correction the comments are asking for. THREE THINGS REDDIT DOES NOT ALLOW, and you must not offer them: (1) a post’s TITLE can never be changed by any API — `title` exists only on Reddit’s submit endpoint, so a published title is frozen for every client, not just this one; (2) a LINK post cannot be edited at all — Reddit documents this endpoint as editing "the body text of a comment or self-post" and refuses a link post; (3) a post that has already been deleted cannot be edited. In each case the only remedy is to delete and submit again, which loses the score, the age and the whole comment thread — say that plainly instead of implying an edit is possible. The result is READ BACK from Reddit, so an accepted edit that did not apply is reported as NOT confirmed rather than narrated as done. 0 credits. Needs Reddit connected.

ParameterTypeDescription
postIdrequiredstringthe post id, its t3_… fullname, or the permalink (list_reddit_posts returns them)
textrequiredstringthe new body markdown — this REPLACES the existing body

delete_reddit_post#

Delete a Reddit post destructive

PERMANENTLY delete one of the connected account’s Reddit posts. Reddit has no undelete. Call it WITHOUT confirm first: nothing is deleted, and it answers with the post’s real title, subreddit, score and comment count read back from Reddit. Show the user that, get an unambiguous yes, then call again with confirm:true — plus confirmName (its exact title) once it has comments or a real score, because confirming that you meant to delete SOMETHING does not prove you aimed at the right post. TELL THE USER THIS BEFORE THEY AGREE: deleting a Reddit post does NOT delete the comments under it — Reddit keeps the thread and shows the post as [deleted], so the conversation stays public with only their side removed. Reddit’s delete endpoint returns an empty success for every call, including one aimed at a post the account did not write, so the verdict here comes from re-reading the post afterwards and never from that response. 0 credits. Needs Reddit connected.

ParameterTypeDescription
postIdrequiredstringthe post id, its t3_… fullname, or the permalink
confirmbooleanREQUIRED true — deletion is permanent
confirmNamestringthe post’s EXACT title as the unconfirmed call reported it — required once it has comments or a real score

list_reddit_comments#

Comments on a Reddit post read-only

Read the comments under one of the connected account’s Reddit posts — author, text, score, whether it is the poster’s own reply, and when. On Reddit the thread IS the value of a post, and this is where the questions, objections and exact customer wording live: the same raw material for ad copy that list_meta_comments and list_youtube_comments give you on the other channels, from the audience that argues back hardest. Each row carries the fullname to pass to reply_to_reddit_comment. Read-only, 0 credits. Needs Reddit connected.

ParameterTypeDescription
postIdrequiredstringthe post id, its t3_… fullname, or the permalink
limitnumber1–100, default 25
sort"top" | "new" | "confidence" | "controversial" | "old" | "qa"default top

reply_to_reddit_comment#

Reply on Reddit

Reply on Reddit as the connected account — either a top-level comment on a post, or a reply to somebody’s comment. This publishes PUBLICLY under their username immediately, so show the user the exact wording and get an explicit yes BEFORE calling. Reddit judges brands harder on how they behave in comments than on what they post: answer the actual question, in plain language, and do not paste marketing copy — an account that does gets buried and can get the whole domain banned from the subreddit. parentId is a FULLNAME, not a bare id: t3_… replies to a POST (a new top-level comment), t1_… replies to a COMMENT. list_reddit_comments returns the right one on every row. 0 credits. Needs Reddit connected.

ParameterTypeDescription
parentIdrequiredstringt3_… fullname of a post (top-level comment) or t1_… fullname of a comment (a reply to it)
textrequiredstringthe reply markdown

PINTEREST

list_pinterest_boards#

List Pinterest boards read-only

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

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

pinterest_analytics#

Pinterest organic analytics read-only

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

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

create_pinterest_board#

Create a Pinterest board

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

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

post_to_pinterest#

Create a Pin

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

ParameterTypeDescription
hookstringWHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer "which hooks work", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. "direct_callout", "mid_problem", "before_after") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works.
subjectstringWHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. "winter coat", "free trial", "founder story"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group.
boardIdrequiredstringnumeric board id from list_pinterest_boards — the user picks it, never guess
imageUrlstringa Hermoso render image URL (or an upload_file url)
videoUrlstringa Hermoso render video URL — takes 1–2 minutes to ingest
imageUrlsstring[]CAROUSEL — an ORDERED list of image (and, where the channel allows, video) URLs published as ONE post the viewer swipes through. THIS IS NOT “post several” — it is a single post with several slides, which is what a multi-slide creative (a listicle, a “1/6 · SWIPE” deck) actually needs; publishing only its first slide tells the viewer to swipe at something that cannot. The ORDER is the product. Limits per channel: Instagram 2–10 (images, videos or a mix), Threads 2–20 (mix allowed), Facebook 2+ (Meta publishes no documented maximum; Hermoso caps the upload fan-out at 30 and says so), LinkedIn company Pages 2–20 (images only), Pinterest 2–5 (images only), TikTok up to 35. One url here is simply an ordinary single post. Anything a channel cannot do is REFUSED with the real reason — nothing is ever quietly downgraded to one slide.
idempotencyKeystringSAFE RETRIES. Publishing can take minutes (a video upload, a carousel of ten slides) and a transport can time out while the post SUCCEEDS — retrying blind is how the same thing gets posted twice. Pass any stable string here and a repeat of the SAME publish returns the ORIGINAL post id instead of posting again (24h). You do not have to: an identical publish is auto-recognised for 10 minutes anyway. If a call times out or errors ambiguously, CALL AGAIN WITH THE SAME KEY — that is the safe move, and it will either report the original post or publish it for the first time. It never posts twice.
allowDuplicatebooleanpost it even though an identical post was just made or attempted. Only pass this when the user genuinely wants the same thing posted twice, or when you have LOOKED at the account and confirmed a timed-out attempt did not land.
titlestringPin title, max 100 characters
descriptionstringPin description, max 800 characters — this is what Pinterest search reads
linkstringdestination URL the Pin clicks through to
altTextstring | string[]accessibility alt text, max 500 characters. PIN-LEVEL: Pinterest’s API has no per-item alt text at all, so on a CAROUSEL the FIRST description is used for the whole Pin and the reply states that the others were not sent.
slideTextobject[]PINTEREST CAROUSEL ONLY — per-slide title, description and destination LINK, one object per slide in slide order. This is the ONLY genuine per-slide caption on any channel Hermoso publishes to: slide 4 can send people to the product ON slide 4, where every other platform gives a carousel one shared caption. Omit any field to leave it unset; the Pin’s own title/description/link still describe the Pin as a whole. Pinterest publishes no length limit on these, so nothing is truncated. More entries than slides is refused rather than dropped.
coverImageUrlstringvideo Pins only — a render to use as the cover frame
boardSectionIdstringoptional section within the board

OPERATING A PIN AND A BOARD AFTER THEY EXIST

list_pinterest_pins#

List Pins on a Pinterest board read-only

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

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

update_pinterest_pin#

Edit a published Pin

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

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

save_pinterest_pin#

Save a Pin to another board

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

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

delete_pinterest_pin#

Delete a Pin destructive

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

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

update_pinterest_board#

Rename or re-privacy a Pinterest board

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

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

delete_pinterest_board#

Delete a Pinterest board destructive

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

ParameterTypeDescription
boardIdrequiredstringnumeric board id from list_pinterest_boards
confirmbooleanREQUIRED true — the board and its Pins are gone for good
confirmNamestringthe board’s EXACT name as the unconfirmed call reported it
confirmChildrennumberthe number of Pins the unconfirmed call reported on the board

GOOGLE BUSINESS PROFILE

list_business_locations#

List Google business listings read-only

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

No parameters.

post_to_google_business#

Post to Google Business Profile

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

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

list_google_business_posts#

List Google Business Profile Posts read-only

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

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

delete_google_business_post#

Delete a Google Business Profile Post destructive

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

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

list_google_business_reviews#

Read the reviews on a Google Business listing read-only

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

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

reply_to_google_business_review#

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

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

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

list_google_business_questions#

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

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

ParameterTypeDescription
locationIdstring
limitnumber1–20, default 10
pageTokenstring

answer_google_business_question#

Answer a question on a Google Business listing destructive

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

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

google_business_insights#

Google Business Profile performance read-only

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

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

THE LISTING ITSELF, AND THE ACCOUNT UNDER IT

get_business_location#

Read a Google Business Profile listing read-only

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

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

update_business_location#

Update a Google Business Profile listing destructive

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

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

google_business_account#

Google Business Profile account for a listing read-only

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

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

post_to_youtube#

Post a video to YouTube

Publish a finished video to the brand’s connected YouTube channel. Pass a Hermoso render URL (or an upload_file url for a local/external file). DEFAULTS TO UNLISTED (link-only — not on the channel, not searchable, but shareable by link AND usable as a YouTube/Google ad). Pass privacy:"public" to put it ON the channel (a public publish — confirm with the user first) or privacy:"private" for eyes-only. Do NOT use "private" for anything meant to run as an ad — private videos CANNOT be used as ads; unlisted is the ad-ready setting. SCHEDULE it with publishAt, FILE it under the right categoryId (the default 22 "People & Blogs" is wrong for most ads), SUBSCRIBER NOTIFICATIONS FOLLOW PRIVACY — a public publish announces the video to the channel’s subscribers (YouTube’s own default), while unlisted/private uploads stay quiet; pass notifySubscribers explicitly to override either way. Needs a connected YouTube channel (Settings ▸ Connectors ▸ YouTube).

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

youtube_channel#

Get the connected YouTube channel read-only

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

No parameters.

YOUTUBE: MEASURE + MANAGE

list_youtube_videos#

List the brand’s own YouTube uploads read-only

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

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

youtube_video_insights#

Performance of one of your YouTube videos read-only

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

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

youtube_channel_report#

YouTube analytics broken down by dimension read-only

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

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

YOUTUBE REPORTING API

youtube_bulk_report#

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

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

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

list_youtube_report_jobs#

List YouTube bulk reporting jobs read-only

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

No parameters.

delete_youtube_report_job#

Delete a YouTube bulk reporting job destructive

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

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

update_youtube_video#

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

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

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

delete_youtube_video#

Delete a video from the connected YouTube channel destructive

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

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

YOUTUBE CHANNEL BRANDING

update_youtube_channel#

Apply the brand to the YouTube channel itself

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

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

set_youtube_watermark#

Set or remove the YouTube branding watermark

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

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

YOUTUBE BATCH STATS + TRAINABILITY

list_youtube_video_stats#

Batch YouTube video stats read-only

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

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

set_youtube_thumbnail#

Set the custom thumbnail on a YouTube video

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

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

list_youtube_comments#

Read comments on one of your YouTube videos read-only

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

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

reply_to_youtube_comment#

Reply to a YouTube comment

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

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

YOUTUBE CHANNEL MANAGEMENT

list_youtube_playlists#

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

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

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

manage_youtube_playlist#

Create, rename or delete a YouTube playlist destructive

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

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

manage_youtube_playlist_items#

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

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

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

YOUTUBE CHANNEL SECTIONS + PLAYLIST IMAGES

manage_youtube_channel_section#

Channel homepage sections destructive

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

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

manage_youtube_playlist_image#

Custom playlist cover image

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

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

list_youtube_captions#

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

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

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

manage_youtube_caption#

Upload, replace or delete a caption track destructive

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

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

moderate_youtube_comment#

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

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

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

list_youtube_categories#

List the video categories you can publish under read-only

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

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

google_business_search_keywords#

What people searched to find this business read-only

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

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

list_bluesky_posts#

List the brand's own Bluesky posts read-only

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

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

bluesky_post_metrics#

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

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

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

list_bluesky_convos#

List Bluesky direct-message conversations read-only

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

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

read_bluesky_dm#

Read the messages in one Bluesky conversation read-only

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

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

send_bluesky_dm#

Send a Bluesky direct message

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

ParameterTypeDescription
convoIdstringreply into an existing conversation (from list_bluesky_convos). Omit to start one with `to`.
tostringthe recipient’s Bluesky handle, e.g. alice.bsky.social. Ignored when convoId is given.
textrequiredstringthe message, up to 1000 characters
replyToMessageIdstringreply to a specific message in the conversation

mark_bluesky_convo_read#

Mark a Bluesky conversation as read

Clear the unread count on one Bluesky DM conversation, optionally only up to a specific message. Useful after triaging an inbox so the next list_bluesky_convos does not surface the same thread again. Reports the unread count Bluesky reads back, not the one requested.

ParameterTypeDescription
convoIdrequiredstringfrom list_bluesky_convos
messageIdstringmark read only up to this message; omit to clear the whole conversation

tiktok_creator_info#

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

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

No parameters.

post_to_tiktok#

Post a video or photo post to TikTok

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

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

tiktok_account#

Read the connected TikTok account read-only

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

No parameters.

list_tiktok_videos#

List the connected account’s TikTok posts read-only

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

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

upload_meta_asset#

Upload an asset to a Meta ad account

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

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

create_meta_campaign#

Create a Meta ad campaign (paused)

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

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

set_meta_campaign_status#

Activate or pause a Meta campaign

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

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

SHOPIFY: the merchant's own storefront

list_shopify_products#

List the Shopify catalog read-only

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

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

publish_to_shopify_product#

Publish an image onto a Shopify product

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

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

merchant_report#

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

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

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

list_merchant_issues#

List Merchant Center account issues read-only

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

ParameterTypeDescription
merchantCenterIdrequiredstringfrom list_merchant_accounts

TIKTOK ACCOUNT

tiktok_account_status#

Check the TikTok account authorization read-only

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

No parameters.

list_tiktok_comments#

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

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

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

list_tiktok_comment_replies#

List every reply to one TikTok comment read-only

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

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

comment_on_tiktok_video#

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

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

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

reply_to_tiktok_comment#

Reply to a comment on the brand’s TikTok post

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

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

moderate_tiktok_comment#

Like, hide or delete a TikTok comment destructive

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

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

upload_tiktok_comment_image#

Upload an image for a TikTok comment

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

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

set_tiktok_post_ad_authorization#

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

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

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

get_tiktok_post_ad_authorization#

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

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

ParameterTypeDescription
itemIdrequiredstringthe TikTok post id

extend_tiktok_post_ad_authorization#

Extend a post’s Spark-Ads authorization

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

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

delete_tiktok_post_ad_authorization#

Delete a post’s Spark-Ads authorization code destructive

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

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

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

list_tiktok_mentions#

Posts that mention the brand on TikTok read-only

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

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

get_tiktok_mention#

Read one mention from the TikTok mentions webhook read-only

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

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

list_tiktok_mention_comments#

Comments that mention the brand on TikTok read-only

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

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

tiktok_mention_top_terms#

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

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

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

list_tiktok_brand_hashtags#

The brand hashtags TikTok tracks for this account read-only

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

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

manage_tiktok_brand_hashtags#

Turn a brand hashtag on or off for TikTok tracking

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

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

list_tiktok_brand_hashtag_posts#

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

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

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

TIKTOK AUDIENCE INSIGHTS AND CATEGORY BENCHMARKS

tiktok_account_insights#

TikTok follower demographics and daily performance read-only

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

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

tiktok_category_benchmark#

TikTok industry averages for a business category read-only

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

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

search_tiktok_ads_targeting#

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

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

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

tiktok_ads_report#

TikTok ads performance report read-only

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

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

create_tiktok_ads_campaign#

Create a TikTok campaign (forced paused)

Create the top tier of a TikTok ad — the campaign, which fixes the OBJECTIVE everything under it optimises toward. CREATED PAUSED AND THERE IS NO OVERRIDE, and TikTok is the one platform where that is NOT the vendor’s own behaviour: TikTok creates ENABLED by default, so Hermoso forces operation_status DISABLE and overrides a caller who asks for ENABLE. It spends nothing until set_tiktok_ads_status(confirm:true). OBJECTIVES that create cleanly, each proven with a real create: TRAFFIC (the default), REACH, VIDEO_VIEWS, ENGAGEMENT, WEB_CONVERSIONS, LEAD_GENERATION, APP_INSTALL, PRODUCT_SALES, CONVERSIONS. RF_REACH (Reach & Frequency) also works but ONLY with budgetMode BUDGET_MODE_INFINITE — TikTok’s own words are "The budget type for Reach & Frequency must be unlimited". Five further values sit in TikTok’s schema enum and are REFUSED BY NAME with the reason (CATALOG_SALES is retired, SHOP_PURCHASES needs TikTok Shop on the advertiser, and APP_PROMOTION / RF_ENGAGEMENT / RF_APP_INSTALL are withheld) — the server owns that list, so a refusal names its cause and is TikTok’s restriction rather than a Hermoso fault. budgetMode is BUDGET_MODE_DAY (the default), BUDGET_MODE_TOTAL, BUDGET_MODE_DYNAMIC_DAILY_BUDGET or BUDGET_MODE_INFINITE. A campaign alone can never serve: build an ad group under it, then an ad. The result is READ BACK from TikTok and says so when the read-back could not be run.

ParameterTypeDescription
advertiserIdstringfrom list_tiktok_ads_accounts — omit only when exactly one is reachable
namerequiredstringthe campaign name in TikTok Ads Manager
objectivestringdefault TRAFFIC. e.g. TRAFFIC, REACH, VIDEO_VIEWS, ENGAGEMENT, WEB_CONVERSIONS, LEAD_GENERATION, APP_INSTALL, PRODUCT_SALES, CONVERSIONS — or RF_REACH with budgetMode BUDGET_MODE_INFINITE. The server validates it and refuses an unavailable one BY NAME with TikTok’s reason.
budgetModestringdefault BUDGET_MODE_DAY — or BUDGET_MODE_TOTAL / BUDGET_MODE_DYNAMIC_DAILY_BUDGET / BUDGET_MODE_INFINITE. RF_REACH requires BUDGET_MODE_INFINITE.
budgetnumbercampaign budget in the advertiser’s own currency — not needed with BUDGET_MODE_INFINITE

create_tiktok_ads_ad_group#

Create a TikTok ad group (targeting, budget, bidding, schedule)

Create an ad group under an existing TikTok campaign — the tier that holds the budget, the bid, the placements, the schedule and ALL the targeting. CREATED PAUSED with no override (TikTok would otherwise create it ENABLED, and a live ad group under a paused campaign is exactly how spend escapes a one-level pause); it spends nothing until set_tiktok_ads_status(confirm:true). TIKTOK REQUIRES THIRTEEN FIELDS TOGETHER and its error names only the FIRST missing one per round trip, so the server checks the whole set locally and refuses with EVERY absentee at once — which matters here because QPS is 1 and each round trip costs a second. Sensible defaults are supplied for placement, pacing, promotion type, schedule type, optimization goal, billing event, bid type and budget mode, so the ONE thing you must resolve yourself is LOCATION: locationIds is effectively mandatory (TikTok: "‘location_ids’ or ‘zipcode_ids’ must be specified") and comes from search_tiktok_ads_targeting(kind:"location"). Resolve interest and language ids the same way — a guessed id either fails the create or targets the wrong people, silently. CONVERSION CAMPAIGNS (WEB_CONVERSIONS / CONVERSIONS / PRODUCT_SALES / LEAD_GENERATION) NEED A PIXEL: set optimizationGoal CONVERT, pass pixelId AND optimizationEvent, and billingEvent OCPM — TikTok pairs a billing event with every goal MANUALLY and derives nothing, so the server supplies the right one if you leave it out. Without a pixel TikTok refuses with "Please select a pixel." The result is READ BACK from TikTok, and the pixel and event it reports are the STORED ones.

ParameterTypeDescription
advertiserIdstring
campaignIdrequiredstringthe campaign this ad group belongs to
namerequiredstring
locationIdsrequiredstring[]REQUIRED in practice — ids from search_tiktok_ads_targeting(kind:"location"); TikTok refuses an ad group without them
placementsstring[]default ["PLACEMENT_TIKTOK"]
placementTypestringdefault PLACEMENT_TYPE_NORMAL
optimizationGoalstringwhat TikTok optimises delivery toward — default CLICK; it has to fit the campaign objective and TikTok’s refusal names the valid values
billingEventstringwhat TikTok charges for — default CPC; must fit the optimization goal
bidTypestringdefault BID_TYPE_NO_BID (let TikTok bid). A manual bid type needs bid.
bidnumberbid price in the advertiser’s own currency — required by the manual bid types
pacingstringdefault PACING_MODE_SMOOTH, which spends the budget evenly across the day. PACING_MODE_FAST spends as fast as delivery allows — a real money decision, so it is never chosen for you.
budgetModestringdefault BUDGET_MODE_DAY
budgetnumberad-group budget in the advertiser’s own currency
scheduleTypestringdefault SCHEDULE_FROM_NOW
scheduleStartTimestring"YYYY-MM-DD HH:MM:SS" in the advertiser’s timezone
scheduleEndTimestringonly needed when the schedule type is a fixed window
promotionTypestringwhat is being promoted — default WEBSITE
ageGroupsstring[]TikTok age band values — omit to reach every age
gendersstring[]omit to reach everyone
languagesstring[]language codes from search_tiktok_ads_targeting(kind:"language")
interestCategoryIdsstring[]ids from search_tiktok_ads_targeting(kind:"interest")
interestKeywordIdsstring[]ids from search_tiktok_ads_targeting(kind:"interest_keyword") — TikTok pairs this with interestCategoryIds under Interests
pixelIdstringREQUIRED when optimizationGoal is CONVERT or VALUE, and NOT ACCEPTED with any other goal — that is TikTok’s own two-sided rule, and it is why a conversion campaign built without one dies at the ad group with "Please select a pixel." Get it from list_tiktok_ads_pixels, or create_tiktok_ads_pixel for a new one.
optimizationEventstringREQUIRED whenever pixelId is given (and when optimizationGoal is IN_APP_EVENT or VALUE) — the conversion TikTok optimises toward. Web events: ON_WEB_DETAIL, ON_WEB_SEARCH, ON_WEB_ADD_TO_WISHLIST, ON_WEB_CART, INITIATE_ORDER, ADD_BILLING, SHOPPING, CONSULT, DOWNLOAD_START, FORM, ON_WEB_REGISTER, ON_WEB_SUBSCRIBE. Pick the one that matches the goal: SHOPPING for a purchase, FORM for a lead, ON_WEB_REGISTER for a signup. The event must already EXIST on the pixel or TikTok refuses with "This pixel event type does not exist."
customConversionIdstringoptional narrower rule from list_tiktok_ads_custom_conversions — valid only alongside a pixel and only when optimizationGoal is CONVERT or IN_APP_EVENT
promotionTargetTypestringLEAD_GENERATION campaigns only: INSTANT_PAGE (a TikTok in-app form) or EXTERNAL_WEBSITE (your own landing page). With EXTERNAL_WEBSITE + optimizationGoal CLICK, TikTok forbids pixelId and optimizationEvent.
conversionBidnumbertarget cost per conversion for oCPM — required by TikTok when bidType is BID_TYPE_CUSTOM and billingEvent is OCPM

POST PERFORMANCE

list_meta_posts#

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

List the connected Facebook Page's or Instagram account's OWN existing posts — id, caption, permalink, publish date and format. THIS IS THE TOOL THAT GETS YOU THE postId every other Meta read needs: meta_post_insights, list_meta_comments and manage_meta_post all require one, and until now the only way to have a postId was to have just published it yourself with post_to_meta. Use it for "how did our last few posts do", to find a post the user describes loosely, or before backfill_posts. Pass target:'instagram' for the linked IG account (Stories are excluded — Meta's media edge does not return them); Facebook hides unpublished drafts unless you ask for them. Only ever reads a Page the brand has connected. Read-only, 0 credits.

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

list_published_posts#

List what this brand has published read-only

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

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

list_hooks#

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

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

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

post_performance#

Which hooks and subjects are getting traction read-only

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

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

diagnose_posts#

What to fix next, post by post read-only

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

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

collect_post_metrics#

Read how the recorded posts performed

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

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

backfill_posts#

Import a channel’s past posts

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

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