Publish organically to the channels a brand has connected, or queue posts on a schedule.
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 destructive
PUT A CUSTOM COVER ON A PLAYLIST — the last call in a chain that already existed, since make_thumbnail renders the artwork and the playlist tools own the playlist. Without one YouTube shows the first video's thumbnail. action:'list' reads what is on a playlist, 'set' uploads a cover, 'delete' removes it. imageUrl must be a Hermoso-hosted URL, up to 50MB — a Hermoso render, a make_thumbnail result, or the user’s OWN artwork brought in with upload_file, which turns any local or external file into a URL this accepts. Nothing else is fetched server-side. YOUTUBE ANSWERS EVERY FAILURE HERE AS AN HTTP 500 "Internal error encountered" with the real reason buried inside it, so a plain relay would report a missing playlist id as a Hermoso outage — the refusals here are the real ones. If it comes back refused, the first thing to check is CHANNEL VERIFICATION: custom imagery needs a verified YouTube channel (add a phone number at youtube.com/verify), and on an unverified channel the sibling call that sets a custom video thumbnail is refused in the same way. Free.
| 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. Warning: LOW-VOLUME TERMS ARE SUPPRESSED: Google withholds an exact count for them and returns only an upper bound, so those rows come back with impressions=null and below=<threshold>. Report those as “fewer than N” — NEVER as zero and never as the threshold itself, both of which are numbers a marketer would act on and neither is true. Counts are UNIQUE USERS per month summed across the window, not impressions; the two are not comparable. Google keeps roughly 12 months of history. Read-only, 0 credits. Needs Google Business Profile connected.
| Parameter | Type | Description |
|---|
locationId | string | which listing, when the profile 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 |
|---|
brand | string | WHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done. |
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”). Warning: NEEDS THE follows.read PERMISSION, added 2026-08-31: an X connection authorized before that date does NOT carry it and cannot have it added — the tool says so and asks for a reconnect rather than surfacing X’s raw refusal. 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 | |
list_x_broadcasts#
List X live broadcasts read-only
The connected X account's LIVE VIDEO broadcasts: past and current ones (title, state, share URL, live and total viewers, start/end) and SCHEDULED ones (start, end, whether it goes live automatically or waits for go_live, recurring series). kind 'all' (default) reads both, 'live' or 'scheduled' one; pass id for a single broadcast. Read-only. Needs X connected with the broadcast permissions added 2026-09-21: an X connection made before then must be reconnected once under Settings > Connectors > X (the tool says so without calling X). Costs credits (X bills per call).
| Parameter | Type | Description |
|---|
kind | "all" | "live" | "scheduled" | |
id | string | one broadcast id (alphanumeric, up to 13 characters) |
ids | string | comma-separated broadcast ids (live kind, up to 100) |
maxResults | number | 1-100, default 25 |
paginationToken | string | |
oldestStart | string | scheduled kind: earliest start, ISO 8601 or epoch ms |
newestStart | string | scheduled kind: latest start, ISO 8601 or epoch ms |
manage_x_broadcast#
Schedule, update, go live on or cancel an X broadcast destructive
Schedule, update, go live on, or cancel an X LIVE VIDEO broadcast on the connected account. action 'schedule' needs sourceId (the stream key of the ingest source, from Media Studio > Producer on x.com), startAt, and manualPublish, which is REQUIRED with no default: true = nothing airs until action 'go_live'; false = X puts the brand ON AIR automatically at startAt (needs confirm:true). 'update' changes only the fields you pass (Hermoso re-sends the rest, because X's update replaces the whole schedule). 'go_live' airs a manualPublish schedule NOW, publicly, as the brand (confirm:true). 'cancel' deletes a schedule and cannot be undone (confirm:true; rollForward:true shifts a recurring series instead of leaving a gap). Confirm the exact broadcast, time and stream with the user before passing confirm:true; a refusal is free. Needs X connected with the broadcast permissions added 2026-09-21: an X connection made before then must be reconnected once under Settings > Connectors > X (the tool says so without calling X). Costs credits (X bills per call).
| Parameter | Type | Description |
|---|
actionrequired | "schedule" | "update" | "go_live" | "cancel" | |
id | string | broadcast id, for update / go_live / cancel |
sourceId | string | stream key of the ingest source (schedule) |
startAt | string | ISO 8601 or epoch ms |
endAt | string | ISO 8601 or epoch ms; X requires one on update |
manualPublish | boolean | required on schedule: true waits for go_live, false auto-publishes at startAt |
title | string | |
description | string | |
locale | string | |
availableForReplay | boolean | |
isLocked | boolean | |
chatOption | string | X chat permission option (numeric string) |
thumbnailMediaId | string | pre-live slate media id |
recurrence | object | |
rollForward | boolean | |
confirm | boolean | true only after the user approved this exact public or irreversible action |
read_x_broadcast_chat#
Read an X broadcast chat read-only
The chat on one of the connected X account's live broadcasts, newest first: message id, text, author handle, time and reply-to. Use it to answer viewers or find a message to moderate. Read-only. Needs X connected with the broadcast permissions added 2026-09-21: an X connection made before then must be reconnected once under Settings > Connectors > X (the tool says so without calling X). Costs credits (X bills per call).
| Parameter | Type | Description |
|---|
broadcastIdrequired | string | |
maxResults | number | 1-200, default 100 |
paginationToken | string | |
send_x_broadcast_chat#
Send a message to an X broadcast chat
Post ONE chat message, as the brand, into a RUNNING live broadcast on the connected X account (max 140 characters; a longer one is refused, never cut). It is PUBLIC in front of everyone watching: show the user the exact wording, get a yes, then pass confirm:true. replyTo answers one specific message. Needs X connected with the broadcast permissions added 2026-09-21: an X connection made before then must be reconnected once under Settings > Connectors > X (the tool says so without calling X). Costs credits (X bills per call).
| Parameter | Type | Description |
|---|
broadcastIdrequired | string | |
textrequired | string | |
replyTo | string | chat message id to reply to |
confirm | boolean | |
moderate_x_broadcast#
Moderate an X broadcast chat destructive
Moderate the connected X account's live broadcast chat. action 'remove_message' (broadcastId + messageId; cannot be undone, confirm:true), 'mute' (broadcastId + userId or username; until = when a timeout ends, omit to mute for the broadcast; messageId removes that message while muting), 'unmute', and the account's PERSISTENT chat moderators, which apply to every current and future broadcast: 'list_moderators', 'add_moderator', 'remove_moderator' (userId or username). Only the host or a moderator can moderate, and X says so if not. A username costs one extra lookup. Needs X connected with the broadcast permissions added 2026-09-21: an X connection made before then must be reconnected once under Settings > Connectors > X (the tool says so without calling X). Costs credits (X bills per call).
| Parameter | Type | Description |
|---|
actionrequired | "remove_message" | "mute" | "unmute" | "list_moderators" | "add_moderator" | "remove_moderator" | |
broadcastId | string | |
messageId | string | |
userId | string | numeric X account id |
username | string | X handle, resolved to the id for you |
until | string | mute end, ISO 8601 or epoch ms |
confirm | boolean | required for remove_message |
x_chat_settings#
Read the X Chat privacy settings read-only
The connected X account's X Chat (direct message) PRIVACY SETTINGS: who can message the brand (allowDmsFrom: all / verified / following), whether subscribers can always message it, the low-quality request filter, read receipts, and audio/video call permissions. Use it before changing them with update_x_chat_settings, or to answer 'can customers DM us on X'. Read-only. Needs X connected with direct-message access: an X connection made before 2026-08-25 must be reconnected once under Workspace > Connectors > X (the tool says so without calling X). Costs credits (X bills per call).
No parameters.
update_x_chat_settings#
Change the X Chat privacy settings
Change the connected X account's X Chat privacy settings: the account-wide answer to 'open our DMs to everyone' or 'turn off read receipts'. Pass only what changes. allowDmsFrom: 'all' (anyone), 'verified' (verified accounts only) or 'following' (accounts the brand follows); narrowing it stops customers the brand does not follow from messaging it, so say that before changing it. Call settings are merged onto the stored ones (X does not promise a partial call setting keeps the rest). Returns the settings X stored, not what was sent. Reversible. Needs X connected with direct-message access: an X connection made before 2026-08-25 must be reconnected once under Workspace > Connectors > X (the tool says so without calling X). Costs credits (X bills per call).
| Parameter | Type | Description |
|---|
allowDmsFrom | "all" | "verified" | "following" | |
alwaysAllowSubscribers | boolean | subscribers can always message the brand, whatever allowDmsFrom says |
qualityFilter | boolean | true = low-quality message requests go to a separate folder |
readReceipts | boolean | send and receive read receipts |
callsEnabled | boolean | audio and video calls on or off |
enhancedCallPrivacy | boolean | |
acceptCallsFrom | object | |
list_x_chats#
List X Chat conversations read-only
The connected X account's X CHAT conversations: id (group ids start with g), type, muted, disappearing-message timer, screenshot controls, admins, members, PENDING join requests, which actions are admin-only, and the active invite link. X Chat is END-TO-END ENCRYPTED, so message text and group names cannot be read here (list_x_dms reads the plain direct-message history). view 'admin_requests' (id = a group the brand admins) lists who asked to become an admin; view 'invite' (inviteLink, or id + inviteToken) previews an invite link: group name, member count, whether the brand is in it. view 'message_requests' lists the MESSAGE REQUESTS waiting outside the inbox (inbox 'priority', the default, or 'low_quality' for X's filtered tab): who sent each one (@handle, name, followers, verified), which accounts the brand follows also follow them, and whether they changed their name or picture recently (X's impersonation hint); the message itself is encrypted. Show that to the user before manage_x_chat accept_request. withMembers:true resolves member @handles, and each user X returns is billed. Read-only. Billed per conversation or request returned, plus one user lookup per request sender, so keep maxResults small. Needs X connected with direct-message access: an X connection made before 2026-08-25 must be reconnected once under Workspace > Connectors > X (the tool says so without calling X). Costs credits (X bills per call).
| Parameter | Type | Description |
|---|
view | "conversations" | "admin_requests" | "invite" | "message_requests" | default 'conversations' |
inbox | "priority" | "low_quality" | message_requests: X's Priority tab (default) or the filtered low-quality one |
withSenders | boolean | message_requests: resolve who sent each request (default true, each sender billed) |
id | string | one conversation; for admin_requests, the group (g + digits) |
maxResults | number | 1-100 (message_requests 1-50), default 10; every conversation or request returned is billed |
paginationToken | string | nextToken from a previous call |
withMembers | boolean | also resolve member/admin @handles (each one billed) |
inviteLink | string | an x.com/i/chat/group_join/... link, for view invite |
inviteToken | string | the 10-character token, with id, for view invite |
manage_x_chat#
Manage an X Chat conversation destructive
Manage one of the connected X account's X Chat conversations (id from list_x_chats). For the brand only: 'mute' / 'unmute'; 'delete' removes it from the brand's inbox for good while the others keep theirs (confirm:true). For everyone in it: 'set_disappearing' (disappearAfter like '24h' or '7d'), 'clear_disappearing', 'screen_capture' (blockScreenshots OR notifyOnScreenshot, one per call). GROUPS (id starts with g; the brand must be an admin): 'admin_settings' (adminOnly {addMember, editGroupInfo, sendMessage, startCall, editMessageTtl, blockScreenCapture}; sendMessage:true makes it announcement-only), 'add_admins' / 'remove_admins' (userIds), 'remove_members' (userIds, confirm:true), 'leave' (confirm:true), 'approve_admin_request' / 'reject_admin_request' (userId), 'reject_join_requests' (userIds; APPROVING a join re-keys the encrypted group, so it is done in the X app), 'enable_invite' (optional title, avatarUrl, welcomeMessage, expiresAt; X stores that preview UNENCRYPTED and shows it to anyone with the link), 'disable_invite' (the shared link dies for good: confirm:true). INBOX, for the brand only: 'archive' / 'unarchive', 'mark_read' (may send the other person a read receipt). MESSAGE REQUESTS: 'accept_request' (id = the sender's numeric id, or the request's conversationId from list_x_chats view message_requests) moves the conversation into the inbox; show the user who sent it first, since the message itself cannot be read here. Confirm the exact conversation with the user before passing confirm:true; a refusal is free. The answer is X's own yes or no, never an echo. Needs X connected with direct-message access: an X connection made before 2026-08-25 must be reconnected once under Workspace > Connectors > X (the tool says so without calling X). Costs credits (X bills per call).
| Parameter | Type | Description |
|---|
actionrequired | "mute" | "unmute" | "delete" | "set_disappearing" | "clear_disappearing" | "screen_capture" | "admin_settings" | "add_admins" | "remove_admins" | "remove_members" | "leave" | "approve_admin_request" | "reject_admin_request" | "reject_join_requests" | "enable_invite" | "disable_invite" | "accept_request" | "archive" | "unarchive" | "mark_read" | |
idrequired | string | conversation id from list_x_chats: g + digits for a group, or the other person's numeric id for a 1:1 (accept_request: the sender's numeric id) |
userIds | string[] | NUMERIC X account ids (add_admins, remove_admins, remove_members, reject_join_requests) |
userId | string | NUMERIC X account id (approve_admin_request, reject_admin_request) |
disappearAfter | string | set_disappearing: '24h', '7d', '90m' or a number of seconds |
blockScreenshots | boolean | |
notifyOnScreenshot | boolean | |
adminOnly | object | admin_settings: true = only admins may |
title | string | enable_invite: the group name shown on the link preview (stored unencrypted) |
avatarUrl | string | enable_invite: https image for the preview |
welcomeMessage | string | |
expiresAt | string | enable_invite: ISO 8601 or epoch ms; omit for a link that does not expire |
confirm | boolean | true only after the user approved this exact delete / leave / removal / link shutdown |
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 |
|---|
brand | string | WHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done. |
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 |
|---|
brand | string | WHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done. |
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. Warning: 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. Warning: 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 profile |
list_linkedin_events#
List a LinkedIn Page’s events read-only
The events a LinkedIn company Page is organizing — name, link, start and end times, online or in-person, visibility and lifecycle state. LinkedIn is the one channel where an event is a first-class object rather than a post about one, so this is how you see what a B2B brand actually has scheduled (webinars, launches, live sessions) and pair it with the posts promoting it. Only Pages the user has SHARED with this profile are readable. Warning: Needs the Events permission: an administrator switches it on for the deployment, and because LinkedIn grants permissions at authorise time and never retroactively, every existing LinkedIn connection must RECONNECT once before it works. The tool tells you which of those two applies rather than failing generically. Read-only, 0 credits. Needs LinkedIn connected.
| Parameter | Type | Description |
|---|
organizationId | string | the Page to read — omit if only one Page is shared with this profile; 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. Warning: TELEGRAM HAS NO PER-POST ANALYTICS AT ANY PERMISSION LEVEL: its Message object carries no `views` field and the Bot API has no history method, so post views and a post list are genuinely unavailable to a bot — both come back under `absent` with the reason, and neither is ever reported as zero. The bot must be a member of the chat. Read-only, 0 credits. Needs Telegram connected.
| 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 |
|---|
brand | string | WHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done. |
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 character, e.g. the thumbs-up emoji |
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 destructive
Use this when the user wants to post a video or photo post to TikTok now. Publishing costs no credits. Publish to the user’s connected TikTok account — a finished VIDEO, or a PHOTO POST (TikTok’s photo/slideshow format). A photo post carries 1 to 35 images and ONE image is simply a one-slide photo post, so there is nothing special to do for a single picture: pass imageUrls, in the order the slides should appear, and optionally coverIndex. Pass videoUrl for a video. Never pass both — TikTok has no mixed post. TWO destinations. destination:"post" (THE DEFAULT) publishes it LIVE on their profile: TikTok requires the user to CHOOSE the privacy themselves (no default is allowed), so call tiktok_creator_info, show them their real privacy options, and get their choice and an explicit yes before calling. destination:"draft" is ONLY for when the user asks for a draft, or wants to add a TikTok sound or trending audio (TikTok’s API takes no sound for a VIDEO): BEFORE sending, tell them plainly it lands in their TikTok inbox as a DRAFT, that they add the sound in TikTok’s editor, and that THEY must publish it from the TikTok app — nothing goes live until they do. Never pick draft on your own. A photo post published with destination:"post" gets a TikTok-recommended track automatically (autoAddMusic, default on) that they can change in the app. Pass Hermoso render URLs (or upload_file urls for local/external files). Needs TikTok connected (Settings > Connectors > TikTok).
| 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 profile 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 PROFILE this post belongs to — id or exact name from list_brands (a shared workspace: its profile id). Beats the connection's pin for THIS CALL ONLY; a name that matches no profile, or two, is REFUSED and nothing is posted. |
hook | string | WHAT ANGLE this post is built on, recorded only at publish time. post_performance ranks hooks on it (a winner needs 5 posts sharing ONE hook), so pass a list_hooks id (e.g. "direct_callout", "before_after") or reuse your own wording EXACTLY across a campaign. Omit it and this post never votes on which hook works. |
subject | string | WHAT THIS POST IS ABOUT — product, feature, offer or theme (e.g. "winter coat", "free trial"). post_performance's second grouping axis: reuse the exact wording, as with hook. |
recipe | string | the post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format |
ideaId | string | short id of the content-plan idea this post came from |
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" (default) = live on the profile now (needs the privacy the user chose + their explicit yes); "draft" = to their TikTok inbox for them to finish and publish in the app — only when they ask for a draft or want to add a TikTok sound, and only after telling them so. |
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 |
platformCover | boolean | VIDEO COVER. Omit it (the default) and Hermoso sets the video’s best frame — the same frame as its Library thumbnail — as the cover (TikTok video_cover_timestamp_ms, on a direct post — a draft takes no cover, you pick it in the TikTok app). true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins. |
tiktok_post_status#
Check whether a TikTok post finished publishing read-only
Follow a TikTok post that post_to_tiktok sent but TikTok was still processing (it came back pending:true). Pass the publishId it returned. Answers one of three states: done (PUBLISH_COMPLETE, with the public postId when TikTok gives one), failed (with TikTok’s reason), or still processing, which is normal for a few minutes and is NOT a failure. Never post the video again while it is processing. Free, read-only. Needs TikTok connected (Settings > Connectors > TikTok).
| Parameter | Type | Description |
|---|
publishIdrequired | string | the publishId post_to_tiktok returned |
account | string | which connected TikTok account the post went out on (@handle or id from list_connector_accounts) when the profile has more than one |
tiktok_account#
Read the connected TikTok account read-only
Read the connected TikTok account: display name, username, bio, verified status, and their follower / following / total-likes / video counts. Use it for “how many followers do we have on TikTok”, “how is our TikTok doing”, or to confirm whose account is linked before posting. Read-only. Needs TikTok connected (Settings > Connectors > TikTok).
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. Warning: TIKTOK OFFERS NO WAY TO DELETE OR EDIT A PUBLISHED POST through its API — not the caption, not the privacy level, not the comment/duet/stitch settings, not the cover. Every one of those is fixed at the moment of publishing. If the user wants a TikTok changed or taken down, tell them plainly that it has to be done in the TikTok app; do not look for a tool for it. Read-only, 0 credits. Needs TikTok connected.
| Parameter | Type | Description |
|---|
brand | string | WHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done. |
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) destructive
Create a campaign on a connected Meta ad account. Always created PAUSED — it spends NOTHING until you activate it with set_meta_campaign_status(confirm:true). Optionally set a dailyBudgetUsd. Pass adAccountId (from list_meta_pages) + an objective. Needs ads-management permission on the connected account. CATALOG / ADVANTAGE+ CATALOG ADS (also called DPA or dynamic product ads) ARE SUPPORTED: pass productCatalogId here and productSetId on the ad set, and the campaign is catalog-backed. Say YES when a user asks for catalog, Advantage+ catalog, DPA or feed-driven ads.
| 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 destructive
Turn a campaign ON (ACTIVE) or OFF (PAUSED). ACTIVATING STARTS REAL AD SPEND — you MUST first show the user the campaign name + its daily budget, get an explicit yes, then call with status:"ACTIVE" and confirm:true. Pausing is always safe. Needs ads-management permission.
| 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. 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 |
|---|
brand | string | WHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done. |
target | "facebook" | "instagram" | default facebook; 'instagram' reads the Page's linked IG business account |
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 profile 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, newest first, across all channels — channel, permalink, caption, format, the HOOK and SUBJECT it was written to, and its measured engagement. The WHOLE history, no cap: pass the reply's nextCursor as `cursor` for older posts. Each row says how it was recorded: 'captured' (written at publish time — the hook is what the author intended) or 'backfilled' (reconstructed from the platform afterwards). A dash for engagement means the platform reported no number — NOT zero. Read-only, 0 credits.
| Parameter | Type | Description |
|---|
brand | string | WHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done. |
channel | 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 |
cursor | string | nextCursor from a previous reply: the next, older page |
list_hooks#
The hook + setting libraries, and which hooks are working read-only
The curated menu of VISUAL scroll-stop HOOKS (how an ad opens) and SETTINGS (where it is staged) that plan_ad and render_ad accept, PLUS this brand's measured traction per hook. Call it before planning an ad to pick a hook deliberately instead of letting the model improvise one, and call it after publishing to see which ones are actually landing. Three things it will not do: it never recommends a hook from thin data — a verdict is SUPPRESSED below 5 measured posts and the reason is stated; it never compares across channels; and it reports hooks you have NEVER TRIED as a fact, not as advice, because 'you haven't tried this' is an observation and 'you should' would be a verdict drawn from zero data. A hook marked unusable in this brief says WHY (an on-screen-text hook cannot ride an authentic/UGC render, which carries zero on-screen text). Read-only, 0 credits.
| 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. |
list_templates#
Industry ad templates read-only
Ready-to-run ad BRIEFS by industry: dropshipping, ecommerce, mobile apps, restaurants, real estate and local services (12 trades: plumbers, electricians, hvac, roofers, landscapers, house-cleaners, pest-control, auto-repair, salons, med-spas, dentists, gyms). Compact list by default; pass `id` for one template in full: its brief (filled with the active profile's name, and the trade's specifics when `trade` is given), what the user must supply, the recommended format/aspect/length, and its web deep link. To run one, fill the [square-bracket] slots from what the user gave you, then pass the brief to plan_ad as `product` (with its format and durationSeconds) and render the plan (render_ad for video, generate_image for images). Every part is editable: a template is a shortcut, not a lane, so change or ignore anything that does not fit. These are NOT the native HTML formats of make_template_ad. Read-only, 0 credits.
| Parameter | Type | Description |
|---|
vertical | string | dropshipping | ecommerce | mobile-app | restaurants | real-estate | local-services; omit for all |
trade | string | for local services: plumbers, electricians, hvac, roofers, landscapers, house-cleaners, pest-control, auto-repair, salons, med-spas, dentists or gyms. Fills the trade specifics into the brief |
id | string | one template id (from the list) to return in full, with its brief |
post_performance#
Which hooks and subjects are getting traction read-only
Aggregate this brand's published posts to answer WHICH HOOKS AND SUBJECTS WORK. Groups by hook (default), subject, format (recipe), channel, media format or posting hour, reports the engagement RATE within each channel, and ranks the best and worst POSTS in each channel. Describe a post by the creative it carried (what it shows, its format, its link), not by its caption — the caption is the least important part of a post. THREE THINGS IT DELIBERATELY WILL NOT DO, and you should repeat them rather than paper over them: (1) it never sums metrics across channels — a LinkedIn impression and a TikTok view are different units, so every comparison is within one channel; (2) it SUPPRESSES a verdict below 5 measured posts and says so, because a confident recommendation from 3 posts is worse than none; (3) a post with no recorded hook (published outside Hermoso, or backfilled without a creation match) counts toward channel and format totals but never votes on which hook works. Present the `finding` verbatim if there is one, and the reason if there is not. Read-only, 0 credits.
| Parameter | Type | Description |
|---|
brand | string | WHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done. |
axis | "hook" | "subject" | "recipe" | "channel" | "media" | "hour" | what to group by — default hook; recipe = the format of the creative |
channel | string | restrict to one channel |
days | number | look back N days (1-730) over the whole history; omit for the recent posts only |
diagnose_posts#
What to fix next, post by post read-only
WHAT TO FIX NEXT, post by post — and the tool that fixes it. post_performance tells you which hook is AHEAD; this tells you what is WRONG with a given post and where the next edit goes. Under-distributed is A HOOK PROBLEM (change the opening: mine_angles, then list_hooks, then plan_variations). Seen but not held is A RETENTION PROBLEM (plan_variations to rebuild the middle against the same hook). Seen, held, and still not converting is AN OFFER PROBLEM. FOUR REFUSALS, AND YOU SHOULD REPEAT THEM RATHER THAN PAPER OVER THEM: (1) a post younger than ~24h is TOO EARLY and is never called a failure — it has not had its run; (2) a metric the platform does not publish is UNMEASURED, never zero — Facebook has published no post reach since 2026-06-15, Reddit publishes no impressions, and Google Business publishes nothing per-post at all; (3) below 5 measured posts on a channel there is no baseline of the brand's own, and the ONLY fallback is a published short-video hook floor that is NOT our measured number and does not transfer off TikTok/Instagram/YouTube — it is attributed in the output and you should attribute it too; (4) it does not always find a problem, and 'nothing here needs fixing' is a real answer rather than a failure to look. Hermoso cannot see conversions for an organic post — no channel reports installs or purchases against a post id — so the offer rung runs ONLY when the user tells you they are not converting and you pass converting:false. Print `summary` verbatim. Read-only, 0 credits.
| 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 |
|---|
brand | string | WHICH PROFILE the post lives in — id or exact name from list_brands (a shared workspace: its profile id). Needed when it was published in a profile this connection is not pinned to; applies to THIS CALL ONLY. A name that matches no profile, or two, is REFUSED and nothing is done. |
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 profile has more than one |