Skip to main content
Glama
hermoso-ai

Hermoso

Official

Server Configuration

Describes the environment variables required to run the server.

NameRequiredDescriptionDefault

No arguments

Instructions

Guidance the server publishes about itself, which clients place ahead of the tool catalog so the model reads it before choosing anything.

This server publishes no instructions, or was last inspected before Glama recorded them.

Capabilities

Features and capabilities supported by this server

Protocol revision2025-11-25

CapabilityDetails
tools
{
  "listChanged": true
}
resources
{
  "listChanged": true
}

Tools

Functions exposed to the LLM to take actions

NameDescription
enable_toolsA

Switch on a group of tools that is not in this session's roster. WORKS ON CLIENTS THAT RE-READ THE TOOL LIST (stdio, the CLI); a host that fixed its roster at connect time — ChatGPT does — will not show the new tools until it reconnects, and this tool says so in its reply rather than reporting a success you cannot use. The connect-time route that always works is ?tools=all on the server URL. The default roster is every group EXCEPT ads and analytics, which are held out purely on SIZE: paid-campaign management is by far the largest group, most of the total schema weight across eleven ad platforms, and measurement is a third again on top of everything else. Most sessions need neither. Nothing in either is unfinished or unsafe — they are one call away. CALL THIS THE MOMENT YOU NEED ONE. If the user asks to build, budget, target, report on or change an ad campaign on any platform, call enable_tools({groups:['ads']}) first and the tools appear. If they ask about their own site or product analytics, a tag/tracking container, or how a search engine crawls, indexes or ranks their site, call enable_tools({groups:['analytics']}). Groups: core, research, create, channels, analytics, ads, files, workspace — or 'all'. Free, instant, and it never turns anything off.

post_to_blueskyA

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.

delete_bluesky_postA

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

post_to_telegramA

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.

list_telegram_chatsA

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.

list_telegram_dmsA

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

delete_telegram_messageA

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

list_inboxA

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

reply_to_inbox_itemA

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

hermoso_capabilitiesA

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

hermoso_creditsA

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.

buy_creditsA

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.

report_bugA

Report a bug in Hermoso to the team. Use this when something in Hermoso genuinely misbehaves — a tool errors unexpectedly, returns a wrong or malformed result, a render comes back broken, or documented behaviour doesn't match what happened. Include what you were trying to do, the exact tool call and arguments, and what came back. Do NOT use it for out-of-credits, a policy refusal, or a missing capability (use request_feature for that). Free, no credits.

request_featureA

Ask the Hermoso team for a capability that doesn't exist yet. Use this when you need something Hermoso genuinely can't do — an unsupported platform or channel, a missing model, an export format, a tool that would have completed the user's task but isn't available. Say what the user was trying to achieve, not just the feature name — the use case is what gets built. Free, no credits.

billing_statusA

Show this account's billing at a glance: current plan (id + label + the price it is ACTUALLY billed — quote plan.priceUsd per plan.period, not plan.monthlyUsd), credit balance, whether auto-reload is on, whether a card is on file, and whether YOU (this key) have ADMIN rights to change billing. Read-only, free. Call it before upgrade_plan / set_auto_reload to know what's possible — members have read-only billing. IN A SHARED TEAM WORKSPACE A MEMBER SEES THE PLAN AND THE BALANCE ONLY: the workspace owner's payment card and auto-reload belong to them and are not reported (billingScope:'member'). Never tell a member there is no card on file — the honest answer is that you cannot see it.

upgrade_planA

Change this account's SUBSCRIPTION plan (admin only). Call with no argument to list the plans (id · monthly price · monthly credits); call again with plan set to a plan id. A NEW subscriber gets a ready-to-pay Stripe Checkout URL to hand your human — THEY pay on Stripe (agents never spend money directly). If the account already has a paid plan, or you're DOWNGRADING, the change is made by a person in the app (Settings → Billing) and the tool returns exactly what to do. Members (read-only billing) get an honest 'ask an admin' message. Nothing is charged until your human pays.

set_auto_reloadA

Turn automatic credit reloads on or off (admin only): when the balance drops below a threshold, the card on file is charged for a top-up pack — SERVER-SIDE, even with no app open. Requires a saved card, added once in the app at first checkout/top-up; if there's none the tool tells you exactly where to add it. After that one-time card setup, agents can manage auto-reload, top-ups and plan links fully. Members (read-only billing) get an 'ask an admin' message.

list_brandsA

List every brand on this account (id + name) and which one this connection currently acts on, PLUS any brand another account shared with you (a team workspace). Multi-brand accounts: call this, then use_brand to switch. A SHARED workspace is switched into the SAME way — pass its name or the profile id printed here to use_brand. If a brand looks empty (no connected accounts, no Library) when the app shows it full, you are almost certainly acting on a different workspace: call this first. Read-only, free.

use_brandA

Pin which brand this connection acts on (multi-brand accounts). Pass the brand id or exact name from list_brands. Works for a brand on your own account AND for a workspace another account SHARED with you — for a shared one pass its name or the profile id list_brands prints, and access is verified against your real membership before it is pinned. Persists for this API key until changed, on every surface (hosted connector included — no environment variables, no restart).

create_brandA

Add a NEW brand workspace (a separate brand/client on this account) and switch to it. Each workspace has its OWN brand profile, memory, swipefile, Library, avatars, skills, playbooks and connectors — nothing leaks between them. Use this for a second brand or a new client; use draft_brand to FILL a workspace, and update_brand to edit one. Re-running with the same name returns the existing workspace instead of a duplicate. Free (the ~50-credit research cascade only starts when you then run draft_brand).

delete_brandA

PERMANENTLY delete a brand workspace and EVERYTHING in it — brand profile, memory, swipefile, Library creations, generated assets, avatars, skills, playbooks, chats — and disconnect its connected accounts. Irreversible, and it applies to everyone the workspace is shared with. Call it WITHOUT confirm first: it reports exactly what that workspace holds. Show the user that inventory verbatim, get an unambiguous yes, then call again with confirm:true — plus, if the workspace is not empty, confirmName set to its exact name and confirmConnectors set to the number of connected accounts it reported. Those two exist because confirming INTENT does not prove you picked the right WORKSPACE, and a wrong target is how a live brand was destroyed. The account's FIRST/anchor brand cannot be deleted this way (it holds the workspace's root storage) — that one is replaced from the app.

meta_page_insightsA

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.

meta_post_insightsA

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.

instagram_insightsA

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.

list_instagram_mediaA

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.

instagram_collaboratorsA

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.

list_meta_commentsA

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.

comment_on_meta_postA

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

reply_to_meta_commentA

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.

moderate_meta_commentA

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.

list_threads_postsA

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.

threads_insightsA

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.

list_threads_repliesA

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.

reply_to_threadA

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.

hide_thread_replyA

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

delete_threadA

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.

repost_threadA

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.

threads_publishing_limitA

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.

list_threads_mentionsA

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.

search_threads_keywordA

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.

list_meta_pagesA

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.

upload_fileA

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= with no body.) Returns {url, kind, bytes}.

search_threads_locationsA

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

threads_profileA

Read ANY public Threads account by handle — name, bio, avatar, verified badge, follower count, and its lifetime likes / quotes / reposts / views. This is competitor and creator research on Meta’s own data rather than a scraper: read a rival’s positioning straight off their profile instead of guessing it, then read what they are posting with list_threads_profile_posts and draft the brand’s answer. A bare handle ("nike"), an "@nike" and a threads.net profile link all resolve — Meta matches the handle EXACTLY, so a display name will not. IF NOTHING COMES BACK, DO NOT SAY THE ACCOUNT DOES NOT EXIST: read the note, which says whether that is a real absence or our app’s approval state. Read-only, 0 credits. Needs Threads connected.

list_threads_profile_postsA

The recent PUBLIC posts of any Threads account — the raw material for a teardown, for mine_angles, and for ad copy in the language the market actually uses. Use it after threads_profile, or on its own when you already know the handle, and then USE what it returns: draft the brand’s next post or plan_ad off the angles you found. AN EMPTY LIST IS NEVER PROOF THEY HAVE NOT POSTED — Meta returns nothing for a private account, for any account under 100 followers, and for handles our app is not yet approved to read (the threads_profile_discovery permission); the note says which of those applies. Read-only, 0 credits. Needs Threads connected.

search_instagram_hashtagA

Read the PUBLIC Instagram posts carrying a hashtag — the caption, the media, the permalink and the like and comment counts — on the brand’s own Meta credentials rather than a scraper. This is category and competitor listening: find how a market actually talks about a product, then USE it — feed the captions into mine_angles, or write the brand’s next post or ad from the language you found. TWO EDGES AND THEY ANSWER DIFFERENT QUESTIONS: edge:"recent" (default) is what has been posted in the LAST 24 HOURS, which is Meta’s own hard limit, so a hashtag with millions of posts legitimately returns ZERO on a quiet day — never report that as "nobody is posting about this", ask again with edge:"top", which has no time limit. Only PUBLIC, non-promoted posts are ever returned, from public accounts only, and never in chronological order. A SHORT RESULT IS NOT A SMALL HASHTAG: Instagram limits a page by total response SIZE rather than by post count, so a tag whose posts are carousels returns fewer rows per page than a tag whose posts are single images — never compare two tags by how many rows came back. BUDGET: Instagram allows each Instagram account 30 UNIQUE hashtags in any rolling 7 days, so choose the tags deliberately; re-querying one already used in the window is free. Read-only, 0 credits.

list_whatsapp_accountsA

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

list_whatsapp_templatesA

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

create_whatsapp_templateA

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

delete_whatsapp_templateA

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

send_whatsapp_messageA

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

send_whatsapp_broadcastA

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

post_to_metaA

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.

schedule_postA

Queue a post to go out at a future time, to one or more connected channels at once (facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, bluesky, telegram — TEN channels, and every one of them is live. google_business is accepted by the schema too but is currently HELD BACK: Google allowlists that API per project and ours reads 0 QPM, so it is refused at enqueue rather than failing hours later). A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order — never schedule just its first slide. 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.

list_scheduledA

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.

reschedule_postA

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.

cancel_scheduledA

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

retry_scheduledA

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.

duplicate_scheduledA

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.

get_post_refillA

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.

set_post_refillA

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.

run_post_refillA

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.

post_to_linkedinA

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

post_to_xA

Publish to the user’s connected X (Twitter) account — a single post, a post with an image or video render attached, a reply to an existing post, or a whole THREAD (pass thread as an array and each part is posted as a reply to the one before). This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Can also run a POLL (2-4 options) instead of media, and restrict who may reply. LENGTH IS THE POSTING ACCOUNT’S, NOT A FLAT 280: 280 characters on an ordinary account, but UP TO 25,000 if that account has X Premium. Hermoso reads the account’s own subscription from X and sends the post rather than refusing something it may be entitled to; if X declines it on length, the reply says so and names the subscription X reported. Over 25,000 is refused for free — that is X’s own ceiling on every account. Text is NEVER truncated. AND A LONG POST IS CHEAPER THAN A THREAD: it is ONE billed X call where the same words split across five posts are five, so prefer one long post over threading when the account has Premium. Only the first ~280 characters show in the timeline; the rest sits behind “Show more”. Write altText whenever you attach a render. COSTS CREDITS: X charges per API request, so every post in a thread is billed, and a post containing a LINK costs roughly 13× one without — mention the cost before publishing a long thread. Each brand also has a rolling 24-hour ceiling on what it can spend at X, and a request that would cross it is refused WHOLE before anything publishes, so a batch of posts is bounded rather than open-ended. THIS TOOL POSTS ORGANICALLY — it does not create an ad campaign. X ads are built with create_x_ads_campaign → create_x_ads_line_item → create_x_ads_promoted_tweet, and a promoted post needs a post id, so publish here first and promote that post. Needs X connected (Settings ▸ Connectors ▸ X).

