Publish organically to the channels a brand has connected, or queue posts on a schedule.
THREADS read + manage
list_threads_posts#
List your Threads posts read-only
List recent posts on the brand’s connected Threads account (id, text, media, permalink, timestamp). Use it to find a post id for threads_insights, list_threads_replies, reply_to_thread or delete_thread.
| Parameter | Type | Description |
|---|
limit | number | how many posts (1–50, default 15) |
threads_insights#
Threads insights read-only
Performance for ONE Threads post (views, likes, replies, reposts, quotes, shares) when postId is given, or for the whole ACCOUNT when it is omitted — views, likes, replies, reposts, quotes, LINK CLICKS, follower count, and follower_demographics broken down by country, city, age or gender. Note the two metric sets differ: "clicks" exists only at account level and "shares" only on a single post, and an unknown metric is refused by name rather than dropped. since/until narrow the account window (Threads has no data before 2024-04-13, and followers_count / follower_demographics are lifetime metrics that ignore a window — the reply says so when that happens). Threads returns no demographics below 100 followers; an absent block means the account is under Meta’s floor, NOT that the audience is empty.
| Parameter | Type | Description |
|---|
postId | string | post id from list_threads_posts — omit for account-level insights |
metrics | string[] | account metrics: views, likes, replies, reposts, quotes, clicks, followers_count, follower_demographics |
breakdown | string[] | country / city / age / gender — required by follower_demographics, exactly one |
since | string | YYYY-MM-DD window start (account scope) |
until | string | YYYY-MM-DD window end (account scope) |
list_threads_replies#
List replies on a Threads post read-only
Read the replies on a Threads post. Set conversation:true to walk the entire thread rather than only direct replies. Use before reply_to_thread so you answer with the actual conversation in view.
| Parameter | Type | Description |
|---|
postIdrequired | string | post id from list_threads_posts |
conversation | boolean | true = the whole thread, not just direct replies |
limit | number | how many replies (1–50, default 25) |
reply_to_thread#
Reply on Threads
Post a reply to a Threads post — the brand’s own or someone else’s. This PUBLISHES publicly under the brand’s account, so show the user the exact wording and get their go-ahead first.
| Parameter | Type | Description |
|---|
replyToIdrequired | string | the post id being replied to |
textrequired | string | reply text (max 500 characters) |
hide_thread_reply#
Hide or unhide a Threads reply
Hide a reply on the brand’s Threads post (or unhide it with hide:false) — for spam and abuse moderation.
| Parameter | Type | Description |
|---|
replyIdrequired | string | reply id from list_threads_replies |
hide | boolean | false to UNHIDE (default true) |
delete_thread#
Delete a Threads post destructive
PERMANENTLY delete one of the brand’s Threads posts. IRREVERSIBLE — Threads has no undelete. Call it WITHOUT confirm first: nothing is deleted, and it answers with the post’s real text plus its views, likes, replies and reposts read back from Threads. Show the user that, get an unambiguous yes, then call again with confirm:true — plus confirmName (the post’s exact text as it was reported) once anyone has engaged with it. confirmName exists because confirming that you meant to delete SOMETHING does not prove you aimed at the right post, and a wrong id must not be confirmable blind. Threads allows only 100 deletions per account per rolling 24 hours; threads_publishing_limit says how many are left, and a quota refusal otherwise reads like a broken connection. Note Meta documents nothing about what a delete does to the replies underneath a post, so do not promise the conversation survives. 0 credits.
| Parameter | Type | Description |
|---|
postIdrequired | string | post id from list_threads_posts |
confirm | boolean | REQUIRED true — deletion is permanent; only set it after the user has explicitly agreed |
confirmName | string | the post’s exact text as the unconfirmed call reported it — required once it has any likes, replies or reposts |
repost_thread#
Repost a Threads post
Repost an existing Threads post to the brand’s own Threads profile — the Threads equivalent of a retweet. It is how a brand amplifies a customer’s post, a mention, or one of its own older threads without copying the text, and there was previously no way to do it. Works on any Threads post id: list_threads_posts, list_threads_mentions and search_threads_keyword all return them. This creates a NEW post on the profile, so show the user what is being reposted and get a yes first. Threads publishes NO un-repost endpoint — because a repost returns its own media id, deleting THAT id with delete_thread is the likely undo, but Meta does not document it, so check the profile afterwards rather than promising it worked. 0 credits. Needs Threads connected.
| Parameter | Type | Description |
|---|
postIdrequired | string | the Threads post id to repost |
threads_publishing_limit#
Threads quota remaining read-only
How much of the brand’s Threads quota is left right now — posts (250 per rolling 24 hours), replies (1,000), DELETIONS (100) and location searches (500) — each as used, total and REMAINING. Check it before any bulk operation, and read it the moment Threads starts refusing: a quota refusal is otherwise indistinguishable from a broken connection or a missing permission, and reconnecting cannot fix it. A number comes back null when Threads did not report it, never as 0 — "none left" and "we could not tell" are different answers. Read-only, 0 credits. Needs Threads connected.
No parameters.
list_threads_mentions#
Threads mentions of the brand read-only
Posts where someone MENTIONED the brand on Threads — anywhere, not just under your own posts. This is brand listening: real objections, questions and the exact language customers use, which is strong raw material for ad copy and for mine_angles. Use list_threads_replies instead when you want the conversation under one specific post.
| Parameter | Type | Description |
|---|
limit | number | how many mentions (1–50, default 25) |
search_threads_keyword#
Search Threads by keyword read-only
Search PUBLIC Threads posts for a keyword or topic — competitor listening, finding what people say about a product, or sourcing real customer language for ad copy. Distinct from search_threads, which reads a specific profile.
| Parameter | Type | Description |
|---|
qrequired | string | keyword or phrase |
searchType | "TOP" | "RECENT" | TOP (default) or RECENT |
WHATSAPP BUSINESS
list_whatsapp_accounts#
WhatsApp Business accounts and numbers read-only
The WhatsApp Business Accounts SHARED WITH THIS BRAND and the phone numbers registered on each — the ids every other WhatsApp tool needs, plus each number’s QUALITY RATING, which is what decides how many messages Meta will let it send. Start here. A WABA with no number cannot send anything and the reply says so: Hermoso does not register or verify numbers, that is WhatsApp Manager. A business portfolio that could not be read is REPORTED rather than dropped — an account missing from this list would read as "the brand has no WhatsApp", which is a claim about their business and not about our read. TWO ACCOUNTS CAN CARRY THE SAME DISPLAY NAME — always quote the `display` field or the id when naming one to a user, never the bare name. ONLY THE ACCOUNTS SHARED WITH THIS BRAND ARE REACHABLE. One Meta login often administers WhatsApp for several businesses, so a human ticks which belong to this brand under Settings ▸ Connectors ▸ Meta ▸ Manage accounts (or set_connector_accounts(provider:"meta")); nothing else here can see or touch the rest. With none ticked every WhatsApp tool refuses and names that as the way out. With exactly ONE ticked, wabaId is optional — pass it only to disambiguate. Read-only, 0 credits (WhatsApp conversations are billed by META to the business directly, never in Hermoso credits). Needs Meta connected.
No parameters.
list_whatsapp_templates#
WhatsApp message templates read-only
The message templates on one WhatsApp Business Account with the REVIEW STATUS Meta actually holds for each. THE STATUS IS THE POINT: an APPROVED template is the only thing WhatsApp accepts outside the 24-hour customer-service window, and Meta’s own refusal for anything else says "the template does not exist in the specified language or the template has not been approved" — two completely different problems in one sentence. Each row also reports its LANGUAGE, because Meta treats en and en_US as different templates and sending against the wrong one is refused as if it were unapproved. Each row also reports the VARIABLES the template carries — the distinct {{1}}, {{2}} placeholders per component, with the total — because send_whatsapp_message must supply EXACTLY that many and Meta refuses a mismatch with 132000. Read the count off that field, never off the one-line body preview, which is capped. Read-only, 0 credits. Needs Meta connected.
| Parameter | Type | Description |
|---|
wabaId | string | the WhatsApp Business Account id, from list_whatsapp_accounts. Optional when exactly one account is shared with this brand; required when several are, and refused by name for one that is not shared |
name | string | filter to one template name (Meta matches it exactly) |
language | string | filter to one language code, e.g. en_US |
status | string | filter to one review status, e.g. APPROVED or PENDING |
limit | number | how many (1–200, default 50) |
create_whatsapp_template#
Create a WhatsApp message template
Submit a new WhatsApp message template for Meta’s review. This is the ONLY way a business can start a WhatsApp conversation: outside the 24-hour window that opens when a customer messages first, WhatsApp accepts approved templates and nothing else. CATEGORY IS NOT COSMETIC — MARKETING (anything promotional), UTILITY (a transactional follow-up to something the customer did) or AUTHENTICATION (a one-time passcode) decides both what Meta charges for the conversation and the policy the content is reviewed against, and Meta RE-CATEGORISES a template whose content does not match the claim; the reply reports the category Meta actually assigned, not the one requested. The name is lowercase letters, digits and underscores only. Language is never defaulted: en and en_US are different templates to Meta. Submitting does NOT make it sendable — it comes back PENDING and has to be re-read with list_whatsapp_templates. CAROUSEL TEMPLATES ARE SUPPORTED: add a “CAROUSEL” component whose “cards” array holds 2–10 cards, each card carrying its own HEADER (format IMAGE or VIDEO for a media carousel, PRODUCT for a catalog one), an optional card BODY of at most 160 characters — a different and far shorter limit than the message bubble’s 1024 — and up to 2 buttons. Meta requires every card to carry THE SAME components, and if any card has body text then all of them must. At creation a card is only its “components” array; “card_index” belongs to the send payload, not to this one. A MEDIA HEADER NEEDS AN UPLOADED ASSET: any IMAGE, VIDEO or DOCUMENT header — including every card header in a media carousel — must carry example: { header_handle: [ … ] } holding a handle from Meta’s Resumable Upload API, or Meta refuses the whole template with a bare “Invalid parameter”. Hermoso does not upload template media yet, so it refuses that locally and by name instead. A TEXT header needs no handle, and a PRODUCT card carousel takes its pictures from a connected catalog. 0 credits.
| Parameter | Type | Description |
|---|
wabaId | string | the WhatsApp Business Account id, from list_whatsapp_accounts. Optional when exactly one account is shared with this brand; required when several are, and refused by name for one that is not shared |
namerequired | string | lowercase letters, digits and underscores only, e.g. "order_shipped" (max 512) |
languagerequired | string | the language code, e.g. en_US, es_MX, pt_BR — Meta treats each as its own template |
categoryrequired | "MARKETING" | "UTILITY" | "AUTHENTICATION" | MARKETING = promotional, UTILITY = transactional follow-up, AUTHENTICATION = one-time passcode. It sets the price and the review policy. |
componentsrequired | object[] | Meta’s component array. A BODY is the only REQUIRED one: [{ type: "BODY", text: "Your order {{1}} has shipped." }]. Optional HEADER (text max 60), FOOTER (max 60) and BUTTONS (up to 10 in total). Body text max 1024. A carousel is { type: “CAROUSEL”, cards: [{ components: [{ type: “HEADER”, format: “IMAGE” }, { type: “BODY”, text: … }] }, …] } — 2–10 cards, all with the same components, card body max 160, up to 2 buttons each. |
parameterFormat | string | POSITIONAL (default, {{1}}) or NAMED |
delete_whatsapp_template#
Delete a WhatsApp message template destructive
PERMANENTLY delete a WhatsApp message template. META DELETES EVERY LANGUAGE OF A NAME AT ONCE, which is a wider blast radius than most callers picture, so call it WITHOUT confirm first: nothing is deleted and it reports exactly how many templates and which languages would go. Then show the user that, get an unambiguous yes, and call again with confirm:true AND confirmName set to the template’s exact name — confirming that you meant to delete SOMETHING does not prove you aimed at the right template. Any message sent against a deleted template afterwards is refused. The answer is a RE-READ from Meta, never the 200: if the template still comes back it is reported as not deleted. 0 credits.
| Parameter | Type | Description |
|---|
wabaId | string | the WhatsApp Business Account id. Optional when exactly one is shared with this brand; an account that is not shared is refused by name |
namerequired | string | the template name (Meta matches it exactly) |
confirm | boolean | REQUIRED true to actually delete — deletion is permanent and covers every language |
confirmName | string | the template’s exact name, echoed back — required alongside confirm |
send_whatsapp_message#
Send a WhatsApp message
Send a WhatsApp message from one of the brand’s registered numbers to a customer. TWO KINDS, and which one is legal depends on time: pass `template` (an APPROVED template name) to START a conversation — the only thing WhatsApp accepts outside the 24-hour customer-service window — or `text` for a free-form reply INSIDE that window, which opens only when the customer messages the business first. Hermoso does not receive WhatsApp webhooks yet, so it CANNOT see whether that window is open: a free-form message is sent optimistically and Meta refuses it with a re-engagement error if it is closed, which the reply explains rather than reporting as a broken connection. THIS REACHES A REAL PERSON’S PHONE AND META BILLS THE BUSINESS for the conversation, so it is confirm-gated: show the user the exact message and the recipient, get an explicit yes, then call again with confirm:true. WhatsApp policy REQUIRES the business to hold the recipient’s opt-in first. An unapproved template is refused BEFORE the send, against the status Meta holds. ACCEPTED IS NOT DELIVERED — the reply reports what Meta accepted, and delivery arrives on a webhook we do not receive, so never tell the user it was delivered. THE VARIABLE COUNT IS CHECKED BEFORE ANYTHING IS DISPATCHED: Hermoso reads the template’s own components back from Meta and refuses a payload that supplies the wrong number of values, naming exactly what is missing — for free, before a real phone is involved, instead of Meta answering 132000 after the attempt. It refuses only what it can prove; anything it cannot determine is sent for Meta to judge. 0 Hermoso credits.
| Parameter | Type | Description |
|---|
phoneNumberIdrequired | string | WHICH NUMBER IT SENDS FROM — from list_whatsapp_accounts (a WABA can have several) |
torequired | string | the recipient in full international form, e.g. +14165550142 |
template | string | an APPROVED template name — required to start a conversation outside the 24-hour window |
language | string | which language of that template, e.g. en_US. Omitted, Hermoso uses the first one it finds and reports which. |
wabaId | string | the account the template’s review status is read from. Optional — it defaults to the account that owns phoneNumberId, and a wabaId naming a DIFFERENT account is refused rather than silently preferred |
components | object[] | the template’s variable values, in Meta’s component form. The count must match the {{1}}, {{2}} placeholders exactly. Hermoso checks this against the template’s real components before dispatching and refuses a provable mismatch by name. A media header needs its own component holding the link even though it carries no {{n}}, and a carousel needs one entry per card. |
text | string | a free-form message — only lands INSIDE the 24-hour window |
confirm | boolean | REQUIRED true — this sends a real message to a real phone and Meta bills the business for it |
send_whatsapp_broadcast#
Broadcast a WhatsApp template to many people
Send ONE approved WhatsApp template to MANY recipients — a customer list, a segment, a launch announcement. TEMPLATES ONLY: WhatsApp accepts free-form text only inside the 24-hour customer-service window that opens when THAT person messages the business, so a free-form broadcast is a separate bet on a separate window per person and Meta refuses each closed one. META HAS NO BULK ENDPOINT, so this is a paced fan-out — one message per recipient, about 10 a second — which is why it runs as a QUEUED JOB and returns a job id rather than a result: nothing has been sent when the call returns. Poll it by calling this tool again with `jobId` (or use get_job), and read the PER-RECIPIENT outcome; a partial failure is reported as one, never as success. PER-RECIPIENT VARIABLES ARE THE POINT: pass recipients as objects { to, components } so each person gets their own {{1}}, {{2}} values in a single broadcast. Duplicates are removed (the same number twice is a second charge for one person and trips Meta’s 6-second per-recipient limit) and unusable numbers are REFUSED BY NAME rather than silently dropped — pass skipInvalid:true to send to the rest, and they are still named in the result. RUN IT WITH dryRun:true FIRST: that costs nothing, sends nothing, and reports the exact recipient count, the template’s real category, what Meta bills for it, and whether the list fits inside the business portfolio’s moving 24-hour messaging limit. ⚠ META DOES NOT DELIVER MARKETING MESSAGES TO WHATSAPP USERS IN THE UNITED STATES AT ALL (error 131049, platform-wide since 1 April 2025) — the sends are still ACCEPTED and given message ids, so a US marketing broadcast looks like a success and is not one; the dry run says how many recipients are on +1. META BILLS THE BUSINESS, not Hermoso credits: per message delivered, at Meta’s own per-country rates, marketing always charged and utility/authentication free inside an open service window. WhatsApp policy REQUIRES the recipient’s opt-in first and nothing in the API enforces it — Meta punishes it afterwards through quality rating. Confirm-gated in code: show the user the message, the template and the recipient count, get an explicit yes, then call again with confirm:true AND confirmCount set to the exact number of distinct recipients. ACCEPTED IS NOT DELIVERED — never report a broadcast as delivered. 0 Hermoso credits.
| Parameter | Type | Description |
|---|
jobId | string | READ MODE — pass a broadcast’s job id (and nothing else) to get its status and per-recipient outcome instead of starting a new one |
phoneNumberId | string | WHICH NUMBER IT SENDS FROM — from list_whatsapp_accounts (a WABA can have several). Required to SEND; omit only when reading a broadcast back with jobId |
template | string | an APPROVED template name. Required to SEND — a broadcast cannot be free-form text; omit only when reading a broadcast back with jobId |
language | string | which language of that template, e.g. en_US. Omitted, Hermoso uses the first one it finds and reports which. |
wabaId | string | the account the template is read from. Optional — it defaults to the account that owns phoneNumberId, and one naming a DIFFERENT account is refused |
recipients | object[] | the list: either phone numbers in full international form ["+14165550142", …], or objects { to, components } when each person gets different template values. Duplicates are removed and counted; invalid numbers are refused by name unless skipInvalid is true |
components | object[] | template values SHARED by every recipient, in Meta’s component form. A per-recipient `components` overrides it. The count must match the template’s {{1}}, {{2}} placeholders — Hermoso checks that against the real template and refuses a provable mismatch before anything is dispatched |
skipInvalid | boolean | drop unusable numbers instead of refusing the whole list. They are still named in the result — nothing is ever silently dropped |
dryRun | boolean | cost nothing, send nothing, queue nothing: report exactly who would be messaged, what Meta charges for it, and whether it fits the 24-hour messaging limit |
confirm | boolean | REQUIRED true — this sends real messages to real phones and Meta bills the business for every one |
confirmCount | number | REQUIRED — the exact number of DISTINCT valid recipients, echoed back. confirm:true alone cannot tell a 12-person test from a 2,000-person send |
post_to_meta#
Post to Facebook, Instagram or Threads
Publish to a connected Facebook Page, its linked Instagram, OR the brand’s Threads account — text/link/image/VIDEO/CAROUSEL. A MULTI-SLIDE creative is a CAROUSEL, not several posts: pass the slides in order as imageUrls[] and they publish as ONE swipeable post (Instagram album, Threads carousel, Facebook multi-photo post). Never publish slide 1 of a deck on its own — the creative tells the viewer to swipe. target:"facebook" (default) posts to the Page; target:"instagram" publishes a photo or Reel to the linked IG business account (needs an image or video); target:"threads" posts to the connected Threads account (text, image, or video). Works with ANY media — a finished Hermoso ad OR an arbitrary user file: imageUrl/videoUrl accept a public https URL, a data: URI, or a Hermoso /generated path; for a LOCAL file (e.g. on the user’s desktop) call upload_file first and pass the url it returns. INSTAGRAM COLLAB: pass `collaborators` (up to 3 usernames) to invite other accounts to CO-AUTHOR the post — it then shows on their profile too once they accept, which is the reach play behind every creator partnership. This PUBLISHES immediately — confirm the copy + media with the user first. Needs a connected Meta account (Settings ▸ Connectors ▸ Meta) with posting permission; Threads needs its own connection.
| Parameter | Type | Description |
|---|
brand | string | WHICH BRAND this post belongs to — the id or exact name from list_brands (a workspace shared with you: its profile id). Use it whenever the account has more than one brand and you are not certain which one this connection is pinned to: it beats the pin for THIS CALL ONLY and changes nothing about the connection. A name that matches no brand, or two brands, is REFUSED and nothing is posted — never resolved to the pin, which is the account you were guarding against. |
hook | string | WHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer "which hooks work", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. "direct_callout", "mid_problem", "before_after") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works. |
subject | string | WHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. "winter coat", "free trial", "founder story"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group. |
message | string | post text / caption |
imageUrl | string | public https URL, a data: URI, or a Hermoso /generated path (upload_file gives you one for a local file) |
videoUrl | string | public https URL, data: URI, or /generated path — FB video post / IG Reel |
productTags | object[] | INSTAGRAM SHOPPING — make the post SHOPPABLE by tagging products from the brand’s own catalog; tapping a tag opens the product’s price sheet inside Instagram. Instagram only. ON A PHOTO each tag is {product_id, x, y}, where x and y are FRACTIONS of the image from 0.0 (left/top) to 1.0 (right/bottom) — 0.5,0.5 is the middle — and BOTH are required, max 20. ON A REEL it is {product_id} ALONE with no coordinates, max 30. ON A CAROUSEL it is an array PER SLIDE ([[{…}], [], [{…}]]) because Instagram tags each slide’s own container, max 5 per slide and 20 across the post. Ids come from search_instagram_shopping_products; call list_instagram_shopping_catalogs FIRST, because tagging needs an APPROVED Instagram Shop and without one this fails after the media is already uploaded. A tag whose product is not “approved” is stored and shown to nobody. |
imageUrls | string[] | CAROUSEL — an ORDERED list of image (and, where the channel allows, video) URLs published as ONE post the viewer swipes through. THIS IS NOT “post several” — it is a single post with several slides, which is what a multi-slide creative (a listicle, a “1/6 · SWIPE” deck) actually needs; publishing only its first slide tells the viewer to swipe at something that cannot. The ORDER is the product. Limits per channel: Instagram 2–10 (images, videos or a mix), Threads 2–20 (mix allowed), Facebook 2+ (Meta publishes no documented maximum; Hermoso caps the upload fan-out at 30 and says so), LinkedIn company Pages 2–20 (images only), Pinterest 2–5 (images only), TikTok up to 35. One url here is simply an ordinary single post. Anything a channel cannot do is REFUSED with the real reason — nothing is ever quietly downgraded to one slide. |
idempotencyKey | string | SAFE RETRIES. Publishing can take minutes (a video upload, a carousel of ten slides) and a transport can time out while the post SUCCEEDS — retrying blind is how the same thing gets posted twice. Pass any stable string here and a repeat of the SAME publish returns the ORIGINAL post id instead of posting again (24h). You do not have to: an identical publish is auto-recognised for 10 minutes anyway. If a call times out or errors ambiguously, CALL AGAIN WITH THE SAME KEY — that is the safe move, and it will either report the original post or publish it for the first time. It never posts twice. |
allowDuplicate | boolean | post it even though an identical post was just made or attempted. Only pass this when the user genuinely wants the same thing posted twice, or when you have LOOKED at the account and confirmed a timed-out attempt did not land. |
async | boolean | publish in the BACKGROUND and return a job id to poll with get_job, instead of waiting. USE THIS FOR VIDEO: a Facebook or Instagram video publish routinely outlives an agent transport, and a timeout on the synchronous path leaves you unable to tell whether the post is live. With async:true nothing can time out — the job reports the post id and url when it lands. |
link | string | a URL to attach (FB text post only) |
collaborators | string[] | INSTAGRAM COLLAB — up to 3 Instagram usernames invited to CO-AUTHOR this post. Once one accepts, the post appears on THEIR profile too, with both handles in the header and the likes and comments shared — it is how a brand reaches a creator’s audience without paying for placement, and it is the single most-asked thing a scheduler normally cannot do. Pass handles only ("hermosoai"), not profile links; a leading @ is fine. INSTAGRAM ONLY — Facebook and Threads have no collab post at all and are refused BY NAME rather than silently dropping the co-authors — and never on a Story. AN INVITE IS NOT A CO-POST: publishing SENDS a request the other account must accept in their Instagram notifications, and until they do the post is on this brand’s profile ALONE; they may also decline, and Instagram sends no notification either way. So never report the post as live on both accounts — read the invite status back out of the reply, and use instagram_collaborators later to find out whether they accepted. |
target | "facebook" | "instagram" | "threads" | default facebook; instagram → the Page’s linked IG; threads → the brand’s connected Threads account |
account | string | WHICH Instagram account when target is instagram and the brand has several — Page-linked and Instagram Login accounts alike; an @username or id from list_connector_accounts("instagram"). Several and none named is refused by name; omit when there is one. |
scheduleAt | string | FACEBOOK ONLY — schedule instead of posting now. ISO timestamp (2026-08-01T09:00:00Z) or unix seconds; must be 10 minutes to 30 days ahead. Facebook holds the post and publishes it at that time, so nothing has to stay running on our side. Instagram and Threads have NO scheduling in Meta’s API — passing this for them is refused rather than silently posted immediately. |
locationId | string | TAG A PLACE. THREADS: a place id from search_threads_locations. INSTAGRAM: the NUMERIC ID OF A FACEBOOK PAGE associated with that location, not a place name and not coordinates; a non-numeric value is refused rather than sent. |
audience | object | FACEBOOK PAGE POST ONLY — limit who can see this organic post to people in these places and/or over this age (Facebook allows 13, 15, 18, 21 or 25). Not on Instagram, Threads or a Facebook Reel (a vertical clip becomes a Reel): those are refused. Region and city keys come from the Meta ads location search. |
place | string | FACEBOOK — tag a location on the Page post. The NUMERIC ID OF THE FACEBOOK PAGE for that place, not a place name and not coordinates. |
callToAction | "BOOK_TRAVEL" | "BUY_NOW" | "CALL_NOW" | "DOWNLOAD" | "GET_DIRECTIONS" | "LEARN_MORE" | "LIKE_PAGE" | "MESSAGE_PAGE" | "NO_BUTTON" | "OPEN_LINK" | "SHOP_NOW" | "SIGN_UP" | "WATCH_MORE" | FACEBOOK — a real call-to-action BUTTON on the Page post. The types that open a destination (SHOP_NOW, LEARN_MORE, SIGN_UP, BUY_NOW, DOWNLOAD, OPEN_LINK, WATCH_MORE, BOOK_TRAVEL) need somewhere to go: the post’s own `link`, or `callToActionLink`. CALL_NOW, MESSAGE_PAGE, LIKE_PAGE and GET_DIRECTIONS act on the Page itself and take none. |
callToActionLink | string | FACEBOOK — where the button goes, when that is not the post’s own `link`. |
linkName | string | FACEBOOK — override the HEADLINE of the link preview. Without it the post shows whatever Open Graph title the destination carries, which on a bare landing page is often nothing. Needs `link`. MEASURED 2026-09-17: Facebook only honours these when the Page’s business has VERIFIED the domain (Business Manager ▸ Brand safety ▸ Domains) — without that Meta refuses the post outright with “Only owners of the URL…”, so leave them out unless the brand owns and has verified that link’s domain. |
linkDescription | string | FACEBOOK — override the DESCRIPTION of the link preview. Needs `link`. MEASURED 2026-09-17: Facebook only honours these when the Page’s business has VERIFIED the domain (Business Manager ▸ Brand safety ▸ Domains) — without that Meta refuses the post outright with “Only owners of the URL…”, so leave them out unless the brand owns and has verified that link’s domain. |
linkPicture | string | FACEBOOK — override the IMAGE of the link preview: a public http(s) url Facebook fetches itself. Needs `link`. MEASURED 2026-09-17: Facebook only honours these when the Page’s business has VERIFIED the domain (Business Manager ▸ Brand safety ▸ Domains) — without that Meta refuses the post outright with “Only owners of the URL…”, so leave them out unless the brand owns and has verified that link’s domain. |
coverUrl | string | INSTAGRAM REEL COVER — a public image url Instagram fetches and uses as the cover in the Reels tab. REELS ONLY, and the alternative to `thumbOffset`: passing both is refused, since they are two answers to the same question. Run a local file through upload_file first. |
thumbOffset | number | INSTAGRAM REEL COVER, the other way — which frame becomes the cover, in MILLISECONDS from the start of the video. REELS ONLY. Use it instead of `coverUrl` when the right cover is already a frame of the clip. |
shareToFeed | boolean | INSTAGRAM REEL — true puts the Reel in the Feed grid as well as the Reels tab. REELS ONLY. Left unset it follows Instagram’s own default; Hermoso does not flip it either way on the user’s behalf. |
audioName | string | INSTAGRAM REEL — the name of the Reel’s audio track, which is what viewers tap through to. REELS ONLY. |
paidPartnership | boolean | INSTAGRAM — the PAID PARTNERSHIP label. A COMPLIANCE DECLARATION, the same kind Hermoso already carries for TikTok and X: set it whenever the post is sponsored, gifted or otherwise paid for. Opt-in and never inferred — it is the poster’s own statement about their commercial relationship. |
brandedContentSponsorIds | string[] | INSTAGRAM — the numeric Instagram USER IDS of the brands behind that label (at most 2, and ids rather than @handles). Naming sponsors IS asking for the label, so setting these with `paidPartnership:false` is refused instead of publishing brand credits with no disclosure. |
trialReel | "MANUAL" | "SS_PERFORMANCE" | INSTAGRAM TRIAL REEL — publish this Reel to NON-FOLLOWERS ONLY at first (Instagram allows trials only on accounts above its follower threshold — about 1,000 followers; an ineligible account is refused by name and nothing is posted), so a hook can be tested on a cold audience without spending it on the people who already follow the brand. Instagram then shows it to followers only if it graduates. MANUAL = the creator graduates it by hand in the Instagram app; SS_PERFORMANCE = Instagram graduates it automatically if it performs well. REELS ONLY and INSTAGRAM ONLY: an image, a carousel, a Facebook post or a Threads post is REFUSED BY NAME rather than quietly published as an ordinary post — a trial that silently goes to every follower is the exact opposite of what was asked for. Omit it for a normal Reel. |
story | boolean | INSTAGRAM STORY — publish this as a 24-hour Story instead of a feed post. One image OR one video: Instagram has no carousel story, so a carousel is REFUSED BY NAME rather than quietly posted to the feed. A Story carries NO CAPTION (there is nowhere to show one), no collaborators, no product tags and no trialReel — passing any of those is refused by name and nothing is posted, because a story that silently drops the words is worse than one that never went. INSTAGRAM ONLY: a Facebook Page story is a different upload and is not built, so a Facebook channel with `story` set is refused rather than published to the feed. |
aiGenerated | boolean | INSTAGRAM / FACEBOOK REEL — Meta’s is_ai_generated self-disclosure. OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, media that came through upload_file or from an external URL (the user’s own photographs or footage) is NOT — a real photo must never carry Instagram’s “AI info” label. Pass true or false only to override: false strips the label from something Hermoso would otherwise declare, true declares a render the user uploaded themselves. |
altText | string | string[] | ACCESSIBILITY — the description screen readers announce, and what the platform otherwise auto-generates badly or not at all. Describe what is actually IN the picture, never the caption. ONE STRING describes the picture; on a CAROUSEL it describes EVERY slide. Pass an ARRAY of strings instead to describe each slide separately, aligned to the slide order — that is strictly better on a multi-slide post, because one sentence read out over six different pictures is wrong for five of them. More descriptions than pictures is refused rather than dropped. WHERE IT LANDS, per Meta’s own docs: INSTAGRAM image posts and the IMAGE slides of an Instagram carousel (up to 1000 characters; dropped rather than sent on a Reel or a video slide, which Meta do not support); FACEBOOK Page photos including every photo of an album, via alt_text_custom (Meta publish no length for it, so nothing is truncated); THREADS on a single-image or single-video post ONLY — Meta document no way to attach alt text to a Threads CAROUSEL slide, so a Threads carousel publishes undescribed and the reply says so rather than risking the whole post on a guess. (Meta’s AI disclosure defaults from PROVENANCE: a Hermoso render is declared is_ai_generated; media that came through upload_file or an external URL, i.e. the user’s own photos or footage, is NOT. Pass `aiGenerated` to force it either way.) |
pageId | string | target Page id (from list_meta_pages); omit = first Page |
linkAttachment | string | THREADS TEXT POSTS ONLY — a full http(s) URL rendered as a clickable link card. This is how a Threads post carries a destination at all; without it a link is just text. Meta allows at most 5 links per post and refuses a link attachment on a post that also carries an image or video, so Hermoso refuses that combination up front rather than letting Meta silently drop it. |
replyControl | "everyone" | "accounts_you_follow" | "mentioned_only" | "parent_post_author_only" | "followers_only" | THREADS ONLY — who may reply. Default is everyone. |
quotePostId | string | THREADS ONLY — the id of a post to QUOTE. A quote post is a distinct post type, not a formatting option. |
topicTag | string | THREADS ONLY — one topic tag for discovery, 1–50 characters. A leading # is stripped for you; Threads refuses "." and "&". |
countryCodes | string[] | THREADS ONLY — restrict the post to these ISO 3166-1 alpha-2 countries. ⚠ This is an ALLOWLIST, so the post becomes INVISIBLE in every country not named — it narrows reach, it does not target. |
crossreshareToIg | boolean | THREADS ONLY — ALSO share this Threads post to the linked Instagram account AS A STORY (not a feed post), in the same publish. NOT available on a Threads CAROUSEL, which is refused by name rather than silently dropped. THERE IS NO CONFIRMATION: Threads returns no field saying whether the Story was created, so report it as REQUESTED and tell the user to check their Instagram Stories — never that it is live. |
crossreshareDarkMode | boolean | THREADS ONLY — render that Instagram Story in dark mode. Only meaningful alongside crossreshareToIg; on its own it is refused rather than silently ignored, because a parameter that never reaches the wire must not look accepted. |
YOUTUBE CHANNEL SECTIONS + PLAYLIST IMAGES
manage_youtube_channel_section#
Channel homepage sections destructive
THE SHELVES ON THE CHANNEL HOMEPAGE — what a visitor sees first, and the only place a chosen playlist can be put above YouTube’s own default layout. action:'list' reads them in the order they appear, 'create' adds one, 'update' replaces one, 'delete' removes one (confirm-gated, and there is no undo — the layout has to be rebuilt by hand). EVERY WRITE IS PUBLIC IMMEDIATELY: a channel homepage is not a draft. `type` decides what the shelf holds — singlePlaylist and multiplePlaylists take playlist ids, multipleChannels takes channel ids and those two plus multiplePlaylists take a title you choose, while popularUploads, recentUploads, subscriptions and the rest are filled by YouTube and take neither. `position` is zero-based and is what re-orders the page. DO NOT TREAT A LIST AS A READ-BACK: YouTube's own section list lags a write by a few seconds in BOTH directions — measured, it returned nothing right after a create and still returned a deleted section right after a delete — so a write reports what YouTube returned and a delete reports as ACCEPTED, and re-listing straight away can show the old layout. Free.
| Parameter | Type | Description |
|---|
action | "list" | "create" | "update" | "delete" | defaults to 'list' |
type | string | singlePlaylist | multiplePlaylists | popularUploads | recentUploads | likes | allPlaylists | likedPlaylists | recentPosts | recentActivity | liveEvents | upcomingEvents | completedEvents | multipleChannels | postedVideos | postedPlaylists | subscriptions. Required to create or update |
title | string | the heading, and only multiplePlaylists and multipleChannels take one — YouTube writes the heading for every other type |
playlists | string[] | playlist ids, from list_youtube_playlists. Required for singlePlaylist (exactly one) and multiplePlaylists |
channels | string[] | channel ids to feature. Required for multipleChannels |
position | number | zero-based position on the homepage. Leave it off and YouTube places the section |
style | "horizontalRow" | "verticalList" | leave it off to let YouTube choose |
sectionId | string | for update and delete — from action:'list' |
hl | string | language for the returned titles, e.g. "en" |
confirm | boolean | must be true to actually delete |
manage_youtube_playlist_image#
Custom playlist cover image
PUT A CUSTOM COVER ON A PLAYLIST — the last call in a chain that already existed, since make_thumbnail renders the artwork and the playlist tools own the playlist. Without one YouTube shows the first video's thumbnail. action:'list' reads what is on a playlist, 'set' uploads a cover, 'delete' removes it. imageUrl must be a Hermoso-hosted URL, up to 50MB — a Hermoso render, a make_thumbnail result, or the user’s OWN artwork brought in with upload_file, which turns any local or external file into a URL this accepts. Nothing else is fetched server-side. YOUTUBE ANSWERS EVERY FAILURE HERE AS AN HTTP 500 "Internal error encountered" with the real reason buried inside it, so a plain relay would report a missing playlist id as a Hermoso outage — the refusals here are the real ones. If it comes back refused, the first thing to check is CHANNEL VERIFICATION: custom imagery needs a verified YouTube channel (add a phone number at youtube.com/verify), and on an unverified channel the sibling call that sets a custom video thumbnail is refused in the same way. Free.
| Parameter | Type | Description |
|---|
playlistIdrequired | string | from list_youtube_playlists |
action | "list" | "set" | "delete" | defaults to 'list' |
imageUrl | string | for action:'set' — a Hermoso render or make_thumbnail URL |
imageId | string | for action:'delete' — from action:'list' |
list_youtube_captions#
List (and read) a video’s caption tracks read-only
List the caption/subtitle tracks on one of the connected channel’s videos, and optionally DOWNLOAD one as text. A caption TRACK is not the same thing as burned-in captions: a track is what YouTube indexes the video by, what a viewer toggles on, and what accessibility depends on. Each row says whether YouTube generated it automatically (`isAutoGenerated`, trackKind ASR) — those are read-only and cannot be edited or deleted. Downloading is also the fastest way to get an existing video’s full script back for repurposing. Read-only, 0 credits. Needs a connected YouTube channel.
| Parameter | Type | Description |
|---|
videoIdrequired | string | the YouTube video id — captions are listed per video |
download | string | a captionId or a language code to also download as text |
format | "srt" | "vtt" | "sbv" | download format, default srt |
manage_youtube_caption#
Upload, replace or delete a caption track destructive
Attach a real subtitle track to one of the connected channel’s videos, replace one, or delete one. Pass the caption file BODY itself as `text` (SRT or WebVTT), not a URL. `language` is required and may not be guessed — YouTube uses it to decide which viewers see the track. A track YouTube generated itself (trackKind ASR) is refused by name: YouTube owns it and it cannot be edited or deleted through any API; to replace it, upload your own track in the same language and YouTube prefers yours. Delete is confirm-gated — viewers with captions on lose them immediately and YouTube stops indexing the video by that text, though it can be re-uploaded if you still have the file. 0 credits. Needs a connected YouTube channel.
| Parameter | Type | Description |
|---|
actionrequired | "upload" | "update" | "delete" | upload | update | delete |
videoId | string | required for upload; strongly recommended for update and delete so the track can be resolved and read back |
captionId | string | required for update and delete (from list_youtube_captions) |
text | string | the caption file body — SRT or WebVTT. Required for upload. |
language | string | BCP-47 code such as en, es, pt-BR. Required for upload. |
name | string | a label shown in YouTube’s caption picker |
draft | boolean | true keeps the track unpublished while you review it |
confirm | boolean | required to delete |
list_youtube_categories#
List the video categories you can publish under read-only
List the video categories YouTube will accept on an upload in a given country. post_to_youtube takes a `categoryId` and this is the only way to discover a valid one — the id set AND the names differ by country, which is why regionCode is required rather than defaulted. Categories YouTube marks not-assignable are filtered out by default because an upload using one is refused. Read-only, 0 credits. Needs a connected YouTube channel.
| Parameter | Type | Description |
|---|
regionCoderequired | string | ISO 3166-1 alpha-2 country code — US, GB, DE. YouTube’s category ids differ by country, so this cannot be guessed. |
includeUnassignable | boolean | also return categories YouTube will refuse on upload (default false) |
google_business_search_keywords#
What people searched to find this business read-only
The actual search terms people typed on Google Search and Maps before this business listing appeared — the only keyword data a local business gets for free, and the direct input to their Google Ads keyword set, page titles and profile description. google_business_insights answers HOW MANY people found the listing; this answers WHICH WORDS they used. ⚠ LOW-VOLUME TERMS ARE SUPPRESSED: Google withholds an exact count for them and returns only an upper bound, so those rows come back with impressions=null and below=<threshold>. Report those as “fewer than N” — NEVER as zero and never as the threshold itself, both of which are numbers a marketer would act on and neither is true. Counts are UNIQUE USERS per month summed across the window, not impressions; the two are not comparable. Google keeps roughly 12 months of history. Read-only, 0 credits. Needs Google Business Profile connected.
| Parameter | Type | Description |
|---|
locationId | string | which listing, when the brand has more than one (list_business_locations) |
months | number | how many whole months back, default 3, cap 12. The current month is excluded because it is always partial. |
limit | number | max terms, default 100, which is also Google’s maximum |
pageToken | string | nextPageToken from a previous call |
list_bluesky_posts#
List the brand's own Bluesky posts read-only
The brand's OWN recent Bluesky posts, newest first — and THIS is where the at:// AT-URI every other Bluesky tool needs comes from. bluesky_post_metrics and delete_bluesky_post both address a post by AT-URI, so without this the only way to hold one was to have just published it in the same conversation; an agent reviewing past work had no way to name anything. Each row carries the text, when it went out, its web URL, its live like/repost/reply/quote/bookmark counts, and whether it is a REPOST of someone else's post or a reply — a repost is not the brand's own creative and must not be reported as its performance. Optional filter: posts_no_replies, posts_with_media, posts_with_replies, posts_and_author_threads (an unknown one is refused by name). Bluesky publishes NO impression or view count in any lexicon, so these are counts with no denominator and no engagement rate can be computed from them. Read-only, 0 credits.
| Parameter | Type | Description |
|---|
limit | number | how many posts, 1–100 (default 25). 100 is Bluesky's own maximum. |
cursor | string | nextCursor from a previous call — a short page is NOT end-of-feed |
filter | string | posts_no_replies | posts_with_media | posts_with_replies | posts_and_author_threads |
search_x#
Search X (Twitter) read-only
Keyword search across the PUBLIC X index — the last 7 days. This is the X half of ad-spy research, alongside search_tiktok / search_instagram / search_reddit / search_threads / search_youtube. X’s own search OPERATORS work and are the point: `from:nike`, `-is:retweet`, `has:media`, `has:links`, `lang:en`, `"exact phrase"`, `(a OR b)`. Every post comes back with impressions, likes, reposts, replies, quotes and bookmarks, so you can rank what actually landed rather than what merely exists. ONLY THE LAST 7 DAYS: the full archive needs a tier we do not hold, and an older window is refused by name rather than silently truncated. Costs a small number of credits (X bills per post read, and this bills the ACTUAL count returned, never the number asked for). Needs X connected.
| Parameter | Type | Description |
|---|
queryrequired | string | the search, X operators included — e.g. `(protein OR creatine) -is:retweet has:media lang:en` |
maxResults | number | 10–100, default 25 |
sortOrder | "recency" | "relevancy" | default relevancy |
startTime | string | ISO 8601, no earlier than 7 days ago |
endTime | string | |
nextToken | string | from a previous page |
x_search_counts#
How often a query is posted on X read-only
The VOLUME of a query on X over time — counts per day, hour or minute for the last 7 days, plus the total. Use it to size a topic BEFORE spending behind it, or to tell a real spike from a flat line. Cheaper than search_x because it returns numbers rather than posts: one read regardless of volume. Needs X connected.
| Parameter | Type | Description |
|---|
queryrequired | string | same operator syntax as search_x |
granularity | "minute" | "hour" | "day" | default day |
x_trends#
What is trending on X read-only
Trending topics on X for a place, with the post volume behind each — raw material for hook writing and for judging whether a moment is worth entering. `woeid` is a Yahoo Where-On-Earth id and defaults to 1 (WORLDWIDE), so it answers without one: 23424977 United States, 23424975 United Kingdom, 23424775 Canada, 23424748 Australia. A trend X returns no volume for is reported as unknown, NEVER as zero. Needs X connected.
| Parameter | Type | Description |
|---|
woeid | string | numeric Where-On-Earth id; default 1 = worldwide |
maxTrends | number | 1–50, default 25 |
x_follows#
Who follows an X account (or who it follows) read-only
The follower or following LIST for an X account — handles, names, bios, locations and each one’s own follower/following/post counts. The COUNTS are already free on x_account and x_user; this is WHO, which is what audience analysis and creator vetting actually need. With no username it reads the CONNECTED account (“who follows us”); pass a handle to read anyone public (“who follows our competitor”). ⚠ NEEDS THE follows.read PERMISSION, added 2026-08-31: an X connection authorized before that date does NOT carry it and cannot have it added — the tool says so and asks for a reconnect rather than surfacing X’s raw refusal. Capped at 100 per call because X bills per user returned; page with nextToken. Needs X connected.
| Parameter | Type | Description |
|---|
username | string | handle with or without the @ — omit to read the connected account |
direction | "followers" | "following" | default followers |
maxResults | number | 1–100, default 50 |
paginationToken | string | |
list_x_blocks#
Who the connected X account has blocked read-only
The accounts the connected X account has BLOCKED, with handle, name, bio and follower count. READ-ONLY: X refuses the block and unblock WRITE on the access tier Hermoso's app runs on (measured 2026-09-15: X's own "attached to a Project" refusal with the scope granted), so blocking itself is done on x.com and there is no block tool here. Needs the block.read permission: an X connection made before 2026-09-15 must be reconnected once under Settings ▸ Connectors ▸ X. Costs credits (X bills per API call).
| Parameter | Type | Description |
|---|
maxResults | number | 1–1000, default 100 |
paginationToken | string | |
x_user#
Look up any public X account read-only
ACCOUNT-LEVEL numbers for ANY public X account by handle — followers, following, posts, listed and media counts, plus bio, location, verified status and account age. The research twin of `x_account` (which reads YOUR connected account): use this to size a competitor or vet a creator before working with them. Returns the identical shape as x_account, so the two are never described differently, and a counter X does not return is reported as unknown rather than zero. Needs X connected.
| Parameter | Type | Description |
|---|
usernamerequired | string | the handle, with or without the @ |
x_account#
Read the connected X account read-only
ACCOUNT-LEVEL numbers for the connected X account — FOLLOWERS, following, total posts, listed count, media count — plus display name, bio, location, verified status and when the account was created. This is the tool for “how many followers do we have on X”, “is our X growing”, or to confirm whose account is linked before posting; for a single post use x_post_metrics or x_post_insights instead. A counter X does not return comes back under `absent` with the reason and is NEVER reported as zero. Read-only. Rides the account lookup Hermoso already makes when it posts — X bills per request and not per field — so it costs at most one User: Read and is free on a cached lookup. Needs X connected (Settings ▸ Connectors ▸ X).
No parameters.
edit_telegram_message#
Edit a Telegram post
Change the TEXT or CAPTION of a message already posted to a Telegram channel, group or chat — the fix for a typo that would otherwise cost the post AND its accumulated views, since deleting and reposting starts from zero. Pass `text` for a plain post or `caption` for one carrying media; Telegram refuses the wrong one and its error names the other. Free, reversible, and 0 credits — use this instead of delete_telegram_message wherever the post can be salvaged. Needs Telegram connected.
| Parameter | Type | Description |
|---|
chatIdrequired | string | the channel’s @username or numeric chat id |
messageIdrequired | number | from post_to_telegram, or the number at the end of a t.me link |
text | string | the new text, for a post with no media |
caption | string | the new caption, for a post carrying a photo/video/album |
linkPreview | boolean | false to suppress the link preview |
pin_telegram_message#
Pin or unpin a Telegram message
Pin a message to the top of a Telegram channel or group, or unpin one. A channel’s pinned message is its most valuable real estate and its de-facto call to action. Pinning is SILENT by default (no member notification) — pass notify:true to ping. To unpin, pass unpin:true; omitting messageId then unpins the most recently pinned message, which is what “unpin” usually means. The bot must be an administrator with pin rights. Free, 0 credits. Needs Telegram connected.
| Parameter | Type | Description |
|---|
chatIdrequired | string | the channel’s @username or numeric chat id |
messageId | number | required to PIN; optional when unpinning |
unpin | boolean | true to unpin instead of pin |
notify | boolean | true to notify members; default silent |
create_telegram_invite_link#
Create a trackable Telegram invite link
Create a NAMED invite link to a Telegram channel or group, optionally with an expiry, a member cap, or join approval. THIS IS THE ONLY SUBSCRIBER ATTRIBUTION TELEGRAM OFFERS: the Bot API exposes no per-post views and no post history, so “which campaign brought these subscribers” is otherwise unanswerable — give each campaign its own named link and Telegram reports how many joined through each. A member limit and join approval are mutually exclusive in Telegram’s own docs and passing both is refused by name. The bot must be an administrator with invite rights. Free, 0 credits. Needs Telegram connected.
| Parameter | Type | Description |
|---|
chatIdrequired | string | the channel’s @username or numeric chat id |
name | string | what this link is for — the campaign name; up to 32 characters, and the whole point of the tool |
expiresAt | string | ISO 8601 timestamp after which the link stops working |
memberLimit | number | how many people may join through it; cannot be combined with requireApproval |
requireApproval | boolean | each join waits for an admin; cannot be combined with memberLimit |
create_linkedin_event#
Create a LinkedIn event
Create a LinkedIn LIVE event on a company Page the brand administers. ⚠ THIS REQUIRES LINKEDIN LIVE ACCESS on the member’s account: `liveVideo` is the only online format LinkedIn documents for event creation, and a member without Live access is refused with “The member does not have LINKEDIN_LIVE creation access” (measured live 2026-08-31). Check the account has it before offering this. ⚠ CREATED BUT NOT ANNOUNCED: LinkedIn requires a SEPARATE post to make an event publicly visible, so creating one puts nothing in anyone’s feed and the brand decides when to tell people. That separation is LinkedIn’s own and it is why an agent cannot accidentally broadcast to a company’s followers. ONLINE events only for now — an in-person event needs a full address object and a custom background image needs a three-step upload, and neither is offered rather than failing at LinkedIn. Needs the Events permission (administrator flag) and a reconnect, the same two conditions as list_linkedin_events. 0 credits.
| Parameter | Type | Description |
|---|
namerequired | string | the event title |
startsAtrequired | string | ISO 8601, must be in the future — LinkedIn rejects an event that has already begun |
endsAt | string | ISO 8601, after startsAt; defaults to one hour after the start |
description | string | |
organizationId | string | the Page — omit if only one is shared with this brand |
list_linkedin_events#
List a LinkedIn Page’s events read-only
The events a LinkedIn company Page is organizing — name, link, start and end times, online or in-person, visibility and lifecycle state. LinkedIn is the one channel where an event is a first-class object rather than a post about one, so this is how you see what a B2B brand actually has scheduled (webinars, launches, live sessions) and pair it with the posts promoting it. Only Pages the user has SHARED with this brand are readable. ⚠ Needs the Events permission: an administrator switches it on for the deployment, and because LinkedIn grants permissions at authorise time and never retroactively, every existing LinkedIn connection must RECONNECT once before it works. The tool tells you which of those two applies rather than failing generically. Read-only, 0 credits. Needs LinkedIn connected.
| Parameter | Type | Description |
|---|
organizationId | string | the Page to read — omit if only one Page is shared with this brand; list_linkedin_pages shows them |
start | number | |
count | number | max 50, default 10 |
search_linkedin_ad_library#
Search LinkedIn’s official Ad Library read-only
Search the ads running on LinkedIn, from LinkedIn’s OWN Ad Library, and get three things no ad scrape can give you: the advertiser’s actual TARGETING (location, language, audience, company, job, age, gender, interests, traits — both the segments they INCLUDED and the ones they EXCLUDED), estimated impressions as a range plus the percentage split BY COUNTRY, and, for ads LinkedIn REMOVED, the advertising policy that was violated and how it was caught. That last one is the cheapest way to learn what not to write. It is GLOBAL, not an EU-only transparency feed. COVERAGE IS BOUNDED and the reply always says so: nothing created before 2023-06-01, and an ad is dropped one year after its last impression, so an empty result usually means the window rather than the market. 25 ads per page. Needs LinkedIn connected — no extra permission, any LinkedIn connection works. 0 credits.
| Parameter | Type | Description |
|---|
keyword | string | searched in the ad’s content; several words are a logical AND, not a phrase |
advertiser | string | advertiser name, e.g. Notion |
payerName | string | who PAID for the ad, which is often a different entity from the advertiser |
countries | string | comma-separated ISO codes the ad was served in, e.g. US,GB |
dateFrom | string | YYYY-MM-DD, inclusive — must be given together with dateTo |
dateTo | string | YYYY-MM-DD, EXCLUSIVE — must be given together with dateFrom |
includeFacets | string | only ads whose targeting INCLUDES these facet categories: LANGUAGE, LOCATION, AUDIENCE, AGE, GENDER, COMPANY, EDUCATION, JOB, INTERESTS, TRAITS |
excludeFacets | string | only ads whose targeting EXCLUDES these facet categories |
impressionsFrom | number | lower bound of the estimated impressions range; pass both bounds or neither |
impressionsTo | number | upper bound; pass both bounds or neither |
sort | "DESCENDING" | "ASCENDING" | by creation time; default DESCENDING (newest first) |
start | number | |
count | number | max 25 |
search_linkedin_brand_partnerships#
Find sponsored creator posts on LinkedIn read-only
Search ORGANIC LinkedIn posts that creators have themselves disclosed as brand partnerships — paid influencer content, not ads bought through Campaign Manager. Use it to see which creators a competitor is paying, what a sponsored post in your category actually looks like, or to vet a creator’s existing brand relationships before approaching them. This is a different dataset from search_linkedin_ad_library and from search_linkedin_ads: those are paid placements, these are disclosed endorsements in the feed. DIFFERENT CONTRACT from the ad search, so do not carry parameters across: `keyword` is REQUIRED here, `dateRange` is an ENUM (PAST_24H / PAST_WEEK / PAST_MONTH) rather than a date range, and the reply is POST URLS ONLY — no advertiser, no targeting, no impressions. Posts are queryable for one year after publication. 25 per page. Needs LinkedIn connected, no extra permission. 0 credits.
| Parameter | Type | Description |
|---|
keywordrequired | string | REQUIRED on this finder, unlike the ad search — searched in the post content; several words are a logical AND |
dateRange | "PAST_24H" | "PAST_WEEK" | "PAST_MONTH" | an ENUM, not a date range; omit to search everything LinkedIn still holds |
start | number | |
count | number | max 25 |
list_business_categories#
Google Business categories for a country read-only
Google’s CATEGORY vocabulary for a country — the primary category is a required field on a Business Profile and one of the strongest LOCAL RANKING signals, and Google accepts only its own stable ids (`categories/gcid:coffee_shop`), never a display name. So this is the lookup you run BEFORE setting a category, and `filter` searches display names (“pizza”, “dentist”). regionCode is REQUIRED because Google’s list genuinely differs by country — there is no worldwide answer. view:"FULL" additionally returns the service types and the attributes valid for each category, which is how you join this to list_business_attributes. Read-only, 0 credits. Needs Google Business Profile connected.
| Parameter | Type | Description |
|---|
regionCoderequired | string | ISO 3166-1 alpha-2 country code — US, GB, CA, AU |
languageCode | string | BCP 47, default en |
view | "BASIC" | "FULL" | FULL adds service types and valid attributes |
filter | string | search the display names, e.g. “coffee” |
pageSize | number | |
pageToken | string | |
list_business_attributes#
Google Business attributes available for a listing read-only
The ATTRIBUTES a Business Profile can set — accessibility, amenities, service options, payment types — with the valid values for each. These are what local searchers FILTER on, so a listing with none set is invisible to those filters. Scope it exactly ONE of three ways and no more: locationName (`locations/123`) for what THAT listing may set, categoryName (`categories/gcid:…`, from list_business_categories) for a category, or regionCode for a country. Passing more than one is refused by name, because Google silently ignores the ones it does not use and would answer about a different scope than you asked for. Read-only, 0 credits. Needs Google Business Profile connected.
| Parameter | Type | Description |
|---|
locationName | string | locations/{id} — from list_business_locations |
categoryName | string | categories/gcid:… — from list_business_categories |
regionCode | string | ISO 3166-1 alpha-2 country code |
languageCode | string | |
showAll | boolean | |
pageSize | number | |
pageToken | string | |
business_google_updated#
Edits Google is proposing to a listing read-only
What GOOGLE thinks a Business Profile should say — the pending edits it has inferred from the web, from user submissions and from its own sources, with `changedFields` naming exactly which fields it would change. THESE CAN GO LIVE IF NOBODY LOOKS, which is why a business needs to see them. An empty result is a real and good answer (Google is proposing nothing) and is reported as such, never as a failure. Read-only — accepting or rejecting an edit is a write this tool does not do. 0 credits. Needs Google Business Profile connected.
| Parameter | Type | Description |
|---|
locationNamerequired | string | locations/{id} — from list_business_locations |
telegram_account#
Read a Telegram channel or group read-only
ACCOUNT-LEVEL numbers for a Telegram channel, group or chat — its SUBSCRIBER / MEMBER COUNT — plus title, @username, description, invite link and linked discussion group. This is the answer to “how many subscribers does our Telegram channel have”. WHICH CHAT IS ALWAYS ASKED FOR: pass chatId (a public channel’s @username, or the numeric id) — the Bot API publishes no method that lists the chats a bot is in, so there is no default and guessing would report another channel’s numbers as this one’s; list_telegram_chats reports any chat that messaged the bot in the last 24 hours. ⚠ TELEGRAM HAS NO PER-POST ANALYTICS AT ANY PERMISSION LEVEL: its Message object carries no `views` field and the Bot API has no history method, so post views and a post list are genuinely unavailable to a bot — both come back under `absent` with the reason, and neither is ever reported as zero. The bot must be a member of the chat. Read-only, 0 credits. Needs Telegram connected.
| Parameter | Type | Description |
|---|
chatIdrequired | string | the channel’s @username or numeric chat id — required; there is no default |
bluesky_account#
Read a Bluesky account read-only
ACCOUNT-LEVEL numbers for a Bluesky account — FOLLOWERS, following and total posts — plus display name, bio, avatar and when it was created. With no argument it reads the CONNECTED account, which is the answer to “how many followers do we have on Bluesky”; pass `actor` (a handle or DID) to read any other account, which is free and is how you size a competitor there. The reply says which one it read: `self` is true only for the connected account, compared on the DID because a Bluesky handle can change. A counter Bluesky does not return comes back under `absent` with the reason and is NEVER reported as zero — all three are optional in the lexicon. Read-only, 0 credits, no extra permission. Needs Bluesky connected (Settings ▸ Connectors ▸ Bluesky, or connect_connector).
| Parameter | Type | Description |
|---|
actor | string | a Bluesky handle (hermoso.ai or @hermoso.ai) or a did:plc:… — omit it to read the connected account |
bluesky_post_metrics#
Read likes, reposts, replies and quotes on your Bluesky posts read-only
Read live engagement for up to 25 of the connected account’s Bluesky posts — likes, reposts, replies, quotes and bookmarks. Address a post by its AT-URI (the `at://…` value post_to_bluesky returns), not its web URL. Bluesky publishes NO impression or view count in any AT Protocol lexicon, so these are COUNTS with no denominator and no engagement rate can be computed from them — do not present one. A uri Bluesky returns nothing for is reported as MISSING (deleted, or not on the connected account), never as zero engagement. Read-only, 0 credits. Needs Bluesky connected.
| Parameter | Type | Description |
|---|
urisrequired | string[] | AT-URIs of the posts, at most 25 (Bluesky’s own maximum for one call) |
list_bluesky_convos#
List Bluesky direct-message conversations read-only
Read the connected Bluesky account’s DM conversations — who each one is with, how many messages are unread, and whether it is a REQUEST (someone the account does not follow, which Bluesky holds separately, the same idea as a message request). Free, no credits, no vendor approval: AT Protocol app passwords are not scoped. It DOES need a PRIVILEGED app password — one created with direct-message access ticked — and says so precisely if the saved one cannot chat, which is a property of the password and NOT a broken connection. Filters: readState "unread", status "request" or "accepted", kind "direct" or "group". An unknown filter value is refused by name, never silently dropped.
| Parameter | Type | Description |
|---|
limit | number | how many conversations, 1–100 (default 25) |
cursor | string | walk further back — pass the cursor from a previous call |
readState | "unread" | only conversations with unread messages |
status | "request" | "accepted" | "request" = from someone this account does not follow |
kind | "direct" | "group" | |
read_bluesky_dm#
Read the messages in one Bluesky conversation read-only
Read a Bluesky DM conversation in CHRONOLOGICAL order (Bluesky returns newest-first on the wire; this reverses it so the conversation reads top to bottom). Each message says whether it was sent by this account. A message with no text is reported as DELETED rather than as an empty message — “they unsent it” and “they sent nothing” are different facts. Get a convoId from list_bluesky_convos.
| Parameter | Type | Description |
|---|
convoIdrequired | string | from list_bluesky_convos |
limit | number | how many messages, 1–100 (default 30) |
cursor | string | walk further back through older messages |
send_bluesky_dm#
Send a Bluesky direct message
Send ONE direct message on the connected Bluesky account, either into an existing conversation (convoId) or to a named handle (a conversation is opened for you — the handle is resolved to a DID first, because Bluesky identifies chat members by DID). A DM allows 1000 characters, NOT the 300 a post allows. Refusals name the real situation rather than a generic failure: the recipient has DMs off, only accepts DMs from people they follow, has blocked this account, is suspended, or does not exist. There is deliberately no broadcast form — a recipient is always named.
| Parameter | Type | Description |
|---|
convoId | string | reply into an existing conversation (from list_bluesky_convos). Omit to start one with `to`. |
to | string | the recipient’s Bluesky handle, e.g. alice.bsky.social. Ignored when convoId is given. |
textrequired | string | the message, up to 1000 characters |
replyToMessageId | string | reply to a specific message in the conversation |
dryRun | boolean | CHECK FIRST, SEND NOTHING. Asks Bluesky whether a DM to `to` would be delivered at all and returns canChat plus the reason when it would not — that account has DMs off, only accepts them from people it follows, has blocked this one, is suspended, or does not exist. Free, and the message is NOT sent. Use it before writing a long DM to someone who has never been messaged, so a refusal costs nothing instead of arriving after the words were written. `text` is still validated for length so a dry run cannot pass on a message the real send would refuse. |
mark_bluesky_convo_read#
Mark Bluesky conversations as read
Clear the unread count on Bluesky DMs — ONE conversation, or the WHOLE ACCOUNT when convoId is omitted (Bluesky’s own mark-all, narrowable with status to just the message requests or just the accepted threads). Useful after triaging an inbox so the next list_bluesky_convos does not surface the same threads again. Reports the unread count — or the number of conversations — Bluesky reads back, not the one requested. Free.
| Parameter | Type | Description |
|---|
convoId | string | from list_bluesky_convos. OMIT to mark EVERY conversation on the account read. |
messageId | string | mark read only up to this message; omit to clear the whole conversation |
status | "request" | "accepted" | mark-all only: narrow it to just the message requests, or just the accepted conversations. Omit to clear both. An unknown value is refused rather than dropped — a dropped one would clear everything when you asked to clear only the requests. |
manage_bluesky_convo#
Accept, mute, lock, leave or prune a Bluesky DM conversation destructive
Act on one Bluesky DM conversation. ACCEPT a message request — Bluesky holds DMs from people the account does not follow in a separate requests folder, and until one is accepted it stays there. MUTE / UNMUTE it. LOCK / UNLOCK it (no new messages). LEAVE it. Or DELETE one message from THIS account’s view. TWO THINGS TO SAY OUT LOUD BEFORE USING THEM: deleting is FOR SELF ONLY — the other person still sees the message, because Bluesky offers no delete-for-everyone in chat — and leaving a conversation cannot be undone from here. Accepting a request that was already accepted is reported as such rather than as a change, because Bluesky says so by returning no revision. Free, and it needs the same PRIVILEGED app password as every other Bluesky DM tool. Get a convoId from list_bluesky_convos.
| Parameter | Type | Description |
|---|
actionrequired | "accept" | "mute" | "unmute" | "lock" | "unlock" | "leave" | "deleteMessage" | what to do to the conversation |
convoIdrequired | string | from list_bluesky_convos |
messageId | string | required for deleteMessage — from read_bluesky_dm |
react_to_bluesky_dm#
React to a Bluesky direct message
Add or remove an emoji reaction on one message in a Bluesky DM — the light acknowledgement that does not need a written reply, and the thing to reach for when someone says “thanks” and a paragraph back would be worse than a thumbs-up. A REACTION IS EXACTLY ONE EMOJI: that is Bluesky’s own rule, so a word or a phrase is refused here rather than by the service (where it comes back as an opaque ReactionInvalidValue). Reports the reactions Bluesky reads the message back carrying, not the one requested. Free.
| Parameter | Type | Description |
|---|
convoIdrequired | string | from list_bluesky_convos |
messageIdrequired | string | from read_bluesky_dm |
valuerequired | string | exactly one emoji, e.g. 👍 |
remove | boolean | true to take the reaction off instead of putting it on |
tiktok_creator_info#
Read the connected TikTok creator’s posting options read-only
Read the connected TikTok creator’s REAL posting options BEFORE posting: which privacy levels THEY are allowed to use, whether comments / duet / stitch are available on their account, their maximum video length, and their nickname. TikTok REQUIRES that the user is shown these actual options and picks a privacy level — never assume or default one. Call this first, show the options, get the user’s pick, then call post_to_tiktok with destination:"post". The SAME privacy options govern PHOTO posts (slideshows), not just video — TikTok takes the same four levels on both. Needs TikTok connected (Settings ▸ Connectors ▸ TikTok).
No parameters.
post_to_tiktok#
Post a video or photo post to TikTok
Publish to the user’s connected TikTok account — a finished VIDEO, or a PHOTO POST (TikTok’s photo/slideshow format). A photo post carries 1 to 35 images and ONE image is simply a one-slide photo post, so there is nothing special to do for a single picture: pass imageUrls, in the order the slides should appear, and optionally coverIndex. Pass videoUrl for a video. Never pass both — TikTok has no mixed post. TWO destinations either way: destination:"post" puts it LIVE on their profile now — that requires `privacy`, and you must call tiktok_creator_info first, show the creator’s real privacy options and get an explicit yes before calling. destination:"draft" (the default, and the safer one) sends it to TikTok for the user to finish and post themselves from the app. Pass Hermoso render URLs (or upload_file urls for local/external files). Needs TikTok connected (Settings ▸ Connectors ▸ TikTok).
| Parameter | Type | Description |
|---|
account | string | WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the brand has more than one tiktok account connected (several and none named is refused by name, never guessed); omit when there is one. |
brand | string | WHICH BRAND this post belongs to — the id or exact name from list_brands (a workspace shared with you: its profile id). Use it whenever the account has more than one brand and you are not certain which one this connection is pinned to: it beats the pin for THIS CALL ONLY and changes nothing about the connection. A name that matches no brand, or two brands, is REFUSED and nothing is posted — never resolved to the pin, which is the account you were guarding against. |
hook | string | WHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer "which hooks work", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. "direct_callout", "mid_problem", "before_after") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works. |
subject | string | WHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. "winter coat", "free trial", "founder story"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group. |
videoUrl | string | the video to post — a Hermoso render URL or an upload_file url. Omit for a photo post. |
imageUrls | string[] | a PHOTO POST: 1–35 image URLs in slide order. One url = a single-image photo post. Do not combine with videoUrl. |
coverIndex | number | photo posts: which slide is the cover, 0-based. Default 0 (the first slide). |
destination | "post" | "draft" | "post" = live on the profile now (needs privacy + an explicit user yes); "draft" = to TikTok for the user to review and post themselves. Default "draft". |
title | string | the caption — hashtags go here (video ≤2200 chars, photo post ≤4000) |
photoTitle | string | photo posts only: a short title above the caption (≤90 chars). Defaults to the caption’s first line. |
privacy | "PUBLIC_TO_EVERYONE" | "MUTUAL_FOLLOW_FRIENDS" | "FOLLOWER_OF_CREATOR" | "SELF_ONLY" | REQUIRED for destination:"post", for photos and video alike. Must be one the creator actually allows — read them from tiktok_creator_info, never guess. |
disableComment | boolean | |
disableDuet | boolean | video only — TikTok has no duet on a photo post |
disableStitch | boolean | video only — TikTok has no stitch on a photo post |
autoAddMusic | boolean | photo posts only: let TikTok add a recommended track (default true — a silent slideshow reads as broken) |
coverTimestampMs | number | video only: which frame to use as the cover, in ms |
aiGenerated | boolean | TikTok’s is_aigc AI-generated-content label (video posts). OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, a video that came through upload_file or an external URL (the user’s own footage) is NOT. true/false overrides. |
brandedContent | boolean | discloses a paid partnership — cannot be combined with SELF_ONLY privacy |
yourBrand | boolean | discloses that this promotes the creator’s own brand |
tiktok_account#
Read the connected TikTok account read-only
Read the connected TikTok account: display name, username, bio, verified status, and their follower / following / total-likes / video counts. Use it for “how many followers do we have on TikTok”, “how is our TikTok doing”, or to confirm whose account is linked before posting. Read-only. Needs TikTok connected (Settings ▸ Connectors ▸ TikTok).
No parameters.
list_tiktok_videos#
List the connected account’s TikTok posts read-only
The connected account’s own TikTok posts with per-video stats — views, likes, comments, shares, duration, cover image and link. TWO WAYS TO ASK: with no arguments it lists the most recent (newest first, up to 20 a page); with videoIds it reads THOSE posts directly however old they are, which is how you answer "how did that specific video do" without paging back through the account. Any id TikTok does not return comes back under `unresolved` — meaning it is not on this account or no longer exists, which TikTok does not distinguish — never as a zero. Only ever the connected user’s OWN videos. ⚠️ TIKTOK OFFERS NO WAY TO DELETE OR EDIT A PUBLISHED POST through its API — not the caption, not the privacy level, not the comment/duet/stitch settings, not the cover. Every one of those is fixed at the moment of publishing. If the user wants a TikTok changed or taken down, tell them plainly that it has to be done in the TikTok app; do not look for a tool for it. Read-only, 0 credits. Needs TikTok connected.
| Parameter | Type | Description |
|---|
limit | number | 1-20, default 10 (ignored when videoIds is given) |
videoIds | string[] | read these specific TikTok video ids instead of listing recent ones — up to 20 per call |
upload_meta_asset#
Upload an asset to a Meta ad account
Upload creative(s) — a finished Hermoso ad OR arbitrary user files (e.g. a folder of media from the user’s desktop) — into a connected ad account’s ASSET LIBRARY so the user or a later ad-build step can use them in their OWN campaigns. Pass `url` for one file, or `urls` (up to 20) to BULK-upload in a single call. Each accepts a public https URL, a data: URI, or a Hermoso /generated path; for LOCAL files call upload_file first and pass the url(s) it returns. Image → image hash; video → video id. Pass adAccountId from list_meta_pages.
| Parameter | Type | Description |
|---|
adAccountIdrequired | string | ad account id (digits or act_… — from list_meta_pages) |
url | string | a single public https URL / data: URI / /generated path |
urls | string[] | up to 20 media URLs/paths for a one-call BULK upload |
kind | "image" | "video" | inferred from the URL if omitted |
name | string | a label for the asset |
create_meta_campaign#
Create a Meta ad campaign (paused)
Create a campaign on a connected Meta ad account. Always created PAUSED — it spends NOTHING until you activate it with set_meta_campaign_status(confirm:true). Optionally set a dailyBudgetUsd. Pass adAccountId (from list_meta_pages) + an objective. Needs ads-management permission on the connected account. CATALOG / ADVANTAGE+ CATALOG ADS (also called DPA or dynamic product ads) ARE SUPPORTED: pass productCatalogId here and productSetId on the ad set, and the campaign is catalog-backed. Say YES when a user asks for catalog, Advantage+ catalog, DPA or feed-driven ads.
| Parameter | Type | Description |
|---|
namerequired | string | campaign name |
adAccountIdrequired | string | ad account id (digits or act_… — from list_meta_pages) |
objective | "OUTCOME_TRAFFIC" | "OUTCOME_AWARENESS" | "OUTCOME_ENGAGEMENT" | "OUTCOME_LEADS" | "OUTCOME_SALES" | "OUTCOME_APP_PROMOTION" | default OUTCOME_TRAFFIC |
dailyBudgetUsd | number | optional campaign daily budget in USD (1–10000); real spend once ACTIVE |
productCatalogId | string | CATALOG (dynamic product) ADS — the PRODUCT CATALOG this campaign advertises, from list_meta_catalogs. This is the CAMPAIGN-level half of a catalog ad; the AD SET takes a productSetId and the creative takes the same set. Passing a product SET id here is refused by name with the level it belongs on. The binding is READ BACK from Meta before it is reported, so a 200 is never narrated as “bound”. |
set_meta_campaign_status#
Activate or pause a Meta campaign
Turn a campaign ON (ACTIVE) or OFF (PAUSED). ACTIVATING STARTS REAL AD SPEND — you MUST first show the user the campaign name + its daily budget, get an explicit yes, then call with status:"ACTIVE" and confirm:true. Pausing is always safe. Needs ads-management permission.
| Parameter | Type | Description |
|---|
campaignIdrequired | string | the campaign id (from create_meta_campaign) |
statusrequired | "ACTIVE" | "PAUSED" | ACTIVE = start spending; PAUSED = stop |
confirm | boolean | REQUIRED true to activate (real spend) — set only after the user explicitly approved the budget |
POST PERFORMANCE
list_meta_posts#
List the Page’s / Instagram account’s own posts read-only
List the connected Facebook Page's or Instagram account's OWN existing posts — id, caption, permalink, publish date and format. THIS IS THE TOOL THAT GETS YOU THE postId every other Meta read needs: meta_post_insights, list_meta_comments and manage_meta_post all require one, and until now the only way to have a postId was to have just published it yourself with post_to_meta. Use it for "how did our last few posts do", to find a post the user describes loosely, or before backfill_posts. Pass target:'instagram' for the linked IG account (Stories are excluded — Meta's media edge does not return them); Facebook hides unpublished drafts unless you ask for them. Only ever reads a Page the brand has connected. Read-only, 0 credits.
| Parameter | Type | Description |
|---|
target | "facebook" | "instagram" | default facebook; 'instagram' reads the Page's linked IG business account |
account | string | which Instagram account — an @handle or id from list_connector_accounts("instagram"). Needed when several are linked, and the way an Instagram Login (standalone) account is reached; omit for the one Page-linked account. |
pageId | string | which connected Page — omit when the brand has only one |
limit | number | how many posts (default 25, max 100) |
cursor | string | paging cursor returned by a previous call |
includeUnpublished | boolean | Facebook only — also return unpublished drafts (hidden by default) |
list_published_posts#
List what this brand has published read-only
List every post Hermoso has recorded publishing for this brand — channel, permalink, caption, format, the HOOK and SUBJECT it was written to, and its measured engagement. This is the brand's own publishing history across all nine channels in one place, and it is the memory that makes 'which hook worked?' answerable at all. Each row says how it was recorded: 'captured' (written at publish time — the hook is what the author actually intended) or 'backfilled' (reconstructed from the platform afterwards, where the hook is only known if the post matched a Hermoso creation). A dash for engagement means the platform reported no number — that is NOT zero engagement. Read-only, 0 credits.
| Parameter | Type | Description |
|---|
channel | string | filter to one channel: facebook, instagram, threads, x, linkedin, youtube, tiktok, reddit, pinterest, google_business |
limit | number | max posts (default 50, max 200), newest first |
list_hooks#
The hook + setting libraries, and which hooks are working read-only
The curated menu of VISUAL scroll-stop HOOKS (how an ad opens) and SETTINGS (where it is staged) that plan_ad and render_ad accept, PLUS this brand's measured traction per hook. Call it before planning an ad to pick a hook deliberately instead of letting the model improvise one, and call it after publishing to see which ones are actually landing. Three things it will not do: it never recommends a hook from thin data — a verdict is SUPPRESSED below 5 measured posts and the reason is stated; it never compares across channels; and it reports hooks you have NEVER TRIED as a fact, not as advice, because 'you haven't tried this' is an observation and 'you should' would be a verdict drawn from zero data. A hook marked unusable in this brief says WHY (an on-screen-text hook cannot ride an authentic/UGC render, which carries zero on-screen text). Read-only, 0 credits.
| Parameter | Type | Description |
|---|
channel | string | restrict the performance half to one channel (facebook, instagram, threads, x, linkedin, youtube, tiktok, reddit, pinterest) |
authentic | boolean | true if the planned ad is an authentic/UGC/creator-register render — on-screen-text hooks are then reported unusable, with the reason |
category | string | the product category (e.g. 'skincare serum', 'protein powder', 'sunglasses') — returns the setting our Location x Tier matrix puts that category in, with the reason |
tier | "luxury" | "premium" | "drugstore" | product tier, used with category — changes the FINISH of the room, never the room. Default premium. |
post_performance#
Which hooks and subjects are getting traction read-only
Aggregate this brand's published posts to answer WHICH HOOKS AND SUBJECTS WORK. Groups by hook (default), subject, format (recipe), channel, media format or posting hour, reports the engagement RATE within each channel, and ranks the best and worst POSTS in each channel. Describe a post by the creative it carried (what it shows, its format, its link), not by its caption — the caption is the least important part of a post. THREE THINGS IT DELIBERATELY WILL NOT DO, and you should repeat them rather than paper over them: (1) it never sums metrics across channels — a LinkedIn impression and a TikTok view are different units, so every comparison is within one channel; (2) it SUPPRESSES a verdict below 5 measured posts and says so, because a confident recommendation from 3 posts is worse than none; (3) a post with no recorded hook (published outside Hermoso, or backfilled without a creation match) counts toward channel and format totals but never votes on which hook works. Present the `finding` verbatim if there is one, and the reason if there is not. Read-only, 0 credits.
| Parameter | Type | Description |
|---|
axis | "hook" | "subject" | "recipe" | "channel" | "media" | "hour" | what to group by — default hook; recipe = the format of the creative |
channel | string | restrict to one channel |
diagnose_posts#
What to fix next, post by post read-only
WHAT TO FIX NEXT, post by post — and the tool that fixes it. post_performance tells you which hook is AHEAD; this tells you what is WRONG with a given post and where the next edit goes. Under-distributed is A HOOK PROBLEM (change the opening: mine_angles, then list_hooks, then plan_variations). Seen but not held is A RETENTION PROBLEM (plan_variations to rebuild the middle against the same hook). Seen, held, and still not converting is AN OFFER PROBLEM. FOUR REFUSALS, AND YOU SHOULD REPEAT THEM RATHER THAN PAPER OVER THEM: (1) a post younger than ~24h is TOO EARLY and is never called a failure — it has not had its run; (2) a metric the platform does not publish is UNMEASURED, never zero — Facebook has published no post reach since 2026-06-15, Reddit publishes no impressions, and Google Business publishes nothing per-post at all; (3) below 5 measured posts on a channel there is no baseline of the brand's own, and the ONLY fallback is a published short-video hook floor that is NOT our measured number and does not transfer off TikTok/Instagram/YouTube — it is attributed in the output and you should attribute it too; (4) it does not always find a problem, and 'nothing here needs fixing' is a real answer rather than a failure to look. Hermoso cannot see conversions for an organic post — no channel reports installs or purchases against a post id — so the offer rung runs ONLY when the user tells you they are not converting and you pass converting:false. Print `summary` verbatim. Read-only, 0 credits.
| Parameter | Type | Description |
|---|
channel | string | restrict to one channel: facebook, instagram, threads, x, linkedin, youtube, tiktok, reddit, pinterest |
limit | number | how many recent posts to diagnose (default 25, max 200). The baseline is always built from EVERY post recorded for the brand, never only these, so a bad month can never become its own definition of normal. |
converting | boolean | pass false ONLY when the user has told you these posts are getting seen and are not converting — it re-reads the ones that are earning their reach as an offer problem instead of a win. Omit when you do not know; we cannot measure it. |
collect_post_metrics#
Read how the recorded posts performed
Fetch fresh performance numbers for this brand's recorded posts and store them as a time-series. Metrics ACCRUE, so a post is read at ~24 hours and again at ~7 days; this collects whichever readings are due and skips the ones already taken. A channel that cannot report a metric records it as ABSENT with the reason — never as zero — and a read that fails is recorded as 'could not tell', which contributes to nothing. X IS SKIPPED BY DEFAULT because X bills us per API call: pass includeMetered:true to include it, and tell the user it costs credits BEFORE you do. The skip is always reported so a channel missing from the numbers is never mistaken for one that performed badly. Free except for X.
| Parameter | Type | Description |
|---|
includeMetered | boolean | also read X, which BILLS CREDITS per post read — ask the user first |
max | number | cap how many posts to read in this run (default 40) |
remeasure | boolean | ALSO re-read posts older than 7 days whose every reading came back empty or failed — use after post_performance reports posts "read but empty", or once a channel's reader has been fixed. Otherwise those windows stay closed. |
backfill_posts#
Import a channel’s past posts
Import this brand's PAST posts from a channel into the performance record, so 'which hook works' can draw on history rather than only on what was published since Hermoso started recording. Supports facebook, instagram, threads, youtube, tiktok, pinterest and bluesky; the others say plainly why they cannot (LinkedIn and Reddit have no enumerate-my-posts endpoint on our grant, X bills per read so it is excluded from bulk import, Google Business has had no per-post insights since 2023, and Telegram's Bot API cannot read a chat's past messages at all — nothing published before Hermoso is recoverable through a bot token). BOUNDED, RESUMABLE AND QUOTED: it runs as a DRY RUN by default and tells you how many posts it found and what reading them will cost — pass confirm:true to import, and pass the returned cursor to continue. AN IMPORTED POST IS WEAKER EVIDENCE THAN A RECORDED ONE and is labelled 'backfilled': its hook is recovered ONLY where the post matches a Hermoso creation by asset or caption. A post made outside Hermoso stays UNATTRIBUTED — it counts toward channel and format totals but never votes on which hook works. Never guess a hook from a caption. Free.
| Parameter | Type | Description |
|---|
channelrequired | "facebook" | "instagram" | "threads" | "youtube" | "tiktok" | "pinterest" | "bluesky" | which channel to import from |
confirm | boolean | actually import — omit for a dry run that only quotes the cost |
limit | number | how many posts this page (default 50, max 200) |
cursor | string | resume from a previous run |
accountRef | string | which Page / account, when the brand has more than one |