post_x_articleA

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

edit_x_postA

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

delete_x_postA

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.

x_post_metricsA

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.

x_post_insightsA

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.

x_post_insights_historicalA

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.

x_mentionsA

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.

list_x_dmsA

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

send_x_dmA

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

post_to_redditA

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

reddit_post_statsA

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.

list_reddit_postsA

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.

edit_reddit_postA

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.

delete_reddit_postA

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.

list_reddit_commentsA

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.

reply_to_reddit_commentA

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.

list_pinterest_boardsA

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

pinterest_analyticsA

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.

create_pinterest_boardA

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.

post_to_pinterestA

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

list_pinterest_pinsA

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.

update_pinterest_pinA

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.

save_pinterest_pinA

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.

delete_pinterest_pinA

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.

update_pinterest_boardA

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.

delete_pinterest_boardA

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.

pinterest_ads_async_reportA

The DEEP Pinterest ad report — Pinterest’s ASYNCHRONOUS lane, which reaches 914 DAYS back (2.5 years) where pinterest_ads_report stops at 90, and carries roughly three times the metric columns (conversion, ROAS and cross-device families the quick report does not have). Use it for anything older than three months, and for revenue questions. Levels: ADVERTISER / CAMPAIGN / AD_GROUP / PIN_PROMOTION / KEYWORD / PRODUCT_GROUP / PRODUCT_ITEM plus their *_TARGETING twins. Pinterest generates it asynchronously, so this may come back pending:true with a token — CALL AGAIN WITH THAT TOKEN to pick it up, and never re-submit without it (a second submit generates a second report). Pinterest’s own windows are enforced here with the reason rather than as an opaque 400: 914 days back over at most 186 days; at HOUR granularity 8 days back over 3; at a PRODUCT_ITEM level 92 back over 31. A finished report link is valid five minutes and the report one hour, so an EXPIRED status means run it again, not that anything failed. Read-only, 0 credits.

pinterest_targeting_analyticsA

WHICH AUDIENCE SEGMENT actually delivered on Pinterest — ad performance broken down by keyword, targeted interest, age bucket, gender, location, region, country, placement, app type, media type and more. targetingTypes is REQUIRED because it is what the report breaks down BY. scope:"account" covers the whole ad account; "campaign" / "adGroup" / "ad" each REQUIRE their own id list, because Pinterest publishes no all-of-them form at those levels — that is Pinterest’s shape, not a limitation here. 90 days back in windows of at most 90 days, refused locally with the reason. An unknown targeting type is refused BY NAME; note Pinterest’s four per-level enums differ slightly, so a value valid at one level can still be refused at another. Read-only, 0 credits.

pinterest_audience_insightsA

WHO the Pinterest audience IS, rather than what it did — interest categories each carrying an affinity INDEX, plus demographics (ages, countries, devices, genders, metros). Three audiences: YOUR_TOTAL_AUDIENCE, YOUR_ENGAGED_AUDIENCE, and PINTEREST_TOTAL_AUDIENCE as the baseline to compare the other two against. This is an input to a creative brief, not a performance report. SAY THIS WHEN REPORTING: an affinity index is how much MORE likely this audience is to engage with a category than Pinterest’s baseline — it is a comparison, never a count — and when Pinterest flags size_is_upper_bound the audience size is an upper bound, not a measurement. There is no date range: Pinterest returns its current snapshot and names the date it is for. Read-only, 0 credits.

search_pinterest_ads_targetingA

THE IDS AN AD GROUP’S targetingSpec NEEDS. create_pinterest_ads_campaign and create_pinterest_ads_ad_group accept interests, locales, locations, age buckets and keywords — this is where those values come from, and guessing one is the worst option available: Pinterest ACCEPTS a well-formed but wrong interest id and the campaign then quietly targets the wrong people. Pass targetingType (INTEREST, GEO, LOCATION, LOCALE, AGE_BUCKET, GENDER, APPTYPE, KEYWORD, AUDIENCE_INCLUDE, AUDIENCE_EXCLUDE) and optionally query to narrow the list. INTERESTS ARE A TREE: pass interestId instead to drill into one and get its children, which is how you get from "Food" to something specific enough to target. Pinterest publishes no search parameter of its own, so query filters the full list HERE — the note says so, because a filter we applied is not a filter the vendor applied. Read-only, 0 credits, no new permission (it uses ads:read, already granted).

list_business_locationsA

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

post_to_google_businessA

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

list_google_business_postsA

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.

delete_google_business_postA

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.

list_google_business_reviewsA

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

reply_to_google_business_reviewA

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.

list_google_business_questionsA

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.

answer_google_business_questionA

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.

google_business_insightsA

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.

get_business_locationA

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

update_business_locationA

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.

google_business_accountA

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.

post_to_youtubeA

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

youtube_channelA

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

list_youtube_videosA

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.

youtube_video_insightsA

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.

youtube_channel_reportA

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.

youtube_bulk_reportA

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.

list_youtube_report_jobsA

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.

delete_youtube_report_jobA

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.

update_youtube_videoA

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.

delete_youtube_videoA

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.

update_youtube_channelA

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.

set_youtube_watermarkA

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.

list_youtube_video_statsA

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.

set_youtube_thumbnailA

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.

list_youtube_commentsA

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.

reply_to_youtube_commentA

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.

list_youtube_playlistsA

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.

manage_youtube_playlistA

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.

manage_youtube_playlist_itemsA

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.

manage_youtube_channel_sectionA

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.

manage_youtube_playlist_imageA

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.

list_youtube_captionsA

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.

manage_youtube_captionA

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.

moderate_youtube_commentA

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.

list_youtube_categoriesA

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.

google_business_search_keywordsA

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

list_bluesky_postsA

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.

bluesky_post_metricsA

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.

list_bluesky_convosA

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

read_bluesky_dmA

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.

send_bluesky_dmA

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.

mark_bluesky_convo_readA

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

manage_bluesky_convoA

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

react_to_bluesky_dmA

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

tiktok_creator_infoA

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

post_to_tiktokA

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

tiktok_accountA

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

list_tiktok_videosA

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.

list_meta_conversationsA

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

read_meta_conversationA

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

reply_to_meta_messageA

Send a text reply to someone who has messaged the brand. THIS REACHES A REAL PERSON — show the user the exact text and who it goes to, get a yes, then send. Pass conversationId as well as recipientId and Hermoso checks Meta’s 24-hour window for free BEFORE sending, and refuses with the real reason instead of letting Meta refuse it; without one it sends and discloses that the window could not be checked. HERMOSO SENDS REPLIES ONLY: messaging_type is always RESPONSE, and proactive messages and message tags are not offered at all — that is a deliberate product boundary, not a gap, and a closed window is a RULE that only the person writing again reopens. recipientId is a PAGE-SCOPED ID on Messenger and an INSTAGRAM-SCOPED ID on Instagram (read_meta_conversation returns it as replyTo); a username or a handle is not one and cannot be turned into one. ACCEPTED IS NOT DELIVERED — never report it as delivered or read. 0 credits.

subscribe_meta_webhooksA

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

meta_webhook_statusA

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

unsubscribe_meta_webhooksA

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

list_meta_webhook_eventsA

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

list_meta_partnership_creatorsA

The creators this brand has set up for PARTNERSHIP ADS — ads that run from a CREATOR’s handle instead of the brand’s, which is what a paid collaboration looks like in feed. TWO SEPARATE LISTS come back and NEITHER IMPLIES THE OTHER: the AD-PERMISSION list is whose content this brand may run as an advert, and the TAG-APPROVAL list is who may tag this brand as a paid partner on their own organic post. Adding a creator to one does nothing for the other, and that is the mistake that makes a partnership ad fail for a reason Meta’s error does not name. Every row carries the status Meta reports VERBATIM. Meta’s own wording on the wire is prose, not the enum its docs publish — measured 2026-08-25, a fresh request reads “Pending Approval” and a revoked one reads “Canceled” (Partnership Ads Hub calls the same two rows “Request sent” and “Inactive”). PENDING means the creator has not accepted yet and this brand cannot advertise their content until they do. REVOKING DOES NOT REMOVE THE ROW: Meta keeps it and flips the status, so read the status rather than the presence of a row. An unrecognised status is passed through as Meta’s own word, never blanked. A list that could not be READ says exactly that; it is never rendered as "this brand has no creators". THE TWO LISTS ANSWER DIFFERENTLY, and Meta is the reason: the ad-permission list can be ENUMERATED, and the tag-approval list CANNOT — Meta requires user_ids on that edge and returns "a list of approved creators, filtered by the user IDs provided", i.e. it CHECKS the people you name and cannot report the rest. So pass creatorIds to ask about specific creators; anyone already on the ad-permission list is checked for you, which is what makes "on one list but not the other" answerable in a single call. With nobody to check, the reply says Meta publishes no way to list that edge rather than reporting a failed read. Read-only, 0 credits.

manage_meta_partnership_creatorA

Add a creator to — or remove one from — this brand’s Partnership Ads lists. Name at least one list: allowAds (whose content this brand may RUN AS AN ADVERT) and/or allowTagging (who may TAG this brand as a paid partner on their own post). NEITHER DEFAULTS ON, because one of them lets somebody attach this brand’s name to their own post. ADDING IS A REQUEST, NOT AN APPROVAL: Meta’s own name for the operation is "send permission request", the creator accepts on their own Instagram account in their own time, and until the read-back reports approved this brand cannot advertise their content. Never tell anyone a partnership is live off the back of this call — report the status the reply carries. THE OUTCOME IS READ BACK OFF META’S OWN LIST, NEVER TAKEN FROM THE WRITE’S REPLY — because Meta answers the ADD with HTTP 500 (code 1, “Please reduce the amount of data you’re asking for”) while performing it, measured 4/4 on 2026-08-25. If this reply carries metaWriteError alongside a successful summary, that error is Meta noise about a write that HAPPENED: do not retry, and do not tell the user it failed. The reply’s verdict is one of applied / already / unchanged / unknown, and unknown means the list could not be re-read — which is not the same as nothing having changed. REMOVING FLIPS A STATUS TO “Canceled”; it does not delete the row. One tool does both lists because Meta removes from them with two DIFFERENT verbs (DELETE on one, a revoke flag on the other) and a caller who assumes one silently does nothing on the other. 0 credits.

instagram_profileA

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

list_instagram_shopping_catalogsA

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

search_instagram_shopping_productsA

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

manage_instagram_product_tagsA

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

list_meta_catalogsA

The PRODUCT CATALOGS this Meta login can advertise from — id, name, how many products each holds, and which business portfolio owns it, WITH THAT PORTFOLIO’S NUMERIC ID. It also lists every business portfolio this login administers (portfolios), whether or not it holds a catalog yet — so this is where the businessId create_meta_catalog needs comes from, and the two tools chain with no trip to Business Manager. A catalog is the input to Advantage+ catalog ads (dynamic product ads), the highest-performing ecommerce format on Meta: one campaign that shows each person the products they are most likely to buy, with the image, name and price taken from the catalog rather than from a render. START HERE for any "advertise my products" ask, then list_meta_product_sets to pick what to advertise. AN EMPTY LIST IS A FACT ABOUT WHAT THIS LOGIN ADMINISTERS, NEVER ABOUT THE MERCHANT: Meta serves catalogs off the BUSINESS PORTFOLIO, so a full Shopify-synced catalog is invisible here if the connected user is not an admin of the portfolio that owns it — read the note and say that, never "you have no catalog". A portfolio that could not be read is reported as unreadable rather than dropped. Read-only, 0 credits.

list_meta_product_setsA

The PRODUCT SETS inside one catalog — a set is a slice of the catalog ("summer dresses", "under $50", "everything"), and it is what an AD SET actually advertises. A catalog id is NOT a valid ad-set binding, so this is the list you pick from before building a catalog ad: pass the chosen id as productSetId on create_meta_adset or create_meta_ad. Meta normally auto-creates an "all products" set with every catalog, so an empty list points at the catalog rather than at the request. A set holding ZERO products binds without error and then serves nothing — the ad runs, spends and shows no product — so check the counts. Read-only, 0 credits.

list_meta_catalog_productsA

A page of the actual PRODUCTS in a catalog — name, retailer id, price, availability, image and, most importantly, Meta’s REVIEW STATUS for each. A REJECTED product stays in the catalog, is counted by the catalog’s catalog.product_count field, and will never be shown in an ad — so this is how you find out why a catalog ad is serving less than the merchant expects. Use it to confirm a catalog really holds what the merchant thinks before pointing money at it. This is a PAGE, not a count: quote the catalog.product_count list_meta_catalogs reports for the total. Read-only, 0 credits.

create_meta_catalogA

Create a NEW product catalog on one of the merchant’s Meta business portfolios. A catalog is the input to Advantage+ catalog ads (dynamic product ads) — build it first, then fill it from the merchant’s feed or Shopify, then point a campaign at it. Name the owning portfolio EITHER by its numeric businessId OR by businessName, exactly as list_meta_catalogs prints it (“DMDB Group”) — a name that matches no portfolio, or more than one, is refused BY NAME with the portfolios that really exist and their ids, never guessed, and it never falls back to “the only portfolio”. Vertical defaults to commerce and CANNOT be changed afterwards, nor can the owning portfolio, so both are worth getting right the first time. IMPORTANT — META DOES NOT REFUSE A DUPLICATE NAME: it silently creates a SECOND catalog with the same name and nothing to tell the two apart, so Hermoso refuses a name the portfolio already uses and tells you the existing id (if a previous create seemed to fail, that id is very likely the catalog it actually made). Pass allowDuplicateName:true if you genuinely want a twin. The confirmation is READ BACK from Meta — its create returns only an id, so the name, vertical and portfolio in the summary were fetched, not echoed. Free, 0 credits.

update_meta_catalogA

Change a product catalog’s settings — most often its NAME. Also accepts the default and fallback product image URLs (what Meta shows when a product row has no usable image), the dynamic-ad display settings, and the destination / flight / store / partner-integration settings for travel and local-inventory catalogs. A catalog’s VERTICAL and its owning business portfolio are permanent and cannot be changed here — Meta publishes no way to change either. THE 200 IS NOT THE ANSWER: Meta replies {"success":true} and never returns the catalog, and it says exactly the same thing for a field it quietly ignored — so the summary is built from a fresh READ-BACK, and if a value does not read back as sent you are told it was NOT confirmed. Free, 0 credits.

meta_catalog_blast_radiusA

Read what is INSIDE a product catalog — how many products, which product sets — and get the exact sentence describing what deleting it would destroy. Free and read-only. CALL THIS BEFORE PROPOSING A DELETE: deleting a catalog is permanent and Meta offers no archive and no undo, so naming the cost should not require aiming the destructive call at the target and reading its refusal. It also gives you the catalog’s exact name, which delete_meta_catalog requires echoed back. 0 credits.

delete_meta_catalogA

PERMANENTLY delete a product catalog. Meta does not let a catalog be removed from a business portfolio and then restored, and it cannot be transferred to another portfolio — there is no archive and no undo, and its product sets go with it, so any ad set still bound to one of those sets keeps spending and stops having products to show. Confirming is therefore not enough on a catalog that holds anything: pass confirm:true PLUS confirmName set to the catalog’s exact name (and confirmChildren when asked), which proves you inspected the catalog you are aiming at rather than merely meaning to delete something. An EMPTY catalog stays a one-call delete. Call meta_catalog_blast_radius first to get those numbers and to show the user what they are agreeing to. The result is the READ-BACK, never Meta’s acknowledgement — if the catalog still resolves afterwards you are told it is NOT confirmed. Free, 0 credits.

list_shopify_productsA

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.

publish_to_shopify_productA

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.

tiktok_account_statusA

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.

list_tiktok_commentsA

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.

list_tiktok_comment_repliesA

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.

comment_on_tiktok_videoA

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

reply_to_tiktok_commentA

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

moderate_tiktok_commentA

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

upload_tiktok_comment_imageA

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

set_tiktok_post_ad_authorizationA

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

get_tiktok_post_ad_authorizationA

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.

extend_tiktok_post_ad_authorizationA

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

delete_tiktok_post_ad_authorizationA

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

list_tiktok_mentionsA

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.

get_tiktok_mentionA

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.

list_tiktok_mention_commentsA

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.

tiktok_mention_top_termsA

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.

list_tiktok_brand_hashtagsA

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.

manage_tiktok_brand_hashtagsA

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.

list_tiktok_brand_hashtag_postsA

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.

tiktok_account_insightsA

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.

tiktok_category_benchmarkA

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.

list_linkedin_pagesA

List the LinkedIn COMPANY PAGES the connected account administers — id, name and the role held on each. ALWAYS call this before post_to_linkedin_page when there is more than one Page: publishing to the wrong company Page is a public mistake and Hermoso never chooses for the user. If it comes back empty, the account holds no Page admin role, or LinkedIn has not granted this app the organization scopes — say that plainly rather than guessing an id. Read-only, free.

post_to_linkedin_pageA

Publish a post to one of the user’s LinkedIn COMPANY PAGES — text, plus optionally an image, a video, a 2–20 image CAROUSEL (LinkedIn calls it a MultiImage post; pass the slides in order as imageUrls[]), or a LINK POST with a real preview card (linkUrl). USE linkUrl WHENEVER THE POINT OF THE POST IS A LINK: LinkedIn disables URL scraping for API partners, so a url sitting in the text renders as plain text with no card, and the card’s title, description and image only exist if you pass linkTitle / linkDescription / linkThumbnailUrl — read them off the page and supply them. The media need not be a Hermoso render — it must be Hermoso-HOSTED because we upload the bytes to LinkedIn ourselves, and upload_file turns ANY file the user already has into such a URL. ORGANIC CAROUSELS ARE COMPANY-PAGE ONLY — a personal profile cannot publish one and is refused by name, so send a deck here rather than to post_to_linkedin. This is a DIFFERENT thing from post_to_linkedin, which publishes to the person’s own profile: pick the one the user actually asked for and never substitute. organizationId comes from list_linkedin_pages; omit it only when the account administers exactly one Page. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. A VIDEO POST CAN CARRY CAPTIONS AND ITS OWN COVER, and both are attached only during the upload: pass captionsSrt (SubRip content — LinkedIn is watched with the sound off) and videoThumbnailUrl (otherwise LinkedIn picks a frame for you). LinkedIn does NOT allow the image, video, captions or thumbnail of a published post to be swapped afterwards, so get all of that right first (the copy can still be edited with manage_linkedin_post).

manage_linkedin_postA

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

linkedin_page_analyticsA

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

manage_meta_postA

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

save_to_driveA

Save a Hermoso render — or ANY file — into the user’s connected Google Drive. Pass a Hermoso render URL as url (or urls[] for several); for a local/external file, call upload_file first and pass the url it returns. Optional folder (created if new) + name. Returns the Drive file(s) with a webViewLink. Needs Google Drive connected (Settings ▸ Connectors ▸ Google Drive — one connection covers Drive, Sheets and Docs). NOTE: Hermoso uses the drive.file scope, so it reaches ONLY the files it created plus any the user explicitly handed over with the Google file picker in the app — never their whole Drive.

list_drive_filesA

List the Google Drive files & folders Hermoso can reach — the ones it created, plus any the user handed over with the Google file picker in the app (the drive.file scope exposes nothing else, never their entire Drive). This is how you find the id of a file the user picked. Filter by query (name contains …), folderId (contents of a folder), or onlyFolders:true. Paginate with pageToken. Read-only.

get_drive_fileA

Fetch one Drive file’s metadata — name, type, size, modified time, a webViewLink to open it and a webContentLink to download it. Pass fileId (from list_drive_files). Read-only.

update_drive_fileA

Update a Drive file: rename (name), move it into a folder (moveToFolderId, optionally removeFromFolderId to move OUT of the old one), or trash / untrash it (trash:true|false). Pass fileId (from list_drive_files). To delete permanently, use delete_drive_file.

delete_drive_fileA

Delete a Drive file. By default it goes to Trash (recoverable); pass permanent:true to delete it forever. Pass fileId (from list_drive_files) + confirm:true. Irreversible when permanent — confirm with the user first.

create_drive_folderA

Create a folder in the user’s Google Drive (optionally nested under parentId) to organize saved files. Returns the folder id + webViewLink. Use that ID as update_drive_file’s moveToFolderId or as parentId for a nested folder. NOTE: save_to_drive’s folder is a NAME, not this id — it find-or-creates a folder by that name, so pass the folder NAME there (or omit and just save, then move with update_drive_file).

create_sheetA

Create a new Google Spreadsheet in the user’s Drive and optionally fill it with rows — e.g. export a swipefile, ad list, or performance report. Pass rows as an array of row arrays (first row = headers). Returns the spreadsheet id + URL. Needs Google Drive connected (Settings ▸ Connectors ▸ Google Drive — one connection covers Drive, Sheets and Docs).

append_to_sheetA

Append rows to a Google Sheet Hermoso can reach — one it created (pass the spreadsheetId from create_sheet) or one the user handed over with the Google file picker in the app (find its id with list_drive_files). rows = array of row arrays.

read_sheetA

Read cells from a Google Sheet Hermoso can reach — one it created, or one the user handed over with the Google file picker in the app (that is how an EXISTING spreadsheet becomes readable; find its id with list_drive_files). Pass the spreadsheetId (from create_sheet) OR paste a Google Sheets URL as sheetUrl. If Google answers that the file was not found, the user has not picked it yet — ask them to pick it in the app rather than retrying. Returns a 2-D array of values.

export_swipefile_deckA

Turn a SWIPEFILE COLLECTION into a real Google Slides deck — one slide per saved ad, carrying the creative, the brand, the ad copy, the run dates with the run length, and the platform. This is the thing a marketer actually presents to a client or a team; until now the swipefile’s only export was JSON. Returns the presentation id + URL. Creates a NEW deck every time: under the drive.file scope Hermoso can only touch files it created, so it cannot add slides to a deck the user already has. A creative whose ad-library link has expired cannot be embedded — Meta signs those URLs with a short expiry — so that slide says so in words and keeps its copy and run dates, and the reply reports how many. Needs Google Drive connected (Settings ▸ Connectors ▸ Google Drive — one connection covers Drive, Sheets, Docs and Slides).

create_docA

Create a new Google Doc in the user’s Drive with a title + optional body text — e.g. export ad copy, a creative brief, or a report. Returns the document id + URL. Needs Google Drive connected (Settings ▸ Connectors ▸ Google Drive — one connection covers Drive, Sheets and Docs).

append_to_docA

Append text to the end of a Google Doc Hermoso can reach — one it created (pass the documentId from create_doc) or one the user handed over with the Google file picker in the app (find its id with list_drive_files).

read_docA

Read the text of a Google Doc Hermoso can reach — one it created, or one the user handed over with the Google file picker in the app (that is how an EXISTING doc becomes readable; find its id with list_drive_files). Pass documentId (from create_doc) OR paste a Google Docs URL as docUrl. Under the drive.file scope it reaches nothing else in the user’s Drive; if Google answers that the file was not found, the user has not picked it yet — ask them to pick it in the app rather than retrying. Returns the plain text. Read-only, free.

list_sheet_tabsA

The tabs in a Google Spreadsheet, each with its name, numeric sheetId, row/column count and position. Call this BEFORE naming a tab in update_sheet / clear_sheet_range / manage_sheet_tabs / format_sheet, and before proposing to delete one — it is how you learn what the file actually contains instead of guessing at a name. Read-only, free.

update_sheetA

CORRECT cells in a Google Sheet — write values to an exact range, overwriting whatever is there. This is the fix append_to_sheet cannot make: appending only ever adds rows at the bottom, so without this a wrong number stays wrong forever and the only "correction" is a second row contradicting the first. Pass range (e.g. "B2:C5", or "Q3 Report!B2" to name a tab — list_sheet_tabs gives the names) and values as an array of row arrays; an anchor cell like "B2" is fine and the block is written down and right from it. Writing into EMPTY cells goes straight through. Writing OVER cells that already hold values is REFUSED first, naming exactly how many filled cells would be overwritten — show the user that, get a yes, then call again with confirm:true. The result is READ BACK from the sheet, so what you report is what the sheet now holds rather than what Google accepted.

clear_sheet_rangeA

Empty a range of cells in a Google Sheet, leaving the rows themselves in place. DESTRUCTIVE: call it WITHOUT confirm first and nothing is cleared — you get back the real number of filled cells in that exact range. Show the user that number, get an unambiguous yes, then call again with confirm:true AND confirmCells set to it. The echo is not ceremony: it is what catches naming A1:Z1000 when you meant A1:Z10, which is the mistake that actually happens. There is deliberately NO default range. The clear is read back and reported as confirmed only if the range really is empty afterwards. To remove a whole tab instead, use manage_sheet_tabs.

manage_sheet_tabsA

Add, rename or delete a tab in a Google Spreadsheet. action:"add" + title · action:"rename" + tab + newTitle · action:"delete" + tab. Name the tab by its TITLE or its numeric sheetId (list_sheet_tabs gives both); an unknown tab is refused with the real list rather than a Google error nobody can map back. DELETING a tab destroys everything on it: call it without confirm first to get the filled-cell count, then confirm:true + confirmCells. Google does not allow removing the LAST remaining tab in a file, and that is refused by name with the way out (clear it, or delete the whole file with delete_drive_file). Every action is read back from the spreadsheet before it is reported as done.

format_sheetA

Make an exported sheet readable: bold the header row, FREEZE it so it stays visible while scrolling, and auto-size the columns so nothing is cut off. Worth calling right after create_sheet — a raw export with unsized columns and a header that scrolls away is the difference between a spreadsheet someone reads and one they close. Changes no cell VALUE, so it is never gated. The defaults do all three on the first tab; pass tab to pick another, freezeRows:0 to skip freezing, boldHeader:false or autoResize:false to skip those.

update_docA

EDIT a Google Doc — the correction append_to_doc cannot make, which until now meant a doc could only ever grow and a wrong line stayed in it forever. Two shapes: replacements:[{find, replace}] rewrites specific text wherever it appears (call read_doc first and match the text EXACTLY; matchCase:false ignores case), or rewrite:"…" replaces the ENTIRE body (rewrite:"" empties it). Find/replace runs immediately and REPORTS how many occurrences changed — zero matches is reported as a FAILURE to match, never as a quiet success, because a text edit that silently does nothing is worse than one that visibly fails. A whole-body rewrite is destructive: call it without confirm first to get the character count, then confirm:true + confirmCells. Both are index-free by design — an agent cannot reliably compute Google’s character offsets, and a wrong offset deletes the wrong sentence.

save_to_onedriveA

Save a Hermoso render — or ANY file — into the user’s connected Microsoft OneDrive. Pass a Hermoso render URL as url (or urls[] for several); for a local/external file, call upload_file first and pass the url it returns. Optional folder (created if new) + name. Returns the OneDrive file(s) with a webViewLink. Needs OneDrive connected (Settings ▸ Connectors ▸ OneDrive).

list_onedrive_filesA

List files & folders in the user’s OneDrive — the root by default, a folder’s contents (folderId), or a name search (query). onlyFolders:true lists folders only. Paginate with pageToken (the cursor from a previous call). Read-only.

get_onedrive_fileA

Fetch one OneDrive item’s metadata — name, type, size, modified time, a webViewLink to open it and a webContentLink to download it. Pass fileId (from list_onedrive_files). Read-only.

update_onedrive_fileA

Update a OneDrive item: rename (name) and/or move it into a folder (moveToFolderId). Pass fileId (from list_onedrive_files). To remove an item, use delete_onedrive_file.

delete_onedrive_fileA

Delete a OneDrive item — it moves to the OneDrive recycle bin (recoverable there). Pass fileId (from list_onedrive_files) + confirm:true. Confirm the exact file with the user first.

convert_onedrive_fileA

Turn a file already in the user’s OneDrive into a PDF or a JPG — Microsoft does the conversion on its own servers, so nothing is re-encoded here and nothing is lost in a screenshot. It reads about 130 source formats, which is the point: PowerPoint and Word decks, Excel, Photoshop PSD, Illustrator AI, Sketch, 3D (fbx/glb/obj), video (mp4/mov/webm), HEIC from an iPhone, and the raw camera formats (CR2, NEF, ARW, DNG) that nothing else in this product can open. Use it to turn a client’s deck into images you can actually put in an ad, to get a usable JPG out of a designer’s PSD or a photographer’s raw file, or to hand someone a PDF of a spreadsheet. CONVERTING TO JPG REQUIRES BOTH width AND height — Microsoft refuses the call without them. The result is stored at a durable Hermoso URL you can pass straight to a render or a post; Microsoft’s own conversion link expires within minutes, so do not hand that one to anyone. Needs OneDrive connected — no new permission.

create_onedrive_folderA

Create a folder in the user’s OneDrive (optionally nested under parentId) to organize saved files. Returns the folder id + webViewLink. Use that id as update_onedrive_file’s moveToFolderId or as parentId for a nested folder. NOTE: save_to_onedrive’s folder is a NAME (find-or-created), not this id.

plan_adA

Creative director: turn a brand + product/brief into a finished ad CONCEPT — copy variants (headline/primary/cta) plus an image_concept.prompt OR a video_storyboard, with the resolved recipe + the model ids to render with. Renders nothing; chain its output into generate_image / generate_video. THE USER’S EXPLICIT LENGTH IS SOVEREIGN: when they name a duration ("a 30 second ad", "make it 45s"), pass it as durationSeconds — the board is then AUTHORED to that length (its scenes sum to it) and render_ad renders it as one clip or stitched acts accordingly. Leaving it out lets the planner pick its own default, which is how an explicit ask silently becomes a 15s spot. Spends credits.

generate_imageA

Render a finished ad IMAGE and return its served URL. refImages (local paths or URLs) force product-accurate compositing (drops a real product into the scene). MULTI-BRAND CAUTION: useBrand hydration pulls the SAVED workspace brand — when working a brand that is NOT the saved one (a fresh draft_brand), pass that brand's own productImages/logo as refImages (and useBrand:false) or the output composites the WRONG brand's product. NOTE that the saved-brand hydration also decides the ENGINE: attaching product photos routes the render to the compositing model, so a model you named is only honoured when no references ride — pass raw:true (or useBrand:false) to render on exactly the model you asked for. model = a catalog id from hermoso_capabilities (omit for the default). RAW MODEL ACCESS: raw:true dispatches your prompt to the model BYTE-IDENTICAL — no rewriting, no appended guidance, no negative prompt, no brand references attached on your behalf. Credits, the durable delivery of the finished asset and the per-model validation are unchanged. Fast (seconds). Spends credits.

make_thumbnailA

Render a click-driving YOUTUBE / Shorts / Instagram THUMBNAIL or video cover — the full production pipeline (concept framework → casting → scene → render → surgical tweaks → text), not a bare image prompt. Use this for any "thumbnail", "video cover", "video preview" or MrBeast-style packaging ask INSTEAD of generate_image. About 9 credits per variant; the headline overlay is free.

CONCEPT — every thumbnail must open an INFORMATION GAP (the image raises a question the title answers) while staying truthful to the video. Brainstorm ≥5 concepts across the 16 frameworks before you pick, and feel free to combine two. Frameworks (pass as framework): before_after · social_ui · three_step · screenshot · posed_portrait (the default) · posed_action · specific_day · graphical · landscape · map_aerial · product · adding_text · repetition · size_difference · news_clip · amplified_reality. Call hermoso_capabilities for each one's full 'realize it with' note plus the emotion, overlay-style, font and rim-colour catalogs.

THREE GATES, all BEFORE you render:

  1. WHO IS IN FRAME — never assume and never silently substitute a stranger. If the framework puts a person in frame and no face photo is attached, the tool refuses (nothing rendered, nothing charged) and tells you to ask the user once: themselves (send a face photo → the identity gets locked), a generated person (castGenericPerson:true), or a people-free framework.

  2. TEXT — the default is a CLEAN render with the headline TYPESET OVER THE TOP afterwards (free, always legible, correctly spelled). Just pass headline. Only set bakeText:true if the user explicitly asks for the words painted INTO the image — verified live, that renders the asked-for words correctly but leaks garbled invented text across the rest of the frame. Never infer text intent from the topic or the framework.

  3. HOW MANY — ask once whether they want one thumbnail or a SET (offer 4: the same concept at different emotions and/or camera takes). Default is 1; variants caps at 16.

IDENTITY LOCK is automatic for every attached face photo. emotion is the single biggest CTR lever on a face: shock · hype · fear · confusion · determination · smug · charisma · disgust · awe · rage · laugh (or your own phrase). Finished thumbnail needs a fix? Re-call with tweak + sourceImage for a surgical, pixel-faithful edit (emotion / background / background_color / rim_light) instead of re-rendering — tweaks chain. ALWAYS check the returned postRenderCheck against the image before you present it.

PROMPT LANGUAGE — write every DESCRIPTIVE field in ENGLISH (sceneBrief, keyElements, location, composition, background, topic, each person's describe, and every reference field), translating the user's wording where needed: the image models are trained on English and a non-English scene description renders noticeably worse. Text that gets BAKED OR TYPESET stays verbatim in the user's own language — headline, headlineLines and bakedUiText are never translated.

generate_voiceA

RAW text-to-speech from the voice-model catalog: speak a script in a chosen voice and return the served MP3 URL. For a standalone voiceover / narration clip — NOT for adding audio to a video (render_ad and generate_video voice their own spots; change_voice re-voices a finished clip). engine picks the voice model (default 'seed-audio'; also 'eleven-v3', 'minimax-speech', 'kokoro'); voice is a preset name from that engine (see hermoso_capabilities → voice engines) — a name that engine does not have is REFUSED for free with its real list, and a few engines generate their own voice and take no preset at all (the reply says which voice actually spoke). Paid (a couple of credits by length; ≤900 characters).

generate_textA

Text generation against the writing-model catalog (Claude, Gemini, GPT, Llama, DeepSeek…) — ad copy, hooks, scripts, rewrites, brainstorms. Prompt-only, no ad assembly (for a finished on-brand creative use plan_ad → render_ad). BY DEFAULT the model answers as a marketing copywriter (a short house system prompt is applied, which is what you want for ad copy); pass raw:true for a plain, unstyled answer from the model itself with NO system prompt at all. model = a writing-model id from hermoso_capabilities (omit for the default Claude orchestrator). Paid (a credit or two by length).

render_adA

RECOMMENDED for finished video ADS: render a plan_ad concept through the SAME quality pipeline as the Hermoso web Studio — timed shot list, exact/clean speech (no garbled words), text composited in post (never model-painted), brand end card, licensed music bed, real product references. Pass plan_ad’s full structured output as creative. Honors the plan’s render_plan structure/duration: a storyboard that FITS ONE CLIP OF THE RENDER MODEL renders as a single continuous pass; anything longer automatically renders as STITCHED ACTS (the fewest balanced clips, each at most one model clip) — never time-compressed into one clip. That threshold is the render model’s own maximum, not a fixed number: most models cap a clip at 15s and the longest-clip one goes to 30s, so use dryRun:true to see the act split this plan will actually get, for free, before spending. CAST A SAVED CREATOR with creator so the SAME person stars in this ad as in the last one (list_creators is the roster) — otherwise every render invents a new face. Renders take 1–3 min; keep polling get_job if it returns still-rendering. Spends credits.

make_template_adA

Render a NATIVE-STYLE TEMPLATE ad from pure HTML — no AI video/image model in the loop, renders in ~30 seconds for a couple of credits. Perfect for native-feel social ads at volume. YOU author the content (short, casual, believable — never marketing-speak). Templates (pass as config.template): 'imessage-chat' (VIDEO ~15s: a real-looking iMessage thread where a friend reveals the product as a rich-link card; config: { thread: { contactName, messages: [{from:'them'|'me', text?, product?:{image,title,domain}}] }, theme?:'dark'|'light', endCard:{headline,cta,domain,logo?,color} } — 4-6 short lowercase bubbles, product card mid-thread from 'me', 1-2 excited replies after); 'chatgpt-chat' (VIDEO: a ChatGPT answer streams the punchline; config: { question, answer (may bold the brand), productImage?, endCard }); 'apple-notes' (VIDEO: an iPhone note types itself out; config: { title, lines: string[], theme?, endCard }); 'value-prop' (VIDEO ~17s kinetic typography: config: { hook (≤40 chars), claims: string[] (3-5 COMPLETE phrases, ≤6 words / ≤34 chars each — a finished thought, NEVER a clipped clause like 'Looks good on any'), productImages: string[] (2-3 DISTINCT photos — one rotates per card), palette: string[], endCard }); 'static-mockup' (IMAGE: config: { style:'imessage'|'notes'|'card', size?:{w,h}, ...style fields }); 'airdrop-carousel' (VIDEO ~10s: an iOS AirDrop share card springs up and cycles 3-16 REAL product photos to a full-lineup payoff; config: { brandName, products: [{image, title?}], contactLine?, endCard }); 'app-ui-tour' (VIDEO ~12-16s for APP brands: floating-iPhone mockup walks through REAL app screenshots with kinetic captions; config: { hook?, appName, iconImage?, beats: [{screenImage, caption}] (2-6), palette?, fontStack?, endCard }); 'imessage-cascade' (VIDEO ~12s: iOS notification banners spring in and stack over a blurred backdrop; config: { notifications: [{sender, text}] (4-8), backgroundImage?, endCard }); 'photo-grid' (VIDEO ~8s: collage assembles real photos one at a time; config: { title?, photos: [{image, label?}] (4-9), palette?, fontStack?, endCard }); 'vignette' (VIDEO ~12s: cinematic Ken-Burns hero film; config: { hook, lines: [2-4 ≤40ch], heroImage, palette?, fontStack?, endCard }); 'kinetic-type' (VIDEO ~9-15s typographic motion design with NO VOICEOVER — it is NOT a silent asset: it always carries its own synthesised SFX (whoosh/tick/chime) and, once a curated track is on file, the family's loudest music bed at -16 LUFS; config.music:'off' silences the bed but never the SFX: 3-6 short phrases each land word by word on a full-bleed brand card (product beats caption the phrase over the photo instead), the longest word picked out in the brand accent, and a skewed accent slab wipes every cut; supply productImages and every OTHER beat becomes a full-bleed product shot with its phrase captioned over it — with none it renders as pure typography, so it needs NO photos; config: { phrases: string[] (3-6, ≤34 chars each — punchy, declarative, ONE idea per phrase, a finished thought never a clipped clause), productImages?: string[] (up to 4 DISTINCT photos), palette?: string[], fontStack?, endCard }); 'myth-vs-fact' (VIDEO ~15-26s VO-FIRST kinetic explainer with a real VOICEOVER — the family's ONE paid-audio format: a calm-authority read busts 2-4 myths, each MYTH line slamming in with a red per-line strike then the counter FACT line landing bold+affirmative, word-level KARAOKE lighting each word as the VO speaks it; config: { pairs: [{ myth (≤50ch, the common wrong belief), fact (≤60ch, the corrective truth — wrap its payoff phrase in [brackets] to accent it) }] (2-4), palette?, fontStack?, endCard }. Real product truths only — NEVER invent stats. Costs the flat template credits PLUS a small voiceover charge); 'carousel' (MULTI-IMAGE: 5-10 branded 1080×1080 PNG slides for Meta/LinkedIn/IG carousels — returns an images[] array, one PNG per slide; config: { cover: { hook?, title }, slides: [{ headline (≤8 words), support? (≤16 words), stat?: { value, label } }] (3-8; a stat slide is a REAL user-supplied number like '94%' or '40k+' + a label, never invented), cta: { headline, cta?, domain? }, productImage?, logo?, palette?, fontStack?, endCardColor? }). Every VIDEO format except myth-vs-fact (VO-first, deliberately dry) also gets a mood-matched MUSIC BED when a curated track is on file (the library ships empty — no track means no bed, never a paid generation) under its own SFX, from the curated library — free, no model, no extra credits; set config.music:'off' for a silent cut or a mood name (upbeat/calm/warm/epic/tense/playful/elegant/hype/chill/dramatic) to re-mood it. Image URLs may be any public URL — the server localizes them. Spends a couple of credits.

finish_videoA

Post-process an EXISTING rendered video (its served mp4 URL) with the proven direct-response 'reviewer' finish and/or a film-grain pass — no AI model, ~30s, a couple of credits. pills=true composites a header pill (e.g. '10/10 would buy again'), a brand-accent sub-pill, and 3-4 green-check proof pills cascading in on the beat (YOU author the copy: header ≤40 chars, sub ≤34, each point ≤44 — concrete real benefits, never fabricated stats). grain=true applies a subtle camera-grain finish that makes photoreal AI renders look phone-shot ('less AI') — works alone or with pills. Returns a NEW video; the original is untouched.

post_editA

MECHANICAL post-production on an EXISTING rendered video (its served mp4 URL) — an ordered plan of whitelisted primitives executed by ffmpeg (+ Chrome for typeset cards) in seconds for ~2 credits flat, NO AI model, the original untouched (returns a NEW video). The lane for: append a branded end card ('add an end card with our logo and website' — ADDS its seconds, never re-renders), trim, speed (0.5-2x), mute (whole or a window), audio_gain (-20..+6 dB), fade_out, corner logo watermark, anti-AI film grain. Up to 6 ops per plan, applied in order. Brand assets (name/domain/logo/accent) load from the workspace brand automatically; override per-call if needed. NEVER use generate_video/render_ad for these mechanical asks.

fix_beatA

Surgically re-render ONE time window (1.5-8s) of an existing rendered video and splice it back on the VIDEO TRACK ONLY — the rest of the video and ALL audio stay byte-identical. Use when one beat/shot is broken ('the shot at 8 seconds glitches') and a full re-render would waste the parts that worked; bills only the replacement clip's seconds (~1/3 of a full render). Do NOT pick a window covering spoken dialogue (a video-only splice under speech breaks lip-sync) — pass speechWindows to enforce this.

clip_videoA

Cut ONE long video into several RANKED, ready-to-post short clips (podcast, webinar, interview, conference talk, long ad cut → Reels/Shorts/TikTok). Transcribes the source with timestamps, picks the strongest SELF-CONTAINED moments, then cuts + reframes each with ffmpeg — no video model renders anything, which is why it's fast and cheap. THE VERTICAL REFRAME IS SUBJECT-AWARE: a few stills per clip go to ONE cheap vision call, which decides a SINGLE crop offset that is held for that clip's whole length — so a speaker sitting camera-left is not cropped out of their own clip, while the framing still never drifts INSIDE a clip (a per-frame crop truncates to whole pixels and shimmers, so it is deliberately not tracked). It costs one small vision call per clip, billed as its own event. When nothing is being discarded, or no single subject can be located, the crop stays dead centre exactly as before — read reframedToSubject and each clip's reframeWhy back off the result rather than assuming either way. ACCEPTS: (a) a YouTube link (or Vimeo / Loom / Dailymotion / Streamable / Rumble / Wistia / Twitch / TED) — the server pulls the video down itself; (b) a direct https .mp4/.mov/.webm; (c) a Hermoso /generated/ URL (upload_file turns a local file into one). NOT supported: TikTok / Instagram / Facebook links, and anything age-restricted, private, members-only, geo-blocked or still LIVE — those fail fast with the real reason and are fully refunded, so ask for a direct file or an upload rather than retrying. Source must be at least ~15s and under ~600MB; only the first ~40 minutes is analysed (the result reports truncated:true when it hits that). Cost: a ~7-credit hold, settled to the exact transcription + encode cost, plus the clip-selection model's tokens billed as their own small event. RETURNS clips[] — each with its OWN served mp4 URL, title, hook, ready-to-post caption, 0-100 score and source timecode — not a single video. SUBTITLES ARE BURNED IN BY DEFAULT — slim white CAPS, thin black outline, bottom safe band, no box and no plate — because short-form is watched on mute; pass captions:false for clean footage. TIMING IS APPROXIMATE, NOT WORD-LEVEL: each cue is anchored to the transcript's own per-sentence timestamp and split inside a sentence by character count, so it tracks the speech closely but is not frame-accurate sync — never promise that. Read captionsBurned back off the result: it counts the clips that actually carry a burned track, and captionNote says why any are bare.

make_explainerA

Turn a TOPIC into a finished narrated explainer video. Writes a sectioned script, paints a BURST of pictures per section (about one every 1.5s — most of them one-detail edits of the frame before, so it reads as movement rather than a slideshow), narrates each section with TTS, holds each picture PERFECTLY STILL for its own slice of the narration (the motion is the CUT RATE — a slow move on a still shimmers), then composites the end card (and any on-screen text you asked for) with the Chrome+ffmpeg engine the ads use (text is never model-painted, so it never garbles). BURNED ON-SCREEN TEXT IS OFF BY DEFAULT — the narration carries the point and the pictures carry the story, so the film ships clean unless the user asks otherwise; captions:true adds held key points and subtitles:true adds narration-timed CAPS (see both). It is an image film WITH motion, not N video-model renders — that's what keeps it affordable. style picks the visual family: the default 'cinematic' is photoreal editorial; every other id is a STYLED, strictly non-photoreal look (illustrated / collage / clay / pixel …) that first renders ONE style-key image and then locks every scene to it, so the whole film holds one look. Cost at the default frame density: a ~130-credit hold for a 60s explainer on the default style, ~100 styled; frameDensity:'lean' roughly halves it and 'minimal' (one picture per section) is ~30. All settle to the exact per-frame image + narration spend (a longer target = more sections = more). Takes SEVERAL minutes — one image render per frame; independent frames are painted concurrently, so it is far faster than the frame count suggests. Needs the writing model and a narration voice engine connected. NOT the tool for a short product ad — use render_ad or generate_video for those, and make_template_ad for the deterministic native formats.

product_sizzleA

Render an 18-30s music-led PRODUCT SIZZLE: ONE 15s Seedance 2.0 hero clip of the product, diced into fast cuts and intercut with typeset spec/CTA cards on a brand-coloured grain background, mixed to a music bed. Faceless by design — no people, no voiceover, no spoken lines; the cards carry every word, so nothing is left to a video model's spelling. Pass a real packshot as refImage or the label will not be yours. EXPENSIVE — the hero clip is the only paid leg and it is a full 15s Seedance render: ≈1,040 credits at the DEFAULT 1080p, ≈470 at 720p, ≈220 at 480p, ≈4,130 at 4k (call hermoso_capabilities for the live seedance-2 per-duration numbers; the dicing and the cards are free, and the music bed is already included in the quoted figure). Confirm the spend with the user before calling. For a talking/UGC ad use render_ad or generate_avatar; for a cheap deterministic format use make_template_ad.

generate_videoA

Render a RAW video clip from your own prompt and return its served mp4 URL. For finished brand ADS prefer render_ad (it runs the Studio quality pipeline — composited text, clean speech, end card, music); use this for raw/experimental clips or precise manual control. ONE generation = one continuous clip up to the model’s longest listed duration — the longest-clip model in the catalog today renders a full multi-beat spot of up to 30 SECONDS in ONE unbroken take with native synchronized audio, so never assume a generic 8–10s cap and never stitch something that fits one clip; durationSeconds must be one of the model’s durations from hermoso_capabilities, which is the live list. TO GET A SPECIFIC MODEL, NAME IT in model: an unnamed render is routed by the server’s own auto-pool, which is narrower than the catalog, so the longest-clip and highest-resolution models are reached by naming them and not by omitting the field. Renders take 1–3 min. refImage anchors the opening frame; ttsScript adds a voiceover. AUDIO IS NOT FREE AND NOT OPTIONAL BY DEFAULT: a clip delivered with no audio of its own gets a music bed composed and CHARGED on top of the render (see musicMood and audio) — on a cheap short draft the bed can cost as much as the clip. Pass refVideo (a clip URL) to EDIT an existing video instead of generating from scratch — the omni engine transforms that clip per your prompt, inheriting the source clip’s canvas + length (aspectRatio/durationSeconds are ignored for an edit). RAW MODEL ACCESS: your prompt is NOT dispatched verbatim by default — a few small guards are appended (packaging/label safety when no reference image rides, a negative prompt on the models that take one, reference-binding lines when references ride) and hex colour codes are rewritten to colour names. Pass raw:true for none of that. raw:true dispatches your prompt to the model BYTE-IDENTICAL — no rewriting, no appended guidance, no negative prompt, no brand references attached on your behalf. Credits, the durable delivery of the finished asset and the per-model validation are unchanged. Spends credits (Starter plan is video-blocked server-side).

generate_avatarA

Render a TALKING-AVATAR / creator lip-sync clip from a portrait image + a script. Blocks until done (1–3 min). Requires the avatar capability (canAvatar in hermoso_capabilities). Spends credits.

stitch_videoA

Render a multi-scene STITCHED video (≥2 scenes) — ONLY for spots LONGER than ONE clip of the chosen model. A multi-beat ad that FITS one clip renders better and cheaper as ONE single-pass generate_video/render_ad (a single generation carries the whole hook→demo→payoff arc) — never stitch those. What fits is the model’s own maximum from hermoso_capabilities, not a fixed number: 15s on most models, 30s on the longest-clip one, so a 30s spot need not be stitched at all if you name that model. Blocks until done. Spends credits.

get_jobA

Poll a render job by id. Returns status (queued|running|done|error), progress, and on done the served media URL. Renders take 1–3 minutes: keep calling this until done/error without asking the user — several calls is normal, not a stall. An id that does not exist on this account answers status "not_found" — that is FINAL: stop polling it, and do not re-fire the render (that double-charges).

list_skillsA

List the bundled Hermoso SKILLS — multi-step workflow instructions (SKILL.md) that orchestrate the other tools (research an ad space, plan+render a finished ad, product photoshoot, raw generation) — plus the in-app strategy skills and creative recipes. Call get_skill to load a bundle. Read-only, free.

get_skillA

Load a bundled skill’s full SKILL.md workflow instructions by name (from list_skills). Follow the loaded instructions to run that workflow with the other tools. Read-only, free.

save_skillA

Save a reusable custom SKILL — a named creative directive/playbook applied to future ads (a hook formula, a UGC recipe, a compliance rule, a named specialist persona like “our founder-story style” or “short-form ad strategist”). Distill an imperative, self-contained directive. Merges into the workspace Skills library (list_skills shows built-ins + your custom skills).

delete_skillA

Delete one of the workspace’s CUSTOM skills by id (from list_skills). Built-in skills/recipes can’t be deleted. Minor + re-creatable, so no confirm needed.

list_memoryA

List the durable facts & preferences saved in this workspace’s Memory (what the studio remembers about the brand, audience, taste, and do/don’t rules) — the same Memory the web app shows. These shape every future ad. Read-only, free.

rememberA

Save a durable fact or PREFERENCE about the brand, audience, or the user’s creative TASTE (e.g. “audience is first-time homebuyers”, “prefers bold lime accents”, “always captions off”) into the workspace Memory so it shapes FUTURE ads. For lasting things, not one-off requests. Merges into the existing Memory (never overwrites); de-dupes on identical text.

forgetA

Delete a saved Memory item by its id (from list_memory). Records a cross-device delete so it doesn’t come back. Minor + re-creatable (you can remember it again), so no confirm needed.

list_swipefileA

List this workspace's SWIPEFILE — the saved-ad research board: every named collection and the ads/creatives kept in it (advertiser, headline, body copy, media URL, platform, when it was saved, and any taste tags). The SAME board the web app's Swipefile tab shows. Use it to answer "what have we saved?", to mine the user's own taste before planning an ad, or to find a reference to remix. Read-only, free.

save_to_swipefileA

Save one or more ads/creatives to a named SWIPEFILE collection, creating the collection if it does not exist — the headless twin of the ♥ on every ad card in the web app. Use it whenever research turns up something worth keeping: a competitor ad from search_meta_ads / pull_competitor_ads, an organic post, or one of your own renders. Saved ads persist to the workspace board the web Swipefile tab shows, and feed the taste signal every future ad is planned against. De-dupes: re-saving the same ad (same key/link/media) MOVES it into the named collection instead of duplicating it. Free.

list_playbooksA

List the PLAYBOOKS saved in this workspace — the reusable strategy cards (winning hooks, angles, formats and the concrete plays to run) kept from teardowns, angle mining and creatives worth repeating. The same Playbooks the web app's Playbooks tab lists. Read one before planning an ad so you re-run what already worked instead of starting cold. Read-only, free.

save_playbookA

Save a reusable PLAYBOOK — the strategy takeaways worth re-running: the hooks that work, the angles, the formats, and the concrete plays. Use it to keep what a competitor_teardown or mine_angles just found, or to bank a creative you want to repeat. Lands in the same Playbooks library the web app lists, runs and manages. Distinct from save_skill (a directive applied to every ad) and from the swipefile (raw saved creative). Free.

delete_playbookA

Delete a saved playbook by id (from list_playbooks). Records a cross-device delete so it does not come back on the next sync. Minor + re-creatable, so no confirm needed.

list_creatorsA

List this workspace’s SAVED CREATORS — the reusable on-camera cast (AI creators made here, a person pulled from a social profile, a consented photo upload). Read-only, FREE. Each entry gives the name, the PORTRAIT URL, where the portrait came from and whether a real person’s likeness consent is on file, how many extra pose plates exist, and any chosen or cloned voice. TO PUT ONE IN A FINISHED AD, pass their id or name as render_ad’s creator — that casts them for the whole spot (and skips the character-portrait render, so it costs less than not casting anyone). THE PORTRAIT URL IS THE REUSE HANDLE for the raw lanes — pass it as generate_avatar’s image (a talking clip of them), generate_video’s refImage (they star in the scene), recast_motion’s image (they perform a reference clip’s motion), or generate_image’s refImages. CALL THIS BEFORE OFFERING TO GENERATE A NEW PERSON: re-casting somebody the workspace already has keeps the SAME face across every ad, while a fresh person costs credits and breaks that continuity. An empty answer means the workspace genuinely has no cast yet — say so and offer generate_avatar / save_creator, never invent a roster.

save_creatorA

Add a portrait to this workspace’s reusable CAST so the SAME person can star in future ads — the headless twin of the app’s + ▸ Pick a creator ▸ save. Pass the portrait’s public url (a generate_image render of a person, a headshot, any public photo) plus a name to call them by; from then on list_creators returns them and their url can be re-passed to generate_avatar / generate_video / recast_motion. Saving is FREE and renders nothing. LIKENESS — source says what the portrait IS: leave it "generated" for an AI-made person, and use "upload"/"social" ONLY for a REAL person. Pass consented:true only when the user has told you that person agreed to their likeness being used; never assert that on their behalf.

delete_creatorA

Remove a saved creator from this workspace’s cast by id (from list_creators). Records a cross-device delete so they don’t reappear on the user’s other devices. It only drops the roster entry — ads already rendered with that person are untouched — and the same portrait can be saved again with save_creator, so no confirm is needed.

update_brandA

Patch SPECIFIC fields of the workspace brand profile (name, domain, sells, summary, category, audience, positioning, voice, style, goal) WITHOUT overwriting the rest — a read-modify-write on the saved brand. Use for “change our voice to playful”, “we sell to dentists now”. To onboard a brand from scratch, use draft_brand. Only pass the fields you’re changing.

store_getA

Read one of this workspace’s data stores by key, for visibility into what the app holds — playbooks, swipefile, saved locations, avatars, creations, chats, brand, memory, skills. Read-only, free. Allowed keys: heist.memory.v1, heist.skills.v1, heist.playbooks.v1, heist.avatars.v1, heist.locations.v1, heist.chats.v1, heist.creations.v1, heist.assets.v1, heist.brand.v1, adInspo.swipefile.v1. (The typed tools — list_memory / list_skills / get_brand — are friendlier for those; use store_get for the rest.)

get_settingsA

Read this account's app settings — the LANGUAGE Hermoso writes ads, copy and answers in, the app appearance (theme), and whether the weekly competitor-watch email is on. Same settings as the web app's Settings pane. Read-only, free.

update_settingsA

Change this account's app settings. language = the language EVERY ad, script, plan and answer is written in from now on (say the language in plain English, e.g. "German", "Japanese", "Brazilian Portuguese") — it applies to renders made over MCP as well as in the app. theme = the app's appearance, "dark" or "light". watchEmail = the weekly competitor-watch email on/off. Only pass what you are changing. Account-wide (every brand), and it takes effect on the next call.

list_connectorsA

List the third-party accounts connected to this workspace (Meta, Google Ads, Google Drive/Sheets/Docs, YouTube, LinkedIn, OneDrive, Slack, …) — provider, status and the connected account label — PLUS which providers are available to connect. IT ALSO FLAGS A CONNECTION WHOSE PERMISSIONS ARE OUT OF DATE: a provider writes its granted permission set into the token at consent time, so a connection authorized before a permission was approved does not carry it and never will — those calls are refused by the provider and no retry or wait can change it. Check this FIRST when a connected provider starts refusing things. The fix is a browser: the user reconnects under Workspace ▸ Connectors. Read-only, free.

list_connector_accountsA

Show every identity a connected account can act as, and which ones this BRAND is currently allowed to use — Facebook Pages + Instagram + Meta ad accounts, Google Ads customers, LinkedIn company Pages (and the personal profile), Pinterest ad accounts, Microsoft Advertising accounts. One person often administers several; only the ticked ones can be posted to or spent from. Call this before set_connector_accounts, and let the USER pick — never guess. Providers: meta, google_ads, linkedin, pinterest_ads, linkedin_ads, reddit_ads, apple_ads, microsoft_ads, google_business, google_analytics, snapchat_ads, x_ads, tiktok_ads, google_tag_manager, google_search_console, bing_webmaster. Read-only, free.

set_connector_accountsA

Set WHICH of a connector's accounts this brand is allowed to post to and spend from — Facebook Pages / Instagram / Meta ad accounts, Google Ads customers, LinkedIn company Pages (and the personal profile), Pinterest or Microsoft Advertising ad accounts. Pass ids from list_connector_accounts. This REPLACES the current selection: anything you leave out is un-shared, and an EMPTY list shares nothing (publishing then refuses — it fails closed by design, and the server re-verifies every id against the live connection, so an id the account cannot actually reach is rejected rather than saved). Ask the user which accounts they mean; posting as the wrong Page is a public mistake. Providers: meta, google_ads, linkedin, pinterest_ads, linkedin_ads, reddit_ads, apple_ads, microsoft_ads, google_business, google_analytics, snapchat_ads, x_ads, tiktok_ads, google_tag_manager, google_search_console, bing_webmaster. Free.

disconnect_connectorA

Disconnect a third-party account from this workspace (Meta, Google Ads, Google Drive/Sheets/Docs, YouTube, TikTok, LinkedIn, X, Reddit, Pinterest, Google Business, Microsoft Advertising/OneDrive, Slack, …). This always drops the stored credentials, so every tool for that provider stops working immediately and posts/campaigns already published are NOT affected. WHETHER IT ALSO REVOKES THE GRANT AT THE PROVIDER DEPENDS ON THE PROVIDER — a few (Threads, Microsoft) publish no revocation endpoint, so the authorisation stays in place until the user removes it in that provider's own settings. The unconfirmed call reports which it is for this provider (list_connectors also carries it as revokesAtProvider) — relay that verbatim rather than promising a revoke. RECONNECTING NEEDS A BROWSER (the provider's consent screen) — an agent cannot undo this. Name the provider to the user, then call with confirm:true. Use list_connectors for the exact provider ids.

leave_connectorA

On a connector that several teammates can contribute their OWN account to (see list_connectors — the row reports multiContributor), remove YOURS from this brand: your stored credential is dropped and the accounts you shared stop being shared. Your teammates' accounts on the same connection keep working, and nothing changes at the provider — reconnecting in a browser shares again. Use this instead of disconnect_connector when the connection is not yours to remove: disconnect_connector revokes the grant at the provider and only the person who created the connection may call it.

list_teamA

List the members of the current brand workspace — email, role (admin/member) and status. Read-only, free.

invite_memberA

Invite someone to this brand workspace by email (role: member = read-only on billing, admin = full). This SENDS a real email invite / share link — an account change. Confirm the exact email + role with the user, then call with confirm:true.

remove_memberA

Remove a member from this brand workspace by email — they lose access (you can re-invite them later). Confirm the exact person with the user, then call with confirm:true.

set_roleA

Change a workspace member’s role — admin (full access incl. billing) or member (read-only on billing). A privilege change: confirm the exact person + new role with the user, then call with confirm:true.

list_jobsA

List the most recent render jobs + how many are currently running, so you can report on or resume in-flight work.

list_errorsA

The errors actually recorded against this workspace, GROUPED by fingerprint — the same failure at the same call site is one row with a hit count and first/last seen, sorted defects-first. Each row says whose side it is: ours (a defect worth fixing), user (a refusal we deliberately authored, e.g. not-connected or out-of-credits), or unknown (a vendor 4xx we cannot attribute — never guessed). Free text, tokens, emails and creative are redacted before anything is stored, so an input echo shows shapes and lengths, not content. Filter by surface (http/mcp/agent/job/client) or kind. Read-only, 0 credits. Scoped to your own workspace; an operator whose client carries the admin key sees every account.

error_detailA

One error group in full by fingerprint (from list_errors): every field, plus the most recent redacted occurrences — status, connector, job id, workspace, and a shape-only echo of the inputs. This is what makes a bug reproducible. Read-only, 0 credits.

find_competitorsA

Discover a brand's competitor / similar / adjacent brands from its domain (Claude grounded by web search). mode=competitors (default, excludes the searched company), inspiration (best relevant ads incl. it), or company. 0 credits.

pull_competitor_adsA

THE FAST PATH for "show me the ads is running" — one named brand’s real live ads from the META (Facebook/Instagram) ad library, deduped, sorted, with the right page resolved. A single call, back in a few seconds. Prefer this over research_ads whenever the brand is named. Meta only, deliberately: it has by far the richest creative and is what people mean by "their ads". For Google or LinkedIn specifically, use search_google_ads or search_linkedin_ads. Spends credits.

set_competitor_watchA

Set (or STOP) this workspace's standing COMPETITOR WATCH — the weekly job that re-checks each named brand's ad libraries and reports what is NEW since last time. The same watch the web app's Ad Spy ▸ Watching tab manages, and the same one the weekly digest email is sent from (turn that email on/off with update_settings({watchEmail})). This REPLACES the whole watched list, it does not add to it — pass every brand you want watched, every time. Max 5 brands (the server trims past that). Pass an EMPTY list to stop the watch entirely, which also clears the findings. Give a domain wherever you know one: Google Ads Transparency is looked up BY DOMAIN and is skipped for a brand without one, and the domain is what resolves the right Meta page for a brand with an ambiguous name. The run itself spends credits against the ad libraries (roughly 3 per brand on Meta, 1 each on Google and LinkedIn) and is hard-capped per run server-side, so an oversized watch is trimmed rather than allowed to run away. Setting the list is free; only a run spends. runNow:true runs it once IMMEDIATELY (a background job — it spends now) and then keeps the weekly cadence; leave it off and the first check is a week out. The country and the platform mix are NOT settable here — a re-set inherits whatever the pending run already carried (US / Meta for a watch that has never been configured otherwise). Read the findings back with list_watch_findings.

list_watch_findingsA

Read what the standing COMPETITOR WATCH has found — the new ads each watched brand has launched since the last check, plus the watch's own state (who is watched, when it last ran, when it runs next, and whether the last run actually succeeded). The same board the web app's Ad Spy ▸ Watching tab renders. Use it to answer "what are our competitors running that's new?", to feed a teardown, or to save something worth keeping with save_to_swipefile. Findings marked seed:true are NOT new launches — the first check of a brand has nothing to diff against, so it seeds the board with what that brand is running right now; only later runs surface genuine changes. Read-only and free — it returns the stored results of past runs and never triggers a check (set_competitor_watch({runNow:true}) is what runs one).

research_adsA

Open-ended ad research that needs JUDGMENT across platforms — comparisons, "what angle is working", "who else is doing this", anything where the right sources are not known up front. It is an agentic loop (several rounds of library pulls plus a written synthesis) and typically takes 30-60 seconds, so it is the WRONG tool for a question that names its own answer. For one named brand’s live ads use pull_competitor_ads; for one keyword or one advertiser on Meta use search_meta_ads — both are a single call and return in a few seconds. Spends credits — an agentic loop, so a handful rather than the one-call cost of a targeted search.

search_meta_adsA

Structured Meta (Facebook/Instagram) Ad Library pull — use when you know exactly WHAT to fetch: a keyword (query) OR one advertiser (companyName / pageId). Returns compact JSON {page_name, body, cta, link, dates, media} per ad. For open-ended research that needs judgment across platforms, use research_ads instead. Spends a credit or two.

search_google_adsA

Structured Google Ads Transparency pull for ONE advertiser (by domain or advertiserId) — use when you know the brand; use research_ads for open-ended research. Deliberately fetches the cheap BASIC listing (get_ad_details=false, ~1 credit — the detailed variant with per-ad headlines costs 25 credits/call and is not exposed here). Returns compact JSON {advertiser, format, adUrl, image, firstShown, lastShown} per ad.

search_linkedin_adsA

Structured LinkedIn Ad Library search by company name, keyword, or companyId — use for a targeted B2B pull; use research_ads for open-ended research. Returns compact JSON {advertiser, headline, description, cta, link, media, dates, impressions} per ad — LinkedIn is the one library exposing real impression counts. Spends about a credit.

search_tiktokA

Organic TikTok keyword search (there is NO TikTok ad library) — top-performing videos to mine for hooks/trends/remixable creative. Returns compact JSON {desc, author, handle, plays, likes, link, cover} per video, ranked by plays. Use research_ads for open-ended research. Spends about a credit.

search_instagramA

Organic Instagram REELS keyword search (/v2/instagram/reels/search — our only IG keyword surface; profile/hashtag pulls go through fetch_social_data with a handle). Returns compact JSON {desc, author, handle, plays, likes, link, cover} per reel, ranked by plays. Spends about a credit.

search_youtubeA

Organic YouTube keyword search (/v1/youtube/search) — videos to mine for hooks/angles/long-form structure. Returns compact JSON {desc (title), author, handle, plays, link, cover} per video, ranked by views. Spends about a credit.

search_redditA

Reddit keyword search (/v1/reddit/search, top-ranked) — a goldmine for the customer's OWN words (pain points, objections, language) to mine into ad hooks and copy. Returns compact JSON {desc (title+selftext), subreddit, upvotes, comments, link} per post. Spends about a credit.

search_threadsA

Organic Threads keyword search (/v1/threads/search) — short-form text/social posts for trend + voice research. Returns compact JSON {desc, author, handle, likes, link, cover} per post. Spends about a credit.

fetch_social_dataA

Generic escape hatch for any ALLOWLISTED long-tail social/web endpoint the dedicated search_* tools don't cover — e.g. {path:'/v1/instagram/profile', params:{handle:'nike'}}. Allowlisted platform families: TikTok (+ TikTok Shop), Instagram, YouTube, Facebook (organic profiles/posts/events/marketplace), LinkedIn (organic posts/companies), Twitter/X, Reddit, Threads, Snapchat, Pinterest, Twitch, Bluesky, Truth Social, Rumble, Spotify, SoundCloud, GitHub, Google search, link-in-bio pages (Linktree etc.). Param names vary per endpoint (profiles use handle, keyword searches use query, Reddit uses subreddit). WARNING: returns RAW provider JSON — large and messy; prefer the dedicated search_* tools. Spends credits.

get_brandA

What Hermoso ALREADY KNOWS for this account/workspace — the same saved brand profile (products, logos, palette, positioning) + learned memory the web Studio uses. Call it when you need to know whether a brand is on file: if hasBrand is true you can omit brand everywhere; if false, onboard with draft_brand. Not a required first step before a render: the create tools read the saved brand by themselves. 0 credits.

draft_brandA

Onboard a brand profile — from a website domain, a free-text description, or a social handle — into a {name, products, logo, …} object you can pass to plan_ad / generate. 0 credits. IMPORTANT: a domain can resolve to a DIFFERENT company than intended (e.g. bala.com is an engineering firm, not the Bala fitness brand at shopbala.com). Before spending any credits on research or renders, VERIFY the returned name (and summary) match the brand the user meant; if it looks wrong, re-draft with the correct domain or a description (pass save:false until confirmed) — this tool cannot ask the user, so the caller owns that check.

list_libraryA

Browse this workspace's Library — every image/video generated in the Studio, newest first (the same Library the web app shows). Returns served URLs you can open directly or hand to fetch_asset for a download link, plus each asset's kind, model, and age. Free, read-only.

fetch_assetA

Resolve a generated asset reference (a /generated/… path or any URL) to a clickable absolute URL + a direct download URL.

analyze_videoA

Break a video ad down into its structure: the verbatim transcript (voiceover + on-screen text) with a beat list, plus duration and sampled frame timestamps. Use to study a reference/competitor ad before remixing its structure. Costs ~a transcription call.

score_adA

Virality/performance prediction for a finished ad (image or video URL): overall score, per-dimension breakdown (scroll-stop, hook, clarity, brand/product, CTA, retention, goal fit), strengths, and the single biggest fix. Use BEFORE spending on distribution, or to rank variants.

reframe_videoA

Reframe a video to a different aspect ratio (e.g. 16:9 master → 9:16 vertical) with smart subject tracking. Paid render; returns the served URL of the reframed video.

upscale_videoA

Upscale a video to higher resolution (2x) for final delivery. Paid render; returns the served URL.

edit_videoA

EDIT/transform an existing video clip with a natural-language instruction (video-to-video) — KEEPS the original motion, timing and edit, changes the subject/setting/style. Use for 'change the background to a city', 'make it nighttime', 'restyle it as claymation', 'swap the product'. Best on 3–10s clips. NOT for mechanical cuts, trims, end cards or watermarks (use post_edit — seconds, ~2 credits, no AI model), NOT for making a new video (generate_video / render_ad), NOT for translating the spoken track (dub_video) and NOT for putting a saved creator's face on the motion (recast_motion). Paid render; returns the served URL of the edited clip.

dub_videoA

Localize a finished video into another language WITHOUT re-rendering it: the spoken track is transcribed, translated, re-voiced and lip-synced back onto the SAME footage, so the visuals, timing and edit are untouched. Just pass the video and the language — the script is read off the source automatically (pass script only to override what it heard). Paid; returns the served URL of the localized video.

change_voiceA

Swap the narration of a finished video into a different voice — keeps the performance, lip-sync, and background sound. Use when the user likes the video but wants a different narrator voice; use dub_video only for language translation. Paid; returns the served URL.

recast_motionA

Motion transfer: re-perform a reference video's motion with a different person/character (supply their image). The reference clip drives the movement; the image supplies the identity. Paid render.

plan_variationsA

Fan a brief into N DISTINCT ad angles (different hooks/mechanics/audiences), each with its own headline + visual brief — then render each with generate_image and rank with score_ad. LLM planning only; renders nothing itself.

competitor_teardownA

Tear a competitor's ad strategy down into an actionable playbook: their opening-hook MIX, longest-running campaign THEMES, the WHITE SPACE nobody in their set runs, 2-3 render-ready COUNTER-PLAYS, and the territories they own that you should avoid. Pass competitor {name, domain?}. CONTRACT: supply ads (raw ad objects from a prior pull_competitor_ads / search_meta_ads call) to tear exactly those down, OR omit ads and this pulls the competitor's real Meta ads first (spends a credit or two, longest-running = proven winners). Auto-tailors the white space + counter-plays to YOUR saved brand. Spends credits (free when you pass ads).

check_ad_policyA

Pre-flight ad copy against Meta's REAL, live Advertising Standards before you run it — a flat 1-credit check. Pulls Meta's actual policy pages and returns a verdict (pass / fix / block) where every flagged issue QUOTES Meta's own policy text verbatim plus a compliant rewrite that keeps the sell. It's a check, not an edit — it never changes the creative. Especially worth running for regulated-adjacent categories (health/supplements, weight-loss or beauty results claims, finance/crypto/insurance, alcohol, dating, gambling) or ANY strong/absolute/guaranteed claim.

remix_staticA

One-click STATIC-AD REMIX: rebuild a competitor/reference STATIC (image) ad as an on-brand version — SAME layout, composition and energy, but YOUR product, brand colours, logo and voice, with every trace of the source brand removed. Pass imageUrl = the static ad image to remix. Uses your saved brand (pass brandId to target a specific brand — that switches this key's active brand like use_brand). IMAGES ONLY — for video ads use render_ad. Bills as one image generation.

mine_anglesA

Mine ad ANGLES from real customer language: gathers the customer's own words (Reddit, TikTok, the brand's review page + review-site results) and returns a RANKED angle bank — each angle tagged (pain / outcome / identity / fear / competitive-displacement / social-proof / contrast), 2-5 VERBATIM proof quotes, a 0-100 score with breakdown, and a ready-to-run hook in the customer's own voice. Reads YOUR saved brand (pass brandId to target a specific brand — that switches this key's active brand like use_brand). To tear down a COMPETITOR use competitor_teardown instead. Spends a few credits.

list_product_photosA

List the product photos ALREADY saved in your workspace — the brand's product library plus any app-store screens (also surfaces photos locked in your OTHER creations, since a set product lands in the shared library). FREE — returns each photo's url + label. Call it before set_product_image to see the existing photos you can reuse. Reads YOUR saved brand (pass brandId to target a specific brand — that switches this key's active brand like use_brand).

set_product_imageA

Lock an image as the ad's real PRODUCT photo and SAVE it as this brand's default product, so every later plan_ad / render_ad / generate_image grounds on the true packaging without being told again. Pass imageUrl = a product shot's URL — an image from a prior research result (an organic Instagram/TikTok post, a scraped page image), a workspace / list_product_photos url, or any public product photo. The server downloads it and runs a product+safety check: a lifestyle/scene shot with no clear product, or an off-category / unsafe image, is REJECTED and NOTHING is locked or saved (the summary says why). On PASS it persists the photo to a DURABLE url, writes it to the brand's product library as the DEFAULT, and READS THE BRAND BACK to confirm — savedToBrand and the summary report what the brand ACTUALLY holds now, never what was asked for, so if it did not become the default you are told instead of finding out from a paid render. Bills one vision check. Reads YOUR saved brand for the category match (pass brandId to target a specific brand — switches this key's active brand like use_brand).

fetch_app_screensA

Pull an APP brand's REAL App Store screenshots into the workspace brand, so a screen-hungry native format can use them. Use when the brand has 0–1 app screens on file and you want make_template_ad(template:'app-ui-tour'), or the user asks to 'pull my app's screenshots'. Pass appName (defaults to the saved brand's name). FREE — a keyless App Store lookup. It needs a CONFIDENT match: an ambiguous or unknown app returns 0 screens and saves nothing, which you should relay plainly rather than retrying with guesses. On success the screens are saved to the brand (durable URLs) and are immediately usable.

list_meta_postsA

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.

list_published_postsA

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.

list_hooksA

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.

post_performanceA

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.

diagnose_postsA

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.

collect_post_metricsA

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.

backfill_postsA

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

Prompts

Interactive templates invoked by user choice

NameDescription

No prompts

Resources

Contextual data attached and managed by the client

NameDescription
hermoso-ad-resultShows the finished Hermoso ad — the image, auto-playing video, or carousel — with the model that rendered it and credits spent.
hermoso-ad-spyShows ad-research results as a grid of ad cards — each competitor ad’s creative thumbnail, advertiser, copy, call to action and run dates, linking out to the ad.
hermoso-capabilitiesBrowsable Hermoso model catalog: image/video/voice/writing models with exact per-render credit costs and a filter row.
hermoso-ad-result-legacyLegacy stable alias of the Hermoso ad result card.
hermoso-ad-spy-legacyLegacy stable alias of the Hermoso ad-research card.
hermoso-capabilities-legacyLegacy stable alias of the Hermoso model catalog.

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/hermoso-ai/hermoso'

If you have feedback or need assistance with the MCP directory API, please join our Discord server