AI marketing agent for social: post, schedule and answer comments and DMs across 15 channels.
Maintain this server? Claim it
Call this server's tools from your browser. Nothing to install.
Requests go through mcptry because browsers can't call most MCP servers directly. Header values are sent to the server for that request only and are never stored or logged.
Tool list from our last check. Connect live to refresh it.
Leave an INTERNAL NOTE on an Intercom conversation: only the brand's teammates see it, never the customer (a summary, a suggested reply, a handover). Without confirm:true it adds nothing and returns the preview; call again with confirm:true once the user agrees. Read back from the conversation. Free. Needs Intercom connected (Settings > Connectors > Intercom, or connect_connector).
the note text teammates will read
Intercom conversation id, from list_intercom_conversations
true once the user agreed to add this note
The server marks this tool as destructive. Running it may delete or overwrite data on the server or in any account you connected with your headers.
Pick your client. Values in capitals, like YOUR_TOKEN, are yours to fill in.
Run in a terminal
claude mcp add --transport http hermoso-social-media-manager 'https://app.hermoso.ai/mcp?tools=app-social&src=registry'
Leave an INTERNAL NOTE on an Intercom conversation: only the brand's teammates see it, never the customer (a summary, a suggested reply, a handover). Without confirm:true it adds nothing and returns the preview; call again with confirm:true once the user agrees. Read back from the conversation. Free. Needs Intercom connected (Settings > Connectors > Intercom, or connect_connector).
| Argument | Type | Description |
|---|---|---|
| note* | string | the note text teammates will read |
| confirm | boolean | true once the user agreed to add this note |
| conversationId* | string | Intercom conversation id, from list_intercom_conversations |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"conversationId",
"note"
],
"properties": {
"note": {
"type": "string",
"description": "the note text teammates will read"
},
"confirm": {
"type": "boolean",
"description": "true once the user agreed to add this note"
},
"conversationId": {
"type": "string",
"description": "Intercom conversation id, from list_intercom_conversations"
}
}
}Post the business’s answer to a public question on the brand’s Google Business Profile listing, or delete the answer already there. THIS IS AN UPSERT — one answer per account, so answering again REPLACES the previous one rather than adding a second. Deleting is public and immediate and is confirm-gated. Needs Google Business Profile connected and the project approved.
| Argument | Type | Description |
|---|---|---|
| text | string | the answer — required unless deleting |
| delete | boolean | true removes the existing answer instead of posting one; needs confirm |
| confirm | boolean | required for delete:true |
| locationId | string | listing id from list_business_locations; omit when only one is shared |
| questionId* | string | from list_google_business_questions |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"questionId"
],
"properties": {
"text": {
"type": "string",
"description": "the answer — required unless deleting"
},
"delete": {
"type": "boolean",
"description": "true removes the existing answer instead of posting one; needs confirm"
},
"confirm": {
"type": "boolean",
"description": "required for delete:true"
},
"locationId": {
"type": "string",
"description": "listing id from list_business_locations; omit when only one is shared"
},
"questionId": {
"type": "string",
"description": "from list_google_business_questions"
}
}
}Append text to the end of a Google Doc Hermoso can reach — one it created (pass the documentId from create_doc) or one the user handed over with the Google file picker in the app (find its id with list_drive_files). Optional `dropdown:{title, options:[2-50], selected}` appends a Google Docs dropdown chip after the text — e.g. text "Status: " + dropdown {title:"Status", options:["Draft","In review","Approved"]} gives a brief a status the team can click to change; the chip is confirmed by reading the doc back. Set it later with update_doc dropdowns.
| Argument | Type | Description |
|---|---|---|
| text | string | text to append at the end of the doc (optional when a dropdown is passed) |
| dropdown | object | a dropdown chip to append after the text |
| documentId* | string | the document id from create_doc |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"documentId"
],
"properties": {
"text": {
"type": "string",
"description": "text to append at the end of the doc (optional when a dropdown is passed)"
},
"dropdown": {
"type": "object",
"required": [
"options"
],
"properties": {
"title": {
"type": "string",
"description": "the dropdown title, e.g. \"Status\""
},
"options": {
"type": "array",
"items": {
"type": "string"
},
"description": "2 to 50 distinct options"
},
"selected": {
"type": "string",
"description": "the option it starts on (default: the first)"
}
},
"description": "a dropdown chip to append after the text"
},
"documentId": {
"type": "string",
"description": "the document id from create_doc"
}
}
}Add to the END of an existing Notion page (pageId or its link, from list_notion_pages): markdown content, a swipefileCollection, or includeSchedule:true. Nothing already on the page changes. Free.
| Argument | Type | Description |
|---|---|---|
| pageId* | string | page id or link |
| content | string | markdown to add |
| includeSchedule | boolean | true appends the scheduled posts too |
| swipefileCollection | string | swipefile collection name or id to append, with each ad's creative, copy, dates and link |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"pageId"
],
"properties": {
"pageId": {
"type": "string",
"description": "page id or link"
},
"content": {
"type": "string",
"description": "markdown to add"
},
"includeSchedule": {
"type": "boolean",
"description": "true appends the scheduled posts too"
},
"swipefileCollection": {
"type": "string",
"description": "swipefile collection name or id to append, with each ad's creative, copy, dates and link"
}
}
}Append rows to a Google Sheet Hermoso can reach — one it created (pass the spreadsheetId from create_sheet) or one the user handed over with the Google file picker in the app (find its id with list_drive_files). rows = array of row arrays.
| Argument | Type | Description |
|---|---|---|
| rows* | array | rows to append — array of row arrays |
| range | string | range to append at (default A1 / first sheet) |
| spreadsheetId* | string | the spreadsheet id from create_sheet |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"spreadsheetId",
"rows"
],
"properties": {
"rows": {
"type": "array",
"items": {
"type": "array",
"items": {
"type": [
"string",
"number",
"boolean"
]
}
},
"description": "rows to append — array of row arrays"
},
"range": {
"type": "string",
"description": "range to append at (default A1 / first sheet)"
},
"spreadsheetId": {
"type": "string",
"description": "the spreadsheet id from create_sheet"
}
}
}Import this brand's PAST posts from a channel into the performance record, so 'which hook works' can draw on history rather than only on what was published since Hermoso started recording. Supports facebook, instagram, threads, youtube, tiktok, pinterest and bluesky; the others say plainly why they cannot (LinkedIn has no enumerate-my-posts endpoint on our grant, X bills per read so it is excluded from bulk import, Google Business has had no per-post insights since 2023, and Telegram's Bot API cannot read a chat's past messages at all — nothing published before Hermoso is recoverable through a bot token). BOUNDED, RESUMABLE AND QUOTED: it runs as a DRY RUN by default and tells you how many posts it found and what reading them will cost — pass confirm:true to import, and pass the returned cursor to continue. AN IMPORTED POST IS WEAKER EVIDENCE THAN A RECORDED ONE and is labelled 'backfilled': its hook is recovered ONLY where the post matches a Hermoso creation by asset or caption. A post made outside Hermoso stays UNATTRIBUTED — it counts toward channel and format totals but never votes on which hook works. Never guess a hook from a caption. Free.
| Argument | Type | Description |
|---|---|---|
| limit | number | how many posts this page (default 50, max 200) |
| cursor | string | resume from a previous run |
| channel* | string | which channel to import from |
| confirm | boolean | actually import — omit for a dry run that only quotes the cost |
| accountRef | string | which Page / account, when the profile has more than one |
Show this account's billing at a glance: current plan (quote plan.priceUsd per plan.period), credit balance, whether auto-reload is on, whether a card is on file, and whether YOU (this key) have ADMIN rights to change billing. Read-only, free. Call it before upgrade_plan / set_auto_reload to know what's possible — members have read-only billing. IN A SHARED TEAM WORKSPACE A MEMBER SEES THE PLAN AND THE BALANCE ONLY: the workspace owner's payment card and auto-reload belong to them and are not reported (billingScope:'member'). Never tell a member there is no card on file — the honest answer is that you cannot see it. plan.memberRate: media models at cost + 10%; plan.legacy: an older plan kept at its price. posting: this month's posting allowance ("N of 10 posts used this month" on Free, unlimited on every paid plan).
No arguments.
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {}
}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).
| Argument | Type | Description |
|---|---|---|
| actor | string | a Bluesky handle (hermoso.ai or @hermoso.ai) or a did:plc:… — omit it to read the connected account |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"actor": {
"type": "string",
"description": "a Bluesky handle (hermoso.ai or @hermoso.ai) or a did:plc:… — omit it to read the connected account"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
}
}
}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.
| Argument | Type | Description |
|---|---|---|
| uris* | array | AT-URIs of the posts, at most 25 (Bluesky’s own maximum for one call) |
| 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. |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"uris"
],
"properties": {
"uris": {
"type": "array",
"items": {
"type": "string"
},
"description": "AT-URIs of the posts, at most 25 (Bluesky’s own maximum for one call)"
},
"brand": {
"type": "string",
"description": "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."
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
}
}
}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.
| Argument | Type | Description |
|---|---|---|
| locationName* | string | locations/{id} — from list_business_locations |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"locationName"
],
"properties": {
"locationName": {
"type": "string",
"description": "locations/{id} — from list_business_locations"
}
}
}Out of credits? Top up with a credit PACK. Call with no argument to list the available packs (id · credits · price). If the account has a saved card and you have billing-admin rights, calling with `pack` quotes the exact charge and calling again with confirm:true AND the quote's quote_token charges the saved card instantly (same one-click top-up as the app — no redirect). If there's no saved card yet, you get a Stripe checkout URL to hand your human for the FIRST purchase; their card saves for one-click after that. Packs only; subscriptions are managed by a person in Billing. IF YOU ARE AN AGENT HOLDING YOUR OWN PAYMENT CREDENTIAL, there is a third path that needs no human at all: `POST /api/billing/machine-payment` with a `packId` answers HTTP 402 carrying an MPP challenge, and grants the pack once you authorise and retry with the credential — the same packs, the same prices, the same credits. `GET /api/billing/config` carries a `machinePayments` block listing the packs with their per-credit rates and saying whether that lane is enabled on this server. Most agents do NOT have their own credential yet, so the checkout link above remains the normal path. To stop running out entirely, turn on auto-reload with set_auto_reload (admin) — low balances then top themselves up from the saved card automatically.
| Argument | Type | Description |
|---|---|---|
| pack | string | the pack id to buy (e.g. pack-2k) — omit to list the available packs first |
| confirm | boolean | set true to actually charge the saved card for `pack` (required for the one-click charge; ignored on the checkout-link path) |
| quote_token | string | the quoteToken returned by the quote step — REQUIRED (with confirm:true) to charge; it binds the exact pack + price you quoted (10-minute validity) and makes a retried confirm idempotent |
Run ANY Hermoso tool by name — including the paid-campaign, analytics and channel-admin tools that are not in this session's starting list — with the same permissions, the same account and the same result as calling it directly. Get the exact `name` and its `args` from find_tools first. This is the route on hosts that cannot reload their tool list mid-conversation (claude.ai, ChatGPT): enable_tools switches a group on server-side, but such a host keeps the list it fetched at connect time. Arguments are validated against the tool's own schema and a mistake is answered with the expected parameters, not a silent default. Refused by name, with the way out, when the tool needs a connector this workspace has not made or is withheld by the host's own policy.
| Argument | Type | Description |
|---|---|---|
| args | object | the tool's arguments as an object, exactly as its own schema takes them |
| name* | string | name |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"name"
],
"properties": {
"args": {
"type": "object",
"description": "the tool's arguments as an object, exactly as its own schema takes them",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"name": {
"type": "string",
"description": "name"
}
}
}Stop a Klaviyo campaign that is scheduled or sending: action 'revert' puts it back to a draft (only before it goes), 'cancel' cancels it for good. Emails already delivered stay delivered. Without confirm:true nothing changes and you get what would happen. Free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).
| Argument | Type | Description |
|---|---|---|
| action | string | |
| confirm | boolean | |
| campaignId* | string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"campaignId"
],
"properties": {
"action": {
"enum": [
"revert",
"cancel"
],
"type": "string"
},
"confirm": {
"type": "boolean"
},
"campaignId": {
"type": "string"
}
}
}Stop a Mailchimp campaign: action 'unschedule' puts a scheduled one back to a draft; 'cancel' stops one that is sending (emails already delivered stay delivered). Without confirm:true nothing changes and you get what would happen. Free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).
| Argument | Type | Description |
|---|---|---|
| action | string | |
| confirm | boolean | |
| campaignId* | string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"campaignId"
],
"properties": {
"action": {
"enum": [
"unschedule",
"cancel"
],
"type": "string"
},
"confirm": {
"type": "boolean"
},
"campaignId": {
"type": "string"
}
}
}Remove a queued post before it goes out. Get the id from list_scheduled. Only works while it is still queued — something already published cannot be unsent (use manage_meta_post to delete a Facebook/Instagram post after the fact).
| Argument | Type | Description |
|---|---|---|
| id* | string | the scheduled post id from list_scheduled |
| brand | string | WHICH PROFILE this post is in — id or exact name from list_brands; needed when it is not the profile this connection is pinned to. A name that matches no profile, or two, is REFUSED. |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"id"
],
"properties": {
"id": {
"type": "string",
"description": "the scheduled post id from list_scheduled"
},
"brand": {
"type": "string",
"description": "WHICH PROFILE this post is in — id or exact name from list_brands; needed when it is not the profile this connection is pinned to. A name that matches no profile, or two, is REFUSED."
}
}
}Cancel a message scheduled in Slack (from list_slack_scheduled). Slack refuses once it is within 60 seconds of posting. Free.
| Argument | 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 | |
| slackWorkspace | string | |
| scheduledMessageId* | string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"channel",
"scheduledMessageId"
],
"properties": {
"brand": {
"type": "string",
"description": "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": {
"type": "string"
},
"slackWorkspace": {
"type": "string"
},
"scheduledMessageId": {
"type": "string"
}
}
}Empty a range of cells in a Google Sheet, leaving the rows themselves in place. DESTRUCTIVE: call it WITHOUT confirm first and nothing is cleared — you get back the real number of filled cells in that exact range. Show the user that number, get an unambiguous yes, then call again with confirm:true AND confirmCells set to it. The echo is not ceremony: it is what catches naming A1:Z1000 when you meant A1:Z10, which is the mistake that actually happens. There is deliberately NO default range. The clear is read back and reported as confirmed only if the range really is empty afterwards. To remove a whole tab instead, use manage_sheet_tabs.
| Argument | Type | Description |
|---|---|---|
| range* | string | the range to clear, e.g. "A2:D50" or "Sheet1!A2:D50" |
| confirm | boolean | true clears the range; call without it first to get the filled-cell count |
| sheetUrl | string | Google Sheets link, instead of spreadsheetId |
| confirmCells | number | echo back the filled-cell count the unconfirmed call reported |
| spreadsheetId | string | Google spreadsheet id; use this or sheetUrl |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"range"
],
"properties": {
"range": {
"type": "string",
"description": "the range to clear, e.g. \"A2:D50\" or \"Sheet1!A2:D50\""
},
"confirm": {
"type": "boolean",
"description": "true clears the range; call without it first to get the filled-cell count"
},
"sheetUrl": {
"type": "string",
"description": "Google Sheets link, instead of spreadsheetId"
},
"confirmCells": {
"type": "number",
"description": "echo back the filled-cell count the unconfirmed call reported"
},
"spreadsheetId": {
"type": "string",
"description": "Google spreadsheet id; use this or sheetUrl"
}
}
}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.
| Argument | Type | Description |
|---|---|---|
| max | number | cap how many posts to read in this run (default 40) |
| 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. |
| 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. |
| includeMetered | boolean | also read X, which BILLS CREDITS per post read — ask the user first |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"max": {
"type": "number",
"description": "cap how many posts to read in this run (default 40)"
},
"brand": {
"type": "string",
"description": "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."
},
"remeasure": {
"type": "boolean",
"description": "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."
},
"includeMetered": {
"type": "boolean",
"description": "also read X, which BILLS CREDITS per post read — ask the user first"
}
}
}Leave a NEW top-level comment on one of the brand’s own posts — a Facebook Page post OR an Instagram post/Reel — as the brand. This is the "link in the first comment" tactic (Facebook suppresses reach on a post with an outbound link in the body, and an Instagram caption link is not clickable), and it is also how you add a pinned clarification, an offer code or a correction without editing the post. It is PUBLIC and posted as the brand: show the user the exact wording and get their go-ahead first. postId comes from list_meta_posts (target:"instagram" for Instagram media ids). INSTAGRAM: text only (Meta documents no attachment on that edge, so attachmentUrl is refused by name), not on live videos; pass account for an Instagram-Login account or one of several. Free.
| Argument | Type | Description |
|---|---|---|
| pageId | string | Page id — omit when only one Page is connected |
| postId* | string | the Facebook Page post id or Instagram media id — from list_meta_posts, or returned by post_to_meta |
| account | string | INSTAGRAM — which account, an @handle or id from list_connector_accounts("instagram"); needed for an Instagram Login account or when several are linked |
| message | string | the comment text. Required unless attachmentUrl is given. |
| attachmentUrl | string | FACEBOOK ONLY — optional public image URL to post as the comment’s attachment (refused on Instagram) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"postId"
],
"properties": {
"pageId": {
"type": "string",
"description": "Page id — omit when only one Page is connected"
},
"postId": {
"type": "string",
"description": "the Facebook Page post id or Instagram media id — from list_meta_posts, or returned by post_to_meta"
},
"account": {
"type": "string",
"description": "INSTAGRAM — which account, an @handle or id from list_connector_accounts(\"instagram\"); needed for an Instagram Login account or when several are linked"
},
"message": {
"type": "string",
"description": "the comment text. Required unless attachmentUrl is given."
},
"attachmentUrl": {
"type": "string",
"description": "FACEBOOK ONLY — optional public image URL to post as the comment’s attachment (refused on Instagram)"
}
}
}Write a NEW top-level comment on a TikTok post the authorized account owns. Text (≤1,200 characters, UTF-8) or an image, and TikTok requires at least one of the two. AN IMAGE HERE MUST BE UPLOADED FIRST — a raw URL is refused on a new comment; call upload_tiktok_comment_image and pass back imageUri + imageWidth + imageHeight together (a reply is the one place TikTok accepts a plain URL). TIKTOK SILENTLY HIDES COMMENTS IT FLAGS AS SPAM and sends no signal when it does, so avoid posting many near-identical comments in a short window, and read the comment back with list_tiktok_comments(status:'PUBLIC') if it matters that it is visible. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION (see tiktok_account_status).
| Argument | Type | Description |
|---|---|---|
| text | string | ≤1,200 characters (UTF-8). Either this or an image is required. |
| videoId* | string | the TikTok post id |
| imageUri | string | from upload_tiktok_comment_image — a raw URL is NOT accepted on a new comment |
| imageWidth | number | required with imageUri |
| imageHeight | number | required with imageUri |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"videoId"
],
"properties": {
"text": {
"type": "string",
"description": "≤1,200 characters (UTF-8). Either this or an image is required."
},
"videoId": {
"type": "string",
"description": "the TikTok post id"
},
"imageUri": {
"type": "string",
"description": "from upload_tiktok_comment_image — a raw URL is NOT accepted on a new comment"
},
"imageWidth": {
"type": "number",
"description": "required with imageUri"
},
"imageHeight": {
"type": "number",
"description": "required with imageUri"
}
}
}Connect an account that links with a KEY rather than a sign-in screen, from here, with no browser: AppLovin Ads, Stripe, Intercom, Mailchimp, Klaviyo, Vercel, ChatGPT Ads, Apple Ads, Bluesky, Telegram, Bing Webmaster Tools, PostHog, Mixpanel, Amplitude, WordPress, Slack, Discord, Webhook. Pass provider and fields in that provider's own field names (listed below, * = required). The key is checked live with the provider before anything is saved, exactly as the app's Connectors page checks it, and the reply is read back from the saved connection. OFFER BOTH WAYS AND LET THE USER CHOOSE: a key pasted into this chat stays in this conversation's history, while pasting it in the app (Workspace > Connectors, or the one-click link https://app.hermoso.ai/?connect=<provider>) keeps it out of the chat. Hermoso never repeats a submitted key back. An account that connects through the provider's own sign-in screen (OAuth) cannot be connected here: this answers with the link to hand the user instead. Apple Ads with no key material first generates a signing key pair and returns the public key to register with Apple plus a setupToken to send back. Fields: applovin_ads {reportKey, eventKey, apiKey, capiKey, accountId} · stripe {apiKey*} · intercom {accessToken*, region} · mailchimp {apiKey*, server} · klaviyo {apiKey*} · vercel {token*, team} · openai_ads {apiKey*} · apple_ads {clientId, teamId, keyId, privateKey, setupToken, orgId} · bluesky {identifier*, appPassword*, pds} · telegram {token*} · bing_webmaster {apiKey*} · posthog {apiKey*, region, host, projectId} · mixpanel {username*, secret*, projectId*, region, workspaceId} · amplitude {apiKey*, secretKey*, region, host} · wordpress {siteUrl*, username*, appPassword*} · slack {webhookUrl, announceWebhookUrl} · discord {webhookUrl, announceWebhookUrl} · webhook {webhookUrl*}.
| Argument | Type | Description |
|---|---|---|
| fields | object | that provider's own field names and values, e.g. {"apiKey":"…"}; the names for each provider are in the description |
| provider |
Turn a file already in the user’s OneDrive into a PDF or a JPG — Microsoft does the conversion on its own servers, so nothing is re-encoded here and nothing is lost in a screenshot. It reads about 130 source formats, which is the point: PowerPoint and Word decks, Excel, Photoshop PSD, Illustrator AI, Sketch, 3D (fbx/glb/obj), video (mp4/mov/webm), HEIC from an iPhone, and the raw camera formats (CR2, NEF, ARW, DNG) that nothing else in this product can open. Use it to turn a client’s deck into images you can actually put in an ad, to get a usable JPG out of a designer’s PSD or a photographer’s raw file, or to hand someone a PDF of a spreadsheet. CONVERTING TO JPG REQUIRES BOTH width AND height — Microsoft refuses the call without them. The result is stored at a durable Hermoso URL you can pass straight to a render or a post; Microsoft’s own conversion link expires within minutes, so do not hand that one to anyone. Needs OneDrive connected — no new permission.
| Argument | Type | Description |
|---|---|---|
| width | number | REQUIRED for jpg — output width in pixels |
| fileId* | string | the OneDrive item id, from list_onedrive_files |
| format | string | default pdf |
| height | number | REQUIRED for jpg — output height in pixels |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"fileId"
],
"properties": {
"width": {
"type": "number",
"description": "REQUIRED for jpg — output width in pixels"
},
"fileId": {
"type": "string",
"description": "the OneDrive item id, from list_onedrive_files"
},
"format": {
"enum": [
"pdf",
"jpg"
],
"type": "string",
"description": "default pdf"
},
"height": {
"type": "number",
"description": "REQUIRED for jpg — output height in pixels"
}
}
}Add a NEW profile (a brand, client, creator or personal workspace) and switch to it. Each has its OWN brand details, memory, swipefile, Library, avatars, skills, playbooks and connectors; nothing leaks between them. Use draft_brand to FILL it and update_brand to edit it. Re-running with the same name returns the existing profile instead of a duplicate. Creating it is free. Drafting a NEW brand into it (draft_brand) needs the credits for its research on the balance, about 100; with fewer, draft_brand refuses before anything runs or is charged and says how many it needs and how many the account has.
| Argument | Type | Description |
|---|---|---|
| name* | string | the name for the new profile (a brand, client or creator name) |
| activate | boolean | switch this connection to the new profile (default true); everything you do next scopes to it |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"name"
],
"properties": {
"name": {
"type": "string",
"description": "the name for the new profile (a brand, client or creator name)"
},
"activate": {
"type": "boolean",
"description": "switch this connection to the new profile (default true); everything you do next scopes to it"
}
}
}Create a new Google Doc in the user’s Drive with a title + optional body text — e.g. export ad copy, a creative brief, or a report. Returns the document id + URL. Needs Google Drive connected (Settings > Connectors > Google Drive — one connection covers Drive, Sheets and Docs).
| Argument | Type | Description |
|---|---|---|
| text | string | body text to insert |
| title | string | document title |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"text": {
"type": "string",
"description": "body text to insert"
},
"title": {
"type": "string",
"description": "document title"
}
}
}Create a folder in the user’s Google Drive (optionally nested under parentId) to organize saved files. Returns the folder id + webViewLink. Use that ID as update_drive_file’s moveToFolderId or as parentId for a nested folder. NOTE: save_to_drive’s `folder` is a NAME, not this id — it find-or-creates a folder by that name, so pass the folder NAME there (or omit and just save, then move with update_drive_file).
| Argument | Type | Description |
|---|---|---|
| name* | string | folder name |
| parentId | string | parent folder id for a nested folder (default: Drive root) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"name"
],
"properties": {
"name": {
"type": "string",
"description": "folder name"
},
"parentId": {
"type": "string",
"description": "parent folder id for a nested folder (default: Drive root)"
}
}
}Create a Klaviyo email CAMPAIGN DRAFT: templateId (create_klaviyo_template), subject, previewText, the audience as listIds and/or segmentIds (excludeIds leaves people out), optional name, fromEmail / fromLabel (default: the account's default sender) and replyTo. It is created as a DRAFT and NOTHING IS SENT; the answer is Klaviyo's own row read back. To send or schedule it use send_klaviyo_campaign, which shows the recipient count first. Free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).
| Argument | Type | Description |
|---|---|---|
| name | string | |
| listIds | array | |
| replyTo | string | |
| subject* | string | |
| fromEmail | string | |
| fromLabel | string | |
| excludeIds | array | |
| segmentIds | array | |
| templateId* | string | |
Make an EMAIL TEMPLATE in Klaviyo from a creative: image (any reference upload_klaviyo_image takes; it goes into the image library first) plus copy YOU write in the brand's voice (read get_brand for voice and colours): headline, body (paragraphs split by a blank line), ctaText + ctaUrl for the button, accent (#RRGGBB button colour). It is laid out as a 600px email with the unsubscribe link and sender address Klaviyo requires. Or pass html for the user's own markup (that footer is added if it has no unsubscribe link). Saves the template only, nothing is sent; returns the templateId for create_klaviyo_campaign. Free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).
| Argument | Type | Description |
|---|---|---|
| body | string | |
| html | string | your own full HTML instead of the layout |
| name | string | |
| text | string | plain-text version |
| image | string | a Library id, Hermoso or upload_file URL, or a public image URL |
| accent | string | #RRGGBB |
| ctaUrl | string | |
| ctaText | string | |
Create an event on a LinkedIn company Page the brand administers: an EXTERNAL online event (a webinar on Zoom, YouTube or your own site — pass url), an IN-PERSON event (pass address), or a LinkedIn Live event (format:"live" — that one needs LinkedIn Live access on the member, and without it LinkedIn refuses with “The member does not have LINKEDIN_LIVE creation access”, measured 2026-08-31). Optionally attach a LinkedIn REGISTRATION FORM (registrationForm:true + privacyPolicyUrl, or your own approved leadFormId); sign-ups arrive as EVENT leads in list_linkedin_leads(leadType:"EVENT"). Read back from LinkedIn. Warning: ANNOUNCING IS A SEPARATE, PUBLIC STEP (announce:true does it in this call): LinkedIn requires a SEPARATE post to make an event publicly visible, so creating one puts nothing in anyone’s feed and the brand decides when to tell people. That separation is LinkedIn’s own and it is why an agent cannot accidentally broadcast to a company’s followers. Edit it with update_linkedin_event until it starts. A custom background image is not offered yet. Needs the Events permission (administrator flag) and a reconnect, the same two conditions as list_linkedin_events. 0 credits.
| Argument | Type | Description |
|---|---|---|
| url | string | external: where it streams or where people join; in_person: an info page |
| name* | string | the event title |
| endsAt | string | ISO 8601, after startsAt; defaults to one hour after the start |
| format | string | inferred from url / address when omitted; live needs LinkedIn Live access |
| address | object | in_person venue |
Create a Mailchimp email CAMPAIGN DRAFT: templateId (create_mailchimp_template), subject, previewText, audienceId and optionally segmentId (part of the audience), optional name, fromName and replyTo (default: the audience's own defaults). It is created as a DRAFT and NOTHING IS SENT; the answer is Mailchimp's own row read back with its send checklist. To send or schedule it use send_mailchimp_campaign, which shows the recipient count first. Free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).
| Argument | Type | Description |
|---|---|---|
| name | string | |
| replyTo | string | |
| subject* | string | |
| fromName | string | |
| segmentId | string | |
| audienceId* | string | |
| templateId* | string | |
| previewText | string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"templateId",
"subject",
"audienceId"
],
"properties": {
"name": {
"type": "string"
},
"replyTo": {
"type": "string"
},
"subject": {
"type": "string"
},
"fromName": {
"type": "string"
},
"segmentId": {
"type": "string"
},
"audienceId": {
"type": "string"
},
"templateId": {
"type": "string"
},
"previewText": {
"type": "string"
}
}
}Make an EMAIL TEMPLATE in Mailchimp from a creative: image (any reference upload_mailchimp_image takes; it goes into the File Manager first) plus copy YOU write in the brand's voice (read get_brand for voice and colours): headline, body (paragraphs split by a blank line), ctaText + ctaUrl for the button, accent (#RRGGBB button colour). It is laid out as a 600px email with the unsubscribe link and mailing address Mailchimp requires. Or pass html for the user's own markup (that footer is added if it has no *|UNSUB|*). Saves the template only, nothing is sent; returns the templateId for create_mailchimp_campaign. Free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).
| Argument | Type | Description |
|---|---|---|
| body | string | |
| html | string | your own full HTML instead of the layout |
| name | string | |
| image | string | a Library id, or a Hermoso or upload_file URL |
| accent | string | #RRGGBB |
| ctaUrl | string | |
| ctaText | string | |
| headline | string | |
| imageAlt |
The container a paid Messenger marketing message is sent from (Meta act_<AD>/message_campaign). Budgets are in the AD ACCOUNT’s currency (the *Usd parameter names are historical); Meta bills per DELIVERED message against the campaign budget, and creating it sends and charges nothing. A NEW campaign cannot send for about an hour (61 minutes measured 2026-09-14) while Meta populates and prepares it (2300012 then 2300041, each with remaining_seconds), so create it ahead of time or reuse one from list_messenger_marketing_campaigns. dailyBudgetUsd OR lifetimeBudgetUsd, and Meta refuses either under USD 30 (code 1885272, measured 2026-09-14) (omit both and Meta sets an estimated daily cap; Meta may spend up to 175% of a daily budget on one day, never more than 7× per week). REGION LAW: Meta lets us serve businesses in 20 countries only — US, Mexico, Brazil, India, Australia, Singapore, UAE, Saudi Arabia, Hong Kong, Taiwan, Thailand, Malaysia, Indonesia, Philippines, Vietnam, New Zealand, Chile, Colombia, Peru, Israel — NOT Canada, the EU or the UK — and cannot deliver to people in the EU, UK, Japan, South Korea or Australia. Political Pages are excluded. dryRun:true shows the exact body.
| Argument | Type | Description |
|---|---|---|
| name* | string | campaign name, shown in Meta and in list_messenger_marketing_campaigns |
| dryRun | boolean | true = show the exact request body and create nothing |
| pageId | string | the sending Page; defaults to the one shared Page |
| endTime | string | ISO time the campaign stops sending |
Create a NEW product catalog on one of the merchant’s Meta business portfolios. A catalog is the input to Advantage+ catalog ads (dynamic product ads) — build it first, then fill it from the merchant’s feed or Shopify, then point a campaign at it. Name the owning portfolio EITHER by its numeric businessId OR by businessName, exactly as list_meta_catalogs prints it (“DMDB Group”) — a name that matches no portfolio, or more than one, is refused BY NAME with the portfolios that really exist and their ids, never guessed, and it never falls back to “the only portfolio”. Vertical defaults to commerce and CANNOT be changed afterwards, nor can the owning portfolio, so both are worth getting right the first time. IMPORTANT — META DOES NOT REFUSE A DUPLICATE NAME: it silently creates a SECOND catalog with the same name and nothing to tell the two apart, so Hermoso refuses a name the portfolio already uses and tells you the existing id (if a previous create seemed to fail, that id is very likely the catalog it actually made). Pass allowDuplicateName:true if you genuinely want a twin. The confirmation is READ BACK from Meta — its create returns only an id, so the name, vertical and portfolio in the summary were fetched, not echoed. Free, 0 credits.
| Argument | Type | Description |
|---|---|---|
| name* | string | what to call the catalog |
| vertical | string | the catalog’s industry (default commerce). PERMANENT — Meta publishes no way to change it after creation. |
| businessId | string | numeric business-portfolio id that will OWN the catalog (from list_meta_catalogs). Meta will not let a catalog move to another portfolio later. A portfolio NAME is accepted here too and is resolved server-side. |
| businessName | string |
Create a folder in the user’s OneDrive (optionally nested under parentId) to organize saved files. Returns the folder id + webViewLink. Use that id as update_onedrive_file’s moveToFolderId or as parentId for a nested folder. NOTE: save_to_onedrive’s `folder` is a NAME (find-or-created), not this id.
| Argument | Type | Description |
|---|---|---|
| name* | string | folder name |
| parentId | string | parent folder id for a nested folder (default: OneDrive root) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"name"
],
"properties": {
"name": {
"type": "string",
"description": "folder name"
},
"parentId": {
"type": "string",
"description": "parent folder id for a nested folder (default: OneDrive root)"
}
}
}Create a board on the connected Pinterest account. Needed because a Pin cannot exist without a board, and a NEW Pinterest business account has none — if list_pinterest_boards comes back empty, make one here rather than telling the user you can't pin. Boards are PUBLIC unless you pass privacy 'SECRET'; a Pin on a secret board is invisible to everyone, so only choose that if the user asked for it.
| Argument | Type | Description |
|---|---|---|
| name* | string | board name, e.g. "Product launches" — keep it something a real Pinterest audience would browse |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| privacy | string | default PUBLIC. SECRET hides the board and every Pin on it from everyone but the account owner. |
| description | string | optional board description (≤500 chars) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"name"
],
"properties": {
"name": {
"type": "string",
"description": "board name, e.g. \"Product launches\" — keep it something a real Pinterest audience would browse"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"privacy": {
"enum": [
"PUBLIC",
"SECRET"
],
"type": "string",
"description": "default PUBLIC. SECRET hides the board and every Pin on it from everyone but the account owner."
},
"description": {
"type": "string",
"description": "optional board description (≤500 chars)"
}
}
}Create a Pinterest product CATALOG (RETAIL for products, or HOTEL) — the container feeds and items fill. Read back from Pinterest. Most merchants can skip it: a feed created without catalogId lands in a default retail catalog. 0 credits.
| Argument | Type | Description |
|---|---|---|
| name* | string | catalog name |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| adAccountId | string | optional — act as this ad account’s owner via Pinterest Business Access (must be shared with this profile) |
| catalogType | string | RETAIL (products) or HOTEL |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"name"
],
"properties": {
"name": {
"type": "string",
"description": "catalog name"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"adAccountId": {
"type": "string",
"description": "optional — act as this ad account’s owner via Pinterest Business Access (must be shared with this profile)"
},
"catalogType": {
"enum": [
"RETAIL",
"HOTEL"
],
"type": "string",
"description": "RETAIL (products) or HOTEL"
}
}
}Create a new Google Spreadsheet in the user’s Drive and optionally fill it with rows — e.g. export a swipefile, ad list, or performance report. Pass rows as an array of row arrays (first row = headers). Returns the spreadsheet id + URL. Needs Google Drive connected (Settings > Connectors > Google Drive — one connection covers Drive, Sheets and Docs).
| Argument | Type | Description |
|---|---|---|
| rows | array | rows to write — array of row arrays; first row = headers |
| title | string | spreadsheet title |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"rows": {
"type": "array",
"items": {
"type": "array",
"items": {
"type": [
"string",
"number",
"boolean"
]
}
},
"description": "rows to write — array of row arrays; first row = headers"
},
"title": {
"type": "string",
"description": "spreadsheet title"
}
}
}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.
| Argument | Type | Description |
|---|---|---|
| name | string | what this link is for — the campaign name; up to 32 characters, and the whole point of the tool |
| chatId* | string | the channel’s @username or numeric chat id |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| 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 |
Submit a new WhatsApp message template for Meta’s review. This is the ONLY way a business can start a WhatsApp conversation: outside the 24-hour window that opens when a customer messages first, WhatsApp accepts approved templates and nothing else. CATEGORY IS NOT COSMETIC — MARKETING (anything promotional), UTILITY (a transactional follow-up to something the customer did) or AUTHENTICATION (a one-time passcode) decides both what Meta charges for the conversation and the policy the content is reviewed against, and Meta RE-CATEGORISES a template whose content does not match the claim; the reply reports the category Meta actually assigned, not the one requested. The name is lowercase letters, digits and underscores only. Language is never defaulted: en and en_US are different templates to Meta. Submitting does NOT make it sendable — it comes back PENDING and has to be re-read with list_whatsapp_templates. CAROUSEL TEMPLATES ARE SUPPORTED: add a “CAROUSEL” component whose “cards” array holds 2–10 cards, each card carrying its own HEADER (format IMAGE or VIDEO for a media carousel, PRODUCT for a catalog one), an optional card BODY of at most 160 characters — a different and far shorter limit than the message bubble’s 1024 — and up to 2 buttons. Meta requires every card to carry THE SAME components, and if any card has body text then all of them must. At creation a card is only its “components” array; “card_index” belongs to the send payload, not to this one. A MEDIA HEADER NEEDS AN UPLOADED ASSET: any IMAGE, VIDEO or DOCUMENT header — including every card header in a media carousel — must carry example: { header_handle: [ … ] } holding a handle from Meta’s Resumable Upload API, or Meta refuses the whole template with a bare “Invalid parameter”. Pass `mediaUrl` on that header (a public JPEG/PNG for IMAGE, MP4 for VIDEO, PDF for DOCUMENT) and Hermoso uploads it through Meta's Resumable Upload API and fills the handle; a header with neither is refused locally and by name. A TEXT header needs no handle, and a PRODUCT card carousel takes its pictures from a connected catalog. 0 credits.
| Argument | Type | Description |
|---|---|---|
| name* | string | lowercase letters, digits and underscores only, e.g. "order_shipped" (max 512) |
Create a category or tag on the WordPress site. (post_to_wordpress also creates any category or tag NAME it is given that the site lacks.) Free.
| Argument | Type | Description |
|---|---|---|
| kind* | string | |
| name* | string | |
| 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. |
| parent | number | a parent category id (categories only) |
| description | string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"kind",
"name"
],
"properties": {
"kind": {
"enum": [
"categories",
"tags"
],
"type": "string"
},
"name": {
"type": "string"
},
"brand": {
"type": "string",
"description": "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."
},
"parent": {
"type": "number",
"description": "a parent category id (categories only)"
},
"description": {
"type": "string"
}
}
}PERMANENTLY delete one of the connected Bluesky account's OWN posts. IRREVERSIBLE — the AT Protocol removes the record from the account's repo, there is no trash and no undelete, and the post's likes, reposts, replies and quotes go with it. Call it WITHOUT confirm first: nothing is deleted, and it reports the post's REAL text and its live like / repost / reply / quote counts read back from Bluesky. Show the user that, get an unambiguous yes, then call again with confirm:true — plus, once the post has ANY engagement, confirmText echoing the post's own text (the first 40 characters is enough; any longer leading run works too). confirmText exists because confirming that you meant to delete SOMETHING does not prove you aimed at the right post, and a wrong id must not be confirmable blind. A brand-new post with nothing on it stays a ONE-call delete. Identify the post by its AT-URI or by just its RECORD KEY — the short id at the end of its bsky.app link, e.g. 3mtc4n3fibn2x. Deleting only ever works on the connected account's own posts; another account's URI is refused. 0 credits. Needs Bluesky connected (Settings > Connectors > Bluesky, or connect_connector).
| Argument | Type | Description |
|---|---|---|
| uri* | string | the post's AT-URI (at://did:plc:…/app.bsky.feed.post/…) as post_to_bluesky returned it, the handle form of the same URI for the CONNECTED account only (at://<its handle>/app.bsky.feed.post/…), or just its record key (3mtc4n3fibn2x) |
| 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. |
| account | string |
PERMANENTLY delete a profile and EVERYTHING in it — brand details, memory, swipefile, Library creations, generated assets, avatars, skills, playbooks, chats — and disconnect its connected accounts. Irreversible, and it applies to everyone the workspace is shared with. Call it WITHOUT confirm first: it reports exactly what that workspace holds. Show the user that inventory verbatim, get an unambiguous yes, then call again with confirm:true — plus, if the workspace is not empty, confirmName set to its exact name and confirmConnectors set to the number of connected accounts it reported. Those two exist because confirming INTENT does not prove you picked the right WORKSPACE, and a wrong target is how a live profile was destroyed. The account's FIRST/anchor profile cannot be deleted this way (it holds the workspace's root storage) — that one is replaced from the app.
| Argument | Type | Description |
|---|---|---|
| brand* | string | profile id or exact name from list_brands |
| confirm | boolean | REQUIRED true — this destroys the whole profile and cannot be undone |
| confirmName | string | the workspace's EXACT name, required when it is not empty — copy it from the inventory this tool returned, after the user has agreed to it |
| confirmConnectors | number | the number of connected accounts the inventory reported, required when there is at least one — the user must specifically agree to losing them, because reconnecting each needs a browser and no agent can do it |
Remove a saved creator from this workspace’s cast by id (from list_creators). Records a cross-device delete so they don’t reappear on the user’s other devices. It only drops the roster entry — ads already rendered with that person are untouched — and the same portrait can be saved again with save_creator, so no confirm is needed.
| Argument | Type | Description |
|---|---|---|
| id* | string | the creator id (from list_creators) |
| brand | string | profile id/name from list_brands, this call only |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"id"
],
"properties": {
"id": {
"type": "string",
"description": "the creator id (from list_creators)"
},
"brand": {
"type": "string",
"description": "profile id/name from list_brands, this call only"
}
}
}PERMANENTLY delete a Discord message Hermoso posted (the bot's or the announcement webhook's). Call it WITHOUT confirm first: nothing is deleted and you get a sentence quoting the message to show the user. Free.
| Argument | 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 | |
| confirm | boolean | true only once the user agreed |
| messageId* | string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"messageId"
],
"properties": {
"brand": {
"type": "string",
"description": "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": {
"type": "string"
},
"confirm": {
"type": "boolean",
"description": "true only once the user agreed"
},
"messageId": {
"type": "string"
}
}
}Delete one automation by id on any channel. Nothing further is sent by it; messages already sent stay; on X the event subscription is removed when no rule needs it. To pause instead, save it with enabled:false. Free.
| Argument | Type | Description |
|---|---|---|
| id* | string | rule id to delete, on any channel; messages already sent stay sent |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"id"
],
"properties": {
"id": {
"type": "string",
"description": "rule id to delete, on any channel; messages already sent stay sent"
}
}
}Delete a Drive file. By default it goes to Trash (recoverable); pass permanent:true to delete it forever. Pass fileId (from list_drive_files) + confirm:true. Irreversible when permanent — confirm with the user first.
| Argument | Type | Description |
|---|---|---|
| fileId* | string | the Drive file id |
| confirm | boolean | REQUIRED true |
| permanent | boolean | true = delete forever; default trashes (recoverable) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"fileId"
],
"properties": {
"fileId": {
"type": "string",
"description": "the Drive file id"
},
"confirm": {
"type": "boolean",
"description": "REQUIRED true"
},
"permanent": {
"type": "boolean",
"description": "true = delete forever; default trashes (recoverable)"
}
}
}Remove a Post from the brand’s Google Business Profile listing. This takes it off Google Search and Maps immediately and CANNOT be undone — confirm with the user first. Pass the full post name from list_google_business_posts. Needs Google Business Profile connected.
| Argument | 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. |
| postId* | string | the full post name from list_google_business_posts (accounts/…/locations/…/localPosts/…) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"postId"
],
"properties": {
"brand": {
"type": "string",
"description": "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."
},
"postId": {
"type": "string",
"description": "the full post name from list_google_business_posts (accounts/…/locations/…/localPosts/…)"
}
}
}Delete one automation by id. Nothing further is sent by it; DMs already sent stay. To pause instead, save it with enabled:false. Free.
| Argument | Type | Description |
|---|---|---|
| id* | string | rule id to delete; messages already sent stay sent |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"id"
],
"properties": {
"id": {
"type": "string",
"description": "rule id to delete; messages already sent stay sent"
}
}
}PERMANENTLY delete a Klaviyo campaign (a draft, a sent or a cancelled one; a scheduled one must be reverted first). Without confirm:true nothing is deleted and you get the campaign it would delete; call again with confirm:true only after the user says yes. Free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).
| Argument | Type | Description |
|---|---|---|
| confirm | boolean | true only after the user approved deleting this campaign |
| campaignId* | string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"campaignId"
],
"properties": {
"confirm": {
"type": "boolean",
"description": "true only after the user approved deleting this campaign"
},
"campaignId": {
"type": "string"
}
}
}PERMANENTLY delete a Klaviyo template. Without confirm:true nothing is deleted and you get the template it would delete; call again with confirm:true only after the user says yes. Free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).
| Argument | Type | Description |
|---|---|---|
| confirm | boolean | true only after the user approved deleting this template |
| templateId* | string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"templateId"
],
"properties": {
"confirm": {
"type": "boolean",
"description": "true only after the user approved deleting this template"
},
"templateId": {
"type": "string"
}
}
}PERMANENTLY delete images/videos from this brand's Library: the entry and the stored file. Pass ids (L0… from list_library) or urls. Without confirm it lists what would be deleted; ask the user, then call again with confirm:true. Free.
| Argument | Type | Description |
|---|---|---|
| ids | array | Library ids from list_library |
| urls | array | file links |
| confirm | boolean | true deletes |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"ids": {
"type": "array",
"items": {
"type": "string"
},
"description": "Library ids from list_library"
},
"urls": {
"type": "array",
"items": {
"type": "string"
},
"description": "file links"
},
"confirm": {
"type": "boolean",
"description": "true deletes"
}
}
}Remove a comment from one of your LinkedIn company Page's posts — your own Page's comment, or somebody else's on your Page. Community Management surface, same separate connection. CONFIRM-GATED: call once without `confirm` to be refused and shown what would go, then again with confirm:true. Deleting is immediate and public. `organizationId` is required because LinkedIn refuses a Page-level delete that does not name the acting organization. The delete is idempotent, so a repeat reports success rather than an error.
| Argument | Type | Description |
|---|---|---|
| confirm | boolean | REQUIRED true to actually delete |
| postUrn* | string | the post the comment sits on |
| commentId* | string | the comment id from list_linkedin_comments (its `id`, not the composite commentUrn) |
| organizationId | string | the company Page doing the deleting — OPTIONAL: LinkedIn requires an acting organization, but it defaults to the Page shared with this profile |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"postUrn",
"commentId"
],
"properties": {
"confirm": {
"type": "boolean",
"description": "REQUIRED true to actually delete"
},
"postUrn": {
"type": "string",
"description": "the post the comment sits on"
},
"commentId": {
"type": "string",
"description": "the comment id from list_linkedin_comments (its `id`, not the composite commentUrn)"
},
"organizationId": {
"type": "string",
"description": "the company Page doing the deleting — OPTIONAL: LinkedIn requires an acting organization, but it defaults to the Page shared with this profile"
}
}
}Remove a lead notification webhook (subscriptionId from list_linkedin_lead_subscriptions). Leads themselves are unaffected and stay readable; only the real-time delivery stops. Read back from LinkedIn. Free.
| Argument | Type | Description |
|---|---|---|
| pageId | string | the company Page that owns the form — from list_linkedin_pages; omit when one Page is shared |
| adAccountId | string | read forms owned by an AD ACCOUNT instead of a Page |
| subscriptionId* | string | webhook id from list_linkedin_lead_subscriptions |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"subscriptionId"
],
"properties": {
"pageId": {
"type": "string",
"description": "the company Page that owns the form — from list_linkedin_pages; omit when one Page is shared"
},
"adAccountId": {
"type": "string",
"description": "read forms owned by an AD ACCOUNT instead of a Page"
},
"subscriptionId": {
"type": "string",
"description": "webhook id from list_linkedin_lead_subscriptions"
}
}
}PERMANENTLY delete a Mailchimp campaign (a draft or a sent one; a scheduled one must be unscheduled first). Without confirm:true nothing is deleted and you get the campaign it would delete; call again with confirm:true only after the user says yes. Free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).
| Argument | Type | Description |
|---|---|---|
| confirm | boolean | true only after the user approved deleting this campaign |
| campaignId* | string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"campaignId"
],
"properties": {
"confirm": {
"type": "boolean",
"description": "true only after the user approved deleting this campaign"
},
"campaignId": {
"type": "string"
}
}
}PERMANENTLY delete a Mailchimp template. Without confirm:true nothing is deleted and you get the template it would delete; call again with confirm:true only after the user says yes. Free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).
| Argument | Type | Description |
|---|---|---|
| confirm | boolean | true only after the user approved deleting this template |
| templateId* | string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"templateId"
],
"properties": {
"confirm": {
"type": "boolean",
"description": "true only after the user approved deleting this template"
},
"templateId": {
"type": "string"
}
}
}Delete a marketing-message campaign. Irreversible: the first call names it and refuses; confirm:true deletes.
| Argument | Type | Description |
|---|---|---|
| confirm | boolean | true to delete; without it the call only names the campaign and deletes nothing |
| campaignId* | string | marketing-message campaign id, from list_messenger_marketing_campaigns |
| adAccountId* | string | ad account id (act_ prefix or digits) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"adAccountId",
"campaignId"
],
"properties": {
"confirm": {
"type": "boolean",
"description": "true to delete; without it the call only names the campaign and deletes nothing"
},
"campaignId": {
"type": "string",
"description": "marketing-message campaign id, from list_messenger_marketing_campaigns"
},
"adAccountId": {
"type": "string",
"description": "ad account id (act_ prefix or digits)"
}
}
}PERMANENTLY delete a product catalog. Meta does not let a catalog be removed from a business portfolio and then restored, and it cannot be transferred to another portfolio — there is no archive and no undo, and its product sets go with it, so any ad set still bound to one of those sets keeps spending and stops having products to show. Confirming is therefore not enough on a catalog that holds anything: pass confirm:true PLUS confirmName set to the catalog’s exact name (and confirmChildren when asked), which proves you inspected the catalog you are aiming at rather than merely meaning to delete something. An EMPTY catalog stays a one-call delete. Call meta_catalog_blast_radius first to get those numbers and to show the user what they are agreeing to. The result is the READ-BACK, never Meta’s acknowledgement — if the catalog still resolves afterwards you are told it is NOT confirmed. Free, 0 credits.
| Argument | Type | Description |
|---|---|---|
| confirm | boolean | true once the user has seen exactly what this destroys and said yes |
| catalogId* | string | numeric catalog id from list_meta_catalogs |
| confirmName | string | the catalog’s exact name, echoed back — required for any catalog that holds products or sets |
| confirmChildren | number | the total number of products + product sets it holds, echoed back |
| allowDeleteWithLiveProductSet | boolean | Meta BLOCKS the delete by default when a product set is live. Setting this forces it through, which changes what a campaign that is spending RIGHT NOW shows. Never set it without saying so. |
Delete a OneDrive item — it moves to the OneDrive recycle bin (recoverable there). Pass fileId (from list_onedrive_files) + confirm:true. Confirm the exact file with the user first.
| Argument | Type | Description |
|---|---|---|
| fileId* | string | the OneDrive item id |
| confirm | boolean | REQUIRED true |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"fileId"
],
"properties": {
"fileId": {
"type": "string",
"description": "the OneDrive item id"
},
"confirm": {
"type": "boolean",
"description": "REQUIRED true"
}
}
}PERMANENTLY delete a board AND EVERY PIN ON IT. This is the heaviest thing that can be done to a Pinterest account and there is no undelete. Call it WITHOUT confirm first: nothing is deleted, and it answers with the board’s real name, how many Pins are on it, how many people FOLLOW it and how many collaborators lose access — all read back from Pinterest. Show the user that, then call again with confirm:true plus confirmName (its exact name) and confirmChildren (the Pin count it reported); those echoes exist because a caller who has not looked at the board cannot supply them, and confirming intent alone does not prove aim. IF THEY ONLY WANT IT OUT OF PUBLIC VIEW: making the board SECRET with update_pinterest_board is reversible, but it needs a Pinterest permission (boards:write_secret) that an older connection may not hold. The unconfirmed refusal says which applies to this connection, read off the live scope set, so relay it verbatim rather than guessing. 0 credits. Needs Pinterest connected.
| Argument | Type | Description |
|---|---|---|
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| boardId* | string | numeric board id from list_pinterest_boards |
| confirm | boolean | REQUIRED true — the board and its Pins are gone for good |
| confirmName | string | the board’s EXACT name as the unconfirmed call reported it |
| confirmChildren | number |
PERMANENTLY delete a Pin. Pinterest has no undelete and no archive for one. Call it WITHOUT confirm first: nothing is deleted, and it answers with the Pin’s real title, its lifetime saves and impressions, and whether it HAS BEEN PROMOTED in an ad — all read back from Pinterest. Show the user that, get an unambiguous yes, then call again with confirm:true plus confirmName (its exact title) once it has saves or has been promoted, because confirming that you meant to delete SOMETHING does not prove you aimed at the right Pin. DELETING A PIN THAT AN AD PROMOTES pulls the creative out from under that ad, so check the promoted flag before agreeing. The verdict is read back from Pinterest, never taken from its 2xx. 0 credits. Needs Pinterest connected.
| Argument | 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. |
| pinId* | string | numeric Pin id from list_pinterest_pins |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| confirm | boolean | REQUIRED true — deletion is permanent |
| confirmName | string | the Pin’s EXACT title as the unconfirmed call reported it — required once it has saves or has been promoted |
Delete a saved playbook by id (from list_playbooks). Records a cross-device delete so it does not come back on the next sync. Minor + re-creatable, so no confirm needed.
| Argument | Type | Description |
|---|---|---|
| id* | string | the playbook id (from list_playbooks) |
| brand | string | profile id/name from list_brands, this call only |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"id"
],
"properties": {
"id": {
"type": "string",
"description": "the playbook id (from list_playbooks)"
},
"brand": {
"type": "string",
"description": "profile id/name from list_brands, this call only"
}
}
}Delete one of the workspace’s CUSTOM skills by id (from list_skills). Built-in skills/recipes can’t be deleted. Minor + re-creatable, so no confirm needed.
| Argument | Type | Description |
|---|---|---|
| id* | string | the custom skill id (from list_skills) |
| brand | string | profile id/name from list_brands, this call only |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"id"
],
"properties": {
"id": {
"type": "string",
"description": "the custom skill id (from list_skills)"
},
"brand": {
"type": "string",
"description": "profile id/name from list_brands, this call only"
}
}
}PERMANENTLY delete a Slack message the Hermoso app posted. Call it WITHOUT confirm first: nothing is deleted and you get a sentence quoting the message to show the user. Free.
| Argument | 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. |
| confirm | boolean | true only once the user agreed |
| messageId* | string | |
| slackWorkspace | string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"messageId"
],
"properties": {
"brand": {
"type": "string",
"description": "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."
},
"confirm": {
"type": "boolean",
"description": "true only once the user agreed"
},
"messageId": {
"type": "string"
},
"slackWorkspace": {
"type": "string"
}
}
}PERMANENTLY delete one message the bot posted to a Telegram chat. Call it WITHOUT confirm first: nothing is deleted and you get a sentence to show the user. There is deliberately NO preview of the message — the Bot API has no method that reads one message back, so anything shown would be invented, and for the same reason the result after deleting is Telegram’s own success answer rather than a verified read-back. TWO VENDOR LIMITS, both Telegram’s and neither ours: "A message can only be deleted if it was sent less than 48 hours ago", and in a CHANNEL the bot needs the Post Messages right to remove even its own posts. Takes the same chatId as post_to_telegram plus the messageId post_to_telegram returned. 0 credits. Needs Telegram connected (Settings > Connectors > Telegram, or connect_connector).
| Argument | 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. |
| chatId* | string | the chat the message is in — the same @username or numeric id it was posted with |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| confirm | boolean | REQUIRED true — Telegram has no trash and no undelete |
| messageId |
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.
| Argument | 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. |
| postId* | string | post id from list_threads_posts |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| confirm | boolean | REQUIRED true — deletion is permanent; only set it after the user has explicitly agreed |
Delete the Spark-Ads authorization code for a post the authorized account owns. CONFIRM-GATED, and not because it spends: ads already built on the post keep running, but no NEW ad can be built against it until a fresh code exists, and TikTok REFUSES this outright while any campaign or ad using the post is still ACTIVE — pause those first. Calling it without confirm changes nothing and returns the sentence describing exactly what would happen. THE READ-BACK INVERTS: proof of success is that TikTok no longer reports a code for the post, and a code that is still there is reported as not confirmed rather than done. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION (see tiktok_account_status).
| Argument | Type | Description |
|---|---|---|
| itemId* | string | the TikTok post id |
| confirm | boolean | REQUIRED true — call without it first to see exactly what would change |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"itemId"
],
"properties": {
"itemId": {
"type": "string",
"description": "the TikTok post id"
},
"confirm": {
"type": "boolean",
"description": "REQUIRED true — call without it first to see exactly what would change"
}
}
}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.
| Argument | Type | Description |
|---|---|---|
| name* | string | the template name (Meta matches it exactly) |
| wabaId | string | the WhatsApp Business Account id. Optional when exactly one is shared with this profile; an account that is not shared is refused by name |
| 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 |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"name"
],
"properties": {
"name": {
"type": "string",
"description": "the template name (Meta matches it exactly)"
},
"wabaId": {
"type": "string",
"description": "the WhatsApp Business Account id. Optional when exactly one is shared with this profile; an account that is not shared is refused by name"
},
"confirm": {
"type": "boolean",
"description": "REQUIRED true to actually delete — deletion is permanent and covers every language"
},
"confirmName": {
"type": "string",
"description": "the template’s exact name, echoed back — required alongside confirm"
}
}
}Delete a WordPress post. Call it WITHOUT confirm first: nothing is deleted and you get a sentence naming the post to show the user. By default it goes to WordPress's TRASH (restorable from Posts > Trash); permanent:true skips the trash and cannot be undone. Free.
| Argument | 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. |
| postId* | number | |
| confirm | boolean | true only once the user agreed to the sentence the first call returned |
| permanent | boolean | skip the trash — cannot be undone |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"postId"
],
"properties": {
"brand": {
"type": "string",
"description": "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."
},
"postId": {
"type": "number"
},
"confirm": {
"type": "boolean",
"description": "true only once the user agreed to the sentence the first call returned"
},
"permanent": {
"type": "boolean",
"description": "skip the trash — cannot be undone"
}
}
}Permanently delete one of the connected account’s posts on X. This CANNOT be undone — confirm the exact post with the user first. Costs credits (X bills per API call). Needs X connected.
| Argument | Type | Description |
|---|---|---|
| id* | string | the numeric X post id — the last part of the post URL |
| 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. |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"id"
],
"properties": {
"id": {
"type": "string",
"description": "the numeric X post id — the last part of the post URL"
},
"brand": {
"type": "string",
"description": "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."
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
}
}
}Stop a YouTube bulk reporting job. IRREVERSIBLE IN A WAY THAT IS EASY TO MISS: the job IS the history — deleting it discards every daily CSV it has accumulated, and a replacement job starts over with only a 30-day backfill, so anything older than that is gone for good. Call WITHOUT confirm first: nothing is deleted and you get the real job read back from YouTube (its report type and when it was scheduled) to show the user. Then call again with confirm:true. Needs a connected YouTube channel.
| Argument | Type | Description |
|---|---|---|
| jobId* | string | from list_youtube_report_jobs |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| confirm | boolean | true only after the user has seen the job and said yes |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"jobId"
],
"properties": {
"jobId": {
"type": "string",
"description": "from list_youtube_report_jobs"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"confirm": {
"type": "boolean",
"description": "true only after the user has seen the job and said yes"
}
}
}PERMANENTLY delete a video from the connected YouTube channel. IRREVERSIBLE — YouTube has no trash and no undelete, and the video's views, comments and every link or embed pointing at it go with it. Call it WITHOUT confirm first: nothing is deleted, and it reports the video's REAL title, privacy, view count and comment count read back from YouTube. Show the user that, get an unambiguous yes, then call again with confirm:true — plus, once the video is public or has any views or comments, confirmTitle set to its exact title. confirmTitle exists because confirming that you meant to delete SOMETHING does not prove you aimed at the right video, and a wrong id must not be confirmable blind. If the user only wants it out of public view, use update_youtube_video(privacy:"private") instead — that is reversible and this is not. Get the videoId from list_youtube_videos. 0 credits. Needs a connected YouTube channel.
| Argument | 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. |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| confirm | boolean | REQUIRED true — deletion is permanent and cannot be undone |
| videoId* | string | the YouTube video id (from list_youtube_videos) |
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. Seen but not held is A RETENTION PROBLEM. 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.
| Argument | Type | Description |
|---|---|---|
| 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. |
| channel | string | restrict to one channel: facebook, instagram, threads, x, linkedin, youtube, tiktok, pinterest, bluesky |
| converting | boolean |
Disconnect a third-party account from this workspace (Meta, Google Ads, Google Drive/Sheets/Docs, YouTube, TikTok, LinkedIn, X, Reddit Ads, Pinterest, Google Business, Microsoft Advertising/OneDrive, Slack, …). This always drops the stored credentials, so every tool for that provider stops working immediately and posts/campaigns already published are NOT affected. IT NEVER REVOKES THE GRANT AT THE PROVIDER: the authorisation stays in place, so the same account keeps working in any other brand it is connected to, until the user removes Hermoso in that provider's own settings. The unconfirmed call names where to do that (list_connectors carries revokesAtProvider:"local") — relay it rather than promising a revoke. RECONNECTING NEEDS A BROWSER (the provider's consent screen) — an agent cannot undo this. Name the provider to the user, then call with confirm:true. Use list_connectors for the exact provider ids.
| Argument | Type | Description |
|---|---|---|
| account | string | on a channel with several connected accounts (TikTok, X, YouTube, Threads, Bluesky, Telegram, Pinterest): remove ONLY this account (@handle or id from list_connector_accounts) and keep the others |
| confirm | boolean | REQUIRED true — reconnecting a sign-in account afterwards needs the user's browser; a paste-a-key account is reconnected with connect_connector |
| provider* | string | provider id exactly as list_connectors reports it, e.g. "meta", "google_ads", "youtube", "linkedin" |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"provider"
],
"properties": {
"account": {
"type": "string",
"description": "on a channel with several connected accounts (TikTok, X, YouTube, Threads, Bluesky, Telegram, Pinterest): remove ONLY this account (@handle or id from list_connector_accounts) and keep the others"
},
"confirm": {
"type": "boolean",
"description": "REQUIRED true — reconnecting a sign-in account afterwards needs the user's browser; a paste-a-key account is reconnected with connect_connector"
},
"provider": {
"type": "string",
"description": "provider id exactly as list_connectors reports it, e.g. \"meta\", \"google_ads\", \"youtube\", \"linkedin\""
}
}
}IMPORTANT: a domain can resolve to a DIFFERENT company than intended (e.g. Bala.com is an engineering firm, not the Bala fitness brand at shopbala.com). Before spending any credits on research or renders, VERIFY the returned `name` (and `summary`) match the brand the user meant; if it looks wrong, re-draft with the correct domain or a description (pass save:false until confirmed) — this tool cannot ask the user, so the caller owns that check.
| Argument | Type | Description |
|---|---|---|
| save | boolean | Default: saves only when NO brand is saved yet; pass true to REPLACE the saved profile (the drafted fields overwrite the saved ones), false to never save |
| domain | string | a website to scrape, or a social link (profile, YouTube channel or one post) to draft from the account it names |
| platform | string | socialHandle platform: instagram/tiktok/youtube/twitter/threads |
| confirmed | boolean | user confirmed it is their brand: read + save site product photos (paid) |
| description | string | a free-text brand description (no website) |
| socialHandle | string | a social handle (creators); needs platform, never guessed |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"save": {
"type": "boolean",
"description": "Default: saves only when NO brand is saved yet; pass true to REPLACE the saved profile (the drafted fields overwrite the saved ones), false to never save"
},
"domain": {
"type": "string",
"description": "a website to scrape, or a social link (profile, YouTube channel or one post) to draft from the account it names"
},
"platform": {
"type": "string",
"description": "socialHandle platform: instagram/tiktok/youtube/twitter/threads"
},
"confirmed": {
"type": "boolean",
"description": "user confirmed it is their brand: read + save site product photos (paid)"
},
"description": {
"type": "string",
"description": "a free-text brand description (no website)"
},
"socialHandle": {
"type": "string",
"description": "a social handle (creators); needs platform, never guessed"
}
}
}Copy an existing scheduled or already-published post into a NEW queued post — the way to run a creative again, reuse a post that worked as the starting point for the next one, or re-send something after it went out. It copies the caption, media, per-channel captions, title, description, tags and the target board / Page / company Page / listing, and ANY of those can be overridden in the same call. Give a new time in `at`, or useQueue:true to drop it into the brand’s next free posting slot. The copy is INDEPENDENT — editing or cancelling it never touches the original — and it is a genuinely new post rather than a re-send, so it publishes even where the original already did. To re-fire only the channels that FAILED, use retry_scheduled instead.
| Argument | Type | Description |
|---|---|---|
| at | string | when the copy goes out: local wall clock (2026-10-30T10:00), UTC or epoch ms (default: an hour from now) |
| id* | string | the post to copy, from list_scheduled |
| link | string | destination URL for the copy (Pinterest, Facebook text post or Google Business post) |
| brand | string | WHICH PROFILE this post is in — id or exact name from list_brands; needed when it is not the profile this connection is pinned to. A name that matches no profile, or two, is REFUSED. |
| title | string | Pinterest or YouTube headline for the copy, max 100 characters |
| chatId |
Change the text of a Discord message Hermoso posted (≤2000 characters) — the bot's or the announcement webhook's. Pass messageId exactly as post_to_discord returned it. Mentions never ping on an edit. Free.
| Argument | Type | Description |
|---|---|---|
| text* | string | |
| 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 | the channel, when messageId is a bare id of a BOT post |
| messageId* | string | the message id post_to_discord returned (a bot post's is "<channel>/<message>") |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"messageId",
"text"
],
"properties": {
"text": {
"type": "string"
},
"brand": {
"type": "string",
"description": "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": {
"type": "string",
"description": "the channel, when messageId is a bare id of a BOT post"
},
"messageId": {
"type": "string",
"description": "the message id post_to_discord returned (a bot post's is \"<channel>/<message>\")"
}
}
}Change the text of a Slack message the Hermoso app posted (≤4,000 characters, Slack's edit limit). messageId is what post_to_slack returned ("<channel>/<ts>"). A message posted through a pasted webhook has no id and cannot be edited. Free.
| Argument | Type | Description |
|---|---|---|
| text* | string | |
| 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. |
| messageId* | string | |
| slackWorkspace | string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"messageId",
"text"
],
"properties": {
"text": {
"type": "string"
},
"brand": {
"type": "string",
"description": "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."
},
"messageId": {
"type": "string"
},
"slackWorkspace": {
"type": "string"
}
}
}Change the TEXT or CAPTION of a message already posted to a Telegram channel, group or chat — the fix for a typo that would otherwise cost the post AND its accumulated views, since deleting and reposting starts from zero. Pass `text` for a plain post or `caption` for one carrying media; Telegram refuses the wrong one and its error names the other. It overwrites the published text, and the previous wording is not kept. Free, 0 credits. Needs Telegram connected.
| Argument | Type | Description |
|---|---|---|
| text | string | the new text, for a post with no media |
| 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. |
| chatId* | string | the channel’s @username or numeric chat id |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| buttons | array | URL buttons under the post. An edit REPLACES the buttons with what it sends, so pass them again to keep them; the reply reads back the buttons Telegram now shows. |
| caption | string | the new caption, for a post carrying a photo/video/album |
EDIT the text of one of the connected account’s own posts on X. Three things about X’s edit model change how you must use this and none is guessable: (1) X REPLACES THE WHOLE TEXT — there is no partial patch, so pass the complete new post; (2) an edit MINTS A NEW POST ID, and the old id keeps resolving and keeps showing the OLD text, so always hand the user the NEW url afterwards or they will circulate a link to the version they just corrected; (3) X’s window is ONE HOUR from the ORIGINAL post and DOES NOT RESTART when a post is edited, and each post has a limited number of edits. Pass whichever id the user has — Hermoso reads X’s edit chain and aims at the newest id, which is the only one X accepts (an edit aimed at the id the user was originally given is refused by X once the post has been edited once). A published ARTICLE can never be edited whatever the subscription, and this says so rather than trying. Editing needs X Premium on the POSTING account; Hermoso attempts it and reports X’s own refusal rather than pre-refusing on a guess about the plan. Everything knowable for free — window closed, edits used up, post ineligible — is refused before anything is billed. Costs credits. Needs X connected.
| Argument | Type | Description |
|---|---|---|
| text* | string | the FULL new text of the post. It replaces the old text entirely. Same length rule as a new post: 280 characters, or up to 25,000 on an X Premium account. |
| 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. |
| postId* | string |
LIST a group of tools that is not in this session's roster. IT IS NOT HOW YOU REACH A TOOL — call_tool runs any Hermoso tool whether or not it is listed, and that works everywhere. Use this when the session will use MANY tools from one area and you want them in your list. WORKS ON CLIENTS THAT RE-READ THE TOOL LIST (stdio, the CLI, Cursor, Claude Code); a host that fixed its roster at connect time — claude.ai and ChatGPT do — will not show the new tools until it reconnects, and this tool says so in its reply rather than reporting a success you cannot use. The connect-time route that always works is `?tools=all` on the server URL. The default roster is CORE-FIRST: the core tools plus a few that make the connection drivable. Every other tool is held out of the LIST on SIZE alone — the whole registry is several hundred thousand tokens of schema re-sent on every turn, and a roster far past the 30-50 tool mark measurably degrades tool choice. The heaviest groups are `ads`, `analytics`, `channel_admin`: paid-campaign management across every ad platform is most of the total schema weight. NOTHING held out is unfinished or unsafe, and nothing is unreachable — find_tools finds it and call_tool runs it. CALL THIS WHEN A WHOLE AREA IS IN PLAY. If the user settles into building, budgeting, targeting or reporting on ad campaigns, call enable_tools({groups:['ads']}) and the tools appear. If they ask about their own site or product analytics, a tag/tracking container, or how a search engine crawls, indexes or ranks their site, call enable_tools({groups:['analytics']}). Groups: core, research, create, channels, channel_admin, analytics, ads, files, workspace — or 'all'. Free, instant, and it never turns anything off.
| Argument | Type | Description |
|---|---|---|
| groups* | array | Groups to switch on, e.g. ['ads']. Unknown names are refused by name rather than ignored. |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"groups"
],
"properties": {
"groups": {
"type": "array",
"items": {
"type": "string"
},
"description": "Groups to switch on, e.g. ['ads']. Unknown names are refused by name rather than ignored."
}
}
}One error group in full by fingerprint (from list_errors): every field, plus the most recent redacted occurrences — status, connector, job id, workspace, and a shape-only echo of the inputs. This is what makes a bug reproducible. Read-only, 0 credits.
| Argument | Type | Description |
|---|---|---|
| fingerprint* | string | the `fp` value from list_errors |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"fingerprint"
],
"properties": {
"fingerprint": {
"type": "string",
"description": "the `fp` value from list_errors"
}
}
}Meta’s own estimate of how many marketing messages a budget would deliver from a Page and what it would cost, before anything is created. Free. Meta marks the metric “in development”, so treat it as a range.
| Argument | Type | Description |
|---|---|---|
| pageId | string | sending Page; omit to use the shared Page |
| adAccountId* | string | ad account id (act_ prefix or digits) |
| lifetimeDays | number | number of days the lifetime budget spans, for the estimate |
| dailyBudgetUsd | number | daily budget in the ad account's currency to estimate delivery for |
| lifetimeBudgetUsd | number | total budget in the ad account's currency to estimate delivery for |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"adAccountId"
],
"properties": {
"pageId": {
"type": "string",
"description": "sending Page; omit to use the shared Page"
},
"adAccountId": {
"type": "string",
"description": "ad account id (act_ prefix or digits)"
},
"lifetimeDays": {
"type": "number",
"description": "number of days the lifetime budget spans, for the estimate"
},
"dailyBudgetUsd": {
"type": "number",
"description": "daily budget in the ad account's currency to estimate delivery for"
},
"lifetimeBudgetUsd": {
"type": "number",
"description": "total budget in the ad account's currency to estimate delivery for"
}
}
}Export the posting schedule to a Notion database, one row per post: date, channels, caption, media link and status. Creates a new database (under parentPageId, or private in the workspace) or writes into an existing one (databaseId), matching its columns by name. historyDays adds posts already published. Up to 100 rows per run; the answer says if more were left. Free.
| Argument | Type | Description |
|---|---|---|
| title | string | name for a new database |
| databaseId | string | an existing database id or link |
| historyDays | number | 0-90 days of published posts to include |
| dataSourceId | string | data source id of an existing database to write rows into, matched to columns by name |
| parentPageId | string | Notion page to create the new database under; omit for a private page in the workspace |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"title": {
"type": "string",
"description": "name for a new database"
},
"databaseId": {
"type": "string",
"description": "an existing database id or link"
},
"historyDays": {
"type": "number",
"description": "0-90 days of published posts to include"
},
"dataSourceId": {
"type": "string",
"description": "data source id of an existing database to write rows into, matched to columns by name"
},
"parentPageId": {
"type": "string",
"description": "Notion page to create the new database under; omit for a private page in the workspace"
}
}
}Turn a SWIPEFILE COLLECTION into a real Google Slides deck — one slide per saved ad, carrying the creative, the brand, the ad copy, the run dates with the run length, and the platform. This is the thing a marketer actually presents to a client or a team. Returns the presentation id + URL. Creates a NEW deck every time: under the drive.file scope Hermoso can only touch files it created, so it cannot add slides to a deck the user already has. A creative whose ad-library link has expired cannot be embedded — Meta signs those URLs with a short expiry — so that slide says so in words and keeps its copy and run dates, and the reply reports how many. Needs Google Drive connected (Settings > Connectors > Google Drive — one connection covers Drive, Sheets, Docs and Slides).
| Argument | Type | Description |
|---|---|---|
| limit | number | max ads to include, 1-60 (default 30) |
| title | string | deck title (default: the collection name) |
| collection | string | the swipefile collection to export, by name or id (default: the first collection) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"limit": {
"type": "number",
"description": "max ads to include, 1-60 (default 30)"
},
"title": {
"type": "string",
"description": "deck title (default: the collection name)"
},
"collection": {
"type": "string",
"description": "the swipefile collection to export, by name or id (default: the first collection)"
}
}
}Extend how long a post the authorized account owns stays promotable as a Spark Ad — and REGENERATE its code if it was deleted. THE DAYS ARE ADDED, NOT SET: TikTok's own example is that a post with 180 days remaining, extended by 180, ends up at 360 — so passing '365' to a post that already has time left does not mean 'expires in a year'. Must be one of 7, 30, 60, 180, 365. Ad authorization has to be ON already (set_tiktok_post_ad_authorization) or TikTok refuses. The reply reads the new window back, because TikTok's own response body is empty. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION (see tiktok_account_status).
| Argument | Type | Description |
|---|---|---|
| itemId* | string | the TikTok post id |
| authorizationDays | number | 7 | 30 | 60 | 180 | 365 — ADDED to whatever is left, not set as an absolute. Default 30. |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"itemId"
],
"properties": {
"itemId": {
"type": "string",
"description": "the TikTok post id"
},
"authorizationDays": {
"type": "number",
"description": "7 | 30 | 60 | 180 | 365 — ADDED to whatever is left, not set as an absolute. Default 30."
}
}
}Resolve a generated asset reference (a /generated/… path or any URL) to a clickable absolute URL + a direct download URL.
| Argument | Type | Description |
|---|---|---|
| url* | string | the asset url or /generated/ path |
| name | string | optional filename for the download |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"url"
],
"properties": {
"url": {
"type": "string",
"description": "the asset url or /generated/ path"
},
"name": {
"type": "string",
"description": "optional filename for the download"
}
}
}SEARCH INSTAGRAM'S CREATOR MARKETPLACE: Meta's own directory of creators who partner with brands, searched AS the brand's Instagram account, so the recommendations are personalised to it. Use it when the user wants creators with first-party data (followers, audience, badges such as Partnership ads / Branded content / Strong hooks / Responsive, marketplace email) or Meta's recommendations. Search by query (keywords), by similarTo (up to 5 handles, not with query), by recommendation (most_relevant_for_me, high_ad_performance, most_ads_experience, similar_brands, similar_audience, interested_in_collaboration), or filters on the creator (countries, states with exactly one country, minFollowers/maxFollowers bands, ageBucket, gender, up to 5 interests, minEngaged/maxEngaged, language, followerGrowth, lastPostWithin, verifiedAccount, hasPortfolio, hasPublicContactEmail, featuredInPaidAds, excludeMessagedCreators) and their audience (audienceCountries, audienceStates, audienceAgeBucket, audienceGender, audienceDevices), or customAudienceId from list_instagram_marketplace_audiences. Username looks up one creator (no other filter allowed). Needs the Meta (Facebook Login) connector with the Page linked to the brand's Instagram, and Meta's instagram_creator_marketplace_discovery permission; when it cannot run it says exactly why and what fixes it, and when the reason is that Meta has not approved Hermoso's app for that permission yet (read LIVE from Meta, and named under Meta by list_connectors) it says so: that is Meta's decision about Hermoso, never a reason to tell the user to reconnect. Free.
| Argument | Type | Description |
|---|---|---|
| limit | number | 1 to 50 |
| query | string | keywords, e.g. "skincare" or "trail running" |
| cursor | string |
Search EVERY Hermoso tool — your starting list is deliberately short, and everything else in the product is here — by name, task or group. Each row gives the tool's PARAMETERS in one line, its CREDIT COST (free means free on every plan; a tool that runs a model quotes the live per-model figure) and its recent HEALTH on this server (failure rate and typical duration, or "no recent calls", which means unseen and not broken). Use it the moment the user asks for something you do not see a tool for (a campaign, an ad set, a lead form, a click-to-WhatsApp ad, a report, keywords, audiences): a tool missing from your list is NEVER proof the feature is missing. Then run the tool with call_tool. A tool that is failing or needs a connector this workspace has not made is ranked last and marked, never hidden — pass onlyHealthy:true if you want those left out. Free, read-only.
| Argument | Type | Description |
|---|---|---|
| group | string | limit to one group: core, research, create, channels, channel_admin, analytics, ads, files, workspace |
| limit | number | how many to return (default 12, max 40) |
| query | string | words from the task or the tool name, e.g. "lead form", "whatsapp", "google ads keyword", "meta insights" |
| onlyHealthy | boolean | leave out tools that are failing their recent calls or that need a connector this workspace has not made. Default false — nothing is hidden unless you ask, because a missing row reads as a missing capability. |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"group": {
"type": "string",
"description": "limit to one group: core, research, create, channels, channel_admin, analytics, ads, files, workspace"
},
"limit": {
"type": "number",
"description": "how many to return (default 12, max 40)"
},
"query": {
"type": "string",
"description": "words from the task or the tool name, e.g. \"lead form\", \"whatsapp\", \"google ads keyword\", \"meta insights\""
},
"onlyHealthy": {
"type": "boolean",
"description": "leave out tools that are failing their recent calls or that need a connector this workspace has not made. Default false — nothing is hidden unless you ask, because a missing row reads as a missing capability."
}
}
}Delete a saved Memory item by its id (from list_memory). Records a cross-device delete so it doesn’t come back. Minor + re-creatable (you can remember it again), so no confirm needed.
| Argument | Type | Description |
|---|---|---|
| id* | string | the memory item id (from list_memory) |
| brand | string | profile id/name from list_brands, this call only |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"id"
],
"properties": {
"id": {
"type": "string",
"description": "the memory item id (from list_memory)"
},
"brand": {
"type": "string",
"description": "profile id/name from list_brands, this call only"
}
}
}Make an exported sheet readable: bold the header row, FREEZE it so it stays visible while scrolling, and auto-size the columns so nothing is cut off. Worth calling right after create_sheet — a raw export with unsized columns and a header that scrolls away is the difference between a spreadsheet someone reads and one they close. Changes no cell VALUE, so it is never gated. The defaults do all three on the first tab; pass `tab` to pick another, freezeRows:0 to skip freezing, boldHeader:false or autoResize:false to skip those.
| Argument | Type | Description |
|---|---|---|
| tab | string | tab title or numeric sheetId (default: the first tab) |
| sheetUrl | string | Google Sheets link, instead of spreadsheetId |
| autoResize | boolean | auto-size columns to fit their content; default true, false skips it |
| boldHeader | boolean | bold the header row; default true, false skips it |
| freezeRows | number | how many top rows to freeze (default 1, 0 = none) |
| spreadsheetId | string | Google spreadsheet id; use this or sheetUrl |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"tab": {
"type": "string",
"description": "tab title or numeric sheetId (default: the first tab)"
},
"sheetUrl": {
"type": "string",
"description": "Google Sheets link, instead of spreadsheetId"
},
"autoResize": {
"type": "boolean",
"description": "auto-size columns to fit their content; default true, false skips it"
},
"boldHeader": {
"type": "boolean",
"description": "bold the header row; default true, false skips it"
},
"freezeRows": {
"type": "number",
"description": "how many top rows to freeze (default 1, 0 = none)"
},
"spreadsheetId": {
"type": "string",
"description": "Google spreadsheet id; use this or sheetUrl"
}
}
}What Hermoso ALREADY KNOWS for the active profile: the same saved profile (products, logos, palette, positioning) + learned memory the web Studio uses. Call it when you need to know whether a brand is on file: if hasBrand is true you can omit brand everywhere; if false, onboard with draft_brand. Not a required first step before a render: the create tools read the saved brand by themselves. 0 credits.
| Argument | Type | Description |
|---|---|---|
| brand | string | profile id/name from list_brands, this call only |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"brand": {
"type": "string",
"description": "profile id/name from list_brands, this call only"
}
}
}Read everything Google holds on one of the brand’s Google Business Profile listings — business name, address, phone numbers, website, categories, description, regular and special hours, service area, labels, store code, open state, and whether the listing can carry a Post at all. This is the listing AS THE MERCHANT LAST SET IT, which is exactly what update_business_location edits; it can differ from what Google Maps shows today, because Google and the public can suggest changes on top. Call it before offering to change anything, and to answer “what does our Google listing actually say?”. Read-only, 0 credits. Needs Google Business Profile connected (Settings > Connectors > Google Business Profile).
| Argument | Type | Description |
|---|---|---|
| locationId | string | which listing, e.g. 'locations/123' from list_business_locations — only needed when more than one is shared with this profile |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"locationId": {
"type": "string",
"description": "which listing, e.g. 'locations/123' from list_business_locations — only needed when more than one is shared with this profile"
}
}
}Read back a Discord message Hermoso posted: its text, link and REACTIONS — the only engagement Discord exposes (there is no view count). Free.
| Argument | 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 | |
| messageId* | string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"messageId"
],
"properties": {
"brand": {
"type": "string",
"description": "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": {
"type": "string"
},
"messageId": {
"type": "string"
}
}
}Fetch one Drive file’s metadata — name, type, size, modified time, a webViewLink to open it and a webContentLink to download it. Pass fileId (from list_drive_files). Read-only.
| Argument | Type | Description |
|---|---|---|
| fileId* | string | the Drive file id (from list_drive_files) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"fileId"
],
"properties": {
"fileId": {
"type": "string",
"description": "the Drive file id (from list_drive_files)"
}
}
}Poll a render job by id. Returns status (queued|running|done|error), progress, and on done the served media URL. Renders take 1–3 minutes: keep calling this until done/error without asking the user — several calls is normal, not a stall. An id that does not exist on this account answers status "not_found" — that is FINAL: stop polling it, and do not re-fire the render (that double-charges).
| Argument | Type | Description |
|---|---|---|
| id* | string | The job id: starts with job_, from a render tool's jobId or from list_jobs |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"id"
],
"properties": {
"id": {
"type": "string",
"description": "The job id: starts with job_, from a render tool's jobId or from list_jobs"
}
}
}One LinkedIn lead by id (from list_linkedin_leads), with every answer named by field. Personal data — show, never republish. Read-only, free.
| Argument | Type | Description |
|---|---|---|
| leadId* | string | lead id from list_linkedin_leads |
| pageId | string | the company Page that owns the form — from list_linkedin_pages; omit when one Page is shared |
| adAccountId | string | read forms owned by an AD ACCOUNT instead of a Page |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"leadId"
],
"properties": {
"leadId": {
"type": "string",
"description": "lead id from list_linkedin_leads"
},
"pageId": {
"type": "string",
"description": "the company Page that owns the form — from list_linkedin_pages; omit when one Page is shared"
},
"adAccountId": {
"type": "string",
"description": "read forms owned by an AD ACCOUNT instead of a Page"
}
}
}Whether a paid Messenger marketing message was delivered. send_messenger_marketing_message returns a tracking id, which only means Meta ACCEPTED it; this reads what Meta pushed back on the Page webhook for each tracking id: sent, delivered (the event Meta bills on), read, clicked (with count), or failed (with Meta’s reason). "unknown" means NO event has arrived, never that it failed: events arrive only when the Page is subscribed to the marketing_message_* fields (the reply says whether it is, read back from Meta; fix with subscribe_meta_webhooks fields ["marketing_messages"]) and only for messages sent after Hermoso began recording them on 2026-09-22. A failure carries no tracking id, so it is matched through Hermoso’s own send log. Read-only, 0 credits.
| Argument | Type | Description |
|---|---|---|
| pageId | string | the sending Page, for the subscription check; defaults to the one shared Page |
| campaignId | string | instead of ids: every send Hermoso logged from this campaign |
| trackingId | string | the marketing_message_tracking_id send_messenger_marketing_message returned |
| trackingIds | array | several at once, ≤200 |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"pageId": {
"type": "string",
"description": "the sending Page, for the subscription check; defaults to the one shared Page"
},
"campaignId": {
"type": "string",
"description": "instead of ids: every send Hermoso logged from this campaign"
},
"trackingId": {
"type": "string",
"description": "the marketing_message_tracking_id send_messenger_marketing_message returned"
},
"trackingIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "several at once, ≤200"
}
}
}What can be known about one story id, usually from a story_reply or story_mention item in list_inbox: its caption, posted time and permalink while it is live, or that it has expired. `state` is live, readable, not_a_story, expired, not_found or unknown. Meta serves a Story for 24 hours and answers an expired one with the same error as a wrong id, so pass repliedAt (the item’s `at`) and the url so an expiry can be stated and a match found. unknown means a read failed, not that the Story is gone. Read-only, 0 credits.
| Argument | Type | Description |
|---|---|---|
| url | string | the story url from the same inbox item |
| 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. |
| pageId | string | one Facebook Page |
| account | string | one Instagram account: an @handle or id |
| storyId | string | the story id from the inbox item, or a media id |
| repliedAt | string | when the reply arrived, the item’s `at`; a reply older than 24 hours means the Story has expired |
Fetch one OneDrive item’s metadata — name, type, size, modified time, a webViewLink to open it and a webContentLink to download it. Pass fileId (from list_onedrive_files). Read-only.
| Argument | Type | Description |
|---|---|---|
| fileId* | string | the OneDrive item id (from list_onedrive_files) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"fileId"
],
"properties": {
"fileId": {
"type": "string",
"description": "the OneDrive item id (from list_onedrive_files)"
}
}
}Show AUTOPILOT POSTING (autoposting, the posting refill) for this profile — "is autopilot on?", "what is waiting for review?": whether it is on, its mode (review = each batch of fresh posts waits as drafts for approval; auto = fresh posts are scheduled straight away), WHICH CHANNELS it posts to with each one’s posts per day and posting times, roughly what it costs a day, when it next runs, how many posts are queued, the DRAFTS waiting for approval (id, time, channels, caption, the new image or video), and the notes it learned from reviews. It also names the channels that CANNOT be posted to and why. On the Free plan, paidOnly is true and note explains the status. Read-only, free.
No arguments.
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {}
}Read this account's app settings — the LANGUAGE Hermoso writes ads and copy in (chat replies follow the language the user writes in), the app appearance (theme), and whether the weekly competitor-watch email is on. Same settings as the web app's Settings pane. Read-only, free.
No arguments.
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {}
}Load a bundled skill’s full SKILL.md workflow instructions by name (from list_skills), or a built-in in-app skill by its id (e.g. youtube_script, retention_skeleton). Follow the loaded instructions to run that workflow with the other tools. Read-only, free.
| Argument | Type | Description |
|---|---|---|
| name* | string | bundle name from list_skills, e.g. hermoso-generate |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"name"
],
"properties": {
"name": {
"type": "string",
"description": "bundle name from list_skills, e.g. hermoso-generate"
}
}
}Read back a Slack message the Hermoso app posted: text, link, REACTIONS and thread REPLIES — the engagement Slack exposes (there is no view count). Free.
| Argument | 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. |
| messageId* | string | |
| slackWorkspace | string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"messageId"
],
"properties": {
"brand": {
"type": "string",
"description": "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."
},
"messageId": {
"type": "string"
},
"slackWorkspace": {
"type": "string"
}
}
}Read the full detail of a SINGLE mention: a mentioning post, or a mentioning comment if you pass commentId as well. This exists for the TikTok mentions WEBHOOK: the event carries only ids, and this turns one into the caption, the creator, the engagement and the thumbnail. TIKTOK ONLY GUARANTEES THE DATA FOR 48 HOURS after the event fires, so a queue that retries tomorrow gets nothing. For anything older read it out of list_tiktok_mentions or list_tiktok_mention_comments instead. itemId is required either way, comment or not. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION with the brand-insights permission. Read-only, free.
| Argument | Type | Description |
|---|---|---|
| fields | array | defaults to every field TikTok publishes for that kind |
| itemId* | string | the post id. It is `video_id` in the webhook event content, or itemId from list_tiktok_mentions. REQUIRED even when reading a comment |
| commentId | string | pass this to read a mentioning COMMENT rather than a mentioning post |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"itemId"
],
"properties": {
"fields": {
"type": "array",
"items": {
"type": "string"
},
"description": "defaults to every field TikTok publishes for that kind"
},
"itemId": {
"type": "string",
"description": "the post id. It is `video_id` in the webhook event content, or itemId from list_tiktok_mentions. REQUIRED even when reading a comment"
},
"commentId": {
"type": "string",
"description": "pass this to read a mentioning COMMENT rather than a mentioning post"
}
}
}Read the Spark-Ads authorization status of a post the authorized account owns — whether it is promotable, its authorization CODE, and the window the authorization runs for. A SPARK AD CANNOT OUTLIVE ITS AUTHORIZATION, so check the end time before building a campaign around a post. TIKTOK ERRORS RATHER THAN ANSWERING when a post has no authorization code at all, so a not-found style refusal from here almost always means Ad authorization was never turned on (or the code was deleted) — turn it on with set_tiktok_post_ad_authorization — and not that anything is broken. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION (see tiktok_account_status). Read-only, free.
| Argument | Type | Description |
|---|---|---|
| itemId* | string | the TikTok post id |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"itemId"
],
"properties": {
"itemId": {
"type": "string",
"description": "the TikTok post id"
}
}
}Read one WordPress post back — WordPress's own title, status, live URL, excerpt and body. Use it to confirm a post really published. Free.
| Argument | 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. |
| postId* | number |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"postId"
],
"properties": {
"brand": {
"type": "string",
"description": "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."
},
"postId": {
"type": "number"
}
}
}Read the Google Business Profile ACCOUNT that owns one of the brand’s listings — the account name, its type (a personal Google account, a location group, a user group or an organization), the connected user’s role on it (primary owner / owner / manager / site manager), the account’s verification state and the permission level. Use it to answer “can we actually edit this listing?” and “whose account is it on?” before offering an edit that Google would refuse anyway. It reads exactly ONE account — the parent of a listing already shared with this profile — and never lists the other accounts the connected Google login can reach; that roster belongs to the account picker (list_connector_accounts). Read-only, 0 credits. Needs Google Business Profile connected.
| Argument | Type | Description |
|---|---|---|
| locationId | string | which listing, from list_business_locations — only needed when more than one is shared with this profile |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"locationId": {
"type": "string",
"description": "which listing, from list_business_locations — only needed when more than one is shared with this profile"
}
}
}How the brand’s Google Business Profile listing actually performed — impressions on Google Search and Maps (desktop and mobile), calls, website clicks, direction requests, messages and bookings — over the last N days. For a local business this is the real-world demand signal, and it is the number an ad campaign should be judged against. NOTE: Google discontinued PER-POST insights in February 2023 and published no replacement, so these are listing-level figures and per-post performance genuinely does not exist in any API — do not promise it. Read-only, 0 credits. Needs Google Business Profile connected.
| Argument | Type | Description |
|---|---|---|
| days | number | how many days back, default 30 |
| metrics | array | optional subset of Google’s daily metrics (BUSINESS_IMPRESSIONS_DESKTOP_MAPS, BUSINESS_IMPRESSIONS_DESKTOP_SEARCH, BUSINESS_IMPRESSIONS_MOBILE_MAPS, BUSINESS_IMPRESSIONS_MOBILE_SEARCH, BUSINESS_DIRECTION_REQUESTS, CALL_CLICKS, WEBSITE_CLICKS, MESSAGING_CLICKS, BUSINESS_BOOKINGS, BUSINESS_FOOD_MENU_CLICKS; Google-deprecated and askable only by name: BUSINESS_CONVERSATIONS, BUSINESS_FOOD_ORDERS). Omit for every current one. An unknown name is refused rather than quietly dropped, so a total is never reported under a metric you did not get. |
| locationId | string | which listing, from list_business_locations |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"days": {
"type": "number",
"description": "how many days back, default 30"
},
"metrics": {
"type": "array",
"items": {
"type": "string"
},
"description": "optional subset of Google’s daily metrics (BUSINESS_IMPRESSIONS_DESKTOP_MAPS, BUSINESS_IMPRESSIONS_DESKTOP_SEARCH, BUSINESS_IMPRESSIONS_MOBILE_MAPS, BUSINESS_IMPRESSIONS_MOBILE_SEARCH, BUSINESS_DIRECTION_REQUESTS, CALL_CLICKS, WEBSITE_CLICKS, MESSAGING_CLICKS, BUSINESS_BOOKINGS, BUSINESS_FOOD_MENU_CLICKS; Google-deprecated and askable only by name: BUSINESS_CONVERSATIONS, BUSINESS_FOOD_ORDERS). Omit for every current one. An unknown name is refused rather than quietly dropped, so a total is never reported under a metric you did not get."
},
"locationId": {
"type": "string",
"description": "which listing, from list_business_locations"
}
}
}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.
| Argument | Type | Description |
|---|---|---|
| limit | number | max terms, default 100, which is also Google’s maximum |
| months | number | how many whole months back, default 3, cap 12. The current month is excluded because it is always partial. |
| pageToken | string | nextPageToken from a previous call |
| locationId | string | which listing, when the profile has more than one (list_business_locations) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"limit": {
"type": "number",
"description": "max terms, default 100, which is also Google’s maximum"
},
"months": {
"type": "number",
"description": "how many whole months back, default 3, cap 12. The current month is excluded because it is always partial."
},
"pageToken": {
"type": "string",
"description": "nextPageToken from a previous call"
},
"locationId": {
"type": "string",
"description": "which listing, when the profile has more than one (list_business_locations)"
}
}
}Probe what this Hermoso account can do RIGHT NOW: available image/video model ids + their credit costs (estimates: a render is charged its actual cost), aspect ratios, video durations, the recipe ids, and the canEdit/canAvatar flags. Call it when you need a specific model id, a credit cost, or a capability you are not sure of. Read-only, free.
No arguments.
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {}
}Return the credit balance, recent priced calls with their credits, and costModel (the one-sentence rule of what costs credits). THE RULE: only AI model runs and Ad Spy research spend credits; publishing, scheduling, ads management, analytics, comments, DMs and connectors cost no credits on any plan (X is the single per-call exception; xCost gives its exact credits per post, with and without a link). Check before kicking off paid generation; answer "does posting cost credits?" with NO. HOW MANY posts is set by the plan, not credits: the Free plan includes 10 posts a month on up to 3 channels, and a post counts once however many channels it goes to (`posting` says how many are used); every paid plan posts without a limit and runs autopilot (Basic $9.99/mo: unlimited posting and Autopilot, no monthly credits; it includes the same 250+ free credits as the Free plan, monthly credits start on Starter, and Autopilot spends credits). Starter, Pro and Studio: media models at cost + 10% (memberRate); Basic and Free pay standard prices.
No arguments.
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {}
}Hide a reply on the brand’s Threads post (or unhide it with hide:false) — for spam and abuse moderation.
| Argument | Type | Description |
|---|---|---|
| hide | boolean | false to UNHIDE (default true) |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| replyId* | string | reply id from list_threads_replies |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"replyId"
],
"properties": {
"hide": {
"type": "boolean",
"description": "false to UNHIDE (default true)"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"replyId": {
"type": "string",
"description": "reply id from list_threads_replies"
}
}
}Hide a reply under the brand’s own X post (hidden:false to unhide) — spam and abuse moderation. A hidden reply is not deleted; it moves behind "View hidden replies". Only works on replies in a conversation the connected account started. Needs the tweet.moderate.write permission: an X connection made before 2026-10-27 must be reconnected once under Settings > Connectors > X (the tool says so without calling X). Costs credits (X bills per API call).
| Argument | Type | Description |
|---|---|---|
| hidden | boolean | default true; false unhides |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| replyId* | string | the REPLY’s id, or its x.com link |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"replyId"
],
"properties": {
"hidden": {
"type": "boolean",
"description": "default true; false unhides"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"replyId": {
"type": "string",
"description": "the REPLY’s id, or its x.com link"
}
}
}Pull the files in a Google Drive or OneDrive FOLDER into this profile's Library, so they can be used like anything rendered here — published, scheduled, cloned, used as a product photo or a reference. Hermoso downloads each file with the user's own connected account (a Drive/OneDrive file is not public) and stores a durable Hermoso url for each. Give `folderId` from list_drive_files / list_onedrive_files with onlyFolders — omit it for the root. GOOGLE DRIVE ONLY SHOWS WHAT THE USER HANDED OVER: our Drive scope is `drive.file`, so Hermoso can see the files and folders it created plus the ones the user picked with the Google picker in the app, and NEVER their whole Drive. Picking a FOLDER hands over the folder alone, not the files in it, so if a folder comes back empty the reply carries a link that opens the picker inside it, where the user selects every file at once (Cmd+A / Ctrl+A); give them that link. SUBFOLDERS ARE NOT WALKED and Google-native docs (Docs/Sheets/Slides) have no file to download: both are reported back BY NAME rather than silently dropped, along with anything too large or unreadable, so you can tell the user exactly what did and did not come across.
| Argument | Type | Description |
|---|---|---|
| limit | number | how many files to bring across in this call (default 10, max 25). Anything over the limit is listed as skipped so you know what is left. |
| folderId | string | the folder to import, from list_drive_files / list_onedrive_files (onlyFolders:true). Omit for the root of the drive. |
| provider* | string | which cloud — `drive` is Google Drive, `onedrive` is Microsoft OneDrive |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"provider"
],
"properties": {
"limit": {
"type": "number",
"description": "how many files to bring across in this call (default 10, max 25). Anything over the limit is listed as skipped so you know what is left."
},
"folderId": {
"type": "string",
"description": "the folder to import, from list_drive_files / list_onedrive_files (onlyFolders:true). Omit for the root of the drive."
},
"provider": {
"enum": [
"drive",
"onedrive"
],
"type": "string",
"description": "which cloud — `drive` is Google Drive, `onedrive` is Microsoft OneDrive"
}
}
}Did the collab invites on an Instagram post get accepted? Reports every collaborator on one of the brand’s OWN Instagram posts with the status Instagram actually holds for it — Accepted (the post is live on their profile too, and its reach now includes their followers), Pending (invited, sitting in their notifications, NOT yet on their profile) or Declined. This is the tool for “did @creator accept yet?”, and the only way to find out: Instagram sends no notification either way. The media id is what post_to_meta returned as `postId`, or any id from list_instagram_media. A post with no collaborators simply reports none. Read-only, 0 credits.
| Argument | Type | Description |
|---|---|---|
| pageId | string | Facebook Page id — omit when only one Page is connected |
| mediaId* | string | the Instagram media id — post_to_meta returns it as postId, list_instagram_media lists the account’s own posts |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"mediaId"
],
"properties": {
"pageId": {
"type": "string",
"description": "Facebook Page id — omit when only one Page is connected"
},
"mediaId": {
"type": "string",
"description": "the Instagram media id — post_to_meta returns it as postId, list_instagram_media lists the account’s own posts"
}
}
}ACCOUNT-level performance for the brand’s connected Instagram Business account — views, reach, accounts engaged, total interactions, likes, comments, shares, saves, profile link taps, replies, reposts and follows/unfollows — plus the AUDIENCE DEMOGRAPHICS (follower_demographics and engaged_audience_demographics, broken down by age, city, country or gender), which is the read that says WHO the content reached rather than how many. Use meta_post_insights for one post and meta_page_insights for the Facebook Page. THERE IS NO "impressions": Meta deprecated it for every API version on 2025-04-21 and replaced it with "views" — an unknown metric is refused by name rather than quietly dropped. Instagram returns NO demographics for an account under 100 followers (or under 100 engagements in the window), and an absent block means exactly that, never an empty audience. Read-only, 0 credits. Needs the Instagram account connected, either through Meta (an Instagram account linked to a Facebook Page) or on its own through the Instagram connector; when several are connected, name the one you mean with account.
| Argument | Type | Description |
|---|---|---|
| since | string | YYYY-MM-DD window start |
| until | string | YYYY-MM-DD window end |
| pageId | string | Facebook Page id the Instagram account is linked to — omit when only one Page is connected |
| period | string | aggregation for reach, the one time-series metric (default day) |
| 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. |
ONE CREATOR FROM INSTAGRAM'S CREATOR MARKETPLACE, IN DEPTH: Meta's first-party insights (total followers, reach, engaged accounts, reels interaction rate, reels hook rate), audience demographics (engaged accounts by gender, age, top countries, top cities), the brands they partnered with in the past year, and their recent posts, branded-content posts and partnership ads with likes, comments, views and shares. Pass username. Demographics:false or media:false skip those parts. A part Meta does not serve for this creator is listed as unavailable rather than failing the lookup. Then: approve them for partnership ads with manage_meta_partnership_creator, or save them to a swipefile collection. Needs the Meta (Facebook Login) connector and Meta's instagram_creator_marketplace_discovery permission; when it cannot run it says why. Free.
| Argument | Type | Description |
|---|---|---|
| media | boolean | default true |
| pageId | string | Facebook Page id linked to the brand's Instagram; from list_meta_pages |
| account | string | direct Instagram account (@handle or id) when several are connected; from list_connector_accounts |
| username* | string | the creator’s Instagram handle |
| demographics | boolean | default true |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"username"
],
"properties": {
"media": {
"type": "boolean",
"description": "default true"
},
"pageId": {
"type": "string",
"description": "Facebook Page id linked to the brand's Instagram; from list_meta_pages"
},
"account": {
"type": "string",
"description": "direct Instagram account (@handle or id) when several are connected; from list_connector_accounts"
},
"username": {
"type": "string",
"description": "the creator’s Instagram handle"
},
"demographics": {
"type": "boolean",
"description": "default true"
}
}
}Turn an Instagram @handle into the NUMERIC Instagram account id, straight from Meta — with the account’s real name, bio, follower count, post count, website and profile picture. Two jobs: it answers "what is @<handle>’s Instagram id?", and it sizes up any brand or creator on Instagram before you work with them (followers and post count are Meta’s own numbers, not a scraper’s). CALL IT BEFORE manage_meta_partnership_creator: that tool’s `allowTagging` list takes NUMERIC IDS ONLY, and an id that did not come from Meta is rejected there with “An unknown error has occurred.” — an error carrying no cause at all, so a scraped or guessed id looks like an outage instead of a bad argument. PROFESSIONAL ACCOUNTS ONLY: Meta reads Instagram Business and Creator accounts here and nothing else. A personal account, a private account, an age-gated account and a handle that does not exist all come back as the SAME refusal, because Meta returns one error code for all four — so never tell a user which of them it was. Read-only, 0 credits, no new permission, no reconnect. RECENT POSTS: pass posts (1–50) to also get that account's latest posts with their like, comment and view counts and links, read first-party from Meta for FREE, for a business or creator account's recent Instagram posts (competitor organic research); page with cursor.
| Argument | Type | Description |
|---|---|---|
| posts | integer | how many of the account’s recent posts to read with like / comment / view counts and links (0–50, default 0). First-party and free |
| cursor | string | next page of posts — the cursor from the previous reply |
| handle* | string | the Instagram handle — "fellowproducts", "@fellowproducts" or an instagram.com profile link all work. Meta matches it EXACTLY and does not strip a leading @, so Hermoso normalises it first. |
An Instagram Story’s numbers: reach, views, replies, shares, link clicks, profile visits, follows, and navigation (forward, back, next story, exit). Meta keeps them for 24 hours, so Hermoso reads every live Story on a schedule, including ones posted from the phone, and they stay readable here after the Story ends. Pass storyId for one, or nothing for the recent ones. `state` says what is known: numbers, below_minimum (Meta withholds them under 5 viewers), not_captured (it ended before a read), or why a read failed; none of those means zero. Meta returns 0 replies for Stories created in Europe and Japan, and the answer says so. Link clicks need a Facebook-login connection. Read-only, 0 credits.
| Argument | Type | Description |
|---|---|---|
| days | number | how far back (1-400, default 30) |
| 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 | rows, newest first (1-100, default 25) |
| pageId | string | one Facebook Page, for its linked Instagram |
| account | string | one Instagram account: an @handle or id |
| storyId | string |
What CUSTOMERS wrote in the brand's Intercom: reads recent conversations in full and returns only the customers' own words (no teammate or bot text; emails, phone numbers and links redacted; no names). Use it for real pains, objections, questions and exact phrases for ad copy, hooks, angles and research, and quote their words rather than paraphrasing. days (default 30), conversations to read (default 25, at most 60), optional text or tagId to focus it. Read-only, free. Needs Intercom connected (Settings > Connectors > Intercom, or connect_connector).
| Argument | Type | Description |
|---|---|---|
| days | number | look-back window in days for conversations |
| text | string | only read conversations containing this text |
| view | string | conversations to read: open, closed or all |
| tagId | string | only read conversations with this Intercom tag id (list_intercom_tags) |
| conversations | number | 1-60, default 25 |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"days": {
"type": "number",
"description": "look-back window in days for conversations"
},
"text": {
"type": "string",
"description": "only read conversations containing this text"
},
"view": {
"enum": [
"open",
"closed",
"all"
],
"type": "string",
"description": "conversations to read: open, closed or all"
},
"tagId": {
"type": "string",
"description": "only read conversations with this Intercom tag id (list_intercom_tags)"
},
"conversations": {
"type": "number",
"description": "1-60, default 25"
}
}
}Invite someone to this profile by email (role: member = read-only on billing, admin = full). This SENDS a real email invite / share link — an account change. Confirm the exact email + role with the user, then call with confirm:true.
| Argument | Type | Description |
|---|---|---|
| role | string | default member |
| email* | string | the invitee’s email |
| confirm | boolean | REQUIRED true — this invites a real person |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"email"
],
"properties": {
"role": {
"enum": [
"member",
"admin"
],
"type": "string",
"description": "default member"
},
"email": {
"type": "string",
"description": "the invitee’s email"
},
"confirm": {
"type": "boolean",
"description": "REQUIRED true — this invites a real person"
}
}
}Klaviyo PERFORMANCE for campaigns (default) or flows (of:'flows'): recipients, delivered, unique opens and open rate, unique clicks and click rate, conversions and conversion rate, REVENUE (conversion_value) and revenue per recipient, unsubscribes, bounces and spam complaints, per campaign (or per flow message), over timeframe (last_7_days, last_30_days default, last_90_days, this_month, last_12_months...) or start + end (at most a year). Conversions and revenue count on conversionMetric (default 'Placed Order'). Narrow with campaignIds / flowIds or channel. Rates are fractions (0.25 = 25%). Klaviyo allows only 2 report queries a minute and 225 a day, so ask once with a wide window; the same question within 10 minutes is answered from the last read unless fresh:true. Read-only, free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).
| Argument | Type | Description |
|---|---|---|
| of | string | |
| end | string | |
| fresh | boolean | |
| start | string | |
| channel | string | |
| flowIds | array | |
| timeframe | string | |
On a connector that several teammates can contribute their OWN account to (see list_connectors — the row reports multiContributor), remove YOURS from this profile: your stored credential is dropped and the accounts you shared stop being shared. Your teammates' accounts on the same connection keep working, and nothing changes at the provider — reconnecting in a browser shares again. Use this instead of disconnect_connector when the connection is not yours to remove: disconnect_connector revokes the grant at the provider and only the person who created the connection may call it.
| Argument | Type | Description |
|---|---|---|
| provider* | string | provider id exactly as list_connectors reports it, e.g. "linkedin", "tiktok_ads", "meta" |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"provider"
],
"properties": {
"provider": {
"type": "string",
"description": "provider id exactly as list_connectors reports it, e.g. \"linkedin\", \"tiktok_ads\", \"meta\""
}
}
}Like (or unlike) an Instagram post, Reel, comment or reply AS the brand’s Instagram account — Meta’s Like Media and Comments API (April 2026). The cheapest engagement a brand does: like the good comments on your own posts and the posts you are tagged in. Pass exactly one of mediaId or commentId; undo:true unlikes. Stories and private accounts cannot be liked. Needs the Meta (Facebook Login) connector — a direct Instagram login has no likes edge. Roughly 200 likes per account per hour. Free.
| Argument | Type | Description |
|---|---|---|
| undo | boolean | true to unlike |
| pageId | string | Facebook Page id when the brand's Instagram is linked to a Page; from list_meta_pages |
| account | string | direct Instagram account (@handle or id) when several are connected; from list_connector_accounts |
| mediaId | string | an IG media id (post or Reel), from list_instagram_media |
| commentId | string | an IG comment or reply id, from list_meta_comments |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"undo": {
"type": "boolean",
"description": "true to unlike"
},
"pageId": {
"type": "string",
"description": "Facebook Page id when the brand's Instagram is linked to a Page; from list_meta_pages"
},
"account": {
"type": "string",
"description": "direct Instagram account (@handle or id) when several are connected; from list_connector_accounts"
},
"mediaId": {
"type": "string",
"description": "an IG media id (post or Reel), from list_instagram_media"
},
"commentId": {
"type": "string",
"description": "an IG comment or reply id, from list_meta_comments"
}
}
}ORGANIC performance for one of the brand’s LinkedIn COMPANY PAGES: total followers, followers gained (organic vs paid) across the window, Page views (all / unique / desktop / mobile), and the impressions, unique impressions, clicks, likes, comments, shares and engagement rate of the Page’s posts. This is what answers “is our LinkedIn actually working” and “did that post land”. LinkedIn excludes sponsored activity from these figures entirely. Pass postUrns (the urn:li:share:… / urn:li:ugcPost:… that post_to_linkedin_page returned) for PER-POST numbers; LinkedIn forbids a date range together with named posts, so that switches to lifetime-per-post. Only Pages the user ticked in Manage accounts (or call list_connector_accounts with provider linkedin, then set_connector_accounts) are readable — a Page the account merely administers is refused, by design. LinkedIn keeps 12 months, follower figures run about 2 days behind, and it OMITS posts with no recorded activity rather than returning zeros: report an absent post or an unavailable section as MISSING data, never as zero. Read-only, 0 credits. Needs LinkedIn connected with the organization scopes.
| Argument | Type | Description |
|---|---|---|
| endDate | string | YYYY-MM-DD, default today |
| postUrns | array | urn:li:share:… / urn:li:ugcPost:… — switches to per-post lifetime numbers instead of the Page total |
| startDate | string | YYYY-MM-DD, default 28 days ago (LinkedIn keeps 12 months) |
| demographics | boolean | also return WHO follows the Page and who visits it (all-time, LinkedIn’s lifetime statistics): the top seniorities, industries, job functions, countries, regions and company sizes, each named — the core B2B "who is our audience" answer |
Follower count of the PERSONAL LinkedIn profile shared with this brand — the lifetime total and, with startDate / endDate, the new followers per day (LinkedIn’s memberFollowersCount). This is the creator / founder growth number; Company Page followers are in linkedin_page_analytics. It needs a personal profile ticked in Manage accounts (list_connector_accounts provider linkedin, then set_connector_accounts with its id to tick it) and the LinkedIn analytics authorization on the LinkedIn card — if the reply says re-authorize, say exactly that. Read-only, free.
| Argument | Type | Description |
|---|---|---|
| endDate | string | YYYY-MM-DD, exclusive, default today |
| startDate | string | YYYY-MM-DD — include for a daily series |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"endDate": {
"type": "string",
"description": "YYYY-MM-DD, exclusive, default today"
},
"startDate": {
"type": "string",
"description": "YYYY-MM-DD — include for a daily series"
}
}
}Where this brand's ALERTS go and whether they are arriving: for each alert channel (Slack, Discord, a generic webhook) whether it is connected, which event groups it receives (renders, posts, approvals, watch, credits, leads — default all), its health (delivering / NOT DELIVERING / stopped because the channel was deleted or revoked — reconnect by pasting a new URL in the app), whether the webhook is signed, and the last deliveries with their HTTP status and any retry queued. Read-only, free.
No arguments.
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {}
}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.
| Argument | Type | Description |
|---|---|---|
| kind | string | direct or group conversations only |
| limit | number | how many conversations, 1–100 (default 25) |
| cursor | string | walk further back — pass the cursor from a previous call |
| status | string | "request" = from someone this account does not follow |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| readState | string | only conversations with unread messages |
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.
| Argument | 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 |
List every profile on this account (id + name; a profile is a brand, a creator or a personal workspace) and which one this connection acts on, PLUS any profile another account shared with you. Call this, then use_brand to switch; a SHARED profile switches the same way (pass its name or the profile id printed here). If a profile looks empty when the app shows it full, you are acting on a different one: call this first. Read-only, free.
No arguments.
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {}
}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.
| Argument | Type | Description |
|---|---|---|
| showAll | boolean | true = send Google's showAll flag for the full attribute list |
| pageSize | number | attributes per page; default 200 |
| pageToken | string | next page token from the previous reply |
| regionCode | string | ISO 3166-1 alpha-2 country code |
| categoryName | string | categories/gcid:… — from list_business_categories |
| languageCode | string | BCP 47 language code; default en |
| locationName | string |
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.
| Argument | Type | Description |
|---|---|---|
| view | string | FULL adds service types and valid attributes |
| filter | string | search the display names, e.g. “coffee” |
| pageSize | number | categories per page |
| pageToken | string | next page token from the previous reply |
| regionCode* | string | ISO 3166-1 alpha-2 country code — US, GB, CA, AU |
| languageCode | string | BCP 47, default en |
List the Google Business Profile listings SHARED WITH THIS PROFILE — id, title, address, website and Maps link. These are the only listings anything here can post to or read: one Google login often manages several businesses (an agency manages its clients’), and the user ticks which of them belong to this profile. Call this before posting whenever more than one is shared and let the USER pick: a Post on the wrong storefront is a public mistake Hermoso will not make for them. If nothing is shared, ask the user to choose — list_connector_accounts("google_business") then set_connector_accounts — and never name or guess a listing. Read-only, 0 credits. Needs Google Business Profile connected (Settings > Connectors > Google Business Profile).
No arguments.
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {}
}Show every identity a connected account can act as, and which ones this PROFILE is currently allowed to use — Facebook Pages + Instagram + Meta ad accounts, Google Ads customers, LinkedIn company Pages (and the personal profile), Pinterest ad accounts, Microsoft Advertising accounts. One person often administers several; only the ticked ones can be posted to or spent from. Call this before set_connector_accounts, and let the USER pick — never guess. Providers: tiktok, x, youtube, threads, bluesky, telegram, pinterest, instagram, meta, google_ads, linkedin, pinterest_ads, linkedin_ads, reddit_ads, apple_ads, microsoft_ads, google_business, google_analytics, snapchat_ads, x_ads, tiktok_ads, google_tag_manager, google_search_console, bing_webmaster. Read-only, free.
| Argument | Type | Description |
|---|---|---|
| provider* | string | which connector’s accounts to list |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"provider"
],
"properties": {
"provider": {
"enum": [
"tiktok",
"x",
"youtube",
"threads",
"bluesky",
"telegram",
"pinterest",
"instagram",
"meta",
"google_ads",
"linkedin",
"pinterest_ads",
"linkedin_ads",
"reddit_ads",
"apple_ads",
"microsoft_ads",
"google_business",
"google_analytics",
"snapchat_ads",
"x_ads",
"tiktok_ads",
"google_tag_manager",
"google_search_console",
"bing_webmaster"
],
"type": "string",
"description": "which connector’s accounts to list"
}
}
}List the third-party accounts connected to this workspace (Meta, Google Ads, Google Drive/Sheets/Docs, YouTube, LinkedIn, OneDrive, Slack, …) — provider, status and the connected account label — PLUS which providers are available to connect. IT ALSO FLAGS A CONNECTION WHOSE PERMISSIONS ARE OUT OF DATE: a provider writes its granted permission set into the token at consent time, so a connection authorized before a permission was approved does not carry it and never will — those calls are refused by the provider and no retry or wait can change it. Check this FIRST when a connected provider starts refusing things. The fix is a browser: the user reconnects under Workspace > Connectors. Read-only, free.
No arguments.
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {}
}List this workspace’s SAVED CREATORS — the reusable on-camera cast (AI creators made here, a person pulled from a social profile, a consented photo upload). Read-only, FREE. Each entry gives the name, the PORTRAIT URL, where the portrait came from and whether a real person’s likeness consent is on file, how many extra pose plates exist, and any chosen or cloned voice. That casts them for the whole spot (and skips the character-portrait render, so it costs less than not casting anyone). THE PORTRAIT URL IS THE REUSE HANDLE for the raw lanes. CALL THIS BEFORE OFFERING TO GENERATE A NEW PERSON: re-casting somebody the workspace already has keeps the SAME face across every ad, while a fresh person costs credits and breaks that continuity. An empty answer means the workspace genuinely has no cast yet.
| Argument | Type | Description |
|---|---|---|
| limit | number | max creators to return (default 24) |
| gender | string | filter the PRESET creators by gender (the saved cast is never filtered) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"limit": {
"type": "number",
"description": "max creators to return (default 24)"
},
"gender": {
"enum": [
"female",
"male"
],
"type": "string",
"description": "filter the PRESET creators by gender (the saved cast is never filtered)"
}
}
}The Discord servers this brand added the Hermoso app to, and the channels in one of them (text, announcement and forum channels), with the default channel marked. Use it before posting when the user has not named a channel, and to show them the choice. Free.
| Argument | 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. |
| query | string | filter channel names |
| discordServer | string | which server (id or name) when there are several |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"brand": {
"type": "string",
"description": "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."
},
"query": {
"type": "string",
"description": "filter channel names"
},
"discordServer": {
"type": "string",
"description": "which server (id or name) when there are several"
}
}
}The brand's DM automations on Instagram, Facebook (Page comments, ads, Messenger) and X, each with its stats, the recent send log and the connected accounts per channel. Pass `channel` to narrow, `id` for one rule's log. Free.
| Argument | Type | Description |
|---|---|---|
| id | string | one rule's id; shows only that rule's send log |
| limit | number | log rows, newest first (default 30) |
| channel | string | instagram, facebook or x; omit for all channels |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"id": {
"type": "string",
"description": "one rule's id; shows only that rule's send log"
},
"limit": {
"type": "number",
"description": "log rows, newest first (default 30)"
},
"channel": {
"enum": [
"instagram",
"facebook",
"x"
],
"type": "string",
"description": "instagram, facebook or x; omit for all channels"
}
}
}List the Google Drive files & folders Hermoso can reach: the ones it created plus any the user picked with the Google file picker in the app (drive.file, never their whole Drive). This is how you find a picked file's id. Filter by query (name contains …), folderId, or onlyFolders:true; paginate with pageToken. Read-only. A FOLDER: picking a folder hands over the folder alone, not its files, so a folder can list empty or partly; the reply then carries `pickUrl`, an app link that opens the picker inside that folder (Cmd+A / Ctrl+A selects every file and subfolder). Give the user that exact link, then list again. recursive:true walks every subfolder Hermoso can see (each row has its `path`) and reports picked files whose folder it cannot see.
| Argument | Type | Description |
|---|---|---|
| query | string | only files whose name contains this |
| folderId | string | list the contents of this folder id |
| pageSize | number | rows per page (1–200, default 50) |
| pageToken | string | cursor from a previous call |
| recursive | boolean | with folderId: the whole tree |
| onlyFolders | boolean | list folders only |
| includeTrashed | boolean |
The errors actually recorded against this workspace, GROUPED by fingerprint — the same failure at the same call site is one row with a hit count and first/last seen, sorted defects-first. Each row says whose side it is: `ours` (a defect worth fixing), `user` (a refusal we deliberately authored, e.g. not-connected or out-of-credits), or `unknown` (a vendor 4xx we cannot attribute — never guessed). Free text, tokens, emails and creative are redacted before anything is stored, so an input echo shows shapes and lengths, not content. Filter by surface (http/mcp/agent/job/client) or kind. Read-only, 0 credits. Scoped to your own workspace; an operator whose client carries the admin key sees every account.
| Argument | Type | Description |
|---|---|---|
| kind | string | 'ours' = a defect; 'user' = a refusal we authored; 'unknown' = we could not tell |
| limit | number | how many groups to return (default 50, max 200) |
| since | string | ISO timestamp — only groups last seen at or after this |
| surface | string | where it happened: http (an API route), mcp (an agent tool), agent (the in-app Studio agent), job (an async render/publish), client (a browser crash) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"kind": {
"enum": [
"ours",
"user",
"unknown"
],
"type": "string",
"description": "'ours' = a defect; 'user' = a refusal we authored; 'unknown' = we could not tell"
},
"limit": {
"type": "number",
"description": "how many groups to return (default 50, max 200)"
},
"since": {
"type": "string",
"description": "ISO timestamp — only groups last seen at or after this"
},
"surface": {
"enum": [
"http",
"mcp",
"agent",
"job",
"client"
],
"type": "string",
"description": "where it happened: http (an API route), mcp (an agent tool), agent (the in-app Studio agent), job (an async render/publish), client (a browser crash)"
}
}
}List the Posts currently on the brand’s Google Business Profile listing — text, topic type, state (LIVE / PROCESSING / REJECTED / SCHEDULED / RECURRING), button and timestamps. Use it to see what is already showing before writing another, or to get the id of one to remove. Read-only, 0 credits. Needs Google Business Profile connected.
| Argument | 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 to return, max 100 (default 20) |
| locationId | string | which listing, from list_business_locations — only needed when there is more than one |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"brand": {
"type": "string",
"description": "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": {
"type": "number",
"description": "how many to return, max 100 (default 20)"
},
"locationId": {
"type": "string",
"description": "which listing, from list_business_locations — only needed when there is more than one"
}
}
}The questions the public has asked on the brand’s Google Business Profile listing, with the answers so far and how many people upvoted each question. Unanswered questions sit publicly on the listing and are read as "this business does not respond" — the reply names the ones with no answer at all. Read-only, 0 credits. Needs Google Business Profile connected and the project approved.
| Argument | Type | Description |
|---|---|---|
| limit | number | 1–20, default 10 |
| pageToken | string | next page token from the previous reply |
| locationId | string | listing id from list_business_locations; omit when only one is shared |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"limit": {
"type": "number",
"description": "1–20, default 10"
},
"pageToken": {
"type": "string",
"description": "next page token from the previous reply"
},
"locationId": {
"type": "string",
"description": "listing id from list_business_locations; omit when only one is shared"
}
}
}The reviews customers have left on the brand’s Google Business Profile listing — star rating, reviewer, the text, when it landed, and whether the business has replied. For a local business this is the highest-leverage surface there is: an unanswered review sits on the listing next to the ad you paid for. The reply says which ones have NO answer yet, so you can work the list rather than read it. Google reports the listing’s own average rating and total review count alongside the page — use those for "how are we doing", never a mean you computed over one page. An empty page is an empty PAGE, not proof the listing has no reviews. Read-only, 0 credits. Needs Google Business Profile connected AND the project approved for Google’s Business Profile APIs (a pending access request, not a setting — the error says so).
| Argument | Type | Description |
|---|---|---|
| limit | number | 1–50, default 20 |
| orderBy | string | default newest first |
| pageToken | string | next page token from the previous reply |
| locationId | string | which listing — omit when only one is shared with this profile |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"limit": {
"type": "number",
"description": "1–50, default 20"
},
"orderBy": {
"enum": [
"updateTime desc",
"updateTime",
"rating",
"rating desc"
],
"type": "string",
"description": "default newest first"
},
"pageToken": {
"type": "string",
"description": "next page token from the previous reply"
},
"locationId": {
"type": "string",
"description": "which listing — omit when only one is shared with this profile"
}
}
}Use this when the user asks for their unanswered comments, messages or reviews. EVERYTHING PEOPLE SAID TO THIS BRAND, across every connected channel, in one list: Facebook and Instagram comments, Threads replies and mentions, YouTube comments, Google Business reviews, Bluesky replies and mentions, and X mentions — plus DIRECT MESSAGES on Meta (Messenger and Instagram Direct), Instagram, Bluesky, X and Telegram. Support tickets too: the open conversations in the brand's Intercom inbox. Use this for 'what do I need to reply to', 'any new comments', 'any new DMs', 'how are people responding'. Each item carries a composite id you hand straight to reply_to_inbox_item. A channel that is not connected is skipped silently; a channel that FAILS to read is named in `notes` rather than dropped, so a short list is never mistaken for a quiet week. Each DM conversation folds to ONE item (the newest thing the other person said). Messenger and Instagram DMs need no argument; each has a messageKind (text, story_mention, story_reply, share, image, video...), a spoken summary, and unavailable:true when Meta withholds the content. Bluesky and X DMs need conversationId (list_bluesky_convos / list_x_dms). FREE ON EVERY CHANNEL EXCEPT X, which bills per API call — X mentions and X DMs cost credits per item returned, and every other source only re-reads what the per-channel tools already read.
| Argument | Type | Description |
|---|---|---|
| limit | number | Max items per source (default 25). |
| pageId | string | Facebook Page id (list_meta_pages) when the brand has several Pages connected: reads that Page's Messenger and Instagram DMs and comments only. Omit to read every connected Page. |
| postId | string | Restrict Facebook/Instagram to one post or media id (Meta reads comments per post). |
Posts where a creator tagged this Instagram account as a COLLABORATOR and is waiting for an answer. The post appears on the brand’s profile only once the brand accepts, and Instagram sends no notification here, so this list is the only way to see them. Answer each with respond_instagram_collab_invite. Read-only, free. Instagram allows 300 reads per account per day.
| Argument | Type | Description |
|---|---|---|
| after | string | cursor from a previous page |
| limit | number | 1–100, default 25 |
| pageId | string | Facebook Page id — omit when only one Page is connected |
| account | string | which Instagram account — @handle or id; omit for the one Page-linked account |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"after": {
"type": "string",
"description": "cursor from a previous page"
},
"limit": {
"type": "number",
"description": "1–100, default 25"
},
"pageId": {
"type": "string",
"description": "Facebook Page id — omit when only one Page is connected"
},
"account": {
"type": "string",
"description": "which Instagram account — @handle or id; omit for the one Page-linked account"
}
}
}Every collaborative post this Instagram account is a co-author on — who posted it, and the COMBINED engagement across all co-authors (total likes and comments, plus saves, shares and reposts where Instagram provides them). Read-only, free.
| Argument | Type | Description |
|---|---|---|
| after | string | cursor from a previous page |
| 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–100, default 25 |
| pageId | string | Facebook Page id — omit when only one Page is connected |
| account | string | which Instagram account — @handle or id; omit for the one Page-linked account |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"after": {
"type": "string",
"description": "cursor from a previous page"
},
"brand": {
"type": "string",
"description": "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": {
"type": "number",
"description": "1–100, default 25"
},
"pageId": {
"type": "string",
"description": "Facebook Page id — omit when only one Page is connected"
},
"account": {
"type": "string",
"description": "which Instagram account — @handle or id; omit for the one Page-linked account"
}
}
}The brand's Instagram DM automations (comment keyword -> DM, DM keyword, story reply, ig.me ref), each with its stats, plus the recent send log and the connected Instagram accounts. Pass `id` for one rule's log. Free.
| Argument | Type | Description |
|---|---|---|
| id | string | one rule's id; shows only that rule's send log |
| limit | number | log rows, newest first (default 30) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"id": {
"type": "string",
"description": "one rule's id; shows only that rule's send log"
},
"limit": {
"type": "number",
"description": "log rows, newest first (default 30)"
}
}
}The brand's custom audiences that Instagram's creator marketplace accepts as a creator filter (Meta's creator_marketplace_brand_info). Pass an id from here as customAudienceId to find_instagram_marketplace_creators to find creators whose followers overlap that audience. An agency passes businessId (its Business portfolio id) to see audiences from client ad accounts. Needs the Meta connector, ads_management and Meta's instagram_creator_marketplace_discovery permission. Free.
| Argument | Type | Description |
|---|---|---|
| pageId | string | Facebook Page id linked to the brand's Instagram; from list_meta_pages |
| account | string | direct Instagram account (@handle or id) when several are connected; from list_connector_accounts |
| businessId | string | agency Business portfolio id |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"pageId": {
"type": "string",
"description": "Facebook Page id linked to the brand's Instagram; from list_meta_pages"
},
"account": {
"type": "string",
"description": "direct Instagram account (@handle or id) when several are connected; from list_connector_accounts"
},
"businessId": {
"type": "string",
"description": "agency Business portfolio id"
}
}
}The connected Instagram Business account’s own recent media — id, caption, media type (feed / reel / story-era), permalink, timestamp, like and comment counts. This is where the media id every other Instagram tool needs comes from: resolve “my latest reel” yourself instead of asking the user for a link, then pass the id to meta_post_insights. Read-only, 0 credits.
| Argument | 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 (1–50, default 15) |
| pageId | string | Facebook Page id — omit when only one Page is connected |
| 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. |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"brand": {
"type": "string",
"description": "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": {
"type": "number",
"description": "how many (1–50, default 15)"
},
"pageId": {
"type": "string",
"description": "Facebook Page id — omit when only one Page is connected"
},
"account": {
"type": "string",
"description": "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."
}
}
}Whether this Instagram account can tag products at all, and which catalogs its SHOP can tag from. CALL THIS FIRST: product tagging needs an APPROVED INSTAGRAM SHOP, and if the account does not have one, tagging fails AFTER the photo is already uploaded. The reply says which of three things is true — eligible, not eligible (a Commerce Manager approval nothing in Hermoso can grant, and not a sign anything is broken), or "could not tell", which is NOT the same as not eligible. AN EMPTY CATALOG LIST IS NOT AN EMPTY CATALOG: Instagram reaches a catalog through the account's SHOP, while list_meta_catalogs reads the business PORTFOLIO — a merchant can have a full catalog there and nothing available here until the shop is approved. Read-only, 0 credits. Whether Meta has approved Hermoso's app for the permission this needs is read LIVE from Meta: when it has not, list_connectors says so under Meta and this tool's refusal says Meta hasn't approved Hermoso for it yet. That is Meta's decision about Hermoso, not the user's connection, so never tell them to reconnect for it.
| Argument | Type | Description |
|---|---|---|
| pageId | string | Facebook Page id — omit when only one Page is connected. Its linked Instagram account is the one that gets tagged. |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"pageId": {
"type": "string",
"description": "Facebook Page id — omit when only one Page is connected. Its linked Instagram account is the one that gets tagged."
}
}
}Support conversations from the brand's OWN Intercom inbox, newest activity first: subject, state, whether the customer is waiting, assignee, customer email, channel, tags and the first message. One Intercom workspace can serve several products, so every filter below combines with the others. view: open (default), unassigned, waiting (customer waiting on a reply), snoozed, closed, recent or all. days bounds it by last update (default 30), or use updatedAfter / updatedBefore / createdAfter / createdBefore (dates). text matches words in the first message; email is the customer's exact email, customer is part of their name or email; contactId; assigneeId (a teammate) or teamId (ids from list_intercom_teammates); tagId or tagName; channel (conversation is the Messenger, or email, whatsapp, sms and so on); priority true or false; unread true. app is the product the customer wrote from (their contact's current_app, case-insensitive, for example Hermoso); contactAttributes and attributes match other custom attributes of the customer or of the conversation. Intercom's search cannot filter on custom attributes or app, so those are matched on the pages read and the answer says how many it read. Pass cursor from the last answer for the next page. Read-only, free. Needs Intercom connected (Settings > Connectors > Intercom, or connect_connector).
| Argument | Type | Description |
|---|---|---|
| app | string | the product the customer wrote from: their contact's current_app attribute, for example Hermoso |
| days | number | 1-365, default 30: conversations updated in the last N days |
| text | string | words that appear in the first message |
| view | string |
The tags in the brand's Intercom workspace (id and name), for tag_intercom_conversation or to filter list_intercom_conversations. Read-only, free. Needs Intercom connected (Settings > Connectors > Intercom, or connect_connector).
No arguments.
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {}
}The brand's Intercom teammates and teams with their ids, for assigning a conversation or filtering the list by assignee. Read-only, free. Needs Intercom connected (Settings > Connectors > Intercom, or connect_connector).
No arguments.
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {}
}List the most recent render jobs + how many are currently running, so you can report on or resume in-flight work.
No arguments.
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {}
}The brand's Klaviyo CAMPAIGNS, newest first: name, status (draft, scheduled, sending, sent or cancelled, read off its messages), send time, and the list / segment ids it targets. Filter by name (contains), status or archived; or pass campaignId to read ONE in full with its subject line, preview text, sender and template. For opens, clicks and revenue use klaviyo_report. Read-only, free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).
| Argument | Type | Description |
|---|---|---|
| name | string | part of a campaign name |
| limit | number | |
| cursor | string | nextCursor from a previous call |
| status | string | |
| archived | boolean | |
| campaignId | string | read one campaign in full |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"name": {
"type": "string",
"description": "part of a campaign name"
},
"limit": {
"type": "number"
},
"cursor": {
"type": "string",
"description": "nextCursor from a previous call"
},
"status": {
"enum": [
"draft",
"scheduled",
"sending",
"sent",
"cancelled"
],
"type": "string"
},
"archived": {
"type": "boolean"
},
"campaignId": {
"type": "string",
"description": "read one campaign in full"
}
}
}The brand's Klaviyo FLOWS (automations such as a welcome series, abandoned cart, post-purchase): name, status (live, manual, draft), trigger type, archived, created and updated, newest first. Filter by status or name. For a flow's opens, clicks and revenue use klaviyo_report with of:'flows'. Read-only, free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).
| Argument | Type | Description |
|---|---|---|
| name | string | part of a flow name |
| limit | number | |
| cursor | string | nextCursor from a previous call |
| status | string | live, manual or draft |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"name": {
"type": "string",
"description": "part of a flow name"
},
"limit": {
"type": "number"
},
"cursor": {
"type": "string",
"description": "nextCursor from a previous call"
},
"status": {
"type": "string",
"description": "live, manual or draft"
}
}
}The pictures in the brand's Klaviyo IMAGE LIBRARY: id, name, hosted URL, format and size, newest first. Read-only, free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).
| Argument | Type | Description |
|---|---|---|
| name | string | part of an image name |
| limit | number | |
| cursor | string | nextCursor from a previous call |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"name": {
"type": "string",
"description": "part of an image name"
},
"limit": {
"type": "number"
},
"cursor": {
"type": "string",
"description": "nextCursor from a previous call"
}
}
}The brand's Klaviyo LISTS with how many profiles each holds (profile count), single or double opt-in, and when each was created and updated, newest first. Klaviyo pages lists 10 at a time: pass nextCursor for more, or name for one exact list. A list id is a campaign audience (create_klaviyo_campaign listIds) or a lead-sync target. Read-only, free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).
| Argument | Type | Description |
|---|---|---|
| name | string | an exact list name |
| limit | number | |
| cursor | string | nextCursor from a previous call |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"name": {
"type": "string",
"description": "an exact list name"
},
"limit": {
"type": "number"
},
"cursor": {
"type": "string",
"description": "nextCursor from a previous call"
}
}
}The brand's Klaviyo SEGMENTS with how many profiles each holds, whether it is active and still processing, newest first, 10 per page (nextCursor for more). A segment id is a campaign audience (create_klaviyo_campaign segmentIds). Read-only, free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).
| Argument | Type | Description |
|---|---|---|
| name | string | an exact segment name |
| limit | number | |
| cursor | string | nextCursor from a previous call |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"name": {
"type": "string",
"description": "an exact segment name"
},
"limit": {
"type": "number"
},
"cursor": {
"type": "string",
"description": "nextCursor from a previous call"
}
}
}The brand's Klaviyo EMAIL TEMPLATES (name, id, editor type, created, updated), newest first, 10 per page. A template id is what create_klaviyo_campaign sends. Read-only, free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).
| Argument | Type | Description |
|---|---|---|
| name | string | part of a template name |
| limit | number | |
| cursor | string | nextCursor from a previous call |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"name": {
"type": "string",
"description": "part of a template name"
},
"limit": {
"type": "number"
},
"cursor": {
"type": "string",
"description": "nextCursor from a previous call"
}
}
}Browse this workspace's Library — every image/video generated in the Studio, and uploads (source:'upload'), newest first (the same Library the web app shows). Returns served URLs you can open directly or hand to fetch_asset for a download link, plus each asset's id, kind, model, and age. Free, read-only.
| Argument | Type | Description |
|---|---|---|
| kind | string | filter by asset kind (default 'all') |
| limit | number | max assets to return (default 20, max 60) |
| uploads | boolean |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"kind": {
"enum": [
"image",
"video",
"document",
"all"
],
"type": "string",
"description": "filter by asset kind (default 'all')"
},
"limit": {
"type": "number",
"description": "max assets to return (default 20, max 60)"
},
"uploads": {
"type": "boolean"
}
}
}Read the comments people have left on one of your LinkedIn company Page's posts. Community Management surface, and it needs its OWN connection: LinkedIn grants the comment scopes on a different app from the one that posts, so connecting LinkedIn does NOT connect this — the card is “LinkedIn Community Management” in Settings > Connectors. Pass the post's urn, which is what post_to_linkedin_page returns. A post nobody has commented on comes back as an EMPTY LIST with a note, never an error: LinkedIn answers 404 for a post with no social activity, and that means “no comments yet”, not “no such post”. Each comment carries its text, author, time, like and reply counts, and `isOurs` so your Page's own replies are distinguishable from other people's comments; `fromOthers` counts the ones actually awaiting an answer. The connected member must hold an ADMINISTRATOR role on the Page.
| Argument | Type | Description |
|---|---|---|
| limit | number | how many to fetch (default 50; clamped below LinkedIn’s own 200 timeout bound rather than refused) |
| postUrn* | string | the post to read — urn:li:share:…, urn:li:ugcPost:… or urn:li:activity:… |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"postUrn"
],
"properties": {
"limit": {
"type": "number",
"description": "how many to fetch (default 50; clamped below LinkedIn’s own 200 timeout bound rather than refused)"
},
"postUrn": {
"type": "string",
"description": "the post to read — urn:li:share:…, urn:li:ugcPost:… or urn:li:activity:…"
}
}
}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.
| Argument | Type | Description |
|---|---|---|
| count | number | max 50, default 10 |
| start | number | pagination offset; default 0 |
| organizationId | string | the Page to read — omit if only one Page is shared with this profile; list_linkedin_pages shows them |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"count": {
"type": "number",
"description": "max 50, default 10"
},
"start": {
"type": "number",
"description": "pagination offset; default 0"
},
"organizationId": {
"type": "string",
"description": "the Page to read — omit if only one Page is shared with this profile; list_linkedin_pages shows them"
}
}
}The lead events LinkedIn has PUSHED to Hermoso for this brand (new lead / deleted lead, with the form and the lead id), newest first. Empty means none have arrived, not that none exist — list_linkedin_leads reads every lead regardless, and subscribe_linkedin_leads is what starts delivery. Read a lead’s answers with get_linkedin_lead. Read-only, free.
| Argument | Type | Description |
|---|---|---|
| limit | number | events to return, newest first; default 50, max 200 |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"limit": {
"type": "number",
"description": "events to return, newest first; default 50, max 200"
}
}
}The LEAD GEN FORMS a LinkedIn company Page or ad account owns — id, name, state, version and the fields each one asks for (firstName, email, company …). If it answers that the connection must be reconnected, say exactly that: the lead-sync permission is granted at authorise time. Read-only, free.
| Argument | Type | Description |
|---|---|---|
| pageId | string | the company Page that owns the form — from list_linkedin_pages; omit when one Page is shared |
| adAccountId | string | read forms owned by an AD ACCOUNT instead of a Page |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"pageId": {
"type": "string",
"description": "the company Page that owns the form — from list_linkedin_pages; omit when one Page is shared"
},
"adAccountId": {
"type": "string",
"description": "read forms owned by an AD ACCOUNT instead of a Page"
}
}
}The lead notification webhooks registered on a LinkedIn Page or ad account, with the id delete_linkedin_lead_subscription takes. Read-only, free.
| Argument | Type | Description |
|---|---|---|
| pageId | string | the company Page that owns the form — from list_linkedin_pages; omit when one Page is shared |
| leadType | string | SPONSORED for an ad account; COMPANY, EVENT or ORGANIZATION_PRODUCT for a Page; defaults by owner |
| adAccountId | string | read forms owned by an AD ACCOUNT instead of a Page |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"pageId": {
"type": "string",
"description": "the company Page that owns the form — from list_linkedin_pages; omit when one Page is shared"
},
"leadType": {
"type": "string",
"description": "SPONSORED for an ad account; COMPANY, EVENT or ORGANIZATION_PRODUCT for a Page; defaults by owner"
},
"adAccountId": {
"type": "string",
"description": "read forms owned by an AD ACCOUNT instead of a Page"
}
}
}The LEADS a LinkedIn lead gen form collected — every response with its answers keyed by field (firstName, lastName, email, company, …), the campaign and creative that produced it, the consents ticked, and whether it was a test lead. Newest first. Filter by formId, a since/until window (ISO date or epoch ms — LinkedIn takes epoch), or testLeadsOnly. THIS IS PERSONAL DATA: show it to the user, hand it to the CRM they name, never repeat it into a post or an unrelated tool. Pass start for the next page. Read-only, free.
| Argument | Type | Description |
|---|---|---|
| limit | number | per page, max 100 |
| since | string | ISO date or epoch milliseconds |
| start | number | offset for the next page |
| until | string | end of the time window, ISO date or epoch milliseconds; omit for now |
| formId | string | only this form (from list_linkedin_lead_forms) |
| pageId | string | the company Page that owns the form — from list_linkedin_pages; omit when one Page is shared |
| leadType | string | defaults by owner: SPONSORED for an ad account, COMPANY (organic Page form) for a Page; EVENT for event forms. LinkedIn refuses SPONSORED on a Page owner |
List the LinkedIn COMPANY PAGES the connected account administers — id, name and the role held on each. ALWAYS call this before post_to_linkedin_page when there is more than one Page: publishing to the wrong company Page is a public mistake and Hermoso never chooses for the user. If it comes back empty, the account holds no Page admin role, or LinkedIn has not granted this app the organization scopes — say that plainly rather than guessing an id. Read-only, free.
No arguments.
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {}
}The brand's Mailchimp AUDIENCES (lists): subscribed member count, total contacts, unsubscribes, double opt-in, the default from name and email, and when each last sent. An audience id is what create_mailchimp_campaign and sync_leads_to_mailchimp take. Read-only, free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).
| Argument | Type | Description |
|---|---|---|
| limit | number | |
| offset | number |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"limit": {
"type": "number"
},
"offset": {
"type": "number"
}
}
}The brand's Mailchimp CAMPAIGNS, newest first: title, status (save = draft, schedule, sending, sent, paused), subject, preview text, sender, audience and segment, recipient count, send time. Filter by status or audienceId; or pass campaignId to read ONE with Mailchimp's own send checklist (is it ready, and what is missing). For opens, clicks and revenue use mailchimp_report. Read-only, free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).
| Argument | Type | Description |
|---|---|---|
| limit | number | |
| offset | number | |
| status | string | |
| audienceId | string | |
| campaignId | string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"limit": {
"type": "number"
},
"offset": {
"type": "number"
},
"status": {
"enum": [
"save",
"paused",
"schedule",
"sending",
"sent"
],
"type": "string"
},
"audienceId": {
"type": "string"
},
"campaignId": {
"type": "string"
}
}
}The pictures in the brand's Mailchimp FILE MANAGER: id, name, hosted URL, size and dimensions, newest first. Read-only, free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).
| Argument | Type | Description |
|---|---|---|
| limit | number | |
| offset | number |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"limit": {
"type": "number"
},
"offset": {
"type": "number"
}
}
}The SEGMENTS and tags of one Mailchimp audience with their member counts and type (saved, static/tag, fuzzy). A segment id narrows a campaign to part of the audience. Read-only, free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).
| Argument | Type | Description |
|---|---|---|
| limit | number | |
| offset | number | |
| audienceId* | string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"audienceId"
],
"properties": {
"limit": {
"type": "number"
},
"offset": {
"type": "number"
},
"audienceId": {
"type": "string"
}
}
}The brand's own Mailchimp EMAIL TEMPLATES (name, id, created, edited). A template id is what create_mailchimp_campaign sends. Read-only, free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).
| Argument | Type | Description |
|---|---|---|
| limit | number | |
| offset | number |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"limit": {
"type": "number"
},
"offset": {
"type": "number"
}
}
}List the durable facts & preferences saved in this workspace’s Memory (what the studio remembers about the brand, audience, taste, and do/don’t rules) — the same Memory the web app shows. These shape every future ad. Read-only, free.
| Argument | Type | Description |
|---|---|---|
| limit | number | max items (default 50, max 200) |
| category | string | filter to one bucket (Brand/Audience/Taste/Do/Don’t/Preference) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"limit": {
"type": "number",
"description": "max items (default 50, max 200)"
},
"category": {
"type": "string",
"description": "filter to one bucket (Brand/Audience/Taste/Do/Don’t/Preference)"
}
}
}The marketing-message campaigns on an ad account: name, id, budgets, schedule. Free.
| Argument | Type | Description |
|---|---|---|
| adAccountId* | string | ad account id (act_ prefix or digits) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"adAccountId"
],
"properties": {
"adAccountId": {
"type": "string",
"description": "ad account id (act_ prefix or digits)"
}
}
}The people who OPTED IN to marketing messages from a Facebook Page — their subscription tokens, the only address a paid Messenger marketing message can go to (there is no "message everyone"). Also reports whether this Meta connection carries the marketing_messages_messenger permission; a connection made before it was added must be reconnected. Free.
| Argument | Type | Description |
|---|---|---|
| limit | number | 1–1000, default 100 |
| pageId | string | Facebook Page id (from list_meta_pages); defaults to the one shared Page |
| customAudienceIds | string | comma-separated Messenger custom audience ids to filter by |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"limit": {
"type": "number",
"description": "1–1000, default 100"
},
"pageId": {
"type": "string",
"description": "Facebook Page id (from list_meta_pages); defaults to the one shared Page"
},
"customAudienceIds": {
"type": "string",
"description": "comma-separated Messenger custom audience ids to filter by"
}
}
}A page of the actual PRODUCTS in a catalog — name, retailer id, price, availability, image and, most importantly, Meta’s REVIEW STATUS for each. A REJECTED product stays in the catalog, is counted by the catalog’s catalog.product_count field, and will never be shown in an ad — so this is how you find out why a catalog ad is serving less than the merchant expects. Use it to confirm a catalog really holds what the merchant thinks before pointing money at it. This is a PAGE, not a count: quote the catalog.product_count list_meta_catalogs reports for the total. Read-only, 0 credits.
| Argument | Type | Description |
|---|---|---|
| limit | number | max rows (1–100, default 25) |
| catalogId* | string | the numeric catalog id from list_meta_catalogs |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"catalogId"
],
"properties": {
"limit": {
"type": "number",
"description": "max rows (1–100, default 25)"
},
"catalogId": {
"type": "string",
"description": "the numeric catalog id from list_meta_catalogs"
}
}
}The PRODUCT CATALOGS this Meta login can advertise from — id, name, how many products each holds, and which business portfolio owns it, WITH THAT PORTFOLIO’S NUMERIC ID. It also lists every business portfolio this login administers (`portfolios`), whether or not it holds a catalog yet — so this is where the businessId create_meta_catalog needs comes from, and the two tools chain with no trip to Business Manager. A catalog is the input to Advantage+ catalog ads (dynamic product ads): one campaign that shows each person the products they are most likely to buy, with the image, name and price taken from the catalog rather than from a render. START HERE for any "advertise my products" ask, then list_meta_product_sets to pick what to advertise. AN EMPTY LIST IS A FACT ABOUT WHAT THIS LOGIN ADMINISTERS, NEVER ABOUT THE MERCHANT: Meta serves catalogs off the BUSINESS PORTFOLIO, so a full Shopify-synced catalog is invisible here if the connected user is not an admin of the portfolio that owns it — read the `note` and say that, never "you have no catalog". A portfolio that could not be read is reported as `unreadable` rather than dropped. Read-only, 0 credits.
No arguments.
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {}
}Read the comments under a Facebook Page post or Instagram media object — customer questions, objections and the exact language real people use about the product. Good raw material for ad copy, and the first step before replying or moderating. REPLIES: a reply is a comment ON a comment, and its id exists only under its PARENT — it is never returned by the post. Each row says how many replies it has; to read them (and to get the id reply_to_meta_comment / moderate_meta_comment need), call this tool again with postId set to that COMMENT id.
| Argument | Type | Description |
|---|---|---|
| limit | number | how many comments (1–50, default 25) |
| cursor | string | the cursor from a previous call. A post with more comments than one page comes back with hasMore + a truncationNote — counts or sentiment drawn from ONE page describe a sample, not the conversation. |
| pageId | string | Page id — omit when only one Page is connected |
| postId* | string | post/media id — or a COMMENT id, which returns that comment’s replies |
| 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. |
The DM threads on the brand’s Facebook Page and its Instagram account — who wrote, when they last wrote, how many messages and how many are unread. THIS IS WHERE THE BUYING QUESTION GETS ASKED. IT READS THE INBOX AND THE MESSAGE REQUESTS BY DEFAULT (Meta’s "other" folder), which is where a DM from someone who does NOT follow the account lands — every thread says which of the two it came from, because a message request is not an accepted conversation and whoever answers it should know that first. Omit `platform` to read BOTH surfaces (one question to a person, two edges to Meta); name one to narrow. AN EMPTY LIST IS NEVER "nobody has messaged you" — the reply says so and names the MEASURED causes, including one the account holder cannot fix: Instagram Direct on this edge needs an app-level Instagram messaging capability, and without it Meta answers with an empty list rather than an error. Never tell anyone to go and switch something on inside the Instagram app — there is no such setting on a professional account. Read one thread with read_meta_conversation. Read-only, 0 credits.
| Argument | Type | Description |
|---|---|---|
| limit | number | threads per surface (1–100, default 25) |
| folder | string | narrow to ONE folder. Omit to read inbox + other, which is almost always what you want. "other" IS the message-requests folder. A folder Meta does not recognise is REFUSED rather than forwarded, because Meta answers an unknown folder with the DEFAULT inbox — so a plausible-looking spelling like "requests" would hand back the ordinary inbox and be reported as "no message requests". |
| pageId | string | Facebook Page id — omit when only one Page is connected |
| account |
List the Facebook Pages (with any linked Instagram business account) and ad accounts on the connected Meta account. Requires the user to have connected Meta (Settings > Connectors > Meta); returns a connect hint if not.
No arguments.
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {}
}The creators this brand has set up for PARTNERSHIP ADS — ads that run from a CREATOR’s handle instead of the brand’s, which is what a paid collaboration looks like in feed. TWO SEPARATE LISTS come back and NEITHER IMPLIES THE OTHER: the AD-PERMISSION list is whose content this profile may run as an advert, and the TAG-APPROVAL list is who may tag this brand as a paid partner on their own organic post. Adding a creator to one does nothing for the other, and that is the mistake that makes a partnership ad fail for a reason Meta’s error does not name. Every row carries the status Meta reports VERBATIM. Meta’s own wording on the wire is prose, not the enum its docs publish — measured 2026-08-25, a fresh request reads “Pending Approval” and a revoked one reads “Canceled” (Partnership Ads Hub calls the same two rows “Request sent” and “Inactive”). PENDING means the creator has not accepted yet and this profile cannot advertise their content until they do. REVOKING DOES NOT REMOVE THE ROW: Meta keeps it and flips the status, so read the status rather than the presence of a row. An unrecognised status is passed through as Meta’s own word, never blanked. A list that could not be READ says exactly that; it is never rendered as "this brand has no creators". THE TWO LISTS ANSWER DIFFERENTLY, and Meta is the reason: the ad-permission list can be ENUMERATED, and the tag-approval list CANNOT — Meta requires `user_ids` on that edge and returns "a list of approved creators, filtered by the user IDs provided", i.e. it CHECKS the people you name and cannot report the rest. So pass `creatorIds` to ask about specific creators; anyone already on the ad-permission list is checked for you, which is what makes "on one list but not the other" answerable in a single call. With nobody to check, the reply says Meta publishes no way to list that edge rather than reporting a failed read. Read-only, 0 credits.
| Argument | Type | Description |
|---|---|---|
| pageId | string | Facebook Page id — omit when only one Page is connected. Both lists live on its linked Instagram account. |
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.
| Argument | 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 (default 25, max 100) |
| cursor | string | paging cursor returned by a previous call |
| pageId | string | which connected Page — omit when the profile has only one |
| tagged | boolean | INSTAGRAM — list posts OTHER accounts tagged the brand in (UGC, creator posts, partner mentions), with each author’s @handle, instead of the brand’s own posts. Free. |
| target |
The PRODUCT SETS inside one catalog — a set is a slice of the catalog ("summer dresses", "under $50", "everything"), and it is what an AD SET actually advertises. Meta normally auto-creates an "all products" set with every catalog, so an empty list points at the catalog rather than at the request. A set holding ZERO products binds without error and then serves nothing — the ad runs, spends and shows no product — so check the counts. Read-only, 0 credits.
| Argument | Type | Description |
|---|---|---|
| limit | number | max rows (1–100, default 50) |
| catalogId* | string | the numeric catalog id from list_meta_catalogs (a catalog NAME is not an id) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"catalogId"
],
"properties": {
"limit": {
"type": "number",
"description": "max rows (1–100, default 50)"
},
"catalogId": {
"type": "string",
"description": "the numeric catalog id from list_meta_catalogs (a catalog NAME is not an id)"
}
}
}The Stories live right now on the connected Instagram accounts and Facebook Pages: id, caption (Instagram), posted time, hours left and permalink, and on Instagram `insights`: reach, views, replies, link clicks and navigation (forward, back, next story, exit). Use it to check that a post or reel really went to Stories, or to match a story id against what is live. includeEnded also returns `ended`, the Stories that have ended with the numbers kept from before they ended. Meta marks nothing on a Story to say it is a reshared reel, so `origin` appears only when the share went through Hermoso, and says how it was found. Meta serves a Story for 24 hours and leaves live video and reshared stories out of this list. A source that could not be read comes back in `unreadable`, never as an empty list. Read-only, 0 credits.
| Argument | Type | Description |
|---|---|---|
| days | number | how far back includeEnded goes (1-400, default 30) |
| 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 | rows, newest first (1-100, default 25) |
| pageId | string | one Facebook Page, and its linked Instagram |
| account | string | one Instagram account: an @handle or id |
The Page events Meta has PUSHED to Hermoso — new comments, mentions, lead-form submissions and inbound Messenger/Instagram DMs — each with the moment it arrived. This is the difference between a brand learning about a comment when someone thinks to look and learning about it when it happens. Scoped to the Pages this profile can actually see, so it never shows another account’s activity. AN EMPTY FEED IS AMBIGUOUS AND THE REPLY SAYS SO: nothing has happened, or the Page is not subscribed (check meta_webhook_status — an unsubscribed Page is silent and looks exactly like a quiet one), or Hermoso restarted and rebuilt the feed from its durable mirror, which keeps recent deliveries rather than all history. Read-only, 0 credits.
| Argument | Type | Description |
|---|---|---|
| field | string | narrow to one kind: feed, mention, leadgen or messages |
| limit | number | how many, newest first (default 50) |
| pageId | string | narrow to one Page — only within the Pages this profile already administers; naming an id you cannot see does not widen the answer |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"field": {
"type": "string",
"description": "narrow to one kind: feed, mention, leadgen or messages"
},
"limit": {
"type": "number",
"description": "how many, newest first (default 50)"
},
"pageId": {
"type": "string",
"description": "narrow to one Page — only within the Pages this profile already administers; naming an id you cannot see does not widen the answer"
}
}
}Find the Notion pages and databases the user shared with Hermoso, by title, newest edited first. Gives the page id that save_to_notion, append_to_notion_page and read_notion_page take, or the database id to save rows into. Read-only, free. Needs Notion connected (Settings > Connectors > Notion).
| Argument | Type | Description |
|---|---|---|
| type | string | limit results to pages or to databases; omit for both |
| limit | number | 1-100, default 25 |
| query | string | words in the title |
| cursor | string | from the previous page |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"type": {
"enum": [
"page",
"database"
],
"type": "string",
"description": "limit results to pages or to databases; omit for both"
},
"limit": {
"type": "number",
"description": "1-100, default 25"
},
"query": {
"type": "string",
"description": "words in the title"
},
"cursor": {
"type": "string",
"description": "from the previous page"
}
}
}List files & folders in the user’s OneDrive — the root by default, a folder’s contents (folderId), or a name search (query). onlyFolders:true lists folders only. Paginate with pageToken (the cursor from a previous call). Read-only.
| Argument | Type | Description |
|---|---|---|
| query | string | search — only items whose name matches this |
| folderId | string | list the contents of this folder id |
| pageSize | number | rows per page (1–200, default 50) |
| pageToken | string | cursor from a previous call |
| onlyFolders | boolean | list folders only |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"query": {
"type": "string",
"description": "search — only items whose name matches this"
},
"folderId": {
"type": "string",
"description": "list the contents of this folder id"
},
"pageSize": {
"type": "number",
"description": "rows per page (1–200, default 50)"
},
"pageToken": {
"type": "string",
"description": "cursor from a previous call"
},
"onlyFolders": {
"type": "boolean",
"description": "list folders only"
}
}
}List the SECTIONS of a Pinterest board with their ids (the boardSectionId post_to_pinterest and save_pinterest_pin take), or with sectionId the Pins in one section. Read-only, 0 credits.
| Argument | Type | Description |
|---|---|---|
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| boardId* | string | from list_pinterest_boards |
| bookmark | string | the next page, from the last reply |
| sectionId | string | read the Pins in this section |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"boardId"
],
"properties": {
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"boardId": {
"type": "string",
"description": "from list_pinterest_boards"
},
"bookmark": {
"type": "string",
"description": "the next page, from the last reply"
},
"sectionId": {
"type": "string",
"description": "read the Pins in this section"
}
}
}List the boards on the user’s connected Pinterest account — id, name, privacy and pin count. ALWAYS call this before post_to_pinterest and let the USER pick: Pinterest requires a board and Hermoso never chooses one for them. Read-only, 0 credits. Needs Pinterest connected (Settings > Connectors > Pinterest).
| Argument | Type | Description |
|---|---|---|
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| privacy | string | filter by board privacy; default is everything the connection can see |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"privacy": {
"enum": [
"ALL",
"PUBLIC",
"PROTECTED",
"SECRET"
],
"type": "string",
"description": "filter by board privacy; default is everything the connection can see"
}
}
}Read the Pinterest product CATALOG side of the connected account — what a Shopping (CATALOG_SALES) campaign serves from. what: catalogs (default), feeds, feed (feedId), processing_results (feedId — did Pinterest ingest the feed, product counts, errors), item_issues (processingResultId — per-product problems), product_groups (optionally feedId / catalogId), product_counts or products (productGroupId), items_batch (batchId). Pinterest catalogs need a BUSINESS account with a claimed website. Read-only, 0 credits.
| Argument | Type | Description |
|---|---|---|
| what | string | which view to read; default catalogs |
| feedId | string | feed id from what:feeds, used with feed, processing_results and product_groups |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| batchId | string | batch id returned by update_pinterest_catalog_items, used with items_batch |
| catalogId | string | catalog id from what:catalogs, used with feeds and product groups |
| adAccountId | string | optional — act as this ad account’s owner via Pinterest Business Access (must be shared with this profile) |
| productGroupId |
The Pins on one of the account’s boards — or, with no boardId, the account’s own Pins across all of them. Each row carries the Pin id, title, description, destination link, alt text, board, creation date, and whether it HAS BEEN PROMOTED in an ad. THIS IS WHERE THE pinId EVERY OTHER PIN TOOL NEEDS COMES FROM: post_to_pinterest returns an id only at the instant it pins, so an agent that did not itself just pin had no way to name a Pin. Prefer passing a boardId — Pinterest’s own spec warns the account-wide listing has known timeouts. Read-only, 0 credits. Needs Pinterest connected.
| Argument | 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–100, default 25 |
| cursor | string | the cursor a previous call returned |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| boardId | string | numeric board id from list_pinterest_boards — omit for the account’s own Pins across all boards |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"brand": {
"type": "string",
"description": "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": {
"type": "number",
"description": "1–100, default 25"
},
"cursor": {
"type": "string",
"description": "the cursor a previous call returned"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"boardId": {
"type": "string",
"description": "numeric board id from list_pinterest_boards — omit for the account’s own Pins across all boards"
}
}
}List the PLAYBOOKS saved in this workspace — the reusable strategy cards (winning hooks, angles, formats and the concrete plays to run) kept from teardowns, angle mining and creatives worth repeating. The same Playbooks the web app's Playbooks tab lists. Read one before planning an ad so you re-run what already worked instead of starting cold. Read-only, free.
| Argument | Type | Description |
|---|---|---|
| full | boolean | true to return every hook/angle/play in the text, not just the headline counts |
| limit | number | max playbooks to return (default 25, max 100) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"full": {
"type": "boolean",
"description": "true to return every hook/angle/play in the text, not just the headline counts"
},
"limit": {
"type": "number",
"description": "max playbooks to return (default 25, max 100)"
}
}
}List the product photos ALREADY saved in your workspace — the brand's product library plus any app-store screens (also surfaces photos locked in your OTHER creations, since a set product lands in the shared library). FREE — returns each photo's url + the product it shows (the shop's own title when one is on file). These are already saved: never ask the user to upload them again. Reads YOUR saved brand (pass brandId to target a specific profile — that switches this key's active profile like use_brand).
| Argument | Type | Description |
|---|---|---|
| brandId | string | a profile id/name from list_brands whose product library to list; omit to use the active profile |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"brandId": {
"type": "string",
"description": "a profile id/name from list_brands whose product library to list; omit to use the active profile"
}
}
}The brand's product catalog: each product's id, title, page link, price (and whether it is current), photos, its own selling points and audience, and when it was last checked against its live page. Free. `refresh:'stale'` re-reads rows older than a day first, `refresh:'all'` re-reads every page.
| Argument | Type | Description |
|---|---|---|
| brandId | string | a profile id/name from list_brands; omit for the active profile |
| refresh | string | re-read product pages before listing: 'stale' (older than a day) or 'all' |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"brandId": {
"type": "string",
"description": "a profile id/name from list_brands; omit for the active profile"
},
"refresh": {
"enum": [
"stale",
"all"
],
"type": "string",
"description": "re-read product pages before listing: 'stale' (older than a day) or 'all'"
}
}
}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.
| Argument | 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 | max posts (default 50, max 200), newest first |
| cursor | string | nextCursor from a previous reply: the next, older page |
| channel | string | filter to one channel: facebook, instagram, threads, x, linkedin, youtube, tiktok, pinterest, bluesky, google_business |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"brand": {
"type": "string",
"description": "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": {
"type": "number",
"description": "max posts (default 50, max 200), newest first"
},
"cursor": {
"type": "string",
"description": "nextCursor from a previous reply: the next, older page"
},
"channel": {
"type": "string",
"description": "filter to one channel: facebook, instagram, threads, x, linkedin, youtube, tiktok, pinterest, bluesky, google_business"
}
}
}Use this when the user asks what is scheduled, what already posted, or whether a post went out. Show what is queued to post and what already went out. Each fired item reports PER-CHANNEL outcomes, so you can see that (say) Instagram published and TikTok failed on the same item rather than a single misleading verdict. Past items are rebuilt from published-post records, one row per channel; they are not job runs (use list_jobs for those). THE LIST IS COMPACT so it fits in one reply: the next 25 queued and the last 15 fired, captions shortened. Pass `id` for ONE post in full (every caption and setting, which you need before reschedule_post replaces a caption map), `channel` to filter, or `upcoming` / `fired` for more rows. Read-only, 0 credits.
| Argument | Type | Description |
|---|---|---|
| id | string | one post id from this list: returns that post in full, every caption and setting included |
| brand | string | WHICH PROFILE to list — the id or exact name from list_brands. Needed when the post lives in a profile this connection is not pinned to: a post you can CREATE in a profile must be manageable there too, without switching the whole connection. A name that matches no profile, or two, is REFUSED. |
| fired | number | how many already-fired posts to list, most recent last (default 15, max 200) |
| channel | string | only posts that include this channel, e.g. "pinterest" or "x" |
| upcoming | number | how many queued posts to list, soonest first (default 25, max 200) |
The tabs in a Google Spreadsheet, each with its name, numeric sheetId, row/column count and position. Call this BEFORE naming a tab in update_sheet / clear_sheet_range / manage_sheet_tabs / format_sheet, and before proposing to delete one — it is how you learn what the file actually contains instead of guessing at a name. Read-only, free.
| Argument | Type | Description |
|---|---|---|
| sheetUrl | string | a Google Sheets URL — the id is extracted from it |
| spreadsheetId | string | the spreadsheet id (from create_sheet, or list_drive_files for one the user picked) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"sheetUrl": {
"type": "string",
"description": "a Google Sheets URL — the id is extracted from it"
},
"spreadsheetId": {
"type": "string",
"description": "the spreadsheet id (from create_sheet, or list_drive_files for one the user picked)"
}
}
}The merchant's real Shopify products — id, title, description, price, images and storefront URL. This is where the productId for publish_to_shopify_product comes from, and it doubles as ground truth about what the brand actually sells (real titles and real photos, not a guess from the website). Newest-updated first. Only works for accounts created by installing Hermoso from the Shopify App Store. Read-only, free.
| Argument | Type | Description |
|---|---|---|
| limit | number | how many products (1–100, default 24) |
| cursor | string | pageInfo.endCursor from a previous call, to page further |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"limit": {
"type": "number",
"description": "how many products (1–100, default 24)"
},
"cursor": {
"type": "string",
"description": "pageInfo.endCursor from a previous call, to page further"
}
}
}List the bundled Hermoso SKILLS — multi-step workflow instructions (SKILL.md) that orchestrate the other tools (research an ad space, plan+render a finished ad, product photoshoot, raw generation) — plus the in-app strategy skills and creative recipes. Call get_skill to load a bundle. Read-only, free.
No arguments.
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {}
}The Slack workspaces this brand added the Hermoso app to, and the channels in one of them (public, plus private ones the app was invited to with /invite @Hermoso), with the default channel marked. Use it before posting when the user has not named a channel, and to show them the choice. Free.
| Argument | 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. |
| query | string | filter channel names |
| slackWorkspace | string | which workspace (id or name) when there are several |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"brand": {
"type": "string",
"description": "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."
},
"query": {
"type": "string",
"description": "filter channel names"
},
"slackWorkspace": {
"type": "string",
"description": "which workspace (id or name) when there are several"
}
}
}Messages the Hermoso app scheduled IN SLACK (post_to_slack with postAt) that have not posted yet. Posts queued with schedule_post are in list_scheduled instead. Free.
| Argument | 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 | |
| slackWorkspace | string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"brand": {
"type": "string",
"description": "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": {
"type": "string"
},
"slackWorkspace": {
"type": "string"
}
}
}List this workspace's SWIPEFILE — the saved-ad research board: every named collection and the ads/creatives kept in it (advertiser, headline, body copy, media URL, platform, when it was saved, and any taste tags). The SAME board the web app's Swipefile tab shows. Use it to answer "what have we saved?", to mine the user's own taste before planning an ad, or to find a reference to remix. Read-only, free.
| Argument | Type | Description |
|---|---|---|
| limit | number | max ads to return (default 50, max 500) |
| collection | string | only list ads in this collection (by name or id) — omit for every collection |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"limit": {
"type": "number",
"description": "max ads to return (default 50, max 500)"
},
"collection": {
"type": "string",
"description": "only list ads in this collection (by name or id) — omit for every collection"
}
}
}List the members of the current profile — email, role (admin/member) and status. Read-only, free.
No arguments.
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {}
}Find the chat ids this Telegram bot can be addressed by. STATE THE LIMIT WHENEVER YOU USE IT: this is NOT the list of chats the bot belongs to — the Bot API publishes no such method — it is every chat that SENT the bot an update in the last 24 hours, which is as long as Telegram keeps an update. A channel the bot posts to every day but nobody messages will NOT appear here, and its absence means nothing at all: post to it by @username or numeric id anyway. If the bot has an outgoing WEBHOOK configured the list is empty for that reason alone (Telegram: getUpdates "will not work if an outgoing webhook is set up"), and the reply says so rather than reading as an empty account. Nothing is consumed — no update offset is confirmed, so this cannot eat the bot’s pending updates. Free, 0 credits.
| Argument | Type | Description |
|---|---|---|
| limit | number | how many recent updates to scan, 1–100 (default 100) |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"limit": {
"type": "number",
"description": "how many recent updates to scan, 1–100 (default 100)"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
}
}
}Read what people have SENT to the connected Telegram bot — the newest message from each chat, newest chat first, so you can see who is waiting on a reply. Reply with post_to_telegram using the chatId shown. Warning: THIS IS A ROLLING 24-HOUR WINDOW, NOT AN INBOX. Telegram keeps undelivered updates for 24 hours and publishes NO history endpoint at all, so anything older is unrecoverable — never report an empty result as 'you have no messages', report it as 'nothing in the last 24 hours'. Two more Telegram rules worth stating before someone concludes the feature is broken: a bot that has an outgoing WEBHOOK configured gets nothing from this at all (Telegram's own rule, and the reply says so), and a bot can never message someone first — they have to write to it. Free — no vendor charge and no credits. Needs Telegram connected (Settings > Connectors > Telegram, or connect_connector).
| Argument | Type | Description |
|---|---|---|
| limit | number | how many raw updates to scan, 1-100 (default 100). Messages are grouped per chat, so this is not the number of rows you get back. |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"limit": {
"type": "number",
"description": "how many raw updates to scan, 1-100 (default 100). Messages are grouped per chat, so this is not the number of rows you get back."
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
}
}
}Posts where someone MENTIONED the brand on Threads — anywhere, not just under your own posts. Use list_threads_replies instead when you want the conversation under one specific post.
| Argument | Type | Description |
|---|---|---|
| limit | number | how many mentions (1–50, default 25) |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"limit": {
"type": "number",
"description": "how many mentions (1–50, default 25)"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
}
}
}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.
| Argument | 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–50, default 15) |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"brand": {
"type": "string",
"description": "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": {
"type": "number",
"description": "how many posts (1–50, default 15)"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
}
}
}The recent PUBLIC posts of any Threads account. AN EMPTY LIST IS NEVER PROOF THEY HAVE NOT POSTED — Meta returns nothing for a private account, for any account under 100 followers, and when the Threads connection predates the `threads_profile_discovery` permission (approved for the app 2026-09-05; reconnect Threads to grant it); the note says which of those applies. Read-only, 0 credits. Needs Threads connected.
| Argument | 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–50, default 25) |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| username* | string | the Threads handle — "nike", "@nike", or a threads.net profile link |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"username"
],
"properties": {
"brand": {
"type": "string",
"description": "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": {
"type": "number",
"description": "how many posts (1–50, default 25)"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"username": {
"type": "string",
"description": "the Threads handle — \"nike\", \"@nike\", or a threads.net profile link"
}
}
}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.
| Argument | Type | Description |
|---|---|---|
| limit | number | how many replies (1–50, default 25) |
| postId* | string | post id from list_threads_posts |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| conversation | boolean | true = the whole thread, not just direct replies |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"postId"
],
"properties": {
"limit": {
"type": "number",
"description": "how many replies (1–50, default 25)"
},
"postId": {
"type": "string",
"description": "post id from list_threads_posts"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"conversation": {
"type": "boolean",
"description": "true = the whole thread, not just direct replies"
}
}
}Public posts whose captions carry one of the brand hashtags this account has enabled. It is the hashtag half of brand monitoring, where list_tiktok_mentions covers @-mentions. Omit `hashtag` for the top posts across every enabled tag; pass one to narrow to it. TWO TIKTOK BEHAVIOURS THAT READ AS BUGS IF NOBODY SAYS THEM: the hashtag filter is CASE-SENSITIVE and must exactly match an enabled tag, and filtering to one tag makes matched_hashtags come back EMPTY on every row. NOTHING IS RETURNED UNTIL HASHTAGS ARE ENABLED. That is a setup step rather than a result: use list_tiktok_brand_hashtags and manage_tiktok_brand_hashtags first, and allow 24 hours after enabling. Capped at the top 1,000 posts of the last 90 days. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION with the brand-insights permission. Read-only, free.
| Argument | Type | Description |
|---|---|---|
| days | number | 1 to 90. Default 90 |
| 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. |
| cursor | number | pagination cursor from the previous reply to fetch the next page |
| fields | array | fields to return; omit for every field TikTok publishes |
| hashtag | string | one ENABLED tag, spelled exactly as enabled. The match is case-sensitive |
The brand hashtags this account curates, in two flavours. kind:'ENABLED' is what TikTok is currently counting (with the date each was turned on, and whether it is old enough to remove). kind:'AVAILABLE' is what TikTok will ACCEPT. A tag qualifies once it has appeared in at least three post captions, either from this account or from a post mentioning its handle. A tag containing the account handle as a substring can also be enabled even when it is not on the available list. AT MOST 50 CAN BE ENABLED PER BRAND. TikTok's own caveat on the available list: test and newly created accounts often return nothing and should not be used to judge whether this works. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION with the brand-insights permission. Read-only, free.
| Argument | Type | Description |
|---|---|---|
| kind | string | default ENABLED |
| username | string | normally resolved from the authorization itself. Pass the @handle (without the @) only if that read is refused |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"kind": {
"enum": [
"ENABLED",
"AVAILABLE"
],
"type": "string",
"description": "default ENABLED"
},
"username": {
"type": "string",
"description": "normally resolved from the authorization itself. Pass the @handle (without the @) only if that read is refused"
}
}
}All replies to a single comment on a post the authorized TikTok account owns — the complete list, where list_tiktok_comments only ever attaches three. TIKTOK DOES NOT RETURN REPLIES TO A HIDDEN COMMENT AT ALL, so an empty list against a hidden parent means 'cannot read', never 'no replies' — check the parent's status first if the answer looks wrong. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION (see tiktok_account_status). Read-only, free.
| Argument | Type | Description |
|---|---|---|
| cursor | number | pagination cursor from the previous reply's cursor field |
| status | string | PUBLIC or ALL (ALL includes hidden replies) |
| videoId* | string | TikTok requires the post id alongside the comment id |
| maxCount | number | number of replies to return in this page |
| commentId* | string | from list_tiktok_comments |
| sortField | string | likes, replies or create_time |
| sortOrder | string | asc or desc |
Read the comments on a TikTok post the AUTHORIZED ACCOUNT OWNS — this is TikTok's answer to list_meta_comments and list_youtube_comments. It sees BOTH public and hidden comments, and by default returns both: TikTok's `status` defaults to ALL, so the list mixes comments the owner hid with comments TikTok's own moderation, privacy or spam filters hid, and those are not the same thing (the second kind may refuse to unhide). Pass status:'PUBLIC' for only what the public sees. A row carrying parentCommentId IS A REPLY, not a top-level comment — that is how TikTok distinguishes them. include_replies attaches at most THREE replies per comment; use list_tiktok_comment_replies for all of them. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION, which is a separate consent from the TikTok Ads advertiser connection — tiktok_account_status says whether this profile has it. Read-only, free.
| Argument | Type | Description |
|---|---|---|
| cursor | number | pagination cursor from the previous reply's cursor field |
| status | string | default ALL, which INCLUDES hidden comments |
| videoId* | string | the TikTok post id (`item_id`) — the last path segment of a tiktok.com/@handle/video/<id> URL |
| maxCount | number | number of comments to return in this page |
| sortField | string | likes, replies or create_time |
| sortOrder |
Comments and replies anywhere on TikTok whose TEXT @-mentions the connected account's handle. This is the conversational half of brand monitoring, where list_tiktok_mentions covers post captions. Same four preconditions as that tool (public post, 90 days, adult author, BUSINESS account), so an empty list is not the same as silence. Capped at the top 1,000 by comment likes, and TIKTOK DELIBERATELY DEPRIORITISES a comment that is nothing but the @-mention with no other text, so bare tags may not appear at all. Sorts on different keys from the post list: VIDEO_LIKES, COMMENT_CREATE_TIME or COMMENT_LIKES. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION with the brand-insights and comment-list permissions. Read-only, free.
| Argument | Type | Description |
|---|---|---|
| days | number | 1 to 90. Default 90 |
| cursor | number | pagination cursor from the previous reply to fetch the next page |
| fields | array | fields to return; omit for every field TikTok publishes |
| regions | array | two-letter codes; filters on the commenting author's registration country |
| maxCount | number | 1 to 100; TikTok defaults this one to 10 |
| sortField | string | VIDEO_LIKES, COMMENT_CREATE_TIME or COMMENT_LIKES |
| sortOrder |
Who is talking about the brand on TikTok: every public post whose CAPTION @-mentions the connected account's handle. This is TikTok's answer to x_mentions and list_threads_mentions, and it is the brand-monitoring read the product had for every other channel and not for TikTok. FOUR THINGS TIKTOK ENFORCES THAT MAKE AN EMPTY RESULT AMBIGUOUS, so read them before reporting silence: the mentioning post must be PUBLIC, it must be under 90 days old, its author must not be underage or of unknown age, and THE WHOLE FAMILY ONLY WORKS FOR A TIKTOK BUSINESS ACCOUNT, so a personal account returns nothing at all (tiktok_account_insights reports which one this is). Capped at the top 1,000 mentioning posts however many exist. VIEWS AND REACH COME BACK NULL, NOT ZERO, on any post under 1,000 of either. That is TikTok withholding a number, never a measurement of nothing. Thumbnail URLs expire after 48 hours. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION with the brand-insights permission (tiktok_account_status says whether this profile has it). Read-only, free.
| Argument | Type | Description |
|---|---|---|
| days | number | look-back window, 1 to 90 (TikTok indexes nothing older). Default 90 |
| cursor | number | pagination cursor from the previous reply to fetch the next page |
| fields | array | defaults to every field TikTok publishes; item_id, create_time, video_link, caption, likes, comments, shares, thumbnail_url, views, reach, creator_handle_name |
| regions | array | two-letter codes to filter the MENTIONING author's registration country, e.g. ['US','GB']. TikTok publishes 162 of them and answers an unknown one with an empty result, so an unpublished code is refused here instead |
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.
| Argument | 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) |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| videoIds | array | read these specific TikTok video ids instead of listing recent ones — up to 20 per call |
The WhatsApp Business Accounts SHARED WITH THIS PROFILE and the phone numbers registered on each — the ids every other WhatsApp tool needs, plus each number’s QUALITY RATING, which is what decides how many messages Meta will let it send. Start here. A WABA with no number cannot send anything and the reply says so: Hermoso does not register or verify numbers, that is WhatsApp Manager. A business portfolio that could not be read is REPORTED rather than dropped — an account missing from this list would read as "the brand has no WhatsApp", which is a claim about their business and not about our read. TWO ACCOUNTS CAN CARRY THE SAME DISPLAY NAME — always quote the `display` field or the id when naming one to a user, never the bare name. ONLY THE ACCOUNTS SHARED WITH THIS PROFILE ARE REACHABLE. One Meta login often administers WhatsApp for several businesses, so a human ticks which belong to this profile under Settings > Connectors > Meta > Manage accounts (or set_connector_accounts(provider:"meta")); nothing else here can see or touch the rest. With none ticked every WhatsApp tool refuses and names that as the way out. With exactly ONE ticked, wabaId is optional — pass it only to disambiguate. Read-only, 0 credits (WhatsApp conversations are billed by META to the business directly, never in Hermoso credits). Needs Meta connected.
No arguments.
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {}
}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.
| Argument | Type | Description |
|---|---|---|
| name | string | filter to one template name (Meta matches it exactly) |
| limit | number | how many (1–200, default 50) |
| status | string | filter to one review status, e.g. APPROVED or PENDING |
| wabaId | string | the WhatsApp Business Account id, from list_whatsapp_accounts. Optional when exactly one account is shared with this profile; required when several are, and refused by name for one that is not shared |
| language | string | filter to one language code, e.g. en_US |
List the posts on the brand's WordPress site, newest first — published, scheduled, drafts, pending and private — each with its id, status, title and live URL. Free. WordPress publishes no per-post view counts, so this carries none.
| Argument | Type | Description |
|---|---|---|
| page | number | |
| 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. |
| search | string | |
| status | string | one status or a comma list: publish, future, draft, pending, private (default all) |
| perPage | number | 1–100, default 20 |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"page": {
"type": "number"
},
"brand": {
"type": "string",
"description": "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."
},
"search": {
"type": "string"
},
"status": {
"type": "string",
"description": "one status or a comma list: publish, future, draft, pending, private (default all)"
},
"perPage": {
"type": "number",
"description": "1–100, default 20"
}
}
}List the WordPress site's categories or tags (kind), most-used first, each with its id. Free.
| Argument | Type | Description |
|---|---|---|
| kind* | string | |
| 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. |
| search | string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"kind"
],
"properties": {
"kind": {
"enum": [
"categories",
"tags"
],
"type": "string"
},
"brand": {
"type": "string",
"description": "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."
},
"search": {
"type": "string"
}
}
}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).
| Argument | Type | Description |
|---|---|---|
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| maxResults | number | 1–1000, default 100 |
| paginationToken | string | next page token from the previous reply |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"maxResults": {
"type": "number",
"description": "1–1000, default 100"
},
"paginationToken": {
"type": "string",
"description": "next page token from the previous reply"
}
}
}The connected X account's LIVE VIDEO broadcasts: past and current ones (title, state, share URL, live and total viewers, start/end) and SCHEDULED ones (start, end, whether it goes live automatically or waits for go_live). kind 'all' (default) reads both, 'live' or 'scheduled' one; pass id for a single broadcast. Read-only. Needs X connected with the broadcast permissions added 2026-09-21: an X connection made before then must be reconnected once under Settings > Connectors > X (the tool says so without calling X). Costs credits (X bills per call).
| Argument | Type | Description |
|---|---|---|
| id | string | one broadcast id (alphanumeric, up to 24 characters) |
| ids | string | comma-separated broadcast ids (live kind, up to 100) |
| kind | string | all (default), live or scheduled |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| maxResults | number | 1-100, default 25 |
| newestStart | string | scheduled kind: latest start, ISO 8601 or epoch ms |
| oldestStart | string | scheduled kind: earliest start, ISO 8601 or epoch ms |
The connected X account's X CHAT conversations: id (group ids start with g), type, muted, disappearing-message timer, screenshot controls, admins, members, PENDING join requests, which actions are admin-only, and the active invite link. X Chat is END-TO-END ENCRYPTED, so message text and group names cannot be read here (list_x_dms reads the plain direct-message history). A filtered page can be short or empty while more remain: keep paging with the same filters. view 'admin_requests' (id = a group the brand admins) lists who asked to become an admin; view 'invite' (inviteLink, or id + inviteToken) previews an invite link: group name, member count, whether the brand is in it. view 'message_requests' lists the MESSAGE REQUESTS waiting outside the inbox (inbox 'priority', the default, or 'low_quality' for X's filtered tab): who sent each one (@handle, name, followers, verified), which accounts the brand follows also follow them, and whether they changed their name or picture recently (X's impersonation hint). A message sent in X Chat is encrypted and cannot be read; one sent as a plain direct message can, with list_x_dms participantId = the sender. Show that to the user before manage_x_chat accept_request. withMembers:true resolves member @handles, and each user X returns is billed. Read-only. Billed per conversation or request returned, plus one user lookup per request sender, so keep maxResults small. Needs X connected with direct-message access: an X connection made before 2026-08-25 must be reconnected once under Workspace > Connectors > X (the tool says so without calling X). Costs credits (X bills per call).
| Argument | Type | Description |
|---|---|---|
| id | string | one conversation; for admin_requests, the group (g + digits) |
| type | string | only 1:1 or only group conversations |
| view | string |
Read the direct messages sent to the connected X account and group them into conversations, newest first — who is waiting on a reply, in their own words. Use it for 'any new DMs on X', 'what did they say', 'anything I need to answer'. Reply with send_x_dm. Messages sent in X Chat are end-to-end encrypted and are NOT in this read, so never say someone did not write from it alone: list_x_chats shows which conversations are unread. COSTS CREDITS PER MESSAGE RETURNED plus one account lookup, so keep maxResults small (default 10) and tell the user the cost before pulling a big page. X KEEPS 30 DAYS of direct-message history and offers no date filter, so a short list can mean an old conversation rather than a quiet inbox — say so rather than reporting silence. There is no conversation-list endpoint at X: this builds the thread list by reading events and grouping them, so a conversation whose last message is older than the page you pulled will not appear. Needs X connected, AND an X account connected before direct messages shipped must be reconnected once (Settings > Connectors > X) — X only grants permissions at authorize time.
| Argument | Type | Description |
|---|---|---|
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| eventTypes | array | MessageCreate (default), ParticipantsJoin, ParticipantsLeave. The join/leave events carry no message and are billed like any other, so the default is messages only. |
| maxResults | number | how many messages to pull, 1-100 (default 10) — EVERY ONE IS BILLED, and omitting it would make X return 100 |
| participantId | string |
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.
| Argument | Type | Description |
|---|---|---|
| format | string | download format, default srt |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| videoId* | string | the YouTube video id — captions are listed per video |
| download | string | a captionId or a language code to also download as text |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"videoId"
],
"properties": {
"format": {
"enum": [
"srt",
"vtt",
"sbv"
],
"type": "string",
"description": "download format, default srt"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"videoId": {
"type": "string",
"description": "the YouTube video id — captions are listed per video"
},
"download": {
"type": "string",
"description": "a captionId or a language code to also download as text"
}
}
}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.
| Argument | Type | Description |
|---|---|---|
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| regionCode* | 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) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"regionCode"
],
"properties": {
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"regionCode": {
"type": "string",
"description": "ISO 3166-1 alpha-2 country code — US, GB, DE. YouTube’s category ids differ by country, so this cannot be guessed."
},
"includeUnassignable": {
"type": "boolean",
"description": "also return categories YouTube will refuse on upload (default false)"
}
}
}Every reply under ONE comment on the connected channel, newest pages first via cursor: check that a reply the brand posted landed, or whether someone already answered a viewer before replying. Pass the top-level comment id from list_youtube_comments (each comment line shows its id and reply count); a REPLY id is recognised and its whole thread is returned, with `note` and `requestedId` saying so. Read-only, free.
| Argument | Type | Description |
|---|---|---|
| limit | number | max replies per page, default 25, cap 100 |
| cursor | string | nextCursor from the previous page |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| commentId* | string | the top-level comment id from list_youtube_comments |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"commentId"
],
"properties": {
"limit": {
"type": "number",
"description": "max replies per page, default 25, cap 100"
},
"cursor": {
"type": "string",
"description": "nextCursor from the previous page"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"commentId": {
"type": "string",
"description": "the top-level comment id from list_youtube_comments"
}
}
}Read the comments under a video on the connected channel — the questions, objections and exact wording real viewers use. Same raw material for ad copy that list_meta_comments gives you on Meta. Returns author, text, like count, timestamp, reply count and comment id, newest first — plus `imageUrl` when a viewer attached an image or GIF to the comment (a signed YouTube link that expires six hours after the read, so download it then if it needs keeping). Read-only, 0 credits. Needs a connected YouTube channel.
| Argument | Type | Description |
|---|---|---|
| limit | number | max comments, default 25, cap 100 |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| videoId* | string | the YouTube video id |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"videoId"
],
"properties": {
"limit": {
"type": "number",
"description": "max comments, default 25, cap 100"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"videoId": {
"type": "string",
"description": "the YouTube video id"
}
}
}List the playlists on the connected YouTube channel — id, title, description, privacy and video count. Pass a playlistId to get that ONE playlist plus its entries in order. IMPORTANT: each entry carries BOTH a `videoId` and an `itemId`; `itemId` is the playlist-ENTRY id and is what manage_youtube_playlist_items needs to remove or re-order a row, because one video can appear in a playlist more than once so a videoId does not identify the entry. Read-only, 0 credits. Needs a connected YouTube channel.
| Argument | Type | Description |
|---|---|---|
| limit | number | max rows, default 25, cap 50 (YouTube’s own maximum) |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| pageToken | string | nextPageToken from a previous call |
| playlistId | string | one playlist to open, with its entries. Omit to list the channel’s playlists. |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"limit": {
"type": "number",
"description": "max rows, default 25, cap 50 (YouTube’s own maximum)"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"pageToken": {
"type": "string",
"description": "nextPageToken from a previous call"
},
"playlistId": {
"type": "string",
"description": "one playlist to open, with its entries. Omit to list the channel’s playlists."
}
}
}The YouTube BULK reporting jobs running on this channel — which report each one generates, its report type id, and when it was scheduled. Call this to find out whether thumbnail-CTR history is already accumulating, and since when, BEFORE promising a user a number: the bulk API can only answer about days after a job existed. Read-only, 0 credits.
| Argument | Type | Description |
|---|---|---|
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
}
}
}Views, likes and comment counts for up to 50 YouTube videos IN ONE CALL, which is how to answer "how are my last twenty uploads doing" without one youtube_video_insights per video. Pass videoIds from list_youtube_videos. IT CARRIES NO TITLES, and that is the resource rather than a bug: VideoStatsSnippet publishes only publishTime, so join on videoId with list_youtube_videos when a name is needed. YouTube calls this endpoint "intentionally not atomic", so a short answer is normal: a video that is private, deleted, or not visible to the connected account simply does not come back, and this tool names the missing ids. Never report a missing id as zero views. Read-only, free.
| Argument | Type | Description |
|---|---|---|
| part | array | defaults to snippet + statistics. An unknown part 400s the whole call, so it is refused here |
| 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. |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| videoIds* | array | up to 50 video ids, from list_youtube_videos |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"videoIds"
],
"properties": {
"part": {
"type": "array",
"items": {
"enum": [
"snippet",
"statistics",
"contentDetails"
],
"type": "string"
},
"description": "defaults to snippet + statistics. An unknown part 400s the whole call, so it is refused here"
},
"brand": {
"type": "string",
"description": "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."
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"videoIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "up to 50 video ids, from list_youtube_videos"
}
}
}List the connected channel’s OWN recent uploads — video id, title, publish date and privacy — so you can resolve a video WITHOUT asking the user for a link. Call this whenever the user names a video loosely ("my latest", "the shorts one", part of a title) and match it yourself; only ask them when two titles are genuinely ambiguous. This is the tool that gets you the videoId every other YouTube tool needs. Includes UNLISTED and PRIVATE videos, which are invisible to any public search. Read-only, 0 credits. Needs a connected YouTube channel.
| Argument | 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 recent uploads to return (default 25, max 50) |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"brand": {
"type": "string",
"description": "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": {
"type": "number",
"description": "how many recent uploads to return (default 25, max 50)"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
}
}
}Mailchimp campaign PERFORMANCE: emails sent, unique opens and open rate (and the rate without machine opens), unique clicks and click rate, unsubscribes, bounces, abuse reports and, where a store is connected to Mailchimp, orders and REVENUE, per campaign sent in the last `days` (default 30), or one campaign by campaignId. Rates are fractions (0.25 = 25%). Read-only, free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).
| Argument | Type | Description |
|---|---|---|
| days | number | 1-3650, default 30 |
| limit | number | |
| offset | number | |
| campaignId | string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"days": {
"type": "number",
"description": "1-3650, default 30"
},
"limit": {
"type": "number"
},
"offset": {
"type": "number"
},
"campaignId": {
"type": "string"
}
}
}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.
| Argument | Type | Description |
|---|---|---|
| action* | string | what to do to the conversation |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| convoId* | string | from list_bluesky_convos |
| messageId | string | required for deleteMessage — from read_bluesky_dm |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"action",
"convoId"
],
"properties": {
"action": {
"enum": [
"accept",
"mute",
"unmute",
"lock",
"unlock",
"leave",
"deleteMessage"
],
"type": "string",
"description": "what to do to the conversation"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"convoId": {
"type": "string",
"description": "from list_bluesky_convos"
},
"messageId": {
"type": "string",
"description": "required for deleteMessage — from read_bluesky_dm"
}
}
}READ the product tags on a post the brand has already published, or ADD/MOVE tags on it. Pass `tags` to update: Meta's own behaviour is "updates coordinates if the product is already tagged; otherwise adds new tag" — so it is ADD-OR-MOVE, never replace-all, and it CANNOT be used to take a tag off. THERE IS NO WAY TO REMOVE A PRODUCT TAG: Meta documents Creating, Reading and Updating on this edge and no delete at all, so Hermoso will not guess at one — deleting the post is the only thing that removes its tags, and the reply says so rather than implying otherwise. The answer is always READ BACK from Instagram, and it separates tags that are STORED from tags that will actually be SHOWN — only an "approved" product ever appears on a published post. Read is free; the update costs 0 credits too. Whether Meta has approved Hermoso's app for the permission this needs is read LIVE from Meta: when it has not, list_connectors says so under Meta and this tool's refusal says Meta hasn't approved Hermoso for it yet. That is Meta's decision about Hermoso, not the user's connection, so never tell them to reconnect for it.
| Argument | Type | Description |
|---|---|---|
| tags | array | ADD or MOVE these tags. Omit to just read. ON A PHOTO each tag is {product_id, x, y}: x and y are FRACTIONS of the image, 0.0 (left/top) to 1.0 (right/bottom), 0.5,0.5 is the middle, and both are required. ON A REEL OR VIDEO it is {product_id} ALONE: Instagram takes no position on a video tag, so coordinates sent for one are not used and the reply says so. Max 20 on a photo or feed video, 30 on a Reel. A carousel is tagged on its SLIDES: pass the slide’s own media id. |
| pageId | string | Facebook Page id — omit when only one Page is connected |
| mediaId* | string | the numeric Instagram media id, from list_instagram_media |
Close, snooze, reopen or assign an Intercom conversation in the brand's shared inbox. action: close | snooze (needs snoozeUntil, a future date-time) | open | assign (assigneeId = a teammate id, or teamId, or assignToMe:true, or unassign:true; ids from list_intercom_teammates). close and assign may carry a message the CUSTOMER sees. Without confirm:true nothing changes and the preview comes back; call again with confirm:true when the user agrees. The new state is read back. Free. Needs Intercom connected (Settings > Connectors > Intercom, or connect_connector).
| Argument | Type | Description |
|---|---|---|
| action* | string | close, snooze (needs snoozeUntil), open or assign |
| teamId | string | team id to assign to, from list_intercom_teammates |
| confirm | boolean | true once the user agreed to this change |
| message | string | optional, close or assign only: the customer sees it |
| unassign | boolean | true = remove the current assignee |
| assignToMe | boolean | true = assign to the connected teammate |
| assigneeId | string | teammate id to assign to, from list_intercom_teammates |
Edit or delete a published LinkedIn post — personal profile or company Page. Pass postUrn, the full urn returned when it was published. action:"edit" changes ONLY THE COPY: LinkedIn does not allow the image or video of a published post to be replaced, so a new visual means a NEW post — tell the user that instead of promising a swap. action:"delete" is immediate and public and requires confirm:true.
| Argument | Type | Description |
|---|---|---|
| text | string | the new copy, for action:"edit" |
| 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. |
| action* | string | edit changes the copy only; delete removes the post and needs confirm |
| confirm | boolean | REQUIRED true to delete |
| postUrn* | string | the full LinkedIn post urn returned by publishing |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"postUrn",
"action"
],
"properties": {
"text": {
"type": "string",
"description": "the new copy, for action:\"edit\""
},
"brand": {
"type": "string",
"description": "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."
},
"action": {
"enum": [
"edit",
"delete"
],
"type": "string",
"description": "edit changes the copy only; delete removes the post and needs confirm"
},
"confirm": {
"type": "boolean",
"description": "REQUIRED true to delete"
},
"postUrn": {
"type": "string",
"description": "the full LinkedIn post urn returned by publishing"
}
}
}Add a creator to — or remove one from — this brand’s Partnership Ads lists. Name at least one list: `allowAds` (whose content this profile may RUN AS AN ADVERT) and/or `allowTagging` (who may TAG this brand as a paid partner on their own post). NEITHER DEFAULTS ON, because one of them lets somebody attach this brand’s name to their own post. ADDING IS A REQUEST, NOT AN APPROVAL: Meta’s own name for the operation is "send permission request", the creator accepts on their own Instagram account in their own time, and until the read-back reports approved this profile cannot advertise their content. Never tell anyone a partnership is live off the back of this call — report the status the reply carries. THE OUTCOME IS READ BACK OFF META’S OWN LIST, NEVER TAKEN FROM THE WRITE’S REPLY — because Meta answers the ADD with HTTP 500 (code 1, “Please reduce the amount of data you’re asking for”) while performing it, measured 4/4 on 2026-08-25. If this reply carries `metaWriteError` alongside a successful summary, that error is Meta noise about a write that HAPPENED: do not retry, and do not tell the user it failed. The reply’s `verdict` is one of applied / already / unchanged / unknown, and `unknown` means the list could not be re-read — which is not the same as nothing having changed. REMOVING FLIPS A STATUS TO “Canceled”; it does not delete the row. One tool does both lists because Meta removes from them with two DIFFERENT verbs (DELETE on one, a `revoke` flag on the other) and a caller who assumes one silently does nothing on the other. 0 credits.
| Argument | Type | Description |
|---|---|---|
| pageId | string | Facebook Page id — omit when only one Page is connected |
| remove | boolean | revoke instead of add |
| creator* | string |
Edit the text of, or delete, a published post. target:"facebook" -> edit the message (action:"edit", message:…) OR delete (action:"delete"); target:"threads" -> delete only (Threads has no edit API); target:"instagram" -> DELETE ONLY — Meta lets you change nothing on a published Instagram post except whether comments are enabled, so a caption cannot be fixed; deleting covers ordinary posts, Stories, Reels and ENTIRE carousel albums (Instagram cannot remove one card out of an album — pass the album’s own media id, from list_instagram_media). Deleting is permanent. FOR INSTAGRAM, CALL IT WITHOUT confirm FIRST: nothing is deleted and you get back the post’s real caption, its likes and comments and how many carousel cards go with it — show the user exactly that, then call again with confirm:true plus confirmName (and confirmChildren for an album) if the refusal asks for them. A post nobody has liked or commented on yet stays a one-call delete. INSTAGRAM DELETE NEEDS A RECONNECT ON AN OLD CONNECTION: the `instagram_manage_contents` permission joined Hermoso’s Meta grant on 2026-08-05, so any Meta connection made before then must be reconnected (Settings > Connectors > Meta) before Instagram will accept a delete. Call the tool rather than pre-refusing — every refusal it can raise names the one thing that fixes it.
| Argument | 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. |
| action* | string | edit the text (FB only), delete the post, or cover: replace the cover of a published Facebook video or Reel (postId = its video id) |
| pageId |
Create, rename or delete a section of a Pinterest board. action:"create" needs name; "rename" needs sectionId and name; "delete" needs sectionId and confirm:true (without confirm it reports what would go). Read back from Pinterest. 0 credits.
| Argument | Type | Description |
|---|---|---|
| name | string | create, rename: up to 180 characters |
| action | string | default create |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| boardId* | string | from list_pinterest_boards |
| confirm | boolean | delete: required true |
| sectionId | string | rename, delete: from list_pinterest_board_sections |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"boardId"
],
"properties": {
"name": {
"type": "string",
"description": "create, rename: up to 180 characters"
},
"action": {
"enum": [
"create",
"rename",
"delete"
],
"type": "string",
"description": "default create"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"boardId": {
"type": "string",
"description": "from list_pinterest_boards"
},
"confirm": {
"type": "boolean",
"description": "delete: required true"
},
"sectionId": {
"type": "string",
"description": "rename, delete: from list_pinterest_board_sections"
}
}
}Create, edit or delete a Pinterest PRODUCT FEED — the CSV / TSV / XML file of your products Pinterest downloads on a schedule to fill the catalog Shopping ads serve from. action:"create" needs name, location (the feed URL), defaultCountry and defaultLocale (Pinterest refuses a feed without them), optionally format, defaultCurrency, defaultAvailability, catalogId, schedule {time HH:MM, timezone}, credentials. action:"update" changes those on feedId. action:"delete" removes feedId and needs confirm:true — without it nothing changes and the reply names what would go. Read back from Pinterest every time. 0 credits.
| Argument | Type | Description |
|---|---|---|
| name | string | feed name; required on create |
| action | string | default create |
| feedId | string | feed to update or delete, from list_pinterest_catalogs what:feeds |
| format | string | feed file format: CSV, TSV, XML or INTEGRATION |
| status | string | ACTIVE or INACTIVE |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| confirm | boolean | required true for delete |
Create, edit or delete a Pinterest PRODUCT GROUP — the slice of a catalog a Shopping ad group promotes (one brand, a price band, a custom label). action:"create" needs name, feedId and filters; "update" changes name / description / filters on productGroupId; "delete" needs confirm:true. Read back from Pinterest. 0 credits.
| Argument | Type | Description |
|---|---|---|
| name | string | product group name; required on create |
| action | string | default create |
| feedId | string | feed the group draws products from; required on create |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| confirm | boolean | required true for delete |
| filters | object | Pinterest product-group filters: {"all_of":[{"BRAND":{"values":["Acme"]}},{"MIN_PRICE":{"values":10}}]} or {"any_of":[…]}. Keys: AVAILABILITY, BRAND, CONDITION, CURRENCY, CUSTOM_LABEL_0-4, CUSTOM_NUMBER_0-4, GENDER, GOOGLE_PRODUCT_CATEGORY_0-6, ITEM_GROUP_ID, ITEM_ID, MAX_PRICE, MEDIA_TYPE, MIN_PRICE, PINTEREST_PRODUCT_CATEGORIES, PRODUCT_GROUP, PRODUCT_TYPE_0-4, TITLE_KEYWORDS — an unknown key is refused by name |
| isFeatured |
Add, rename or delete a tab in a Google Spreadsheet. action:"add" + title · action:"rename" + tab + newTitle · action:"delete" + tab. Name the tab by its TITLE or its numeric sheetId (list_sheet_tabs gives both); an unknown tab is refused with the real list rather than a Google error nobody can map back. DELETING a tab destroys everything on it: call it without confirm first to get the filled-cell count, then confirm:true + confirmCells. Google does not allow removing the LAST remaining tab in a file, and that is refused by name with the way out (clear it, or delete the whole file with delete_drive_file). Every action is read back from the spreadsheet before it is reported as done.
| Argument | Type | Description |
|---|---|---|
| tab | string | which tab — its title or numeric sheetId (rename / delete) |
| title | string | the name for the new tab (action:"add") |
| action* | string | add, rename or delete a tab |
| confirm | boolean | true deletes a tab that holds data; needs confirmCells |
| newTitle | string | what to rename the tab to (action:"rename") |
| sheetUrl | string | Google Sheets link, instead of spreadsheetId |
| confirmCells | number |
Enable or disable the hashtags TikTok counts as this brand's. ADD takes a LIST of up to 10 per call (50 enabled per brand in total); REMOVE takes exactly ONE, because TikTok publishes no bulk removal and a list would silently drop all but one. TWO TIMING RULES THAT ARE EASY TO TRIP: a newly enabled hashtag is not counted for 24 HOURS, so list_tiktok_brand_hashtag_posts shows nothing for it until then; and it CANNOT BE REMOVED FOR 7 DAYS after being enabled, so this tool reads the enable date first and reports the exact wait rather than relaying TikTok's undated refusal. A tag must either be on the AVAILABLE list or contain the account handle as a substring, or TikTok rejects it. THE ANSWER IS THE READ-BACK, not the 200: TikTok's remove response is an empty body, so success means the tag has left the enabled list. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION with the brand-insights permission.
| Argument | Type | Description |
|---|---|---|
| action* | string | ADD (a list of up to 10 tags) or REMOVE (exactly one tag) |
| hashtag | string | REMOVE only. Exactly one |
| hashtags | array | ADD only. Up to 10, with or without the leading # |
| username | string | normally resolved from the authorization itself |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"action"
],
"properties": {
"action": {
"enum": [
"ADD",
"REMOVE"
],
"type": "string",
"description": "ADD (a list of up to 10 tags) or REMOVE (exactly one tag)"
},
"hashtag": {
"type": "string",
"description": "REMOVE only. Exactly one"
},
"hashtags": {
"type": "array",
"items": {
"type": "string"
},
"description": "ADD only. Up to 10, with or without the leading #"
},
"username": {
"type": "string",
"description": "normally resolved from the authorization itself"
}
}
}Schedule, update, go live on, or cancel an X LIVE VIDEO broadcast on the connected account. action 'schedule' needs sourceId (from manage_x_stream_source), startAt, and manualPublish, which is REQUIRED with no default: true = nothing airs until action 'go_live'; false = X puts the brand ON AIR automatically at startAt (needs confirm:true). 'update' changes only the fields you pass (Hermoso re-sends the rest, because X's update replaces the whole schedule). 'go_live' airs a manualPublish schedule NOW, publicly, as the brand (confirm:true). 'cancel' deletes a schedule and cannot be undone (confirm:true). X retired recurring broadcasts: schedule each occurrence on its own. WITHOUT A SCHEDULE: 'create' makes a broadcast on sourceId that posts nothing, 'publish' puts it on air (confirm:true; skipPost:true skips the announcing post), 'end' stops one for everyone (confirm:true), 'delete' removes one and its replay (confirm:true; end it first). X limits broadcasting by account (Premium or 1,000 followers) and says so. Confirm the exact broadcast, time and stream with the user before passing confirm:true; a refusal is free. Needs X connected with the broadcast permissions added 2026-09-21: an X connection made before then must be reconnected once under Settings > Connectors > X (the tool says so without calling X). Costs credits (X bills per call).
| Argument | Type | Description |
|---|---|---|
| id | string | broadcast id, for every action but schedule and create |
| endAt | string | ISO 8601 or epoch ms; X requires one on update |
| title | string | broadcast title |
| action* | string | schedule, update, go_live, cancel, create, publish, end or delete |
Manage one of the connected X account's X Chat conversations (id from list_x_chats). For the brand only: 'mute' / 'unmute'; 'delete' removes it from the brand's inbox for good while the others keep theirs (confirm:true). For everyone in it: 'set_disappearing' (disappearAfter like '24h' or '7d'), 'clear_disappearing', 'screen_capture' (blockScreenshots OR notifyOnScreenshot, one per call). GROUPS (id starts with g; the brand must be an admin): 'admin_settings' (adminOnly {addMember, editGroupInfo, sendMessage, startCall, editMessageTtl, blockScreenCapture}; sendMessage:true makes it announcement-only), 'add_admins' / 'remove_admins' (userIds), 'remove_members' (userIds, confirm:true), 'leave' (confirm:true), 'approve_admin_request' / 'reject_admin_request' (userId), 'reject_join_requests' (userIds; APPROVING a join re-keys the encrypted group, so it is done in the X app), 'enable_invite' (optional title, avatarUrl, welcomeMessage, expiresAt; X stores that preview UNENCRYPTED and shows it to anyone with the link), 'disable_invite' (the shared link dies for good: confirm:true). INBOX, for the brand only: 'archive' / 'unarchive', 'mark_read' (may send the other person a read receipt). MESSAGE REQUESTS: 'accept_request' (id = the sender's numeric id, or the request's conversationId from list_x_chats view message_requests) moves the conversation into the inbox; show the user who sent it first (list_x_dms with participantId reads the message only if it was not sent encrypted). Confirm the exact conversation with the user before passing confirm:true; a refusal is free. The answer is X's own yes or no, never an echo. Needs X connected with direct-message access: an X connection made before 2026-08-25 must be reconnected once under Workspace > Connectors > X (the tool says so without calling X). Costs credits (X bills per call).
| Argument | Type | Description |
|---|---|---|
| id* | 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) |
The connected X account's STREAM SOURCES, the RTMP ingest points a live broadcast plays from. action 'list' (default) and 'get' (id) show each source's id, stream key, RTMPS URL, region and whether video is arriving; 'create' (name, optional region) makes one; 'rename' (id, name); 'delete' (id, confirm:true: it kills the stream key); 'region' reads the nearest ingest region. Pass a source's id as sourceId to manage_x_broadcast. The stream key is a secret, so never post it. Needs X connected with the broadcast permissions added 2026-09-21: an X connection made before then must be reconnected once under Settings > Connectors > X (the tool says so without calling X). Costs credits (X bills per call).
| Argument | Type | Description |
|---|---|---|
| id | string | source id, for get, rename and delete |
| name | string | label, for create and rename |
| action | string | default 'list' |
| region | string | create only: 'atla' or 'pdxa', or an AWS region X maps onto one |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| confirm | boolean | true only after the user approved deleting this source |
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.
| Argument | Type | Description |
|---|---|---|
| name | string | a label shown in YouTube’s caption picker |
| text | string | the caption file body — SRT or WebVTT. Required for upload. |
| draft | boolean | true keeps the track unpublished while you review it |
| action* | string | upload | update | delete |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| confirm | boolean | required to delete |
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.
| Argument | Type | Description |
|---|---|---|
| hl | string | language for the returned titles, e.g. "en" |
| type | string | singlePlaylist | multiplePlaylists | popularUploads | recentUploads | likes | allPlaylists | likedPlaylists | recentPosts | recentActivity | liveEvents | upcomingEvents | completedEvents | multipleChannels | postedVideos | postedPlaylists | subscriptions. Required to create or update |
| style | string | leave it off to let YouTube choose |
| title | string | the heading, and only multiplePlaylists and multipleChannels take one — YouTube writes the heading for every other type |
Create, update or delete a playlist on the connected channel. CREATE makes it PRIVATE unless you pass privacy — a public playlist appears on the channel immediately, so publishing is always a deliberate choice. UPDATE merges: an omitted title or description is LEFT ALONE rather than erased. DELETE is confirm-gated and does NOT delete the videos — what dies is the playlist, its ordering and every link or embed pointing at its URL; a playlist that is public or has videos in it additionally needs confirmTitle echoed back exactly, because confirm alone proves you meant to delete something and not that you aimed at the right playlist. To take a playlist out of public view WITHOUT destroying it use action="update" with privacy="private" — that is reversible and delete is not. 0 credits. Needs a connected YouTube channel.
| Argument | Type | Description |
|---|---|---|
| title | string | required on create; on update, omit to leave unchanged |
| action* | string | create | update | delete |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| confirm | boolean | required to delete |
| privacy | string | default private on create |
| playlistId | string | required for update and delete |
PUT A CUSTOM COVER ON A 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. 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.
| Argument | Type | Description |
|---|---|---|
| action | string | defaults to 'list' |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| imageId | string | for action:'delete' — from action:'list' |
| imageUrl | string | For action:'set' |
| playlistId* | string | from list_youtube_playlists |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"playlistId"
],
"properties": {
"action": {
"enum": [
"list",
"set",
"delete"
],
"type": "string",
"description": "defaults to 'list'"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"imageId": {
"type": "string",
"description": "for action:'delete' — from action:'list'"
},
"imageUrl": {
"type": "string",
"description": "For action:'set'"
},
"playlistId": {
"type": "string",
"description": "from list_youtube_playlists"
}
}
}Add a video to a playlist, remove an entry, or move an entry to a new position. ADD takes playlistId + videoId and returns the new entry’s `itemId`. REMOVE and MOVE take that `itemId` — NOT the videoId — because one video can sit in a playlist more than once, so a videoId does not identify which row to touch; list_youtube_playlists with a playlistId returns the itemId of every entry. Removing an entry does NOT delete the video, only its place in the playlist. Positions are zero-based. 0 credits. Needs a connected YouTube channel.
| Argument | Type | Description |
|---|---|---|
| action* | string | add | remove | move |
| itemId | string | the playlist-ENTRY id — required for remove and move (from list_youtube_playlists) |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| videoId | string | required for add |
| position | number | zero-based position; required for move, optional on add (default: appended) |
| playlistId | string | required for add |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"action"
],
"properties": {
"action": {
"enum": [
"add",
"remove",
"move"
],
"type": "string",
"description": "add | remove | move"
},
"itemId": {
"type": "string",
"description": "the playlist-ENTRY id — required for remove and move (from list_youtube_playlists)"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"videoId": {
"type": "string",
"description": "required for add"
},
"position": {
"type": "number",
"description": "zero-based position; required for move, optional on add (default: appended)"
},
"playlistId": {
"type": "string",
"description": "required for add"
}
}
}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.
| Argument | Type | Description |
|---|---|---|
| status | string | 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. |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| 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 |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"status": {
"enum": [
"request",
"accepted"
],
"type": "string",
"description": "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."
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"convoId": {
"type": "string",
"description": "from list_bluesky_convos. OMIT to mark EVERY conversation on the account read."
},
"messageId": {
"type": "string",
"description": "mark read only up to this message; omit to clear the whole conversation"
}
}
}Read what is INSIDE a product catalog — how many products, which product sets — and get the exact sentence describing what deleting it would destroy. Free and read-only. CALL THIS BEFORE PROPOSING A DELETE: deleting a catalog is permanent and Meta offers no archive and no undo, so naming the cost should not require aiming the destructive call at the target and reading its refusal. It also gives you the catalog’s exact name, which delete_meta_catalog requires echoed back. 0 credits.
| Argument | Type | Description |
|---|---|---|
| catalogId* | string | numeric catalog id from list_meta_catalogs |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"catalogId"
],
"properties": {
"catalogId": {
"type": "string",
"description": "numeric catalog id from list_meta_catalogs"
}
}
}Organic performance for the brand’s connected Facebook Page — views and unique reach (page_media_view / page_total_media_view_unique, Meta’s own replacements for the impressions family it retired), post engagements, video views, daily follows, plus follower and Page-like counts — with the linked Instagram account’s headline numbers alongside. This is ORGANIC reach. Any metric Meta returns no value for is named as MISSING data, which must never be reported as zero.
| Argument | Type | Description |
|---|---|---|
| pageId | string | Page id — omit when the profile has exactly one Page connected |
| period | string | window (default week) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"pageId": {
"type": "string",
"description": "Page id — omit when the profile has exactly one Page connected"
},
"period": {
"enum": [
"day",
"week",
"days_28"
],
"type": "string",
"description": "window (default week)"
}
}
}Performance for a single organic post — on Facebook views/reach (post_media_view, post_total_media_view_unique — Meta’s own replacements for the retired impressions family), clicks, reactions and video watch time; on Instagram views, reach, likes, comments, saves, shares, total interactions and (where the media type has them) follows, profile visits, story navigation and reel watch time. Use it to find which organic posts earned their reach before turning one into a paid ad. A metric Meta returns no value for is reported by name as MISSING — never read it as zero.
| Argument | 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. |
| pageId | string | Page id — omit when only one Page is connected |
| postId* | string | post/media id returned by post_to_meta |
| target | string | which metric set to ask for; an Instagram media id is detected and read as Instagram either way (default: Facebook for a Facebook id) |
| 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. |
What Meta ACTUALLY has recorded for this app on a Page — the fields it will push, read straight back from Meta rather than from anything we stored. CHECK THIS BEFORE CONCLUDING THAT WEBHOOKS ARE NOT WORKING: an unsubscribed Page is completely silent and looks identical to a quiet one, so an empty event feed is not evidence of anything until you have seen the subscription. Read-only, 0 credits.
| Argument | Type | Description |
|---|---|---|
| pageId | string | Facebook Page id — omit when only one Page is connected |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"pageId": {
"type": "string",
"description": "Facebook Page id — omit when only one Page is connected"
}
}
}Moderate a comment on the brand’s Facebook or Instagram post. Hide is the default and can be undone. Deleting is PERMANENT and requires confirm:true after the user has agreed.
| Argument | Type | Description |
|---|---|---|
| action | string | default hide |
| pageId | string | Page id — omit when only one Page is connected |
| 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. |
| confirm | boolean | required (true) only for delete |
| commentId* | string | comment id from list_meta_comments |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"commentId"
],
"properties": {
"action": {
"enum": [
"hide",
"unhide",
"delete"
],
"type": "string",
"description": "default hide"
},
"pageId": {
"type": "string",
"description": "Page id — omit when only one Page is connected"
},
"account": {
"type": "string",
"description": "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."
},
"confirm": {
"type": "boolean",
"description": "required (true) only for delete"
},
"commentId": {
"type": "string",
"description": "comment id from list_meta_comments"
}
}
}Moderate one comment on a TikTok post the authorized account owns: LIKE, UNLIKE, HIDE, UNHIDE or DELETE. TWO RULES TIKTOK ENFORCES AND THIS TOOL STATES UP FRONT. (1) YOU CAN ONLY DELETE A COMMENT THIS ACCOUNT WROTE — anyone else's can be hidden but never deleted, so HIDE is the tool for an unwanted comment from a stranger, and it is reversible. DELETE is permanent and confirm-gated. (2) UNHIDE IS NOT GUARANTEED: TikTok says a comment may stay hidden because ITS OWN moderation, privacy or spam filters are what hid it, in which case accepting the request changes nothing — so pass videoId and the reply reads the comment back and reports its real status rather than claiming success from a 200. HIDE and UNHIDE require videoId; LIKE, UNLIKE and DELETE do not. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION (see tiktok_account_status).
| Argument | Type | Description |
|---|---|---|
| action* | string | LIKE, UNLIKE, HIDE, UNHIDE or DELETE; DELETE only works on comments this account wrote |
| confirm | boolean | REQUIRED true for DELETE, which is permanent — call without it to see exactly what would happen |
| videoId | string | REQUIRED for HIDE and UNHIDE; pass it on the others too and the reply reads the comment back to prove the change landed |
| commentId* | string | from list_tiktok_comments |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"commentId",
"action"
],
"properties": {
"action": {
"enum": [
"LIKE",
"UNLIKE",
"HIDE",
"UNHIDE",
"DELETE"
],
"type": "string",
"description": "LIKE, UNLIKE, HIDE, UNHIDE or DELETE; DELETE only works on comments this account wrote"
},
"confirm": {
"type": "boolean",
"description": "REQUIRED true for DELETE, which is permanent — call without it to see exactly what would happen"
},
"videoId": {
"type": "string",
"description": "REQUIRED for HIDE and UNHIDE; pass it on the others too and the reply reads the comment back to prove the change landed"
},
"commentId": {
"type": "string",
"description": "from list_tiktok_comments"
}
}
}Moderate the connected X account's live broadcast chat. action 'remove_message' (broadcastId + messageId; cannot be undone, confirm:true), 'mute' (broadcastId + userId or username; until = when a timeout ends, omit to mute indefinitely; messageId removes that message while muting), 'unmute', 'list_mutes' (everyone muted across the brand's chats), 'mute_everywhere' / 'unmute_everywhere' (userId or username, every chat of the brand; when the brand moderates another host, broadcastId scopes it to that host), and the account's PERSISTENT chat moderators, which apply to every current and future broadcast: 'list_moderators', 'add_moderator', 'remove_moderator' (userId or username). Only the host or a moderator can moderate, and X says so if not. A username costs one extra lookup. Needs X connected with the broadcast permissions added 2026-09-21: an X connection made before then must be reconnected once under Settings > Connectors > X (the tool says so without calling X). Costs credits (X bills per call).
| Argument | Type | Description |
|---|---|---|
| until | string | mute end, ISO 8601 or epoch ms |
| action* | string | remove_message, mute, unmute, list_moderators, add_moderator, remove_moderator, list_mutes, mute_everywhere or unmute_everywhere |
| userId | string | numeric X account id |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| confirm | boolean |
Moderate a comment on the connected channel — the YouTube twin of moderate_meta_comment. ACTIONS: publish (make it visible again) · hold (hide it pending review) · reject (hide it as unfit for display) · spam (report it to YouTube — a REPORT, not a removal, so do not tell the user it is gone) · delete (permanent; YouTube has no undelete for a comment). hold, reject and publish are all reversible with each other, so only delete is confirm-gated. If the goal is just to take a comment out of public view, use reject rather than delete. banAuthor is valid ONLY with action="reject" — that is YouTube’s own restriction — and auto-rejects every future comment that author leaves on the channel. Get commentId from list_youtube_comments. 0 credits. Needs a connected YouTube channel.
| Argument | Type | Description |
|---|---|---|
| action* | string | publish | hold | reject | spam | delete |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| confirm | boolean | required for delete |
| banAuthor | boolean | reject only — also auto-reject every future comment from this author |
| commentId* | string | id of the comment (from list_youtube_comments) |
action list (default): every sharing link and person with access to a OneDrive file or folder: link URL, view or edit, scope, expiry, password, whether it is inherited from a parent folder. action remove: revoke one (permissionId from the list); anyone relying on it loses access at once and the link cannot be restored, so WITHOUT confirm:true nothing is removed and you get what would go. An inherited permission is removed on the parent folder, and the owner's own access cannot be removed. Removal is verified by reading the list back. 0 credits. Needs OneDrive connected.
| Argument | Type | Description |
|---|---|---|
| action | string | list (default) or remove |
| fileId* | string | OneDrive file or folder id |
| confirm | boolean | required true for remove |
| permissionId | string | for remove |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"fileId"
],
"properties": {
"action": {
"enum": [
"list",
"remove"
],
"type": "string",
"description": "list (default) or remove"
},
"fileId": {
"type": "string",
"description": "OneDrive file or folder id"
},
"confirm": {
"type": "boolean",
"description": "required true for remove"
},
"permissionId": {
"type": "string",
"description": "for remove"
}
}
}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.
| Argument | 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. |
| unpin | boolean | true to unpin instead of pin |
| chatId* | string | the channel’s @username or numeric chat id |
| notify | boolean | true to notify members; default silent |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| messageId | number | required to PIN; optional when unpinning |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"chatId"
],
"properties": {
"brand": {
"type": "string",
"description": "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."
},
"unpin": {
"type": "boolean",
"description": "true to unpin instead of pin"
},
"chatId": {
"type": "string",
"description": "the channel’s @username or numeric chat id"
},
"notify": {
"type": "boolean",
"description": "true to notify members; default silent"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"messageId": {
"type": "number",
"description": "required to PIN; optional when unpinning"
}
}
}Read lists on the connected Pinterest account. what: followers, following, following_boards, websites (each site and whether it is claimed; a site that is not claimed explains a refused link) or website_verification (the code to put on a site to claim it). Claiming itself happens in Pinterest. Read-only, 0 credits.
| Argument | Type | Description |
|---|---|---|
| what* | string | which list |
| limit | number | up to 250 per page |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| bookmark | string | the next page, from the last reply |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"what"
],
"properties": {
"what": {
"enum": [
"followers",
"following",
"following_boards",
"websites",
"website_verification"
],
"type": "string",
"description": "which list"
},
"limit": {
"type": "number",
"description": "up to 250 per page"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"bookmark": {
"type": "string",
"description": "the next page, from the last reply"
}
}
}ORGANIC Pinterest performance — impressions, saves, Pin clicks, outbound clicks and their rates, for the whole ACCOUNT, for the TOP PINS, for the TOP VIDEO PINS (with view-through and average watch time), or for ONE Pin. This is unpaid reach; paid Pinterest ads have their own report. Use scope:"top_pins" to answer "what is actually working on our Pinterest" — it ranks the account’s own Pins by whichever metric you sort on. NOTE Pinterest keeps only 90 DAYS of organic analytics and refuses a longer window, which is refused here with the reason rather than as an opaque error. A VIDEO Pin takes a different metric set from a static one (pass video:true for scope:"pin"). THERE IS NO BOARD ANALYTICS: Pinterest’s v5 API publishes no such endpoint, so board-level performance genuinely does not exist in any API — do not promise it. An unknown metric is refused by name, and a metric Pinterest omits from a row is MISSING data ("if a column has no value, it may not be returned"), never a measured zero. Works on Pinterest’s Trial access tier — unlike creating Pins, every read row in Pinterest’s access-tier table is available on Trial. Read-only, 0 credits.
| Argument | Type | Description |
|---|---|---|
| limit | number | top_pins / top_video_pins: how many (1–50, default 10) |
| pinId | string | required for scope:"pin" — the id post_to_pinterest returned |
| scope | string | default account |
| since | string | YYYY-MM-DD, default 30 days ago; Pinterest allows at most 90 days back |
| until | string |
WHO the Pinterest audience IS, rather than what it did — interest categories each carrying an affinity INDEX, plus demographics (ages, countries, devices, genders, metros). Three audiences: YOUR_TOTAL_AUDIENCE, YOUR_ENGAGED_AUDIENCE, and PINTEREST_TOTAL_AUDIENCE as the baseline to compare the other two against. This is an input to a creative brief, not a performance report. SAY THIS WHEN REPORTING: an affinity index is how much MORE likely this audience is to engage with a category than Pinterest’s baseline — it is a comparison, never a count — and when Pinterest flags size_is_upper_bound the audience size is an upper bound, not a measurement. There is no date range: Pinterest returns its current snapshot and names the date it is for. Read-only, 0 credits.
| Argument | Type | Description |
|---|---|---|
| adAccountId | string | Pinterest ad account id; omit when only one is shared with this profile |
| insightType | string | default YOUR_TOTAL_AUDIENCE |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"adAccountId": {
"type": "string",
"description": "Pinterest ad account id; omit when only one is shared with this profile"
},
"insightType": {
"enum": [
"YOUR_TOTAL_AUDIENCE",
"YOUR_ENGAGED_AUDIENCE",
"PINTEREST_TOTAL_AUDIENCE"
],
"type": "string",
"description": "default YOUR_TOTAL_AUDIENCE"
}
}
}WHICH AUDIENCE SEGMENT actually delivered on Pinterest — ad performance broken down by keyword, targeted interest, age bucket, gender, location, region, country, placement, app type, media type and more. targetingTypes is REQUIRED because it is what the report breaks down BY. scope:"account" covers the whole ad account; "campaign" / "adGroup" / "ad" each REQUIRE their own id list, because Pinterest publishes no all-of-them form at those levels — that is Pinterest’s shape, not a limitation here. 90 days back in windows of at most 90 days, refused locally with the reason. An unknown targeting type is refused BY NAME; note Pinterest’s four per-level enums differ slightly, so a value valid at one level can still be refused at another. Read-only, 0 credits.
| Argument | Type | Description |
|---|---|---|
| adIds | array | ad ids to report on; needed for scope ad |
| scope | string | default account |
| since | string | start date, YYYY-MM-DD; at most 90 days back |
| until | string | end date, YYYY-MM-DD |
| columns | array | Pinterest metric column names to return |
| adGroupIds | array | ad group ids to report on; needed for scope adGroup |
| adAccountId | string |
What people are SEARCHING FOR on Pinterest right now — Pinterest Trends, from the connected Pinterest account (no ad account needed). kind:"keywords" (default): the top 50 trending searches in a market by trendType growing (rising fast this quarter), monthly, yearly or seasonal, filterable by interests, ages, genders and includeKeywords, each with week / month / year growth in search volume. kind:"topics": the featured trend topics with example Pins. kind:"product_categories": the fastest-growing SHOPPING categories (verticals FASHION / HOME_DECOR / BEAUTY). kind:"product_category_details": demographics and trend lines for named categories. Use it to plan seasonal content and Pin titles. Growth is relative search interest, never a count of people. Read-only, 0 credits.
| Argument | Type | Description |
|---|---|---|
| ages | array | age bands to filter keyword trends by, e.g. 25-34 |
| kind | string | what to list: keywords (default), topics, product_categories or product_category_details |
| limit | number | keywords: 1–50 |
| region | string | keywords: US, CA, DE, FR, ES, IT, DE+AT+CH, GB+IE, IT+ES+PT+GR+MT, PL+RO+HU+SK+CZ, SE+DK+FI+NO, NL+BE+LU, AR, BR, CO, MX, MX+AR+CO+CL, AU+NZ. topics / product categories: US, GB+IE, CA. Default US; "UK" means GB+IE. |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
Use this when the user wants a week of social posts planned, or answers yes or no to planning their week. It drafts one batch of 5 posts for the active brand: a new image, a caption and a suggested day and time for each. Without confirm it returns the price and whether the batch fits the account’s posting allowance, and spends nothing. confirm:true starts the batch and bills credits; read the posts with get_job. The posts stay drafts until the user approves them with run_post_refill, and this tool publishes nothing. decline:true records that the user said no.
| Argument | Type | Description |
|---|---|---|
| confirm | boolean | true only after the user agreed to the price |
| decline | boolean | true when the user said no |
| timezone | string | the user’s IANA timezone when known, e.g. America/New_York; the suggested times use it |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"confirm": {
"type": "boolean",
"description": "true only after the user agreed to the price"
},
"decline": {
"type": "boolean",
"description": "true when the user said no"
},
"timezone": {
"type": "string",
"description": "the user’s IANA timezone when known, e.g. America/New_York; the suggested times use it"
}
}
}Use this when the user asks which of their social posts did best. 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.
| Argument | Type | Description |
|---|---|---|
| axis | string | what to group by — default hook; recipe = the format of the creative |
| days | number | look back N days (1-730) over the whole history; omit for the recent posts only |
| 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 |
Use this when the user wants to post to Bluesky. Publishing costs no credits. Publish a post to Bluesky as the connected account. Text up to 300 characters — Bluesky ALSO caps a post at 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; Hermoso checks both before spending the round trip and says which limit and by how much. @MENTIONS written as the full handle (@alice.bsky.social) are looked up and tagged as Bluesky mentions; one that does not resolve stays plain text and the reply names it. MEDIA: either up to 10 images (imageUrls + altText; 5–10 post as a Bluesky gallery, more than 10 is refused, never cut; a picture over Bluesky's 2 MB limit is re-encoded to fit and the reply says so) OR one MP4 video (videoUrl + videoAlt), never both — a Bluesky post record carries a single embed and images and video are two different embed types. Video is MP4 only, up to 300MB at Bluesky's end (Hermoso can fetch up to 150MB from a URL), with optional WebVTT caption tracks; the aspect ratio is measured from the file. Bluesky requires a CONFIRMED EMAIL on the account before it will process any video — if it is unconfirmed you get a refusal saying so, and reconnecting will not help. Links in the text are made clickable automatically. LINK CARDS: Bluesky does NOT scrape links, so a URL posted bare renders as plain blue text — the client composing the post has to build the card. Hermoso builds one AUTOMATICALLY when the post has a URL and NO media: it fetches the page, uses its title/description and uploads its image as the card thumbnail. Pass linkCard:false to suppress it, or linkCard:{uri,title,description,thumbUrl} to control it (give both title and description and the page is not fetched at all). A POST CARRIES ONE EMBED, so a card and images/video cannot both ride: if you pass linkCard explicitly ALONGSIDE media the call is REFUSED by name rather than silently dropping one, and if the URL was merely in the text the MEDIA WINS and the reply says the card was skipped (the link stays clickable either way). Returns the post's public bsky.app URL. Connect at Settings > Connectors > Bluesky.
| Argument | Type | Description |
|---|---|---|
| hook |
Post to the user's Discord server. Free. With the Hermoso bot added it posts as the bot to channel (or the brand's default channel) with attachments up to 25 MB a message, replyTo, threadId (inside a thread), startThread (a thread under the post) and threadName (a forum channel); the messageId it returns ("<channel>/<message>") is what edit, delete and get_discord_message take. Without the bot it posts through the brand's announcement webhook (never the alert one): ≤2000 characters, files up to 20 MB attached (up to 10), larger ones as links. Mentions never ping unless mentions:true. Reactions are the only per-post number (get_discord_message). Not connected: connect_connector with provider discord and no fields returns the install link.
| Argument | Type | Description |
|---|---|---|
| hook | string | The post's angle |
| text | string | the announcement, ≤2000 characters |
| 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. |
| ideaId | string | short id of the content-plan idea this post came from |
| recipe | string | the post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format |
| silent | boolean |
Publish a Post to the brand’s Google Business Profile — the panel that appears on Google Search and Maps for the business. Text, optionally ONE PHOTO, and a call-to-action button. Google’s Posts API accepts NO VIDEO, so pass a still image. This PUBLISHES immediately and publicly on the business listing — show the user the exact text, photo and button and get an explicit yes BEFORE calling. If the account manages several listings, call list_business_locations first and pass locationId. EVENT and OFFER posts both REQUIRE a title and a start date (Google’s rule). On an OFFER, Google IGNORES the button’s link — pass redeemOnlineUrl instead. A CALL button dials the number on the listing and takes no link. Needs Google Business Profile connected (Settings > Connectors > Google Business Profile).
| Argument | Type | Description |
|---|---|---|
| hook | string | WHAT ANGLE this post is built on, recorded only at publish time. Omit it and this post never votes on which hook works. |
| link | string | the URL the button opens — not for CALL, and ignored on an OFFER |
| 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. |
| title | string | headline — REQUIRED for EVENT and OFFER |
| ideaId | string | short id of the content-plan idea this post came from |
Use this when the user wants to post to their personal LinkedIn profile. Publishing costs no credits. Publish a post to the user’s connected LinkedIn profile — text, and optionally an image (imageUrl), a VIDEO (videoUrl, with captionsSrt and a videoThumbnailUrl cover), a 2–20 image MULTI-IMAGE post (imageUrls), a LINK post with a real preview card (linkUrl + linkTitle / linkDescription / linkThumbnailUrl — LinkedIn never scrapes the page), a DOCUMENT post (documentUrl: a PDF / PPTX readers swipe through), and @mentions of companies (mentions). The image does NOT have to be something Hermoso generated: LinkedIn is served the bytes from us, so the URL must be Hermoso-hosted, and upload_file turns ANY file the user already has into exactly that. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Needs a connected LinkedIn account (Settings > Connectors > LinkedIn).
| Argument | Type | Description |
|---|---|---|
| hook | string | WHAT ANGLE this post is built on, recorded only at publish time. Omit it and this post never votes on which hook works. |
| text* | string | the post text |
| 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. |
| title | string | video title |
| ideaId | string |
Use this when the user wants to post to a LinkedIn company Page. Publishing costs no credits. Publish a post to one of the user’s LinkedIn COMPANY PAGES — text, plus optionally an image, a video, a 2–20 image CAROUSEL (LinkedIn calls it a MultiImage post; pass the slides in order as imageUrls[]), or a LINK POST with a real preview card (linkUrl). USE linkUrl WHENEVER THE POINT OF THE POST IS A LINK: LinkedIn disables URL scraping for API partners, so a url sitting in the text renders as plain text with no card, and the card’s title, description and image only exist if you pass linkTitle / linkDescription / linkThumbnailUrl — read them off the page and supply them. The media need not be a Hermoso render — it must be Hermoso-HOSTED because we upload the bytes to LinkedIn ourselves, and upload_file turns ANY file the user already has into such a URL. A DOCUMENT post (documentUrl — a PDF/PPTX deck readers swipe through) is LinkedIn’s strongest organic B2B format. Tag companies with @Name in the text plus mentions. This is a DIFFERENT thing from post_to_linkedin, which publishes to the person’s own profile: pick the one the user actually asked for and never substitute. organizationId comes from list_linkedin_pages; omit it only when the account administers exactly one Page. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. A VIDEO POST CAN CARRY CAPTIONS AND ITS OWN COVER, and both are attached only during the upload: pass captionsSrt (SubRip content — LinkedIn is watched with the sound off) and videoThumbnailUrl (otherwise LinkedIn picks a frame for you). LinkedIn does NOT allow the image, video, captions or thumbnail of a published post to be swapped afterwards, so get all of that right first (the copy can still be edited with manage_linkedin_post).
| Argument | Type | Description |
|---|---|---|
| hook | string | WHAT ANGLE this post is built on, recorded only at publish time. Omit it and this post never votes on which hook works. |
| text |
Use this when the user wants to post now to Facebook, Instagram or Threads. Publish to a connected Facebook Page, its linked Instagram, OR the brand’s Threads account — text/link/image/VIDEO/CAROUSEL. A MULTI-SLIDE creative is a CAROUSEL, not several posts: pass the slides in order as imageUrls[] and they publish as ONE swipeable post (Instagram album, Threads carousel, Facebook multi-photo post). Never publish slide 1 of a deck on its own — the creative tells the viewer to swipe. target:"facebook" (default) posts to the Page; target:"instagram" publishes a photo or Reel to the linked IG business account (needs an image or video); target:"threads" posts to the connected Threads account (text, image, or video). Works with any media, an ad made here or the user’s own file: imageUrl/videoUrl accept a public https URL, a data: URI, or a Hermoso /generated path; for a LOCAL file (e.g. on the user’s desktop) call upload_file first and pass the url it returns. INSTAGRAM COLLAB: pass `collaborators` (up to 3 usernames) to invite other accounts to CO-AUTHOR the post — it then shows on their profile too once they accept. This PUBLISHES immediately — confirm the copy + media with the user first. Needs a connected Meta account (Settings > Connectors > Meta) with posting permission; Threads needs its own connection.
| Argument | Type | Description |
|---|---|---|
| hook | string | WHAT ANGLE this post is built on, recorded only at publish time. Omit it and this post never votes on which hook works. |
| link | string | a URL to attach (FB text post only) |
| 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. |
Use this when the user wants to publish a Pin to Pinterest. Publishing costs no credits. Create a Pin on one of the user’s Pinterest boards from any finished visual they have — image, video, or a 2–5 slide CAROUSEL (pass the slides in order as imageUrls[] and Pinterest publishes one swipeable Pin). Title, description and destination link ride along. The link is what makes a Pin drive traffic, so ask for it rather than omitting it, and the description is the text Pinterest search actually reads. boardId is REQUIRED: call list_pinterest_boards first and let the user choose. This PUBLISHES to their public profile — confirm the board, title and link before calling. Video Pins take a minute or two while Pinterest ingests the file. Needs Pinterest connected (Settings > Connectors > Pinterest).
| Argument | Type | Description |
|---|---|---|
| hook | string | WHAT ANGLE this post is built on, recorded only at publish time. Omit it and this post never votes on which hook works. |
| link | string | destination URL the Pin clicks through to |
| 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. |
| title | string | Pin title, max 100 characters |
| ideaId | string | short id of the content-plan idea this post came from |
Post to the user's Slack. Free. With the Hermoso app installed it posts as the app to channel (or the brand's default channel), uploads images and video, replies in a thread (threadTs) or schedules in Slack (postAt, text only, ≤120 days); the messageId it returns ("<channel>/<ts>") is what edit_slack_message, delete_slack_message and get_slack_message take. Without the app it posts through the brand's announcement webhook (never the alert one): text ≤40,000 characters (refused, never cut), a title line, png/jpg/gif images by link, video as a link; a webhook post has no id, so it cannot be edited, deleted or measured. Not connected: connect_connector with provider slack and no fields returns the install link.
| Argument | Type | Description |
|---|---|---|
| hook | string | The post's angle |
| text | string | |
| 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. |
| title | string | a header line above the text |
| ideaId | string | short id of the content-plan idea this post came from |
| postAt | string | HERMOSO APP: schedule in Slack itself for this ISO time (≤120 days, text only; use schedule_post for files) |
Use this when the user wants to post to a Telegram channel or group. Publishing costs no credits. Publish to a Telegram channel, group or chat as the brand's own bot. WHICH CHAT IS ALWAYS REQUIRED AND IS NEVER GUESSED: pass chatId as the public channel's @username (e.g. @yourchannel) or its numeric id (a group is negative; a supergroup or channel starts with -100). There is no default and there cannot be one — the Telegram Bot API publishes no method that lists the chats a bot belongs to, so Hermoso genuinely cannot know them. list_telegram_chats reports the chats that have MESSAGED the bot in the last 24 hours, which is a shortcut for finding an id and is NOT a roster: a chat missing from it can still be posted to. TEXT: up to 4096 characters on a text-only message, but only 1024 the moment ANY photo or video is attached — a caption is not a message, and Hermoso refuses the over-long one before spending the round trip and says which budget applied. MEDIA: one image (imageUrl), one video (videoUrl), or an ALBUM of 2–10 (imageUrls, in order) in which photos and videos may be MIXED — pass a videoUrl alongside imageUrls and it joins the album as one more item. Hermoso uploads the bytes rather than handing Telegram a link, which is what buys the larger ceilings: 10MB per photo and 50MB per video, where a link would be 5MB and 20MB. THE BOT MUST BE IN THE CHAT — an administrator with Post Messages for a channel, an unrestricted member for a group; if it is not, Telegram refuses and the error says to add it rather than to reconnect. Returns the message id and, for a PUBLIC chat, its t.me link — a private group has no public web link, so `url` comes back null rather than as a link that would 404 for whoever you hand it to. 0 credits. Connect at Settings > Connectors > Telegram.
| Argument | Type | Description |
|---|---|---|
| hook | string | The post's angle |
| text | string | the message. ≤4096 characters on its own; ≤1024 once any image or video is attached. |
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).
| Argument | Type | Description |
|---|---|---|
| hook | string | WHAT ANGLE this post is built on, recorded only at publish time. Omit it and this post never votes on which hook works. |
| 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. |
Use this when the user wants to publish, draft or schedule a BLOG POST on their WordPress site. Publishing costs no credits. A title is REQUIRED and the text becomes the body: plain text or light markdown (## headings, - lists, **bold**, [links](url)) is turned into WordPress blocks, and HTML is sent as written. status: publish (default), draft, pending (submit for review), private (logged-in editors only) or future with a date — a future `date` makes WordPress schedule it natively. The FIRST image becomes the FEATURED image, every other image is uploaded and placed inline, and a video is uploaded and embedded (if the site's host refuses the file as too large, the post plays it from its own link and the reply says so). categories and tags take names or ids; a name the site lacks is CREATED and the reply says which. An over-long title is refused, never cut. WordPress has NO per-post views in its API: never quote a view count for a WordPress post. Self-hosted WordPress only — a WordPress.com site needs its own OAuth sign-in and cannot be connected. Not connected: the user connects it under Workspace > Connectors > WordPress, or you call connect_connector with provider wordpress (siteUrl, username, appPassword).
| Argument | Type | Description |
|---|---|---|
| date | string | ISO time to publish at; a future date schedules it on WordPress itself |
| hook | string | The post's angle |
| slug | string | the end of the post URL |
| tags | array | tag names or ids (a missing name is created) |
| text | string |
Use this when the user wants to post to X (Twitter) now. Publish to the user’s connected X (Twitter) account — a single post, a post with an image or video render attached, a reply to an existing post, or a whole THREAD (pass `thread` as an array and each part is posted as a reply to the one before). This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Can also run a POLL (2-4 options) instead of media, and restrict who may reply. LENGTH IS THE POSTING ACCOUNT’S, NOT A FLAT 280: 280 characters on an ordinary account, but UP TO 25,000 if that account has X Premium. Hermoso reads the account’s own subscription from X and sends the post rather than refusing something it may be entitled to; if X declines it on length, the reply says so and names the subscription X reported. Over 25,000 is refused for free — that is X’s own ceiling on every account. Text is NEVER truncated. AND A LONG POST IS CHEAPER THAN A THREAD: it is ONE billed X call where the same words split across five posts are five, so prefer one long post over threading when the account has Premium. Only the first ~280 characters show in the timeline; the rest sits behind “Show more”. Write altText whenever you attach a render. COSTS CREDITS at X’s per-post price, every post in a thread billed: a post that contains a link (any web address, and every quote post) is X’s link rate, far higher than one without (hermoso_credits gives both figures). When the user asks what a post will cost, call this with dryRun:true: it posts NOTHING and returns that exact post’s costCredits. Each profile also has a rolling 24-hour ceiling on what it can spend at X, and a request that would cross it is refused WHOLE before anything publishes, so a batch of posts is bounded rather than open-ended. THIS TOOL POSTS ORGANICALLY — it does not create an ad campaign. X ads are built with the X Ads campaign tools (campaign, then line item, then promoted post), and a promoted post needs a post id, so publish here first and promote that post. Needs X connected (Settings > Connectors > X).
| Argument | Type | Description |
|---|---|---|
| hook | string |
Use this when the user wants to upload a video or Short to YouTube. Publishing costs no credits. Publish a finished video to the brand’s connected YouTube channel. Pass a Hermoso render URL (or an upload_file url for a local/external file). PUBLISHES PUBLICLY BY DEFAULT: a plain "post this to YouTube" puts it ON the channel (confirm the title with the user, as for any publish) and notifies subscribers as YouTube does. Pass the privacy the user states instead: "unlisted" (link-only) or "private" (eyes-only). A video meant to run as a YouTube/Google AD should go up "unlisted" — private videos CANNOT be used as ads. SCHEDULE it with publishAt, FILE it under the right categoryId (the default 22 "People & Blogs" is wrong for most ads), SUBSCRIBER NOTIFICATIONS FOLLOW PRIVACY — a public publish announces the video to the channel’s subscribers (YouTube’s own default), while unlisted/private uploads stay quiet; pass notifySubscribers explicitly to override either way. THUMBNAIL: pass thumbnailUrl, or a frame of the video is set for free; a thumbnail YouTube refuses never fails the upload, and thumbnailNote says why. YOUTUBE MUSIC: YouTube’s API takes no music track. Only when the user wants a track from YouTube’s own Audio Library: BEFORE uploading, tell them plainly it will go up UNLISTED (not on their channel, nobody sees it) so they can add the track in YouTube Studio on desktop (Content > the video > Editor > Audio), and that THEY must then switch it to Public there themselves; get their yes, then pass privacy:"unlisted". Never choose unlisted for music on your own. Needs a connected YouTube channel (Settings > Connectors > YouTube).
| Argument | Type | Description |
|---|---|---|
| hook | string | WHAT ANGLE this post is built on, recorded only at publish time. Omit it and this post never votes on which hook works. |
| tags | array | up to 30 tags |
| brand |
Publish a long-form ARTICLE to the user’s connected X (Twitter) account — X’s own long-form format, which is a different thing from a long POST. Give it a `title` and a `body` written in MARKDOWN (or plain prose) and Hermoso converts it into the DraftJS `content_state` structure X requires: headings, paragraphs, bulleted and numbered lists, blockquotes, bold / italic / strikethrough, links, horizontal rules, fenced code blocks and pipe tables all carry across. FORMATTING IS NEVER SILENTLY DROPPED — anything X Articles cannot represent (inline `code`, an inline image) REFUSES the article for free and names exactly what and why, and `allowLossy: true` is the explicit way to publish it as plain text anyway. THE HARD LIMIT TO PLAN AROUND: X allows only about 10 Article DRAFTS and 5 Article PUBLISHES per account per DAY, it publishes that cap nowhere, and it counts a FAILED attempt against them — so never iterate on an article by republishing it, and use `publish: false` to save a draft for the user to read in X’s own composer when they want to review before it goes out. This PUBLISHES immediately and PUBLICLY: show the user the full text and get an explicit yes first, because a published Article can NEVER be edited (X’s own rule — edit_x_post will refuse it) and the only remedy is to delete and republish, which costs another of the five. An Article appears in the timeline as a title card, not as body text; readers open it. Costs credits (X bills per API request, and this is three of them). Needs X connected (Settings > Connectors > X).
| Argument | Type | Description |
|---|---|---|
| body* | string | the article body, as markdown or plain prose. Markdown headings, lists, quotes, links, emphasis, ``` code fences and | pipe | tables | are all converted to X’s own Article structure. |
| hook | string | WHAT ANGLE this post is built on, recorded only at publish time. Omit it and this post never votes on which hook works. |
Attach a finished IMAGE or VIDEO to one of the merchant's Shopify product listings, as product media. Pass productId (from list_shopify_products, the gid://shopify/Product/… form) and a PUBLIC https mediaUrl, which is what every Hermoso render returns. Whether it is a video is read from the file (.mp4 / .mov / .webm, or the link's content type); pass kind to say so outright. An image is fetched by Shopify directly; a video is uploaded to Shopify for you (MP4, MOV or WEBM, up to Shopify's 1 GB and 10-minute product-video limits; a file over a limit is refused by name, never cut). Shopify processes media asynchronously: the reply is the status read back from Shopify, and READY means it is live on the listing while PROCESSING means it will appear when Shopify finishes. That is success, not a failure. Only works for accounts created by installing Hermoso from the Shopify App Store. Free: the render was already paid for.
| Argument | Type | Description |
|---|---|---|
| alt | string | alt text for accessibility and SEO; defaults to a generic credit |
| kind | string | only when the link does not say (no file extension and no content type) |
| imageUrl | string | older name for mediaUrl; still accepted |
| mediaUrl | string | any public https image or video URL: a Hermoso render works, and so does ANY file of your own brought in with upload_file |
| productId* | string | gid://shopify/Product/… from list_shopify_products |
Read the ROWS of a Notion database the user shared with Hermoso — e.g. the team's content calendar: each row's title, id, link and every property as a plain value (status, date, channels, caption, links), plus the database's columns with their types and options. Filter with match {"Status": "Scheduled"} (typed from the database's own columns) or a raw Notion filter object; sort with sortBy (a column, or created_time / last_edited_time) and direction. Up to 100 rows per call, cursor for more; the answer says when Notion stopped at its 10,000-result limit. Use it to import a calendar and schedule its posts, or to find the row update_notion_row changes. Read-only, free. Needs Notion connected (Settings > Connectors > Notion).
| Argument | Type | Description |
|---|---|---|
| limit | number | 1-100, default 50 |
| match | object | {"Column": value} — equality for status/select/checkbox/number/date, contains for text/multi-select |
| cursor | string | nextCursor from the previous page of rows |
| filter | object | a raw Notion filter object |
| sortBy | string | column name to sort by, or created_time or last_edited_time |
| archived | boolean | read archived rows instead |
| direction |
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.
| Argument | Type | Description |
|---|---|---|
| value* | string | exactly one emoji character, e.g. the thumbs-up emoji |
| remove | boolean | true to take the reaction off instead of putting it on |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| convoId* | string | from list_bluesky_convos |
| messageId* | string | from read_bluesky_dm |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"convoId",
"messageId",
"value"
],
"properties": {
"value": {
"type": "string",
"description": "exactly one emoji character, e.g. the thumbs-up emoji"
},
"remove": {
"type": "boolean",
"description": "true to take the reaction off instead of putting it on"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"convoId": {
"type": "string",
"description": "from list_bluesky_convos"
},
"messageId": {
"type": "string",
"description": "from read_bluesky_dm"
}
}
}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.
| Argument | Type | Description |
|---|---|---|
| limit | number | how many messages, 1–100 (default 30) |
| cursor | string | walk further back through older messages |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| convoId* | string | from list_bluesky_convos |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"convoId"
],
"properties": {
"limit": {
"type": "number",
"description": "how many messages, 1–100 (default 30)"
},
"cursor": {
"type": "string",
"description": "walk further back through older messages"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"convoId": {
"type": "string",
"description": "from list_bluesky_convos"
}
}
}Read a Discord channel's recent messages, newest first, for context — what members are saying before you write. Other people’s text needs Discord’s Message Content intent; a message Discord withholds is marked contentHidden. Free.
| Argument | 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, 1–100 (default 20) |
| before | string | page back from this message id |
| channel | string | channel id or #name; unset = the default channel |
| discordServer | string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"brand": {
"type": "string",
"description": "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": {
"type": "number",
"description": "how many, 1–100 (default 20)"
},
"before": {
"type": "string",
"description": "page back from this message id"
},
"channel": {
"type": "string",
"description": "channel id or #name; unset = the default channel"
},
"discordServer": {
"type": "string"
}
}
}Read the text of a Google Doc Hermoso can reach — one it created, or one the user handed over with the Google file picker in the app (that is how an EXISTING doc becomes readable; find its id with list_drive_files). Pass documentId (from create_doc) OR paste a Google Docs URL as docUrl. Under the drive.file scope it reaches nothing else in the user’s Drive; if Google answers that the file was not found, the user has not picked it yet — ask them to pick it in the app rather than retrying. Returns the plain text of EVERY tab (each headed by its tab name when there are several), with smart chips (dropdowns, people, dates, rich links) shown as the text Docs displays, plus `dropdowns[]` — each chip with its title, current value and options, which is what update_doc dropdowns sets. Read-only, free.
| Argument | Type | Description |
|---|---|---|
| docUrl | string | a Google Docs URL to read — the document id is extracted from it |
| documentId | string | the document id (from create_doc) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"docUrl": {
"type": "string",
"description": "a Google Docs URL to read — the document id is extracted from it"
},
"documentId": {
"type": "string",
"description": "the document id (from create_doc)"
}
}
}One Intercom conversation in full: every message in order (customer, teammate, bot, internal note) as plain text, plus its state, assignee and tags. Read it before drafting a reply. Read-only, free. Needs Intercom connected (Settings > Connectors > Intercom, or connect_connector).
| Argument | Type | Description |
|---|---|---|
| conversationId* | string | the conversation id from list_intercom_conversations |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"conversationId"
],
"properties": {
"conversationId": {
"type": "string",
"description": "the conversation id from list_intercom_conversations"
}
}
}The messages in one DM thread, oldest to newest, each marked inbound or outbound — plus WHETHER A REPLY IS ALLOWED RIGHT NOW. Meta only permits a reply within 24 hours of the person acting, and the window is computed from their LAST MESSAGE rather than from the thread’s updated time (which includes our own replies and would report the window open for a day after WE spoke). Read this before drafting: "can I answer this?" is the question a person actually has, and finding out by being refused is the worst way to learn it. It also hands back `replyTo`, the id a reply goes to, so nobody has to guess which participant is the customer. EVERY MESSAGE SAYS WHAT IT IS: `kind` (text, story_mention, story_reply, share, image, video, audio, file, sticker, gif, template, unsupported, empty), a one-line `summary` to read out, and `attachments` / `story` / `share` / `inReplyTo` / `reactions`; content Meta withholds (unsupported, or a "use the latest app" placeholder) is `unavailable: true` with `unavailableReason`, never blank text. ON INSTAGRAM Meta serves only the 20 most recent messages — a truncated thread is unreachable history, not a short conversation, and the reply says so. Read-only, 0 credits.
| Argument | Type | Description |
|---|---|---|
| limit | number | messages (1–100, default 25) |
| pageId | string | Facebook Page that owns the DM thread; omit when only one Page is connected |
| account | string | which Instagram account — an @handle or id from list_connector_accounts("instagram"). The way a DIRECT (Instagram Login) account is reached; omit for the Page-linked one. |
| platform | string | so the Instagram 20-message ceiling can be stated when it applies |
Read a Notion page the user shared with Hermoso, as markdown (pageId or a Notion link). A database id answers with its data sources instead. Read-only, free.
| Argument | Type | Description |
|---|---|---|
| pageId* | string | page id or Notion link |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"pageId"
],
"properties": {
"pageId": {
"type": "string",
"description": "page id or Notion link"
}
}
}Read cells from a Google Sheet Hermoso can reach — one it created, or one the user handed over with the Google file picker in the app (that is how an EXISTING spreadsheet becomes readable; find its id with list_drive_files). Pass the spreadsheetId (from create_sheet) OR paste a Google Sheets URL as sheetUrl. If Google answers that the file was not found, the user has not picked it yet — ask them to pick it in the app rather than retrying. Returns a 2-D array of values.
| Argument | Type | Description |
|---|---|---|
| range | string | A1 range, e.g. "A1:D50" (default A1:Z1000) |
| sheetUrl | string | a Google Sheets URL to read — the spreadsheet id is extracted from it |
| spreadsheetId | string | the spreadsheet id (from create_sheet) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"range": {
"type": "string",
"description": "A1 range, e.g. \"A1:D50\" (default A1:Z1000)"
},
"sheetUrl": {
"type": "string",
"description": "a Google Sheets URL to read — the spreadsheet id is extracted from it"
},
"spreadsheetId": {
"type": "string",
"description": "the spreadsheet id (from create_sheet)"
}
}
}Read a Slack channel's recent messages, newest first, for context — what the team is discussing before you write. A public channel is joined first if needed; a private one needs /invite @Hermoso. Free.
| Argument | 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, 1–200 (default 20) |
| oldest | string | only messages after this ts |
| channel | string | channel id or #name; unset = the default channel |
| slackWorkspace | string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"brand": {
"type": "string",
"description": "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": {
"type": "number",
"description": "how many, 1–200 (default 20)"
},
"oldest": {
"type": "string",
"description": "only messages after this ts"
},
"channel": {
"type": "string",
"description": "channel id or #name; unset = the default channel"
},
"slackWorkspace": {
"type": "string"
}
}
}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).
| Argument | Type | Description |
|---|---|---|
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| maxResults | number | 1-200, default 100 |
| broadcastId* | string | broadcast id from list_x_broadcasts |
| paginationToken | string | next page token from the previous reply |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"broadcastId"
],
"properties": {
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"maxResults": {
"type": "number",
"description": "1-200, default 100"
},
"broadcastId": {
"type": "string",
"description": "broadcast id from list_x_broadcasts"
},
"paginationToken": {
"type": "string",
"description": "next page token from the previous reply"
}
}
}Save a durable fact or PREFERENCE about the brand, audience, or the user’s creative TASTE (e.g. “audience is first-time homebuyers”, “prefers bold lime accents”, “always captions off”) into the workspace Memory so it shapes FUTURE ads. For lasting things, not one-off requests. Merges into the existing Memory (never overwrites); de-dupes on identical text. NEVER how Hermoso, a tool, a connector or a platform API behaves (what a call returns, errors, permissions, limits, ids) and never a phone number or email — those are refused.
| Argument | Type | Description |
|---|---|---|
| text* | string | the fact/preference, concise |
| brand | string | profile id/name from list_brands, this call only |
| category | string | short bucket: Brand, Audience, Taste, Do, Don’t, or Preference (default General) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"text"
],
"properties": {
"text": {
"type": "string",
"description": "the fact/preference, concise"
},
"brand": {
"type": "string",
"description": "profile id/name from list_brands, this call only"
},
"category": {
"type": "string",
"description": "short bucket: Brand, Audience, Taste, Do, Don’t, or Preference (default General)"
}
}
}Remove a member from this profile by email — they lose access (you can re-invite them later), AND every connection THEY made on this profile is disconnected with them: their own X, LinkedIn, TikTok, YouTube, Pinterest, Threads… connections (on a channel holding several accounts, only the accounts they added), and any extra login or authorization they added. Connections the owner or anyone else made are never touched, and one with no record of who connected it is treated as the owner's. Queued posts that would publish through their accounts, or that they scheduled themselves, will not go out. The unconfirmed call reports exactly which connections and how many scheduled posts — relay that to the user, then call with confirm:true.
| Argument | Type | Description |
|---|---|---|
| email* | string | the member’s email |
| confirm | boolean | REQUIRED true |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"email"
],
"properties": {
"email": {
"type": "string",
"description": "the member’s email"
},
"confirm": {
"type": "boolean",
"description": "REQUIRED true"
}
}
}Answer a customer review publicly, as the business, on the brand’s Google Business Profile listing — or delete a reply that is already there. THIS IS AN UPSERT: a listing has exactly one reply per review, so replying to a review that already has an answer REPLACES it rather than adding a second. Google only accepts replies on a VERIFIED listing. Deleting is public and immediate, so it is confirm-gated. Write the reply in the brand’s voice and answer the specific complaint — a generic reply under a one-star review is worse than none. Needs Google Business Profile connected and the project approved.
| Argument | Type | Description |
|---|---|---|
| delete | boolean | true removes the existing reply instead of writing one |
| comment | string | the public reply text — required unless you are deleting |
| confirm | boolean | required for delete:true |
| reviewId* | string | from list_google_business_reviews |
| locationId | string | which listing — omit when only one is shared |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"reviewId"
],
"properties": {
"delete": {
"type": "boolean",
"description": "true removes the existing reply instead of writing one"
},
"comment": {
"type": "string",
"description": "the public reply text — required unless you are deleting"
},
"confirm": {
"type": "boolean",
"description": "required for delete:true"
},
"reviewId": {
"type": "string",
"description": "from list_google_business_reviews"
},
"locationId": {
"type": "string",
"description": "which listing — omit when only one is shared"
}
}
}Answer an inbox item BY ITS COMPOSITE ID — the `id` list_inbox returned, e.g. 'facebook:123_456' or 'google_business:accounts/1/locations/2/reviews/3'. Routes to the right channel for you; you do not need to know which reply tool a platform uses. The reply is posted as the brand — PUBLIC on every comment, reply, mention and review, and PRIVATE on the DM sources and on Intercom (it goes to the customer from the brand's support inbox) — so show the user the exact wording and get their go-ahead first either way. X is the one exception and says so: a reply there is a new post, so it goes through post_to_x with replyTo.
| Argument | Type | Description |
|---|---|---|
| id* | string | The composite id from list_inbox — '<source>:<platform id>'. A bare platform id is refused: replying to the wrong network as the brand cannot be taken back. |
| text* | string | The reply, exactly as it should appear publicly. |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"id",
"text"
],
"properties": {
"id": {
"type": "string",
"description": "The composite id from list_inbox — '<source>:<platform id>'. A bare platform id is refused: replying to the wrong network as the brand cannot be taken back."
},
"text": {
"type": "string",
"description": "The reply, exactly as it should appear publicly."
}
}
}Send a reply to the CUSTOMER on an Intercom conversation, from the brand's inbox as the teammate who owns the connection. This reaches a real customer and cannot be unsent. Read the conversation first (read_intercom_conversation), draft the reply in the brand's voice, show the user the exact words, and send only when they ask: call with confirm:true. Without confirm it sends nothing and returns the preview. The result is read back from the conversation Intercom returns. Plain text; blank lines become paragraphs. Never leave a placeholder such as [Your name] or [Order number] in it: Intercom already shows the customer which teammate replied, so sign with the brand's name or leave it unsigned, and ask the user for any fact you do not have. Free. Needs Intercom connected (Settings > Connectors > Intercom, or connect_connector).
| Argument | Type | Description |
|---|---|---|
| confirm | boolean | true only after the user approved this exact reply |
| message* | string | the exact reply the customer will read, with no [placeholders] |
| conversationId* | string | Intercom conversation id, from list_intercom_conversations |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"conversationId",
"message"
],
"properties": {
"confirm": {
"type": "boolean",
"description": "true only after the user approved this exact reply"
},
"message": {
"type": "string",
"description": "the exact reply the customer will read, with no [placeholders]"
},
"conversationId": {
"type": "string",
"description": "Intercom conversation id, from list_intercom_conversations"
}
}
}Speak AS your LinkedIn company Page: comment on one of its posts, or reply to somebody else's comment on it. Community Management surface — same separate connection as list_linkedin_comments. `organizationId` is WHO IS SPEAKING (the Page, from list_linkedin_pages) and `postUrn` is WHAT IS BEING COMMENTED ON; they are different things and both are required. To reply to a particular comment rather than the post itself, pass that comment's `commentUrn` from list_linkedin_comments as `parentComment`. LinkedIn's API does not support images in comments. It also throttles comment creation per member, and a throttle is reported as a throttle so you wait rather than retrying into it.
| Argument | Type | Description |
|---|---|---|
| text* | string | the comment text |
| postUrn* | string | the post being commented on |
| parentComment | string | to REPLY to an existing comment, its commentUrn — omit for a top-level comment |
| organizationId | string | the company Page commenting — OPTIONAL: defaults to the Page shared with this profile on the LinkedIn connector, and is only needed when several are shared |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"postUrn",
"text"
],
"properties": {
"text": {
"type": "string",
"description": "the comment text"
},
"postUrn": {
"type": "string",
"description": "the post being commented on"
},
"parentComment": {
"type": "string",
"description": "to REPLY to an existing comment, its commentUrn — omit for a top-level comment"
},
"organizationId": {
"type": "string",
"description": "the company Page commenting — OPTIONAL: defaults to the Page shared with this profile on the LinkedIn connector, and is only needed when several are shared"
}
}
}Post a public reply to a comment on the brand’s Facebook or Instagram post. This is PUBLIC and posted as the brand — show the user the exact wording and get their go-ahead first. With private:true it instead sends ONE private message to the commenter (Messenger / Instagram Direct) — once per comment, within 7 days.
| Argument | Type | Description |
|---|---|---|
| pageId | string | Page id — omit when only one Page is connected |
| 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. |
| message* | string | reply text |
| private | boolean | true = send ONE PRIVATE message to the person who left the comment (Messenger / Instagram Direct) instead of a public reply — the way to answer "price?" or "DM me". Meta allows one per comment, within 7 days of it. |
| commentId* | string | comment id from list_meta_comments |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"commentId",
"message"
],
"properties": {
"pageId": {
"type": "string",
"description": "Page id — omit when only one Page is connected"
},
"account": {
"type": "string",
"description": "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."
},
"message": {
"type": "string",
"description": "reply text"
},
"private": {
"type": "boolean",
"description": "true = send ONE PRIVATE message to the person who left the comment (Messenger / Instagram Direct) instead of a public reply — the way to answer \"price?\" or \"DM me\". Meta allows one per comment, within 7 days of it."
},
"commentId": {
"type": "string",
"description": "comment id from list_meta_comments"
}
}
}Send a text reply to someone who has messaged the brand. THIS REACHES A REAL PERSON — show the user the exact text and who it goes to, get a yes, then send. Pass `conversationId` as well as `recipientId` and Hermoso checks Meta’s 24-hour window for free BEFORE sending, and refuses with the real reason instead of letting Meta refuse it; without one it sends and discloses that the window could not be checked. HERMOSO SENDS REPLIES ONLY: messaging_type is always RESPONSE, and proactive messages and message tags are not offered at all — that is a deliberate product boundary, not a gap. THE ONE PROACTIVE THING MESSENGER ALLOWS IS A MARKETING-MESSAGES SUBSCRIPTION: to ask a person in the inbox to SUBSCRIBE to marketing messages, call request_messenger_optin (Meta’s own opt-in template, in the ads group — enable_tools(["ads"]) or find_tools if it is not in your list). Never answer that with a plain-text reply asking them to say YES, and never say the capability does not exist. And a closed window is a RULE that only the person writing again reopens. `recipientId` is a PAGE-SCOPED ID on Messenger and an INSTAGRAM-SCOPED ID on Instagram (read_meta_conversation returns it as `replyTo`); a username or a handle is not one and cannot be turned into one. ACCEPTED IS NOT DELIVERED — never report it as delivered or read. 0 credits.
| Argument | Type | Description |
|---|---|---|
| text* | string | the reply |
| pageId | string | Facebook Page that owns the conversation; omit when only one Page is connected |
| account | string | which Instagram account — an @handle or id from list_connector_accounts("instagram"). The way a DIRECT (Instagram Login) account is reached; omit for the Page-linked one. |
| platform | string | MESSENGER or INSTAGRAM; the channel the conversation is on |
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.
| Argument | Type | Description |
|---|---|---|
| text* | string | reply text (max 500 characters) |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| replyToId* | string | the post id being replied to |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"replyToId",
"text"
],
"properties": {
"text": {
"type": "string",
"description": "reply text (max 500 characters)"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"replyToId": {
"type": "string",
"description": "the post id being replied to"
}
}
}Reply to an existing comment on a TikTok post the authorized account owns — TikTok's twin of reply_to_meta_comment and reply_to_youtube_comment. Text (≤1,200 characters) or an image; at least one is required. A REPLY IS THE ONE PLACE TIKTOK ACCEPTS A PLAIN IMAGE URL (pass imageUrl); an uploaded imageUri + width + height also works. TikTok silently hides replies it flags as spam, so read it back with list_tiktok_comment_replies if visibility matters. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION (see tiktok_account_status).
| Argument | Type | Description |
|---|---|---|
| text | string | ≤1,200 characters (UTF-8) |
| videoId* | string | the post the comment sits on — TikTok requires it |
| imageUri | string | from upload_tiktok_comment_image |
| imageUrl | string | a public image URL — TikTok accepts this on a REPLY only, never on a new comment |
| commentId* | string | from list_tiktok_comments |
| imageWidth | number | pixel width of the image sent as imageUri (from upload_tiktok_comment_image) |
| imageHeight | number | pixel height of the image sent as imageUri |
Post a public reply to a comment on the connected channel, as the channel. This is PUBLIC and immediate — show the user the exact reply text and get an explicit yes before calling. Get commentId from list_youtube_comments. Needs a connected YouTube channel.
| Argument | Type | Description |
|---|---|---|
| text* | string | the reply, shown publicly under the video |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| commentId* | string | id of the comment to reply to (from list_youtube_comments) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"commentId",
"text"
],
"properties": {
"text": {
"type": "string",
"description": "the reply, shown publicly under the video"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"commentId": {
"type": "string",
"description": "id of the comment to reply to (from list_youtube_comments)"
}
}
}Report a bug in Hermoso to the team. Use this when something in Hermoso genuinely misbehaves — a tool errors unexpectedly, returns a wrong or malformed result, a render comes back broken, or documented behaviour doesn't match what happened. Include what you were trying to do, the exact tool call and arguments, and what came back. Do NOT use it for out-of-credits, a policy refusal, or a missing capability (use request_feature for that). Free, no credits.
| Argument | Type | Description |
|---|---|---|
| details* | string | what you were doing, the tool + arguments you called, what you expected, and what actually happened (paste the exact error) |
| summary* | string | one-line summary of the bug |
| severity | string | high = blocks the task or loses paid work; medium = wrong output but workable; low = cosmetic |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"summary",
"details"
],
"properties": {
"details": {
"type": "string",
"description": "what you were doing, the tool + arguments you called, what you expected, and what actually happened (paste the exact error)"
},
"summary": {
"type": "string",
"description": "one-line summary of the bug"
},
"severity": {
"enum": [
"low",
"medium",
"high"
],
"type": "string",
"description": "high = blocks the task or loses paid work; medium = wrong output but workable; low = cosmetic"
}
}
}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.
| Argument | Type | Description |
|---|---|---|
| postId* | string | the Threads post id to repost |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"postId"
],
"properties": {
"postId": {
"type": "string",
"description": "the Threads post id to repost"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
}
}
}Ask the Hermoso team for a capability that doesn't exist yet. Use this when you need something Hermoso genuinely can't do — an unsupported platform or channel, a missing model, an export format, a tool that would have completed the user's task but isn't available. Say what the user was trying to achieve, not just the feature name — the use case is what gets built. Free, no credits.
| Argument | Type | Description |
|---|---|---|
| details* | string | what the user was actually trying to achieve, why the current tools couldn't do it, and what you'd expect the capability to do |
| summary* | string | one line: the capability you need |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"summary",
"details"
],
"properties": {
"details": {
"type": "string",
"description": "what the user was actually trying to achieve, why the current tools couldn't do it, and what you'd expect the capability to do"
},
"summary": {
"type": "string",
"description": "one line: the capability you need"
}
}
}Send Meta’s opt-in request (the notification_messages template) to someone ALREADY in a Messenger conversation with the Page, inside the 24-hour window. If they accept, Meta posts a messaging_optin webhook with their subscription token and they appear in list_messenger_subscribers. One identical-title request per person per week. Free to send. The PSID comes from the Page inbox.
| Argument | Type | Description |
|---|---|---|
| psid* | string | the person’s Page-scoped id |
| title* | string | what they are subscribing to, ≤65 characters |
| dryRun | boolean | true = preview the request and send nothing |
| pageId | string | Page that sends the request; omit when only one Page is connected |
| imageUrl | string | optional image shown with the opt-in request |
| timezone | string | their timezone, e.g. America/New_York |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"psid",
"title"
],
"properties": {
"psid": {
"type": "string",
"description": "the person’s Page-scoped id"
},
"title": {
"type": "string",
"description": "what they are subscribing to, ≤65 characters"
},
"dryRun": {
"type": "boolean",
"description": "true = preview the request and send nothing"
},
"pageId": {
"type": "string",
"description": "Page that sends the request; omit when only one Page is connected"
},
"imageUrl": {
"type": "string",
"description": "optional image shown with the opt-in request"
},
"timezone": {
"type": "string",
"description": "their timezone, e.g. America/New_York"
}
}
}Change a post that is still QUEUED — move it to a different time, rewrite the caption, swap the media, add or drop a channel, or change which board / Page / company Page / listing it goes to. PASS ONLY WHAT CHANGES: an omitted field is left exactly as it was, and an explicit empty string CLEARS one (linkedinOrganizationId:"" moves a company-Page post back to the person’s own profile). The edited item is re-checked against the identical rules its create passed — visibility the channel can honour, per-channel length, media the channel can carry — so an edit can never slip past a refusal that a create would have caught. Get the id from list_scheduled. Something that already went out cannot be changed: a published post is edited or removed with manage_meta_post / manage_linkedin_post / delete_x_post, not rescheduled.
| Argument | Type | Description |
|---|---|---|
| at | string | the new time: local wall clock (2026-10-30T10:00, read in `timezone` or the brand’s zone on that date), UTC (…Z) or epoch ms. Future, at most 365 days out. |
| id* | string | the scheduled post id from list_scheduled |
| link | string | destination URL for a Pinterest Pin, Facebook text post or Google Business post |
| poll | object | X — replaces the poll; an empty options list removes it. |
| tags | array | YOUTUBE — replaces the WHOLE tag list; an empty array [] clears the tags. |
| brand |
Answer a collab invite from list_instagram_collab_invites. accept:true makes this account a co-author and the creator’s post appears on its profile; accept:false declines. `accept` is REQUIRED — never guess which way the user wants it. The reply is read back from Instagram (the invite leaves the pending list), not taken from the vendor’s 200. Free. Instagram allows 50 answers per account per day.
| Argument | Type | Description |
|---|---|---|
| accept* | boolean | true = accept and co-author the post; false = decline |
| pageId | string | Facebook Page id — omit when only one Page is connected |
| account | string | which Instagram account — @handle or id; omit for the one Page-linked account |
| mediaId* | string | the media id from list_instagram_collab_invites |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"mediaId",
"accept"
],
"properties": {
"accept": {
"type": "boolean",
"description": "true = accept and co-author the post; false = decline"
},
"pageId": {
"type": "string",
"description": "Facebook Page id — omit when only one Page is connected"
},
"account": {
"type": "string",
"description": "which Instagram account — @handle or id; omit for the one Page-linked account"
},
"mediaId": {
"type": "string",
"description": "the media id from list_instagram_collab_invites"
}
}
}Send a post that FAILED again. A scheduled post fans out across its channels INDEPENDENTLY, so a failure is usually PARTIAL — LinkedIn 401s while Instagram published fine — and this re-fires ONLY the channels that did not succeed by default (list_scheduled reports them as `retryable`). It re-queues the same content as a NEW post that goes out RIGHT AWAY — the queue picks it up on its next pass, within seconds — and the original keeps its failure record so the history still shows what went wrong. Naming a channel that already published is REFUSED rather than quietly posting a second time. Two independent belts stop a double-post: a channel that genuinely published can only REPLAY (nothing is posted), and a channel whose outcome is UNRESOLVED — the platform timed out and may be holding the post — refuses with that reason instead of guessing. Retry after fixing the cause — and you can fix it IN THIS CALL: pass `boardId`, `pageId`, `linkedinOrganizationId`, `locationId`, `message` or `captions` to correct the value that failed, and the corrected post is re-validated exactly like a fresh schedule. That matters because the commonest cause is a field, not an outage: a Pin aimed at the wrong board fails identically however many times it is re-sent. Anything you do not name is copied from the original. To send the same thing again ON PURPOSE, use duplicate_scheduled.
| Argument | Type | Description |
|---|---|---|
| at | string | hold the retry until a later time — ISO timestamp or epoch milliseconds. Leave it out to retry immediately, which is almost always what you want. A time you name here must be at least a minute from now, exactly like any other scheduled post. |
| id* | string | the scheduled post id from list_scheduled |
| brand | string | WHICH PROFILE this post is in — id or exact name from list_brands; needed when it is not the profile this connection is pinned to. A name that matches no profile, or two, is REFUSED. |
Create (or replace) the signing secret for this brand's generic alert webhook and return it ONCE — it is never readable again, so hand it to the user to store. Every event carries Standard Webhooks headers: webhook-id, webhook-timestamp and webhook-signature = v1,<base64 HMAC-SHA256 of "id.timestamp.body"> keyed by the base64 part after whsec_. On a rotation the OLD secret keeps signing alongside the new one for 24 hours (two signatures in the header), then stops: a receiver still holding only the old secret rejects events after that, so rotate only when the user means to replace it. Free.
No arguments.
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {}
}Run AUTOPILOT POSTING (autoposting, paid plans only) NOW instead of waiting for its daily turn, or review the drafts it made: schedule them, delete one, change a caption, or save a note such as "too salesy". By default it is a preview: it returns the posts it WOULD make — each new picture’s scene, the caption, the channels and the price — rendering nothing and queueing nothing. The preview still WRITES the scenes and copy with a model, which bills its model cost (the reply says exactly how many credits). dryRun:false actually renders a fresh image or video for each post within the budget (credits are spent) and then, by mode, schedules them ("auto") or keeps them as drafts ("review"). It never re-posts the Library. REVIEWING A BATCH: pass approve (draft ids, or ["all"]) to schedule drafts, discard to delete them, edit ([{id, message?, captions?, title?, at?, channels?}]) to change one first (a new `at` reschedules it); get_post_refill lists them. learn saves notes future batches follow (free). redo remakes ONE draft from a note and is paid; without confirm:true it returns only the price. A scheduled post can still be pulled before it goes out with cancel_scheduled. Every caption is screened against the voice rules and a failing one is dropped, so a plan can come back shorter than the cadence — the reason is in the notes.
| Argument | Type | Description |
|---|---|---|
| edit | array | REVIEW: change drafts before approving them. Edited copy is screened by the voice rules. |
| redo | object | remake one draft from feedback; paid, so without confirm:true it returns only the price |
| force | boolean | plan a preview even while autoposting is switched off — useful for showing someone what it would do before they turn it on. |
Add a portrait to this workspace’s reusable CAST so the SAME person can star in future ads — the headless twin of the app’s + > Pick a creator > save. Pass the portrait’s public url plus a name to call them by. Saving is FREE and renders nothing. LIKENESS: leave `source` "generated" for an AI-made person (free on every plan) and use "upload"/"social" for a REAL person. Using a real person’s face, likeness or voice confirms you are them or have their consent, take full, unlimited responsibility for its use and will cover any claim against Hermoso (hermoso.ai/terms). Paid plan only; recorded.
| Argument | Type | Description |
|---|---|---|
| look | string | their canonical wardrobe/appearance in words — reused to hold the look steady across ads |
| name* | string | what to call this creator (e.g. “Sarah”) — list_creators and the app’s picker match on it |
| brand | string | profile id/name from list_brands, this call only |
| image | string | REQUIRED except with useAnyway. public https url of the portrait (an existing render’s url, or any public photo). Not a local file path — upload it with upload_file first and save the url that returns |
| poses | array | up to 4 extra full-body / angle plates of the SAME person (public urls) — they make a wider shot hold the identity |
| voice | string | a default voice name for this persona (engines + voices are in hermoso_capabilities) |
Create (no id) or edit (id) an AUTO-DM / AUTO-REPLY automation: "when someone comments GUIDE on my posts, DM them my guide link", "reply to anyone who DMs PRICE with our pricing page", "reply to people who mention us on X". PAUSE one with {id, enabled:false}; PRACTICE MODE (dryRun:true) logs what it would send and sends nothing. Instagram/Facebook: a keyword comment (one post, the next post, any post; on Facebook also ads) gets a private reply in DMs/Messenger, a DM keyword or ig.me/m.me ref link gets a message, with optional link buttons and rotated public replies. X: a keyword @mention or a reply under your post gets a public reply, a DM keyword gets a DM (X allows an automated DM only after the person DMed you). Once per person per post; a DM saying STOP opts that person out. Instagram and Facebook are free; X is billed in credits for every mention, reply or DM X delivers to the account plus each reply or DM sent, and running out stops the X rules. It messages real people, so confirm the text first.
| Argument | Type | Description |
|---|---|---|
| id | string | rule id to edit; omit to create a new automation |
| name | string | name of the automation, shown in the list |
| dryRun | boolean | log what would be sent without sending |
| account | string | the account on that channel: @handle (Instagram, X) or Page name/id (Facebook); omit when only one is connected |
| channel | string | instagram (default), facebook (a Facebook Page: comments on its posts and ads, plus Messenger) or x |
Create (no id) or edit (id) an automation that DMs people automatically: a comment with a keyword on one post / the next post / any post gets a private reply, or a DM / story reply / ig.me link gets an answer. Optional link buttons, public comment replies, follow gate and dry run. Once per person per post; Meta allows private replies within 7 days of the comment. It DMs real people, so confirm the text first. Free, no AI on the send path.
| Argument | Type | Description |
|---|---|---|
| id | string | rule id to edit; omit to create a new automation |
| name | string | name of the automation, shown in the list |
| dryRun | boolean | log what would be sent without sending |
| account | string | @handle or id of the Instagram account; omit when only one is connected |
| enabled | boolean | false pauses the automation without deleting it |
| message | object | what the person receives: {text, buttons}; up to 3 link buttons |
| trigger | object | what starts the rule: {type, keywords, match, scope, ...}; keywords are case-insensitive |
| followGate |
Save an existing Pin onto another of the account’s boards. This is the GENERALLY AVAILABLE way to get a Pin onto the right board — unlike update_pinterest_pin, which Pinterest keeps in a limited beta — so reach for it first when a Pin is on the wrong board. It COPIES rather than moves: Pinterest’s save endpoint creates a new Pin and the original stays where it is, so delete that one with delete_pinterest_pin if it should not be in two places. Let the USER pick the destination board (list_pinterest_boards) — a Pin on the wrong board is a public mistake. 0 credits. Needs Pinterest connected.
| Argument | Type | Description |
|---|---|---|
| pinId* | string | numeric Pin id |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| boardId* | string | the board to save it to, from list_pinterest_boards — the user picks, never guess |
| boardSectionId | string | optional section id within the destination board |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"pinId",
"boardId"
],
"properties": {
"pinId": {
"type": "string",
"description": "numeric Pin id"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"boardId": {
"type": "string",
"description": "the board to save it to, from list_pinterest_boards — the user picks, never guess"
},
"boardSectionId": {
"type": "string",
"description": "optional section id within the destination board"
}
}
}Save a reusable PLAYBOOK — the strategy takeaways worth re-running: the hooks that work, the angles, the formats, and the concrete plays. Lands in the same Playbooks library the web app lists, runs and manages. Distinct from save_skill (a directive applied to every ad) and from the swipefile (raw saved creative). Free.
| Argument | Type | Description |
|---|---|---|
| name* | string | the playbook headline — what it is, in a few words |
| brand | string | profile id/name from list_brands, this call only |
| hooks | array | the opening hooks worth reusing, verbatim |
| plays | array | the concrete plays to run ({title, detail}) — the actionable half |
| angles | array | the persuasion angles ({title, detail}) |
| source | string | where it came from, e.g. “teardown · Ridge” |
| formats | array | the formats/recipes this plays best in (e.g. ugc_selfie, cinematic, static) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"name"
],
"properties": {
"name": {
"type": "string",
"description": "the playbook headline — what it is, in a few words"
},
"brand": {
"type": "string",
"description": "profile id/name from list_brands, this call only"
},
"hooks": {
"type": "array",
"items": {
"type": "string"
},
"description": "the opening hooks worth reusing, verbatim"
},
"plays": {
"type": "array",
"items": {
"type": "object",
"required": [
"title"
],
"properties": {
"title": {
"type": "string",
"description": "the play to run, in a few words"
},
"detail": {
"type": "string",
"description": "how to run the play; optional, up to 600 characters"
}
},
"additionalProperties": {}
},
"description": "the concrete plays to run ({title, detail}) — the actionable half"
},
"angles": {
"type": "array",
"items": {
"type": "object",
"required": [
"title"
],
"properties": {
"title": {
"type": "string",
"description": "the angle, in a few words"
},
"detail": {
"type": "string",
"description": "what the angle is and why it persuades; optional, up to 600 characters"
}
},
"additionalProperties": {}
},
"description": "the persuasion angles ({title, detail})"
},
"source": {
"type": "string",
"description": "where it came from, e.g. “teardown · Ridge”"
},
"formats": {
"type": "array",
"items": {
"type": "string"
},
"description": "the formats/recipes this plays best in (e.g. ugc_selfie, cinematic, static)"
}
}
}Save a reusable custom SKILL — a named creative directive/playbook applied to future ads (a hook formula, a UGC recipe, a compliance rule, a named specialist persona like “our founder-story style” or “short-form ad strategist”). Distill an imperative, self-contained directive. Merges into the workspace Skills library (list_skills shows built-ins + your custom skills).
| Argument | Type | Description |
|---|---|---|
| name* | string | short skill name, e.g. “Founder-story hook” |
| brand | string | profile id/name from list_brands, this call only |
| directive* | string | the full instruction the skill applies when used (1–6 sentences, imperative) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"name",
"directive"
],
"properties": {
"name": {
"type": "string",
"description": "short skill name, e.g. “Founder-story hook”"
},
"brand": {
"type": "string",
"description": "profile id/name from list_brands, this call only"
},
"directive": {
"type": "string",
"description": "the full instruction the skill applies when used (1–6 sentences, imperative)"
}
}
}Save a Hermoso render — or ANY file — into the user’s connected Google Drive. Pass a Hermoso render URL as url (or urls[] for several); for a local/external file, call upload_file first and pass the url it returns. Optional folder (created if new) + name. Returns the Drive file(s) with a webViewLink. Needs Google Drive connected (Settings > Connectors > Google Drive — one connection covers Drive, Sheets and Docs). NOTE: Hermoso uses the drive.file scope, so it reaches ONLY the files it created plus any the user explicitly handed over with the Google file picker in the app — never their whole Drive.
| Argument | Type | Description |
|---|---|---|
| url | string | a single Hermoso render URL to save |
| name | string | file name (single save) |
| urls | array | several render URLs (up to 20) to save in one call |
| folder | string | Drive folder name to save into (created if new) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"url": {
"type": "string",
"description": "a single Hermoso render URL to save"
},
"name": {
"type": "string",
"description": "file name (single save)"
},
"urls": {
"type": "array",
"items": {
"type": "string"
},
"description": "several render URLs (up to 20) to save in one call"
},
"folder": {
"type": "string",
"description": "Drive folder name to save into (created if new)"
}
}
}Save to the user's Notion as a NEW page: a research report, competitor teardown or campaign report as `content` (markdown: headings, lists, links, and an image or video URL on its own line becomes the media), a swipefile collection (swipefileCollection: name or id; each ad with its creative, copy, run dates and link), the content calendar (includeSchedule:true), or a mix. Put it under parentPageId, as a row in databaseId, or omit both for a private page in their workspace. Long text is split across blocks, never cut. The answer is read back from Notion. Free; never overwrites anything.
| Argument | Type | Description |
|---|---|---|
| icon | string | one emoji |
| title | string | title of the new Notion page |
| content | string | markdown body |
| databaseId | string | database id or link: saves as a new row |
| dataSourceId | string | data source id to save into as a new row; pass only one of parentPageId, databaseId, dataSourceId |
| parentPageId | string | page id or link to save under |
| includeSchedule | boolean | add the scheduled posts |
| swipefileCollection | string | swipefile collection name or id |
Save a Hermoso render — or ANY file — into the user’s connected Microsoft OneDrive. Pass a Hermoso render URL as url (or urls[] for several); for a local/external file, call upload_file first and pass the url it returns. Optional folder (created if new) + name. Returns the OneDrive file(s) with a webViewLink. Needs OneDrive connected (Settings > Connectors > OneDrive).
| Argument | Type | Description |
|---|---|---|
| url | string | a single Hermoso render URL to save |
| name | string | file name (single save) |
| urls | array | several render URLs (up to 20) to save in one call |
| folder | string | OneDrive folder name to save into (created if new) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"url": {
"type": "string",
"description": "a single Hermoso render URL to save"
},
"name": {
"type": "string",
"description": "file name (single save)"
},
"urls": {
"type": "array",
"items": {
"type": "string"
},
"description": "several render URLs (up to 20) to save in one call"
},
"folder": {
"type": "string",
"description": "OneDrive folder name to save into (created if new)"
}
}
}Save one or more ads/creatives to a named SWIPEFILE collection, creating the collection if it does not exist — the headless twin of the heart on every ad card in the web app. Saved ads persist to the workspace board the web Swipefile tab shows, and feed the taste signal every future ad is planned against. De-dupes: re-saving the same ad (same key/link/media) MOVES it into the named collection instead of duplicating it. Free.
| Argument | Type | Description |
|---|---|---|
| brand | string | profile id/name from list_brands, this call only |
| items* | array | the ads to save |
| collection* | string | the collection name — an existing one, or a new one to create |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"collection",
"items"
],
"properties": {
"brand": {
"type": "string",
"description": "profile id/name from list_brands, this call only"
},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"key": {
"type": "string",
"description": "a stable id for this ad if you have one (an ad_archive_id, creativeId, …). Omit and one is derived from the link/media so re-saving is idempotent"
},
"body": {
"type": "string",
"description": "the ad copy"
},
"link": {
"type": "string",
"description": "link to the ad in its library / the destination URL"
},
"image": {
"type": "string",
"description": "image URL"
},
"title": {
"type": "string",
"description": "headline / hook"
},
"video": {
"type": "string",
"description": "video URL"
},
"pageName": {
"type": "string",
"description": "alias of advertiser"
},
"platform": {
"type": "string",
"description": "where it ran — 'meta', 'google', 'linkedin', 'tiktok', 'generated', …"
},
"page_name": {
"type": "string",
"description": "alias of advertiser: the field search_meta_ads returns, accepted as-is"
},
"advertiser": {
"type": "string",
"description": "the brand running the ad"
}
},
"additionalProperties": {}
},
"minItems": 1,
"description": "the ads to save"
},
"collection": {
"type": "string",
"description": "the collection name — an existing one, or a new one to create"
}
}
}Use this when the user wants to schedule a social media post for later, on one channel or several at once. Scheduling and publishing cost no credits on any Hermoso plan (X alone bills per call: hermoso_credits gives the exact credits per X post). THE FREE PLAN INCLUDES 10 POSTS A MONTH ON UP TO 3 CHANNELS (a post counts once, however many channels it goes to; counted in the UTC month the post goes out); every paid plan posts without a limit. A post over that allowance is refused here and nothing is queued; the refusal says how many are used and when the count resets. Relay it as written, never retry it and never split the post to fit. billing_status and list_scheduled show how many are used. Queue a post for a future time on one or more connected channels at once: facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, bluesky, telegram, wordpress, discord, slack — every one live. whop works where its connector is offered (list_connectors says) and is held back elsewhere: there a whop post is refused, not queued. A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order — never schedule just its first slide. Name the time in `at`, or pass `useQueue:true` for the brand’s next free POSTING SLOT (what “just queue it” means). imageUrl/videoUrl take a Hermoso render URL or an upload_file URL. `captions` gives a channel its own wording; the rest use `message`. PINTEREST AND YOUTUBE SHOW A TITLE: `title` (max 100 chars), derived from the caption when omitted; YOUTUBE also takes `description`, `tags`, `thumbnailUrl`. Every PER-CHANNEL SETTING is a parameter below, carried straight to the real publisher — TikTok’s `brandedContent` / `yourBrand` disclosures (set them whenever the post is commercial), Google Business `topicType` / `event` / `offer` / `actionType`, an X `thread` / `poll`, Instagram `collaborators`, and the rest. Channels are attempted INDEPENDENTLY: one failing never blocks the others. SOME CHANNELS MUST BE TOLD WHICH ACCOUNT, and Hermoso never guesses: Pinterest needs `boardId` (list_pinterest_boards) or is refused; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages) or it goes to the person’s own profile. Several Facebook Pages need `pageId` (list_meta_pages): a Facebook post on a brand with several Pages and none named is refused when you schedule it, with the Pages listed by name and id, so ask the user which one. Several Google Business listings need `locationId` (list_business_locations), and without it that post is refused when it fires. Resolve those FIRST and let the user pick. A scheduled post GOES LIVE PUBLICLY by default on every channel, never quietly downgraded. Only if the user asks, set `visibility` (or `visibilityByChannel`): ‘unlisted’ (YouTube) · ‘private’ (YouTube, or TikTok SELF_ONLY) · ‘draft’ (TikTok, or an unpublished Facebook Page post). A visibility a channel cannot do is REFUSED now, never posted weaker later.
Audio the brand may legally put under a Reel — music or original sound — with title, artist, length, whether it is eligible for ads, a preview link and a download link. Omit the query for what is TRENDING right now; pass one to search. Everything returned is audio Instagram has authorized for third-party use. Instagram offers this library only to an account linked to a Facebook Page and connected through Meta; an account connected through Instagram Login alone has none, and this says so. TO USE ONE, pass its `id` as `audioId` on post_to_meta or schedule_post (target instagram, a video): Instagram puts the track under the Reel itself. Download links expire after roughly 1.5 days. Read-only, free.
| Argument | Type | Description |
|---|---|---|
| limit | number | 1–50, default 15 |
| query | string | omit for trending audio |
| pageId | string | Facebook Page id — omit when only one Page is connected |
| account | string | which Instagram account — an @handle or id from list_connector_accounts("instagram"); omit for the one Page-linked account |
| audioType | string | default music |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"limit": {
"type": "number",
"description": "1–50, default 15"
},
"query": {
"type": "string",
"description": "omit for trending audio"
},
"pageId": {
"type": "string",
"description": "Facebook Page id — omit when only one Page is connected"
},
"account": {
"type": "string",
"description": "which Instagram account — an @handle or id from list_connector_accounts(\"instagram\"); omit for the one Page-linked account"
},
"audioType": {
"enum": [
"music",
"original_sound"
],
"type": "string",
"description": "default music"
}
}
}Read the PUBLIC Instagram posts carrying a hashtag — the caption, the media, the permalink and the like and comment counts — on the brand’s own Meta credentials rather than a scraper. This is category and competitor listening: find how a market actually talks about a product, then USE it. TWO EDGES AND THEY ANSWER DIFFERENT QUESTIONS: edge:"recent" (default) is what has been posted in the LAST 24 HOURS, which is Meta’s own hard limit, so a hashtag with millions of posts legitimately returns ZERO on a quiet day — never report that as "nobody is posting about this", ask again with edge:"top", which has no time limit. Only PUBLIC, non-promoted posts are ever returned, from public accounts only, and never in chronological order. A SHORT RESULT IS NOT A SMALL HASHTAG: Instagram limits a page by total response SIZE rather than by post count, so a tag whose posts are carousels returns fewer rows per page than a tag whose posts are single images — never compare two tags by how many rows came back. BUDGET: Instagram allows each Instagram account 30 UNIQUE hashtags in any rolling 7 days, so choose the tags deliberately; re-querying one already used in the window is free. Read-only, 0 credits.
| Argument | Type | Description |
|---|---|---|
| tag* | string | the hashtag — "skincare", "#skincare" or an instagram.com/explore/tags/… link all work. Instagram matches it EXACTLY and tags carry no spaces or punctuation. |
| edge | string | "recent" (default) = posted in the last 24 hours; "top" = the best-performing posts, with no time limit. Use "top" whenever "recent" comes back empty. |
| limit | number | how many posts (1–50, default 10). Instagram bounds a page by how much DATA it holds, not by how many posts, so a big ask is fetched in several smaller pages automatically and may still come back short on a carousel-heavy tag — a short result is NEVER a measure of the tag’s size. |
The products in one catalog that can actually be TAGGED on an Instagram post — this is where the product_id for a product tag comes from. Omit `q` to see everything tag-eligible; pass a product name or SKU to narrow it. THIS IS A SMALLER SET THAN THE CATALOG HOLDS: a product can be in the catalog, counted by list_meta_catalog_products, and still not be taggable, so an empty answer here is never evidence the catalog is empty. Meta only SHOWS a tag whose product review_status is "approved" — an unapproved one is accepted, stored and shown to nobody, so the reply flags them. Read-only, 0 credits. Whether Meta has approved Hermoso's app for the permission this needs is read LIVE from Meta: when it has not, list_connectors says so under Meta and this tool's refusal says Meta hasn't approved Hermoso for it yet. That is Meta's decision about Hermoso, not the user's connection, so never tell them to reconnect for it.
| Argument | Type | Description |
|---|---|---|
| q | string | product name or SKU. Omit it to list every tag-eligible product, which is a real ask rather than a missing argument. |
| pageId | string | Facebook Page id — omit when only one Page is connected |
| catalogId* | string | from list_instagram_shopping_catalogs. Meta REQUIRES it — there is no search-every-catalog form. |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"catalogId"
],
"properties": {
"q": {
"type": "string",
"description": "product name or SKU. Omit it to list every tag-eligible product, which is a real ask rather than a missing argument."
},
"pageId": {
"type": "string",
"description": "Facebook Page id — omit when only one Page is connected"
},
"catalogId": {
"type": "string",
"description": "from list_instagram_shopping_catalogs. Meta REQUIRES it — there is no search-every-catalog form."
}
}
}Find people in the brand's Intercom: by exact email, or by part of a name or email (query). Returns id, role (user or lead), name, email, phone, created and last seen. Read-only, free. Needs Intercom connected (Settings > Connectors > Intercom, or connect_connector).
| Argument | Type | Description |
|---|---|---|
| string | exact email | |
| limit | number | 1-50, default 10 |
| query | string | part of a name or email |
| cursor | string | nextCursor from a previous result, to get the next page |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"email": {
"type": "string",
"description": "exact email"
},
"limit": {
"type": "number",
"description": "1-50, default 10"
},
"query": {
"type": "string",
"description": "part of a name or email"
},
"cursor": {
"type": "string",
"description": "nextCursor from a previous result, to get the next page"
}
}
}Search the ads running on LinkedIn, from LinkedIn’s OWN Ad Library, and get three things LinkedIn publishes for each ad: the advertiser’s actual TARGETING (location, language, audience, company, job, age, gender, interests, traits — both the segments they INCLUDED and the ones they EXCLUDED), estimated impressions as a range plus the percentage split BY COUNTRY, and, for ads LinkedIn REMOVED, the advertising policy that was violated and how it was caught. That last one is the cheapest way to learn what not to write. Coverage is global. COVERAGE IS BOUNDED and the reply always says so: nothing created before 2023-06-01, and an ad is dropped one year after its last impression, so an empty result usually means the window rather than the market. 25 ads per page. Needs LinkedIn connected — no extra permission, any LinkedIn connection works. 0 credits.
| Argument | Type | Description |
|---|---|---|
| sort | string | by creation time; default DESCENDING (newest first) |
| count | number | max 25 |
| start | number | pagination offset; default 0 |
| dateTo | string | YYYY-MM-DD, EXCLUSIVE — must be given together with dateFrom |
| keyword | string | searched in the ad’s content; several words are a logical AND, not a phrase |
| dateFrom | string | YYYY-MM-DD, inclusive — must be given together with dateTo |
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. 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.
| Argument | Type | Description |
|---|---|---|
| count | number | max 25 |
| start | number | pagination offset; default 0 |
| keyword* | string | REQUIRED on this finder, unlike the ad search — searched in the post content; several words are a logical AND |
| dateRange | string | an ENUM, not a date range; omit to search everything LinkedIn still holds |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"keyword"
],
"properties": {
"count": {
"type": "number",
"description": "max 25"
},
"start": {
"type": "number",
"description": "pagination offset; default 0"
},
"keyword": {
"type": "string",
"description": "REQUIRED on this finder, unlike the ad search — searched in the post content; several words are a logical AND"
},
"dateRange": {
"enum": [
"PAST_24H",
"PAST_WEEK",
"PAST_MONTH"
],
"type": "string",
"description": "an ENUM, not a date range; omit to search everything LinkedIn still holds"
}
}
}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.
| Argument | Type | Description |
|---|---|---|
| q* | string | keyword or phrase |
| limit | integer | how many results (1–100, default 25) |
| since | string | only posts on/after this date (YYYY-MM-DD, ISO or unix seconds; Threads search reaches back to 2023-07-05) |
| until | string | only posts on/before this date |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| mediaType | string | TEXT, IMAGE or VIDEO — only that kind of post |
| searchMode | string | KEYWORD (default) or TAG — TAG searches a TOPIC TAG (what is being said under #skincare) |
| searchType | string | TOP (default) or RECENT |
| authorUsername |
Search Threads’ public place index by name (or by latitude+longitude) and get location ids. Use this when the brand has a PHYSICAL location — a restaurant, salon, gym, store — so the post can be geotagged to it. Pass the chosen id as post_to_meta(locationId) with target:"threads".
| Argument | Type | Description |
|---|---|---|
| q | string | place name to search, e.g. "Osteria Francescana" |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| latitude | number | latitude (use with longitude to search near a point) |
| longitude | number | longitude |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"q": {
"type": "string",
"description": "place name to search, e.g. \"Osteria Francescana\""
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"latitude": {
"type": "number",
"description": "latitude (use with longitude to search near a point)"
},
"longitude": {
"type": "number",
"description": "longitude"
}
}
}Keyword search across the PUBLIC X index — the last 7 days. X’s own search OPERATORS work and are the point: `from:nike`, `-is:retweet`, `has:media`, `has:links`, `lang:en`, `"exact phrase"`, `(a OR b)`. Every post comes back with impressions, likes, reposts, replies, quotes and bookmarks, so you can rank what actually landed rather than what merely exists. ONLY THE LAST 7 DAYS: the full archive needs a tier we do not hold, and an older window is refused by name rather than silently truncated. Costs a small number of credits (X bills per post read and per distinct author returned with them, and this bills the ACTUAL count returned, never the number asked for). Needs X connected.
| Argument | Type | Description |
|---|---|---|
| query* | string | the search, X operators included — e.g. `(protein OR creatine) -is:retweet has:media lang:en` |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| endTime | string | ISO 8601 end of the search window; within the last 7 days |
| nextToken | string | from a previous page |
| sortOrder | string | default relevancy |
| startTime | string | ISO 8601, no earlier than 7 days ago |
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.
| Argument | Type | Description |
|---|---|---|
| to | string | the recipient’s Bluesky handle, e.g. alice.bsky.social. Ignored when convoId is given. |
| text* | string | the message, up to 1000 characters |
| 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. |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| convoId | string | reply into an existing conversation (from list_bluesky_convos). Omit to start one with `to`. |
SEND or SCHEDULE a Klaviyo campaign draft. An email cannot be unsent, so this is gated on how many people it reaches: without confirm:true nothing is sent and you get Klaviyo's own count of recipients with the subject, sender, lists and timing. Show the user that number; only after they say yes call again with confirm:true AND confirmRecipients set to that count (a mismatch, a zero, or a count Klaviyo cannot produce is refused). sendTime (ISO, in the future) schedules instead of sending now; strategy 'local_timezone' sends at that time in each recipient's own timezone. Free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).
| Argument | Type | Description |
|---|---|---|
| confirm | boolean | true only after the user approved this exact send |
| sendTime | string | ISO date-time in the future; omit to send now |
| strategy | string | |
| campaignId* | string | |
| confirmRecipients | number | the recipient count the dry run showed the user |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"campaignId"
],
"properties": {
"confirm": {
"type": "boolean",
"description": "true only after the user approved this exact send"
},
"sendTime": {
"type": "string",
"description": "ISO date-time in the future; omit to send now"
},
"strategy": {
"enum": [
"static",
"local_timezone"
],
"type": "string"
},
"campaignId": {
"type": "string"
},
"confirmRecipients": {
"type": "number",
"description": "the recipient count the dry run showed the user"
}
}
}Send ONE TEST EMAIL of a Klaviyo template to up to 5 addresses the user names (never to a list), so they can see it in a real inbox. Without confirm:true nothing is sent and you get the preview; call again with confirm:true after they say yes. Klaviyo allows 100 test sends a day. Free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).
| Argument | Type | Description |
|---|---|---|
| confirm | boolean | |
| recipients* | array | up to 5 email addresses |
| templateId* | string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"templateId",
"recipients"
],
"properties": {
"confirm": {
"type": "boolean"
},
"recipients": {
"type": "array",
"items": {
"type": "string"
},
"description": "up to 5 email addresses"
},
"templateId": {
"type": "string"
}
}
}SEND or SCHEDULE a Mailchimp campaign draft. An email cannot be unsent, so this is gated on how many people it reaches: without confirm:true nothing is sent and you get Mailchimp's own recipient count with the subject, sender, audience, timing and Mailchimp's send checklist. Show the user that number; only after they say yes call again with confirm:true AND confirmRecipients set to that count (a mismatch, a zero, or a campaign Mailchimp says is not ready is refused). sendTime (ISO, in the future, on :00/:15/:30/:45; Mailchimp schedules only on the quarter-hour) schedules instead of sending now; timewarp:true sends at that time in each recipient's own timezone. Free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).
| Argument | Type | Description |
|---|---|---|
| confirm | boolean | true only after the user approved this exact send |
| sendTime | string | ISO date-time on the quarter-hour; omit to send now |
| timewarp | boolean | |
| campaignId* | string | |
| confirmRecipients | number | the recipient count the dry run showed the user |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"campaignId"
],
"properties": {
"confirm": {
"type": "boolean",
"description": "true only after the user approved this exact send"
},
"sendTime": {
"type": "string",
"description": "ISO date-time on the quarter-hour; omit to send now"
},
"timewarp": {
"type": "boolean"
},
"campaignId": {
"type": "string"
},
"confirmRecipients": {
"type": "number",
"description": "the recipient count the dry run showed the user"
}
}
}Send ONE TEST of a Mailchimp campaign draft to up to 5 addresses the user names (never to the audience), so they can see it in a real inbox. Without confirm:true nothing is sent and you get the preview; call again with confirm:true after they say yes. Free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).
| Argument | Type | Description |
|---|---|---|
| confirm | boolean | |
| campaignId* | string | |
| recipients* | array | up to 5 email addresses |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"campaignId",
"recipients"
],
"properties": {
"confirm": {
"type": "boolean"
},
"campaignId": {
"type": "string"
},
"recipients": {
"type": "array",
"items": {
"type": "string"
},
"description": "up to 5 email addresses"
}
}
}Send a PAID marketing message on Messenger to opted-in subscribers (Meta act_<AD>/messages). A campaign created in the last ~hour answers 2300012 then 2300041 (Meta still preparing it) with the seconds left; prefer an existing campaign from list_messenger_marketing_campaigns. One message per subscriber per 12 hours — Meta’s rule, enforced before dispatch and by Meta. message.type: text | button (text + up to 3 web_url buttons) | generic (a card: title, subtitle, image, tap-through url, up to 3 buttons) | media (imageUrl or videoId + buttons). Give subscriptionTokens (≤200 per call, from list_messenger_subscribers) OR customAudienceId of a MESSENGER_SUBSCRIBER_LIST audience of 100+ people for a bulk send. Meta bills the ad account per delivered message. A successful send means ACCEPTED, not delivered: read sent / delivered / read / clicked / failed per tracking id with get_messenger_marketing_message_status (Meta pushes them on the Page webhook; the Page must be subscribed with subscribe_meta_webhooks fields ["marketing_messages"]). dryRun:true previews the wire body and sends nothing. Meta’s frequency caps are silent: a refusal saying the person is capped is Meta protecting them, not a broken send.
| Argument | Type | Description |
|---|---|---|
| dryRun | boolean | true = preview the request body and send nothing |
| message* | object | the message to send: {type, text, title, subtitle, imageUrl, videoId, url, buttons} |
| campaignId* | string | the message campaign id (from create_messenger_marketing_campaign or list_messenger_marketing_campaigns) — OR the campaign NAME as the user said it: a non-numeric value is resolved against the account's own campaigns, so you never need to ask for an id |
Send a test alert to one alert channel (provider: slack, discord or webhook) or to every connected one, and report what each answered (delivered, or the HTTP status and the channel's own words). A test that gets through also clears a NOT DELIVERING or stopped state. The webhook test is signed like every event. Free.
| Argument | Type | Description |
|---|---|---|
| provider | string | omit to test every connected alert channel |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"provider": {
"enum": [
"slack",
"discord",
"webhook"
],
"type": "string",
"description": "omit to test every connected alert channel"
}
}
}Send ONE approved WhatsApp template to MANY recipients — a customer list, a segment, a launch announcement. TEMPLATES ONLY: WhatsApp accepts free-form text only inside the 24-hour customer-service window that opens when THAT person messages the business, so a free-form broadcast is a separate bet on a separate window per person and Meta refuses each closed one. META HAS NO BULK ENDPOINT, so this is a paced fan-out — one message per recipient, about 10 a second — which is why it runs as a QUEUED JOB and returns a job id rather than a result: nothing has been sent when the call returns. Poll it by calling this tool again with `jobId` (or use get_job), and read the PER-RECIPIENT outcome; a partial failure is reported as one, never as success. PER-RECIPIENT VARIABLES ARE THE POINT: pass recipients as objects { to, components } so each person gets their own {{1}}, {{2}} values in a single broadcast. Duplicates are removed (the same number twice is a second charge for one person and trips Meta’s 6-second per-recipient limit) and unusable numbers are REFUSED BY NAME rather than silently dropped — pass skipInvalid:true to send to the rest, and they are still named in the result. RUN IT WITH dryRun:true FIRST: that costs nothing, sends nothing, and reports the exact recipient count, the template’s real category, what Meta bills for it, and whether the list fits inside the business portfolio’s moving 24-hour messaging limit. Warning: META DOES NOT DELIVER MARKETING MESSAGES TO WHATSAPP USERS IN THE UNITED STATES AT ALL (error 131049, platform-wide since 1 April 2025) — the sends are still ACCEPTED and given message ids, so a US marketing broadcast looks like a success and is not one; the dry run says how many recipients are on +1. META BILLS THE BUSINESS, not Hermoso credits: per message delivered, at Meta’s own per-country rates, marketing always charged and utility/authentication free inside an open service window. WhatsApp policy REQUIRES the recipient’s opt-in first and nothing in the API enforces it — Meta punishes it afterwards through quality rating. Confirm-gated in code: show the user the message, the template and the recipient count, get an explicit yes, then call again with confirm:true AND confirmCount set to the exact number of distinct recipients. ACCEPTED IS NOT DELIVERED — never report a broadcast as delivered. 0 Hermoso credits.
| Argument |
|---|
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.
| Argument | Type | Description |
|---|---|---|
| to* | string | the recipient in full international form, e.g. +14165550142 — or, for someone who reached the business by WhatsApp username and shared no number, their business-scoped user id exactly as received (e.g. US.13491208655302741918). Authentication templates need the phone number. |
| text | string | a free-form message — only lands INSIDE the 24-hour window |
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).
| Argument | Type | Description |
|---|---|---|
| text* | string | chat message, max 140 characters; a longer one is refused |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| confirm | boolean | true only after the user approved this exact message; it is posted publicly |
| replyTo | string | chat message id to reply to |
| broadcastId* | string | running broadcast id from list_x_broadcasts |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"broadcastId",
"text"
],
"properties": {
"text": {
"type": "string",
"description": "chat message, max 140 characters; a longer one is refused"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"confirm": {
"type": "boolean",
"description": "true only after the user approved this exact message; it is posted publicly"
},
"replyTo": {
"type": "string",
"description": "chat message id to reply to"
},
"broadcastId": {
"type": "string",
"description": "running broadcast id from list_x_broadcasts"
}
}
}Send ONE direct message from the connected X account, either into an existing conversation (conversationId) or to a named account (participantId — the NUMERIC id, not an @handle; sending to someone with no existing thread starts one). THIS IS A PRIVATE MESSAGE TO A REAL PERSON AND IT CANNOT BE TAKEN BACK: show the user the exact wording and the exact recipient and get an explicit yes BEFORE calling. Never send the same message to several people — send one, to one named recipient. X may refuse a message to someone whose settings do not accept DMs from accounts they do not follow; that refusal comes from X, not from Hermoso. Costs credits per send. Needs X connected with direct-message access — an X account connected before DMs shipped must be reconnected once (Settings > Connectors > X).
| Argument | Type | Description |
|---|---|---|
| text* | string | the message, exactly as it should be sent |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| participantId | string | the NUMERIC X account id to message — starts a conversation if there is none. list_x_dms returns it beside every message. |
| conversationId | string | reply into an existing conversation (from list_x_dms) |
| idempotencyKey | string | pass the same key to make a retry safe — a timeout that already sent would otherwise send twice |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"text"
],
"properties": {
"text": {
"type": "string",
"description": "the message, exactly as it should be sent"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"participantId": {
"type": "string",
"description": "the NUMERIC X account id to message — starts a conversation if there is none. list_x_dms returns it beside every message."
},
"conversationId": {
"type": "string",
"description": "reply into an existing conversation (from list_x_dms)"
},
"idempotencyKey": {
"type": "string",
"description": "pass the same key to make a retry safe — a timeout that already sent would otherwise send twice"
}
}
}Choose which event groups one alert channel receives. provider: slack, discord or webhook. events: any of renders (a render finished or failed), posts (a scheduled post published, partly published or failed), approvals (autopilot drafts waiting for review), watch (new competitor ads found by the competitor watch), credits (credits running low), leads (a new lead-form lead from Meta, LinkedIn or Snapchat), connections (an account connected to or disconnected from the brand: account.connected / account.disconnected), inbox (a new Facebook or Instagram comment or direct message: comment.received / message.received; opt-in, never on unless named) — or "all". An empty list keeps the channel connected and sends it nothing. Unknown names are refused by name. Read back from the stored setting. Free.
| Argument | Type | Description |
|---|---|---|
| events* | any | event groups, or "all" |
| provider* | string | alert channel to configure: slack, discord or webhook |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"provider",
"events"
],
"properties": {
"events": {
"anyOf": [
{
"type": "array",
"items": {
"enum": [
"renders",
"posts",
"approvals",
"watch",
"credits",
"leads",
"connections",
"inbox",
"all"
],
"type": "string"
}
},
{
"type": "string",
"const": "all"
}
],
"description": "event groups, or \"all\""
},
"provider": {
"enum": [
"slack",
"discord",
"webhook"
],
"type": "string",
"description": "alert channel to configure: slack, discord or webhook"
}
}
}Turn automatic credit reloads on or off (admin only): when the balance drops below a threshold, the card on file is charged for a top-up pack — SERVER-SIDE, even with no app open. Requires a saved card, added once in the app at first checkout/top-up; if there's none the tool tells you exactly where to add it. After that one-time card setup, agents can manage auto-reload, top-ups and plan links fully. Members (read-only billing) get an 'ask an admin' message.
| Argument | Type | Description |
|---|---|---|
| enabled* | boolean | true to turn auto-reload on, false to turn it off |
| reloadCredits | integer | how many credits to add each reload — must match a credit pack size (see buy_credits) |
| thresholdCredits | integer | reload when the balance drops below this many credits |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"enabled"
],
"properties": {
"enabled": {
"type": "boolean",
"description": "true to turn auto-reload on, false to turn it off"
},
"reloadCredits": {
"type": "integer",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"description": "how many credits to add each reload — must match a credit pack size (see buy_credits)"
},
"thresholdCredits": {
"type": "integer",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"description": "reload when the balance drops below this many credits"
}
}
}Set WHICH of a connector's accounts this profile is allowed to post to and spend from — Facebook Pages / Instagram / Meta ad accounts, Google Ads customers, LinkedIn company Pages (and the personal profile), Pinterest or Microsoft Advertising ad accounts. Pass ids from list_connector_accounts. This REPLACES the current selection: anything you leave out is un-shared, and an EMPTY list shares nothing (publishing then refuses — it fails closed by design, and the server re-verifies every id against the live connection, so an id the account cannot actually reach is rejected rather than saved). Ask the user which accounts they mean; posting as the wrong Page is a public mistake. Providers: tiktok, x, youtube, threads, bluesky, telegram, pinterest, instagram, meta, google_ads, linkedin, pinterest_ads, linkedin_ads, reddit_ads, apple_ads, microsoft_ads, google_business, google_analytics, snapchat_ads, x_ads, tiktok_ads, google_tag_manager, google_search_console, bing_webmaster. Free.
| Argument | Type | Description |
|---|---|---|
| provider* | string | which connector to scope |
| accountIds* | array | the ids (from list_connector_accounts) this profile may use — an empty array shares nothing |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"provider",
"accountIds"
],
"properties": {
"provider": {
"enum": [
"tiktok",
"x",
"youtube",
"threads",
"bluesky",
"telegram",
"pinterest",
"instagram",
"meta",
"google_ads",
"linkedin",
"pinterest_ads",
"linkedin_ads",
"reddit_ads",
"apple_ads",
"microsoft_ads",
"google_business",
"google_analytics",
"snapchat_ads",
"x_ads",
"tiktok_ads",
"google_tag_manager",
"google_search_console",
"bing_webmaster"
],
"type": "string",
"description": "which connector to scope"
},
"accountIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "the ids (from list_connector_accounts) this profile may use — an empty array shares nothing"
}
}
}Choose the DEFAULT Discord channel the Hermoso app posts to: post_to_discord, scheduled Discord posts and Autopilot use it whenever a post names no channel. Shared with everyone on the brand. Ask the user which channel; never pick one for them. Free.
| Argument | 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 | channel id or #name from list_discord_channels |
| discordServer | string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"channel"
],
"properties": {
"brand": {
"type": "string",
"description": "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": {
"type": "string",
"description": "channel id or #name from list_discord_channels"
},
"discordServer": {
"type": "string"
}
}
}Turn AUTOPILOT POSTING (autoposting, the posting refill) on, change it or pause it. Available on paid plans: on the Free plan switching it on is refused, so relay that and do not retry. Examples: "post 2 times a day on Instagram and Bluesky about …, let me review first", "make it 1 a day", "switch to auto-publish", "pause autopilot". PASS ONLY WHAT CHANGES. It always makes NEW posts (a fresh image, carousel or video for each) and never re-posts the Library. WHERE: every connected channel by default (channels narrows it); HOW OFTEN: the brand’s posting times, and channelPostsPerDay sets fewer on a channel (e.g. {"x":1}). There are no credit limits to set — get_post_refill shows roughly what it costs a day. Switching it on is refused when no channel is connected or nothing a day would be made. SWITCHING IT ON ASKS THE BRIEF ONCE: a goal (followers / sales / launch / other) is required; content pillars (get_post_refill suggests some from the brand), formats (mix / video / carousel / image) and an avoid list are optional. Posts are written from the brief, the brand profile, what has worked on each channel (compared only within a channel), the brand’s skills and playbooks and the ads it saved. Ask the user for anything missing (goal, mode, channels) rather than guessing. `mode` picks what happens to each batch: "review" (the default) keeps the fresh posts as drafts until a human approves them (run_post_refill approve), "auto" schedules them straight away. `enabled:false` is the PAUSE — it removes the recurring job outright, and posts already queued are left alone (cancel those with cancel_scheduled). Three posting times means up to three posts a day; postsPerDay sets fewer, and to post MORE per day pass postingTimes (one time per post a day) — e.g. "2 a day" on a brand with 3 posting times is postsPerDay:2, "4 a day" needs four postingTimes.
| Argument | Type | Description |
|---|---|---|
| goal | string | THE BRIEF: what the posts are for — grow followers, drive sales, promote a launch, or other (say what in goalNote). Required to switch it on. |
Change a workspace member’s role — admin (full access incl. billing) or member (read-only on billing). A privilege change: confirm the exact person + new role with the user, then call with confirm:true.
| Argument | Type | Description |
|---|---|---|
| role* | string | the new role |
| email* | string | the member’s email |
| confirm | boolean | REQUIRED true |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"email",
"role"
],
"properties": {
"role": {
"enum": [
"admin",
"member"
],
"type": "string",
"description": "the new role"
},
"email": {
"type": "string",
"description": "the member’s email"
},
"confirm": {
"type": "boolean",
"description": "REQUIRED true"
}
}
}Choose the DEFAULT Slack channel the Hermoso app posts to: post_to_slack, scheduled Slack posts and Autopilot use it whenever a post names no channel. Shared with everyone on the brand. Ask the user which channel; never pick one for them. Free.
| Argument | 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 | channel id or #name from list_slack_channels |
| slackWorkspace | string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"channel"
],
"properties": {
"brand": {
"type": "string",
"description": "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": {
"type": "string",
"description": "channel id or #name from list_slack_channels"
},
"slackWorkspace": {
"type": "string"
}
}
}Turn TikTok's 'Ad authorization' setting ON or OFF for a post the authorized account owns — THIS IS WHERE A SPARK ADS AUTHORIZATION CODE COMES FROM. For somebody ELSE's post the code still comes from that creator, copied out of the TikTok app. IT IS CONFIRM-GATED ON THE WAY ON, and not because it spends: TikTok changes the post's privacy to 'Available for Ads', sends it to their ad review team, says the post 'may also appear as an ad on third party platforms', and treats the call as accepting their Advertising Content Terms on the owner's behalf. Turning it OFF needs no confirm — but TikTok REFUSES to turn it off while an active Spark Ad is using the post, so pause those campaigns first. AuthorizationDays must be one of 7, 30, 60, 180, 365. THE ANSWER IS THE READ-BACK: the reply carries the post's real authorization status and its code, because TikTok's own response body is empty. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION (see tiktok_account_status).
| Argument | Type | Description |
|---|---|---|
| itemId* | string | the TikTok post id |
| confirm | boolean | REQUIRED true when enabling — it makes the post publicly promotable and accepts TikTok’s advertising terms on the owner’s behalf |
| enabled* | boolean | true turns Ad authorization ON (and mints the Spark Ads code); false turns it off |
| authorizationDays | number | 7 | 30 | 60 | 180 | 365 — TikTok publishes exactly those five; default 30 |
Set the CUSTOM THUMBNAIL on a video already on the connected channel, using a Hermoso image. The thumbnail is the single biggest lever on YouTube click-through and YouTube otherwise auto-picks a frame, so a published video without one is leaving reach on the table. It changes ONLY the thumbnail — video, title and privacy are untouched — but it is public and immediate, so show the user which image is going on which video and get a yes first. Custom thumbnails require a VERIFIED YouTube channel (a phone number at youtube.com/verify); without it YouTube refuses and the error says so. Images over YouTube’s 2MB cap are compressed automatically. The image must be Hermoso-HOSTED, which is not the same as Hermoso-GENERATED: the user’s own artwork works, put it through upload_file first and pass the URL that returns. An arbitrary external host is refused. 0 credits. Needs a connected YouTube channel.
| Argument | 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. |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| videoId* | string | the YouTube video id (what post_to_youtube returned) |
| imageUrl* | string | A Hermoso-hosted image URL. An arbitrary external host is refused. |
Set (or remove) the BRANDING WATERMARK — the small subscribe badge overlaid on EVERY video on the channel, including ones uploaded later. One generated asset brands the whole channel at once, which is why it is worth doing before a batch of uploads rather than after. YouTube wants a SQUARE image, at least 150x150, under 10MB, and it renders SMALL: a full logo lockup with text will not read at that size. By default it shows for the whole video; timingType offsetFromStart/offsetFromEnd with offsetMs and durationMs narrows it. THE DATA API PUBLISHES NO WAY TO READ A WATERMARK BACK — there is only set and unset — so this reports 'accepted', never 'confirmed', and says so rather than claiming a verification it did not get. 0 credits. Needs YouTube connected.
| Argument | Type | Description |
|---|---|---|
| action | string | defaults to 'set' when an imageUrl is given |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| imageUrl | string | any public https image URL — a Hermoso render, or ANY file of your own brought in with upload_file. Square, at least 150x150 |
| offsetMs | number | when the watermark appears, relative to timingType |
| durationMs | number | how long it stays on screen |
| timingType | string |
Create a SHARING LINK to a OneDrive file or folder (fileId from list_onedrive_files): type view (read-only, default), edit, or embed (an <iframe> link, personal OneDrive only); scope anonymous (anyone with the link, default), organization (work or school OneDrive only) or users (only people you invite); optional expiresAt (date or ISO) and password (on a personal OneDrive both need a Microsoft 365 subscription; the refusal says so). An ANONYMOUS EDIT link lets anyone holding it change or delete the file, so it needs confirm:true after you show the user that; every other link is created straight away. If Hermoso already made the same kind of link, OneDrive returns that one and the reply says it was reused. Read back from the item's permission list. 0 credits. Needs OneDrive connected.
| Argument | Type | Description |
|---|---|---|
| type | string | view (default), edit, or embed (personal OneDrive only) |
| scope | string | anonymous (default, anyone with the link), organization (work or school), or users (invited people only) |
| fileId* | string | OneDrive file or folder id from list_onedrive_files |
| confirm | boolean | required for an anonymous edit link |
| password | string | personal OneDrive with Microsoft 365 |
| expiresAt | string | e.g. 2026-11-30 or 2026-11-30T17:00:00Z |
Read one of this workspace’s data stores by key, for visibility into what the app holds — playbooks, swipefile, saved locations, avatars, creations, chats, brand, memory, skills. Read-only, free. Allowed keys: heist.memory.v1, heist.skills.v1, heist.playbooks.v1, heist.avatars.v1, heist.locations.v1, heist.chats.v1, heist.creations.v1, heist.assets.v1, heist.brand.v1, adInspo.swipefile.v1. (The typed tools — list_memory / list_skills / get_brand — are friendlier for those; use store_get for the rest.)
| Argument | Type | Description |
|---|---|---|
| key* | string | the store key to read (one of the allowlisted keys) |
| limit | number | max array items to return (default 50) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"key"
],
"properties": {
"key": {
"type": "string",
"description": "the store key to read (one of the allowlisted keys)"
},
"limit": {
"type": "number",
"description": "max array items to return (default 50)"
}
}
}Have LinkedIn push every new lead to Hermoso the moment it is submitted, and optionally relay each event on to the user’s own CRM. THE WEBHOOK LINKEDIN VALIDATES IS ALWAYS HERMOSO’S OWN: LinkedIn challenges it with our app secret (and re-challenges every ~2 hours), which no CRM, Zapier or Make endpoint can answer — so never promise a customer URL as the LinkedIn webhook. Pass forwardTo (public HTTPS) to have Hermoso relay each lead event there; leave it off to keep events in Hermoso only (list_linkedin_lead_events). The reply is read back from LinkedIn, not from the 201. Leads stay readable with list_linkedin_leads either way. Free.
| Argument | Type | Description |
|---|---|---|
| pageId | string | the company Page that owns the form — from list_linkedin_pages; omit when one Page is shared |
| leadType | string | defaults by owner: SPONSORED for an ad account, COMPANY for a Page |
| forwardTo | string | optional public HTTPS URL Hermoso relays each lead event to (a CRM, Zapier, Make) |
| adAccountId | string | read forms owned by an AD ACCOUNT instead of a Page |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"pageId": {
"type": "string",
"description": "the company Page that owns the form — from list_linkedin_pages; omit when one Page is shared"
},
"leadType": {
"enum": [
"SPONSORED",
"COMPANY",
"EVENT",
"ORGANIZATION_PRODUCT"
],
"type": "string",
"description": "defaults by owner: SPONSORED for an ad account, COMPANY for a Page"
},
"forwardTo": {
"type": "string",
"description": "optional public HTTPS URL Hermoso relays each lead event to (a CRM, Zapier, Make)"
},
"adAccountId": {
"type": "string",
"description": "read forms owned by an AD ACCOUNT instead of a Page"
}
}
}Have Meta PUSH events to Hermoso instead of Hermoso polling for them. Subscribe a Page to `feed` (comments, posts, likes and shares — this is what makes a new comment ARRIVE rather than be polled for), `mention` (someone mentions the Page), `leadgen` (a lead-ad form submission delivered the moment it happens, which turns "read the leads when asked" into "the leads arrive"), and `messages` (an inbound Messenger or Instagram DM — THE ONLY WAY to be told about one, and Meta’s 24-hour reply window starts the moment it arrives, so being told rather than polling is time on the clock). Read what has arrived with list_meta_webhook_events. THE ANSWER IS READ BACK FROM META, never the 200: Meta can accept a subscription and record FEWER fields than you asked for, and a caller told "subscribed to leadgen" whose leads never arrive has no way to discover why — so any field Meta did not record is named. Fields Hermoso does not consume (message_reactions, messaging_postbacks, message_echoes) are refused by name with the reason, so an agent gets a fact rather than "unsupported". 0 credits.
| Argument | Type | Description |
|---|---|---|
| fields* | array | what to be told about. `marketing_messages` is shorthand for the five marketing_message_* outcome fields (sent / delivered / read / clicked / failed of a paid Messenger marketing message, read with get_messenger_marketing_message_status). Pass every field the Page should keep: Meta does not document whether a subscribe merges or replaces the list, and the read-back shows what it recorded. DM automation’s own fields (messaging_postbacks, messaging_referrals) are kept when already present, so subscribing never breaks it. At least one — Meta marks it required, and defaulting it would decide on the brand’s behalf what they hear about. |
| pageId | string | Facebook Page id — omit when only one Page is connected |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"fields"
],
"properties": {
"fields": {
"type": "array",
"items": {
"enum": [
"feed",
"mention",
"leadgen",
"messages",
"marketing_messages",
"marketing_message_echoes",
"marketing_message_deliveries",
"marketing_message_reads",
"marketing_message_clicks",
"marketing_message_delivery_failed"
],
"type": "string"
},
"description": "what to be told about. `marketing_messages` is shorthand for the five marketing_message_* outcome fields (sent / delivered / read / clicked / failed of a paid Messenger marketing message, read with get_messenger_marketing_message_status). Pass every field the Page should keep: Meta does not document whether a subscribe merges or replaces the list, and the read-back shows what it recorded. DM automation’s own fields (messaging_postbacks, messaging_referrals) are kept when already present, so subscribing never breaks it. At least one — Meta marks it required, and defaulting it would decide on the brand’s behalf what they hear about."
},
"pageId": {
"type": "string",
"description": "Facebook Page id — omit when only one Page is connected"
}
}
}Put the leads the brand's LEAD FORMS collected (Meta, LinkedIn, TikTok, X Ads, Snapchat, ChatGPT Ads) into a Klaviyo LIST: each lead with an email becomes a Klaviyo profile (name and the form it came from) and joins listId. Nobody is SUBSCRIBED to marketing: a lead form is not consent Hermoso can vouch for, so their consent status is unchanged. Synced leads are remembered per list, so a re-run never repeats a write. since (default 7 days ago), platforms narrows it, dryRun:true lists what would be synced. Needs profiles:write and lists:write on the key. Free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).
| Argument | Type | Description |
|---|---|---|
| since | string | ISO date; default 7 days ago |
| dryRun | boolean | |
| listId* | string | |
| platforms | array |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"listId"
],
"properties": {
"since": {
"type": "string",
"description": "ISO date; default 7 days ago"
},
"dryRun": {
"type": "boolean"
},
"listId": {
"type": "string"
},
"platforms": {
"type": "array",
"items": {
"enum": [
"meta",
"linkedin",
"tiktok",
"x",
"snapchat",
"chatgpt",
"reddit"
],
"type": "string"
}
}
}
}Put the leads the brand's LEAD FORMS collected (Meta, LinkedIn, TikTok, X Ads, Snapchat, ChatGPT Ads) into a Mailchimp AUDIENCE: each lead with an email is added with their name. By default they are NOT subscribed (a non-marketing 'transactional' contact), because a lead form is not consent Hermoso can vouch for; pass consented:true only when the user confirms the form asked for marketing consent. An existing contact's status is never changed, and synced leads are remembered per audience so a re-run never repeats a write. since (default 7 days ago), platforms narrows it, dryRun:true lists what would be synced. Free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).
| Argument | Type | Description |
|---|---|---|
| since | string | ISO date; default 7 days ago |
| dryRun | boolean | |
| consented | boolean | |
| platforms | array | |
| audienceId* | string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"audienceId"
],
"properties": {
"since": {
"type": "string",
"description": "ISO date; default 7 days ago"
},
"dryRun": {
"type": "boolean"
},
"consented": {
"type": "boolean"
},
"platforms": {
"type": "array",
"items": {
"enum": [
"meta",
"linkedin",
"tiktok",
"x",
"snapchat",
"chatgpt",
"reddit"
],
"type": "string"
}
},
"audienceId": {
"type": "string"
}
}
}Add an Intercom tag to a conversation, or remove it (remove:true). Name it by tagId or exact tagName (list_intercom_tags); a tagName the workspace does not have yet is created only with create:true (ask the user first). Internal to the inbox: the customer never sees tags. Read back. Free. Needs Intercom connected (Settings > Connectors > Intercom, or connect_connector).
| Argument | Type | Description |
|---|---|---|
| tagId | string | Intercom tag id from list_intercom_tags; use this or tagName |
| create | boolean | true = create tagName if the workspace has no such tag |
| remove | boolean | true = remove the tag from the conversation; omit or false = add it |
| tagName | string | exact tag name, as an alternative to tagId |
| conversationId* | string | Intercom conversation id to tag |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"conversationId"
],
"properties": {
"tagId": {
"type": "string",
"description": "Intercom tag id from list_intercom_tags; use this or tagName"
},
"create": {
"type": "boolean",
"description": "true = create tagName if the workspace has no such tag"
},
"remove": {
"type": "boolean",
"description": "true = remove the tag from the conversation; omit or false = add it"
},
"tagName": {
"type": "string",
"description": "exact tag name, as an alternative to tagId"
},
"conversationId": {
"type": "string",
"description": "Intercom conversation id to tag"
}
}
}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.
| Argument | Type | Description |
|---|---|---|
| chatId* | string | the channel’s @username or numeric chat id — required; there is no default |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"chatId"
],
"properties": {
"chatId": {
"type": "string",
"description": "the channel’s @username or numeric chat id — required; there is no default"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
}
}
}Check whether a sample comment, mention or DM would trigger a rule (saved by id, or unsaved) and exactly what it would send on which channel; on X it also quotes what X charges for the send. Sends nothing. Free.
| Argument | Type | Description |
|---|---|---|
| id | string | saved rule id to test; omit to test a rule that is not saved yet |
| name | string | name of the automation, shown in the list |
| text* | string | the sample comment, mention or DM |
| dryRun | boolean | log what would be sent without sending |
| account | string | the account on that channel: @handle (Instagram, X) or Page name/id (Facebook); omit when only one is connected |
| channel | string | instagram (default), facebook (a Facebook Page: comments on its posts and ads, plus Messenger) or x |
| enabled | boolean | false pauses the automation without deleting it |
| message | object | what the person receives: {text, buttons}; on X the text is the public reply, up to 280 characters |
Check whether a sample comment or DM would trigger a saved rule and exactly what it would send. Sends nothing. Free.
| Argument | Type | Description |
|---|---|---|
| id* | string | saved rule id to test against |
| text* | string | the sample comment or DM |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"id",
"text"
],
"properties": {
"id": {
"type": "string",
"description": "saved rule id to test against"
},
"text": {
"type": "string",
"description": "the sample comment or DM"
}
}
}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.
| Argument | Type | Description |
|---|---|---|
| since | string | YYYY-MM-DD window start (account scope) |
| until | string | YYYY-MM-DD window end (account scope) |
| postId | string | post id from list_threads_posts — omit for account-level insights |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| metrics | array | account metrics: views, likes, replies, reposts, quotes, clicks, followers_count, follower_demographics |
| breakdown | array |
Read ANY public Threads account by handle — name, bio, avatar, verified badge, follower count, and its lifetime likes / quotes / reposts / views. This is competitor and creator research on Meta’s own data rather than a scraper: read a rival’s positioning straight off their profile instead of guessing it, then read what they are posting with list_threads_profile_posts and draft the brand’s answer. A bare handle ("nike"), an "@nike" and a threads.net profile link all resolve — Meta matches the handle EXACTLY, so a display name will not. IF NOTHING COMES BACK, DO NOT SAY THE ACCOUNT DOES NOT EXIST: read the note, which says whether that is a real absence or our app’s approval state. Read-only, 0 credits. Needs Threads connected.
| Argument | Type | Description |
|---|---|---|
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| username* | string | the Threads handle — "nike", "@nike", or a threads.net profile link |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"username"
],
"properties": {
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"username": {
"type": "string",
"description": "the Threads handle — \"nike\", \"@nike\", or a threads.net profile link"
}
}
}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.
| Argument | Type | Description |
|---|---|---|
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
}
}
}Clean up and consolidate the workspace Memory: drops entries that are about how Hermoso, a tool, a connector or a platform API behaves (product behaviour, not the brand), drops phone numbers and emails, and merges near-duplicate facts into one sentence each. Call with no argument to get the PROPOSAL (what would be removed and merged, with reasons) — nothing changes. Call again with confirm:true to apply it through the same typed writers the app uses (deletes carry tombstones so they stay deleted on every device). One model call, billed at its token cost.
| Argument | Type | Description |
|---|---|---|
| brand | string | profile id/name from list_brands, this call only |
| confirm | boolean | true to APPLY the proposal; omit to only see it |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"brand": {
"type": "string",
"description": "profile id/name from list_brands, this call only"
},
"confirm": {
"type": "boolean",
"description": "true to APPLY the proposal; omit to only see it"
}
}
}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).
| Argument | Type | Description |
|---|---|---|
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
}
}
}The connected TikTok account's OWN analytics: follower demographics broken down by AGE, GENDER, COUNTRY and CITY, the daily series (video views, profile views, likes, comments, shares, reached audience, engaged audience, follower gained/lost/net, and the profile-button clicks a verified Business account collects), and the lifetime counters. This is TikTok's twin of instagram_insights and youtube_channel_report. TWO PRECONDITIONS TIKTOK ENFORCES ON THE DEMOGRAPHICS, and this tool reports which one is in the way instead of returning an empty breakdown: the account must be a BUSINESS account, and it must have at least 100 FOLLOWERS. Below that TikTok withholds the distributions for privacy, which is not the same as an audience it could not measure. The look-back is capped at 60 DAYS, which is SHORTER than the 90 days the brand-mentions tools cover, and daily numbers lag by up to 48 hours. The bio, verified badge and profile link are deliberately not readable here. That needs a TikTok permission this authorization does not request, and tiktok_account on the TikTok posting connector already returns all three. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION with the audience-insights permission (tiktok_account_status says whether this profile has it). Read-only, free.
| Argument | Type | Description |
|---|---|---|
| fields | array | defaults to everything this authorization can read. Demographics are audience_ages, audience_genders, audience_countries, audience_cities |
| endDate | string | YYYY-MM-DD (UTC). Default is yesterday |
| startDate | string | YYYY-MM-DD (UTC). Default is 7 days ago; TikTok keeps at most 60 days |
Report whether this profile holds the TikTok ACCOUNT-HOLDER authorization — the SECOND, separate consent on the same TikTok app that the TikTok Ads connection uses. TikTok issues two different grants: the ADVERTISER one (campaigns, budgets, reporting — that is `tiktok_ads`) and this ACCOUNT one, which is what lets Hermoso read and manage the comments on the brand's own TikTok posts and mint Spark-Ads authorization codes for them. Holding one does NOT give you the other, so a workspace can be fully connected for ads and still answer 'not connected' here — that is a real third state, not a broken session. Reports the state, the TikTok business id every other tool in this family uses, the scopes the grant actually carries, and any scope MISSING from it (TikTok binds scopes at authorize time and never retroactively, so a grant made before a scope was added simply does not have it and only a reconnect fixes that). Connecting is the one step that needs a browser — the reply carries the exact URL to send the user to. Read-only, free.
No arguments.
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {}
}What an average TikTok Business account in a given industry looks like: mean likes, comments, shares, video count, follower count, 30-day follower growth, engagement rate and video views. Pair it with tiktok_account_insights to answer 'are we ahead of our category or behind it', which neither number answers alone. These are TikTok's own cross-account averages, not this brand's numbers. businessCategory must be one of TikTok's twenty-five published values. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION. Read-only, free.
| Argument | Type | Description |
|---|---|---|
| businessCategory* | string | TikTok business category, e.g. BEAUTY; one of TikTok's published values |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"businessCategory"
],
"properties": {
"businessCategory": {
"enum": [
"ART_AND_CRAFTS",
"AUTOMOTIVE_AND_TRANSPORTATION",
"BABY",
"BEAUTY",
"CLOTHING_AND_ACCESSORIES",
"EDUCATION_AND_TRAINING",
"ELECTRONICS",
"FINANCE_AND_INVESTING",
"FOOD_AND_BEVERAGE",
"GAMING",
"HEALTH_AND_WELLNESS",
"HOME_FURNITURE_AND_APPLIANCES",
"MACHINERY_AND_EQUIPMENT",
"MEDIA_AND_ENTERTAINMENT",
"PERSONAL_BLOG",
"PETS",
"PROFESSIONAL_SERVICES",
"PUBLIC_ADMINISTRATION",
"REAL_ESTATE",
"RESTAURANTS_AND_BARS",
"SHOPPING_AND_RETAIL",
"SOFTWARE_AND_APPS",
"SPORTS_FITNESS_AND_OUTDOORS",
"TRAVEL_AND_TOURISM",
"OTHERS"
],
"type": "string",
"description": "TikTok business category, e.g. BEAUTY; one of TikTok's published values"
}
}
}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).
| Argument | Type | Description |
|---|---|---|
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
}
}
}Hashtag ideas for a TikTok caption: 10 suggestions for a keyword, exact matches first and then by how many posts use them, with each tag’s post count. Read-only, free.
| Argument | Type | Description |
|---|---|---|
| keyword* | string | ONE word, no spaces (TikTok refuses a keyword with a space) |
| language | string | TikTok language code, default en (e.g. es, fr, de-DE, pt-BR) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"keyword"
],
"properties": {
"keyword": {
"type": "string",
"description": "ONE word, no spaces (TikTok refuses a keyword with a space)"
},
"language": {
"type": "string",
"description": "TikTok language code, default en (e.g. es, fr, de-DE, pt-BR)"
}
}
}The top 20 KEYWORDS and the top 20 HASHTAGS appearing in the captions of the posts that mention this brand. It is what people say when they talk about it, rather than which posts they said it in. Two TikTok endpoints behind one tool because they take identical parameters and answer the same question at two granularities; kind:'KEYWORDS' or kind:'HASHTAGS' calls only one. Counted across the top 1,000 mentioning posts of the last 90 days, so this is the language of the mentions and not of TikTok at large. Same BUSINESS-account precondition as every mentions tool. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION with the brand-insights permission. Read-only, free.
| Argument | Type | Description |
|---|---|---|
| kind | string | default BOTH, which makes two calls |
| regions | array | two-letter codes to narrow which mentioning posts are counted |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"kind": {
"enum": [
"KEYWORDS",
"HASHTAGS",
"BOTH"
],
"type": "string",
"description": "default BOTH, which makes two calls"
},
"regions": {
"type": "array",
"items": {
"type": "string"
},
"description": "two-letter codes to narrow which mentioning posts are counted"
}
}
}Follow a TikTok post that post_to_tiktok sent but TikTok was still processing (it came back pending:true). Pass the publishId it returned. Answers one of three states: done (PUBLISH_COMPLETE, with the public `url` once TikTok’s moderation has named it; `urlStatus` is ready, pending, private or draft), failed (with TikTok’s reason), or still processing, which is normal for a few minutes and is NOT a failure. Never post the video again while it is processing. Free, read-only. Needs TikTok connected (Settings > Connectors > TikTok).
| Argument | Type | Description |
|---|---|---|
| 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 |
| publishId* | string | the publishId post_to_tiktok returned |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"publishId"
],
"properties": {
"account": {
"type": "string",
"description": "which connected TikTok account the post went out on (@handle or id from list_connector_accounts) when the profile has more than one"
},
"publishId": {
"type": "string",
"description": "the publishId post_to_tiktok returned"
}
}
}Which organic TikTok posts are worth running as Spark Ads: TikTok rates the connected account’s posts HIGH / MEDIUM / LOW, optionally for an objective (REACH, TRAFFIC, VIDEO_VIEWS, ENGAGEMENT, APP_PROMOTION, LEAD_GENERATION, WEB_CONVERSIONS, PRODUCT_SALES) and period (PAST_ONE_MONTH / THREE / SIX). Pass ttoAccountId to rate TikTok One creator-marketplace posts instead. Read-only, free.
| Argument | Type | Description |
|---|---|---|
| time | string | PAST_ONE_MONTH, PAST_THREE_MONTHS or PAST_SIX_MONTHS |
| videoIds | array | rate only these (up to 20) |
| objective | string | campaign objective to rate posts for, e.g. REACH, TRAFFIC or VIDEO_VIEWS; omit for all |
| ttoAccountId | string | rate TikTok One creator posts instead of your own |
| excludeVideoIds | array | post ids to leave out of the ratings |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"time": {
"enum": [
"PAST_ONE_MONTH",
"PAST_THREE_MONTHS",
"PAST_SIX_MONTHS"
],
"type": "string",
"description": "PAST_ONE_MONTH, PAST_THREE_MONTHS or PAST_SIX_MONTHS"
},
"videoIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "rate only these (up to 20)"
},
"objective": {
"type": "string",
"description": "campaign objective to rate posts for, e.g. REACH, TRAFFIC or VIDEO_VIEWS; omit for all"
},
"ttoAccountId": {
"type": "string",
"description": "rate TikTok One creator posts instead of your own"
},
"excludeVideoIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "post ids to leave out of the ratings"
}
}
}Per-POST analytics for the connected TikTok account: views, reach, likes, comments, shares, saves, average watch time, full-watch rate, the RETENTION CURVE (share still watching at each second — the hook read), traffic sources (For You, Follow, Search, Sound, profile…), new followers, profile views, website clicks, lead submissions and the audience by country, gender and new-vs-returning. Newest posts first, up to 20 a page (cursor for more), or name videoIds. Data lags 24-48 hours. Needs the TikTok ACCOUNT authorization; an older one says which permission to re-authorize for. Read-only, free.
| Argument | 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. |
| cursor | string | pagination cursor from the previous reply, to fetch the next page |
| insights | boolean | false = counters only (no retention/traffic/audience) |
| maxCount | number | 1-20, default 20 |
| videoIds | array | post ids to read; omit for the newest posts |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"brand": {
"type": "string",
"description": "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."
},
"cursor": {
"type": "string",
"description": "pagination cursor from the previous reply, to fetch the next page"
},
"insights": {
"type": "boolean",
"description": "false = counters only (no retention/traffic/audience)"
},
"maxCount": {
"type": "number",
"description": "1-20, default 20"
},
"videoIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "post ids to read; omit for the newest posts"
}
}
}Remove this app’s webhook subscription from a Page, so Meta stops pushing its events. It removes the WHOLE subscription — Meta’s DELETE takes no field list — so it is all fields or none; to keep some, call subscribe_meta_webhooks again with just those. Nothing published or posted is affected; only the notifications stop. THE ANSWER IS THE READ-BACK: a subscription still present when read back means the unsubscribe did NOT take effect, and that is reported rather than the 200. 0 credits.
| Argument | Type | Description |
|---|---|---|
| pageId | string | Facebook Page id — omit when only one Page is connected |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"pageId": {
"type": "string",
"description": "Facebook Page id — omit when only one Page is connected"
}
}
}Patch SPECIFIC fields of the active profile (name, domain, sells, summary, category, audience, positioning, voice, style, goal, logos, brand/product name pronunciations, profileType: explore = kept but unused) WITHOUT overwriting the rest — a read-modify-write on the saved brand. Use for “change our voice to playful”, “we sell to dentists now”. To onboard a brand from scratch, use draft_brand. If no brand is saved yet and you only INFERRED one from what the user is making, ask them to confirm it is their brand before saving it (they may be working for a client or just trying things). Only pass the fields you’re changing.
| Argument | Type | Description |
|---|---|---|
| goal | string | current marketing goal |
| logo | string | logo image: URL or local file path to a PNG, JPEG or WebP (no SVG) |
| name | string | brand name |
| brand | string | profile id/name from list_brands, this call only |
| sells | string | what the brand sells |
| style | string | visual style — palette, typography, aesthetic |
| voice | string | brand voice/tone |
| domain | string |
Change the brand’s Google Business Profile listing — hours, phone, website, description, categories, service area, labels, store code, address or the business name. THIS EDITS THE PANEL ON GOOGLE SEARCH AND MAPS, immediately and publicly: there is no draft, no preview and no undo. Pass ONLY what changes, in `fields`, keyed by Google’s own field names: websiteUri, phoneNumbers, regularHours, specialHours, moreHours, profile, categories, storefrontAddress, title, labels, storeCode, openInfo, serviceArea, serviceItems, latlng, adWordsLocationExtensions, relationshipData. CALL IT WITHOUT confirm FIRST — nothing is written, Google validates the payload for you, and you get back the CURRENT value of every field you are about to change, so you can show the user the exact before-and-after; then call again with confirm:true once they approve. Changing the business NAME (title) or ADDRESS (storefrontAddress) additionally needs confirmName set to the listing’s CURRENT name, because Google can suspend a listing over either. Output-only fields (metadata) and immutable ones (languageCode) are refused by name rather than dropped. Use dryRun:true to validate a payload with Google and write nothing. Needs Google Business Profile connected.
| Argument | Type | Description |
|---|---|---|
| dryRun | boolean | validate with Google and write nothing (needs no confirm) |
| fields* | object | the changes, keyed by Google’s Location field names, e.g. {"websiteUri":"https://example.com"} or {"regularHours":{"periods":[…]}} |
| confirm | boolean | true ONLY after the user has seen the exact before-and-after and approved it |
| locationId |
EDIT a Google Doc — the correction append_to_doc cannot make. Two shapes: `replacements:[{find, replace}]` rewrites specific text wherever it appears (call read_doc first and match the text EXACTLY; matchCase:false ignores case), or `rewrite:"…"` replaces the ENTIRE body (rewrite:"" empties it), or `dropdowns:[{title, value}]` sets a dropdown chip (e.g. Status -> Approved — read_doc lists every chip with its options; pass dropdownId when two share a title; an unknown title or option is refused with the real list and nothing changes); the same entry with options:[…] (the COMPLETE new list, 2–50) and/or newTitle changes the dropdown itself for every chip built from it, and an option you drop that a chip shows needs replace:{"Old":"New"}. Find/replace runs immediately and REPORTS how many occurrences changed — zero matches is reported as a FAILURE to match, never as a quiet success, because a text edit that silently does nothing is worse than one that visibly fails. A whole-body rewrite is destructive: call it without confirm first to get the character count, then confirm:true + confirmCells. Both are index-free by design — an agent cannot reliably compute Google’s character offsets, and a wrong offset deletes the wrong sentence.
| Argument | Type | Description |
|---|---|---|
| docUrl | string | a Google Docs URL — the id is extracted from it |
| confirm | boolean | true runs a whole-body rewrite after the character count is shown |
| rewrite | string | replace the WHOLE body with this text ("" empties the doc) |
| dropdowns | array | dropdown chips to set, or dropdowns to change |
Update a Drive file: rename (name), move it into a folder (moveToFolderId, optionally removeFromFolderId to move OUT of the old one), or trash / untrash it (trash:true|false). Pass fileId (from list_drive_files). To delete permanently, use delete_drive_file.
| Argument | Type | Description |
|---|---|---|
| name | string | new name |
| trash | boolean | true -> move to Trash; false -> restore from Trash |
| fileId* | string | the Drive file id |
| moveToFolderId | string | folder id to move the file into (from create_drive_folder / list_drive_files) |
| removeFromFolderId | string | the old parent folder id to remove (when moving) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"fileId"
],
"properties": {
"name": {
"type": "string",
"description": "new name"
},
"trash": {
"type": "boolean",
"description": "true -> move to Trash; false -> restore from Trash"
},
"fileId": {
"type": "string",
"description": "the Drive file id"
},
"moveToFolderId": {
"type": "string",
"description": "folder id to move the file into (from create_drive_folder / list_drive_files)"
},
"removeFromFolderId": {
"type": "string",
"description": "the old parent folder id to remove (when moving)"
}
}
}Edit a Klaviyo campaign that is still a DRAFT: name, subject, previewText, fromEmail, fromLabel, replyTo, templateId, or its audience (listIds / segmentIds / excludeIds). A scheduled one must be reverted first (cancel_klaviyo_campaign). Read back. Free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).
| Argument | Type | Description |
|---|---|---|
| name | string | |
| listIds | array | |
| replyTo | string | |
| subject | string | |
| fromEmail | string | |
| fromLabel | string | |
| campaignId* | string | |
| excludeIds | array | |
| segmentIds | array | |
| templateId | string |
Change a Klaviyo template: a new name, html (the unsubscribe footer is added if it has none) or plain text. Read back. Free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).
| Argument | Type | Description |
|---|---|---|
| html | string | |
| name | string | |
| text | string | |
| templateId* | string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"templateId"
],
"properties": {
"html": {
"type": "string"
},
"name": {
"type": "string"
},
"text": {
"type": "string"
},
"templateId": {
"type": "string"
}
}
}Edit a LinkedIn event the Page organizes: name, description, start / end time, the join or info url, the in-person address or venue details, or discoveryMode (LISTED / URL_ONLY). LinkedIn’s own rules, quoted back when it refuses: an event can be edited only BEFORE it starts (never once cancelled), and online vs in-person never changes. Read back from LinkedIn. 0 credits.
| Argument | Type | Description |
|---|---|---|
| url | string | join or info link for an online or external event |
| name | string | new event title |
| endsAt | string | ISO 8601 |
| address | object | in-person venue address: {line1, city, region, postalCode, country} |
| eventId* | string | from list_linkedin_events or create_linkedin_event |
| startsAt | string | ISO 8601 |
| description | string | new event description |
| venueDetails | string | room, floor or parking notes for an in-person event |
Edit a Mailchimp campaign that is still a DRAFT: name, subject, previewText, fromName, replyTo, templateId, or its audience / segment. A scheduled one must be unscheduled first (cancel_mailchimp_campaign). Read back with its send checklist. Free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).
| Argument | Type | Description |
|---|---|---|
| name | string | |
| replyTo | string | |
| subject | string | |
| fromName | string | |
| segmentId | string | |
| audienceId | string | |
| campaignId* | string | |
| templateId | string | |
| previewText | string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"campaignId"
],
"properties": {
"name": {
"type": "string"
},
"replyTo": {
"type": "string"
},
"subject": {
"type": "string"
},
"fromName": {
"type": "string"
},
"segmentId": {
"type": "string"
},
"audienceId": {
"type": "string"
},
"campaignId": {
"type": "string"
},
"templateId": {
"type": "string"
},
"previewText": {
"type": "string"
}
}
}Replace a Mailchimp template's HTML (Mailchimp's update replaces the whole template, so pass the full html), optionally with a new name. The unsubscribe footer is added if the html has none. Free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).
| Argument | Type | Description |
|---|---|---|
| html* | string | |
| name | string | |
| templateId* | string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"templateId",
"html"
],
"properties": {
"html": {
"type": "string"
},
"name": {
"type": "string"
},
"templateId": {
"type": "string"
}
}
}Change a product catalog’s settings — most often its NAME. Also accepts the default and fallback product image URLs (what Meta shows when a product row has no usable image), the dynamic-ad display settings, and the destination / flight / store / partner-integration settings for travel and local-inventory catalogs. A catalog’s VERTICAL and its owning business portfolio are permanent and cannot be changed here — Meta publishes no way to change either. THE 200 IS NOT THE ANSWER: Meta replies {"success":true} and never returns the catalog, and it says exactly the same thing for a field it quietly ignored — so the summary is built from a fresh READ-BACK, and if a value does not read back as sent you are told it was NOT confirmed. Free, 0 credits.
| Argument | Type | Description |
|---|---|---|
| name | string | new name for the catalog |
| catalogId* | string | numeric catalog id from list_meta_catalogs |
| defaultImageUrl | string | public http(s) image URL Meta falls back to for a product with no image |
| fallbackImageUrl | string | public http(s) image URL used when the default also fails |
| daDisplaySettings | object | dynamic-ad display settings object |
| partnerIntegration | object | partner-integration settings object |
Change properties on ONE row of a Notion database (pageId from query_notion_database) — e.g. mark a calendar row Status "Posted" and put the live post link in its URL column. properties is {"Column": value} by column name: text, number, checkbox, select (an existing option or a new one), status (must be an existing option), multi-select (array; replaces the whole value), date (ISO, or {start, end}), url, email, phone; null clears. Validated against the database's own columns before anything is sent and refused by name otherwise, never coerced; columns you do not name keep their values. Read back from Notion. trash:true moves the row to Notion's trash (restorable; returns a preview first and needs confirm:true), trash:false restores it. Free.
| Argument | Type | Description |
|---|---|---|
| trash | boolean | true moves the row to Notion trash (needs confirm); false restores it |
| pageId* | string | the row id or link |
| confirm | boolean | true confirms moving the row to trash; not needed for other changes |
| properties | object | {"Column": value} |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"pageId"
],
"properties": {
"trash": {
"type": "boolean",
"description": "true moves the row to Notion trash (needs confirm); false restores it"
},
"pageId": {
"type": "string",
"description": "the row id or link"
},
"confirm": {
"type": "boolean",
"description": "true confirms moving the row to trash; not needed for other changes"
},
"properties": {
"type": "object",
"description": "{\"Column\": value}",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
}
}Update a OneDrive item: rename (name) and/or move it into a folder (moveToFolderId). Pass fileId (from list_onedrive_files). To remove an item, use delete_onedrive_file.
| Argument | Type | Description |
|---|---|---|
| name | string | new name |
| fileId* | string | the OneDrive item id |
| moveToFolderId | string | folder id to move the item into (from create_onedrive_folder / list_onedrive_files) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"fileId"
],
"properties": {
"name": {
"type": "string",
"description": "new name"
},
"fileId": {
"type": "string",
"description": "the OneDrive item id"
},
"moveToFolderId": {
"type": "string",
"description": "folder id to move the item into (from create_onedrive_folder / list_onedrive_files)"
}
}
}Rename a board, rewrite its description, or change its privacy. Warning: SETTING A BOARD TO SECRET HIDES EVERY PIN ON IT from everyone but this account — nothing errors and nothing is deleted, the Pins simply stop being public, which is the Pinterest flavour of a post that looks published and is not. Say so and get a yes before doing it; it IS reversible (set PUBLIC again), and the read-back reports how many Pins were hidden. Pinterest accepts only PUBLIC or SECRET on an update: PROTECTED can be chosen when a board is created and can never be set afterwards, so that is refused by name rather than sent and rejected. 0 credits. Needs Pinterest connected.
| Argument | Type | Description |
|---|---|---|
| name | string | new board name |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| boardId* | string | numeric board id from list_pinterest_boards |
| privacy | string | SECRET hides every Pin on the board from everyone but this account |
| description | string | max 500 characters |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"boardId"
],
"properties": {
"name": {
"type": "string",
"description": "new board name"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"boardId": {
"type": "string",
"description": "numeric board id from list_pinterest_boards"
},
"privacy": {
"enum": [
"PUBLIC",
"SECRET"
],
"type": "string",
"description": "SECRET hides every Pin on the board from everyone but this account"
},
"description": {
"type": "string",
"description": "max 500 characters"
}
}
}Add, change or remove PRODUCTS in a Pinterest catalog directly — no feed file needed. items: up to 1,000 of {itemId (your SKU), operation UPSERT (default) / CREATE / UPDATE / DELETE, attributes {title, description, link, image_link, price e.g. "19.99 USD", availability e.g. "in stock", brand, sale_price, …}}. A create or upsert must carry title, link, image_link and price (refused here otherwise). Pinterest processes the batch asynchronously; the batch is read back and its status and every per-item error reported. 0 credits.
| Argument | Type | Description |
|---|---|---|
| items* | array | products to add, change or remove; up to 1,000 per call |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| country | string | default US |
| language | string | default en-US |
| catalogId | string | catalog to write to, from list_pinterest_catalogs |
| adAccountId | string | optional — act as this ad account’s owner via Pinterest Business Access (must be shared with this profile) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"items"
],
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"required": [
"itemId"
],
"properties": {
"itemId": {
"type": "string",
"description": "your SKU for the product"
},
"operation": {
"enum": [
"UPSERT",
"CREATE",
"UPDATE",
"DELETE"
],
"type": "string",
"description": "UPSERT (default), CREATE, UPDATE or DELETE"
},
"attributes": {
"type": "object",
"description": "product fields by name, e.g. title, link, image_link, price: '19.99 USD'",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"updateMask": {
"type": "array",
"items": {
"type": "string"
},
"description": "UPDATE only: attribute names to change; other fields are left as they are"
}
}
},
"description": "products to add, change or remove; up to 1,000 per call"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"country": {
"type": "string",
"description": "default US"
},
"language": {
"type": "string",
"description": "default en-US"
},
"catalogId": {
"type": "string",
"description": "catalog to write to, from list_pinterest_catalogs"
},
"adAccountId": {
"type": "string",
"description": "optional — act as this ad account’s owner via Pinterest Business Access (must be shared with this profile)"
}
}
}Edit a published Pin — its title, description, destination link, alt text, or which board it sits on. Only send the fields that should change. TWO LIMITS TO STATE BEFORE OFFERING THIS. (1) Pinterest marks its Update Pin endpoint "currently in beta and not available to all apps" in its own API description, so it may be refused outright whatever the account’s scopes or access tier — reconnecting cannot change that. If it is refused, save_pinterest_pin gets the Pin onto another board (generally available) and changing the wording means deleting and re-pinning. (2) A published Pin’s IMAGE or VIDEO can never be changed by anyone: Pinterest’s update model has no media field at all, so swapping the creative means delete and re-pin, which loses the Pin’s accumulated saves. The values reported back are what Pinterest STORED, not what was sent. 0 credits. Needs Pinterest connected.
| Argument | Type | Description |
|---|---|---|
| link | string | destination URL, max 2048 |
| 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. |
| pinId* | string | numeric Pin id from list_pinterest_pins |
| title | string | max 100 characters |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
Set the OUTREACH STATUS and/or a NOTE on a creator already saved in the swipefile. Status is one of new | contacted | replied | booked | passed. The note is free text (deal terms, rate, what was sent). Reads back the updated row. Use list_swipefile to find the key. Free.
| Argument | Type | Description |
|---|---|---|
| key* | string | the saved row's key from list_swipefile, e.g. tiktok:handle |
| note | string | replaces the existing note; pass "" to clear it |
| brand | string | profile id/name from list_brands, this call only |
| status | string | outreach status: new, contacted, replied, booked or passed |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"key"
],
"properties": {
"key": {
"type": "string",
"description": "the saved row's key from list_swipefile, e.g. tiktok:handle"
},
"note": {
"type": "string",
"maxLength": 2000,
"description": "replaces the existing note; pass \"\" to clear it"
},
"brand": {
"type": "string",
"description": "profile id/name from list_brands, this call only"
},
"status": {
"enum": [
"new",
"contacted",
"replied",
"booked",
"passed"
],
"type": "string",
"description": "outreach status: new, contacted, replied, booked or passed"
}
}
}Change this account's app settings. language = the language EVERY ad, script and plan is written in from now on (chat replies follow the language the user writes in) (say the language in plain English, e.g. "German", "Japanese", "Brazilian Portuguese") — it applies to renders made over MCP as well as in the app. theme = the app's appearance, "dark" or "light". watchEmail = the weekly competitor-watch email on/off. Only pass what you are changing. Account-wide (every profile), and it takes effect on the next call.
| Argument | Type | Description |
|---|---|---|
| theme | string | app appearance |
| language | string | language for generated ads and copy (chat replies follow the language the user writes in) — e.g. "English", "German", "Japanese" |
| watchEmail | boolean | weekly competitor-watch email on/off |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"theme": {
"enum": [
"dark",
"light"
],
"type": "string",
"description": "app appearance"
},
"language": {
"type": "string",
"description": "language for generated ads and copy (chat replies follow the language the user writes in) — e.g. \"English\", \"German\", \"Japanese\""
},
"watchEmail": {
"type": "boolean",
"description": "weekly competitor-watch email on/off"
}
}
}CORRECT cells in a Google Sheet — write values to an exact range, overwriting whatever is there. This is the fix append_to_sheet cannot make: appending only ever adds rows at the bottom, so without this a wrong number stays wrong forever and the only "correction" is a second row contradicting the first. Pass `range` (e.g. "B2:C5", or "Q3 Report!B2" to name a tab — list_sheet_tabs gives the names) and `values` as an array of row arrays; an anchor cell like "B2" is fine and the block is written down and right from it. Writing into EMPTY cells goes straight through. Writing OVER cells that already hold values is REFUSED first, naming exactly how many filled cells would be overwritten — show the user that, get a yes, then call again with confirm:true. The result is READ BACK from the sheet, so what you report is what the sheet now holds rather than what Google accepted.
| Argument | Type | Description |
|---|---|---|
| range | string | A1 range or anchor cell, e.g. "B2:C5", "B2", or "Q3 Report!B2" (default A1) |
| values | array | array of row arrays to write |
| confirm | boolean | required only when the target range already holds values |
| updates | array | write SEVERAL disjoint ranges in one call, instead of range+values |
| sheetUrl | string | Google Sheets link, instead of spreadsheetId |
| spreadsheetId | string |
Change a WordPress post that already exists: title, body, excerpt, slug, status (publish a draft, unpublish to draft, make private, or schedule with a future date), categories, tags or the featured image. Only the fields passed change; the reply is WordPress's own copy of the post. Free.
| Argument | Type | Description |
|---|---|---|
| date | string | |
| slug | string | |
| tags | array | |
| text | string | the new body — replaces the old one |
| 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. |
| title | string | |
| postId* | number | the post id post_to_wordpress or list_wordpress_posts returned |
| status | string | |
| excerpt |
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).
| Argument | Type | Description |
|---|---|---|
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| allowDmsFrom | string | who may message the brand: all, verified or following |
| callsEnabled | boolean | audio and video calls on or off |
| readReceipts | boolean | send and receive read receipts |
| qualityFilter | boolean | true = low-quality message requests go to a separate folder |
| acceptCallsFrom | object | who may call the brand: flags for each group below |
APPLY THE BRAND TO THE CHANNEL ITSELF — banner art, description, keywords, country and the trailer non-subscribers see. Every other YouTube tool brands the videos; this brands the page they sit on. Under the hood channels.update is a PUT, so the CURRENT settings are read and merged first — otherwise setting a description would silently wipe the channel's keywords, country and trailer. AND YOUTUBE SILENTLY IGNORES SOME FIELDS, channel title above all (usually only changeable in YouTube Studio): the result DIFFS what YouTube actually stored against what was asked for and reports anything that did not stick in `notApplied`. DO NOT REPORT THOSE AS CHANGED — a 200 is what YouTube accepted, not what it stored. A banner must be a Hermoso render (jpeg or png, under 6MB); YouTube re-crops it per device, so the safe area is the middle 1235x338 of a 2048x1152 image. Public and immediate — show the user what is going on the channel first. 0 credits. Needs YouTube connected.
| Argument | Type | Description |
|---|---|---|
| title | string | the channel title — YouTube often accepts and ignores this; the result says whether it stuck |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| country | string | two-letter country code for the channel |
| keywords | string | channel keywords, COMMA-SEPARATED (Google's wire format is one string, not a list) |
| description | string | the channel description — the About text |
Edit an existing video on the connected channel: title, description, tags, privacy (unlisted | public | private), category, schedule (publishAt; "" clears it), madeForKids, aiGenerated, licence, embedding, stats visibility, languages + per-language translations, paid-placement disclosure and brand partner. THIS IS HOW YOU FLIP AN UNLISTED UPLOAD PUBLIC. Making a video PUBLIC puts it on the channel where anyone can find it, so show the user exactly what will change and get an explicit yes before calling with privacy:"public". Fields you omit are left untouched; every change is read back from YouTube and any field it did not apply is named. Needs a connected YouTube channel.
| Argument | Type | Description |
|---|---|---|
| tags | array | video tags, up to 30; omit to leave the current tags |
| 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. |
| title | string | ≤100 chars |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| license | string | YouTube licence: youtube (standard) or creativeCommon |
| privacy | string |
Change the SUBSCRIPTION plan (admin only). No argument lists the monthly plans. Basic ($9.99) is the posting plan: unlimited posting and Autopilot with NO monthly credits (it includes the same 250+ free credits as the Free plan, monthly credits start on Starter, and Autopilot spends credits). Starter, Pro and Studio include monthly credits and no markup on models. Every paid plan posts without a limit (Free: 10 posts a month on 3 channels). New subscriber: a Checkout URL for the human. Existing subscriber upgrading: a quote + quote_token; once the human agrees, call again with confirm:true and quote_token to charge the card on file. Downgrade: a billing-page link; it starts at the term's end.
| Argument | Type | Description |
|---|---|---|
| plan | string | the plan id to move to (e.g. pro) — omit to list the available plans first |
| period | string | monthly only ('yr' buys monthly) |
| confirm | boolean | true (with quote_token) to make a quoted upgrade |
| quote_token | string | the quoteToken from the quote step |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"plan": {
"type": "string",
"description": "the plan id to move to (e.g. pro) — omit to list the available plans first"
},
"period": {
"enum": [
"mo",
"yr"
],
"type": "string",
"description": "monthly only ('yr' buys monthly)"
},
"confirm": {
"type": "boolean",
"description": "true (with quote_token) to make a quoted upgrade"
},
"quote_token": {
"type": "string",
"description": "the quoteToken from the quote step"
}
}
}Persist an ARBITRARY user file (image, video, audio, PDF or document, up to 150MB) into Hermoso and get back a durable public URL that EVERY publish, schedule and ad-build tool accepts. Kept in the Library. THIS IS THE BRING-YOUR-OWN-CREATIVE PATH: it is for files that have NOTHING to do with a Hermoso render (media on the user's desktop, an agency's finished ad, a photo they shot), and it means you can publish, schedule and run ads through Hermoso without generating anything here. Provide exactly ONE source — passing two is an error, never a silent preference: `url` (ANY public http(s) link — Hermoso fetches it server-side, so nothing crosses this connection and there is no practical size limit; THIS IS THE ONE THAT ALWAYS WORKS, including on the hosted connector), `dataUri` (a base64 data: URI — keep it under ~15MB, since the bytes travel over this connection). If the file is already at a public https URL, the Meta, Reddit and ChatGPT-Ads tools take it directly and re-host it safely. When in doubt, use this: its URL works everywhere. (Calling the underlying HTTP route directly? POST /api/upload takes the file's RAW BYTES as the request body with its own content-type — NOT multipart/form-data — or ?url=<link> with no body.) Returns {url, kind, bytes}.
| Argument | Type | Description |
|---|---|---|
| url | string | a PUBLIC http(s) URL Hermoso fetches server-side (private/internal addresses are refused, and every redirect hop is re-checked). Works on every surface including the hosted connector, and the bytes never cross this connection — prefer this whenever the file is reachable on the web. |
| name | string | original file name — helps pick the right extension |
| dataUri | string | base64 data: URI of the file bytes (data:<mime>;base64,<…>) — bytes travel over this connection, so keep it small |
| getUploadUrl |
Put a picture into the brand's Klaviyo IMAGE LIBRARY for use in emails: a Hermoso render or Library id (list_library gives L0, L1…), a Hermoso link, an upload_file URL, or a public https image link (Klaviyo fetches an outside link itself). Klaviyo takes JPEG, PNG or GIF up to 5 MB, so another format or a bigger file is converted to a JPEG first; a video is refused. Returns Klaviyo's own image row (id and hosted URL). create_klaviyo_template uploads its image itself, so call this only to stock the library. Free. Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).
| Argument | Type | Description |
|---|---|---|
| name | string | |
| image* | string | a Library id (L0), a Hermoso or upload_file URL, or a public https image URL |
| hidden | boolean | keep it out of the visible asset library |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"image"
],
"properties": {
"name": {
"type": "string"
},
"image": {
"type": "string",
"description": "a Library id (L0), a Hermoso or upload_file URL, or a public https image URL"
},
"hidden": {
"type": "boolean",
"description": "keep it out of the visible asset library"
}
}
}Put a picture into the brand's Mailchimp FILE MANAGER for use in emails: a Hermoso render or Library id (list_library gives L0, L1...), a Hermoso link, or an upload_file URL (for an outside picture, call upload_file first). JPEG, PNG and GIF go up as they are; another format or a file over 5 MB is converted to a JPEG first; a video is refused. Returns Mailchimp's own file row (id and hosted URL). create_mailchimp_template uploads its image itself, so call this only to stock the File Manager. Free. Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).
| Argument | Type | Description |
|---|---|---|
| name | string | |
| image* | string | a Library id (L0), or a Hermoso or upload_file URL |
| folderId | number | a File Manager folder id |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"image"
],
"properties": {
"name": {
"type": "string"
},
"image": {
"type": "string",
"description": "a Library id (L0), or a Hermoso or upload_file URL"
},
"folderId": {
"type": "number",
"description": "a File Manager folder id"
}
}
}Turn a public image URL into the imageUri that comment_on_tiktok_video needs — TikTok will not take a raw URL on a new comment, only on a reply. Returns imageUri, imageWidth and imageHeight, and ALL THREE must be passed back together: TikTok rejects dimensions that do not match what it stored. Limits are TikTok's own — at most 5 MB, JPG/JPEG/PNG/WebP, between 360x360 and 1080x1920 (or 1920x1080). upload_file turns a local file into a URL this accepts. NEEDS THE TIKTOK ACCOUNT AUTHORIZATION (see tiktok_account_status).
| Argument | Type | Description |
|---|---|---|
| imageUrl* | string | a public URL to the image — upload_file turns a local file into one |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"imageUrl"
],
"properties": {
"imageUrl": {
"type": "string",
"description": "a public URL to the image — upload_file turns a local file into one"
}
}
}Pin which profile this connection acts on. Pass the profile id or exact name from list_brands. Works for your own profiles AND one another account SHARED with you (pass its name or the profile id list_brands prints); access is verified against your real membership before it is pinned. Persists for this API key until changed, on every surface (hosted connector included — no environment variables, no restart).
| Argument | Type | Description |
|---|---|---|
| brand* | string | profile id (e.g. default / p_xxx), its exact name from list_brands, or the profile id of one shared with you |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"brand"
],
"properties": {
"brand": {
"type": "string",
"description": "profile id (e.g. default / p_xxx), its exact name from list_brands, or the profile id of one shared with you"
}
}
}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).
| Argument | Type | Description |
|---|---|---|
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
}
}
}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).
| Argument | Type | Description |
|---|---|---|
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
}
}
}The follower or following LIST for an X account — handles, names, bios, locations and each one’s own follower/following/post counts. The COUNTS are already free on x_account and x_user; this is WHO, which is what audience analysis and creator vetting actually need. With no username it reads the CONNECTED account (“who follows us”); pass a handle to read anyone public (“who follows our competitor”). Warning: NEEDS THE follows.read PERMISSION, added 2026-08-31: an X connection authorized before that date does NOT carry it and cannot have it added — the tool says so and asks for a reconnect rather than surfacing X’s raw refusal. Default 20, max 100 per call (X bills per account returned); page with nextToken. Needs X connected.
| Argument | Type | Description |
|---|---|---|
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| username | string | handle with or without the @ — omit to read the connected account |
| direction | string | default followers |
| maxResults | number | 1–100, default 20 |
| paginationToken | string | next page token from the previous reply |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"username": {
"type": "string",
"description": "handle with or without the @ — omit to read the connected account"
},
"direction": {
"enum": [
"followers",
"following"
],
"type": "string",
"description": "default followers"
},
"maxResults": {
"type": "number",
"description": "1–100, default 20"
},
"paginationToken": {
"type": "string",
"description": "next page token from the previous reply"
}
}
}Read the posts mentioning the connected X account — who is talking to the brand, in their own words, newest first. Use it to find what deserves a reply (reply with post_to_x + replyToId) and to mine real objections and customer language for ad copy. COSTS CREDITS PER MENTION RETURNED and per distinct author X returns with them, plus one account lookup — keep maxResults small (default 10) and tell the user the cost before pulling a big page. Needs X connected.
| Argument | Type | Description |
|---|---|---|
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| sinceId | string | only return mentions newer than this post id |
| maxResults | number | how many mentions to pull, 5-100 (default 10) — every one is billed |
| paginationToken | string | next_token from a previous call, to page further back |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"sinceId": {
"type": "string",
"description": "only return mentions newer than this post id"
},
"maxResults": {
"type": "number",
"description": "how many mentions to pull, 5-100 (default 10) — every one is billed"
},
"paginationToken": {
"type": "string",
"description": "next_token from a previous call, to page further back"
}
}
}Advertiser-grade analytics for the connected account’s OWN posts on X — impressions, engagements, LINK CLICKS, profile visits, video views and video completion quartiles. This is the read that answers “did the creative work”, which x_post_metrics cannot: public metrics show likes and reposts, never clicks or video retention. Takes up to 25 post ids in one call. COSTS CREDITS PER POST READ, so ask about the posts that matter rather than everything. If X returns no rows, say so — that is missing data, not zero performance. Needs X connected.
| Argument | Type | Description |
|---|---|---|
| ids* | array | numeric X post ids (max 25) — the last part of each post URL |
| 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. |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| granularity | string | default Total |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"ids"
],
"properties": {
"ids": {
"type": "array",
"items": {
"type": "string"
},
"description": "numeric X post ids (max 25) — the last part of each post URL"
},
"brand": {
"type": "string",
"description": "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."
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"granularity": {
"enum": [
"Total",
"Daily",
"Hourly",
"Weekly"
],
"type": "string",
"description": "default Total"
}
}
}The same advertiser-grade X analytics as x_post_insights — impressions, engagements, LINK CLICKS, profile visits, video views and video completion quartiles — over ANY date range instead of only the last 28 hours. This is the one to use for “how did last week’s post do”, “compare these three posts over the month”, or any retrospective: x_post_insights physically cannot see past yesterday, so asking it about an older post returns nothing and that is not zero performance. Takes up to 25 post ids at once; the window defaults to the last 28 days when you name none, and the window actually queried is reported back. COSTS CREDITS PER POST READ — X bills us per API call — so say the cost before pulling a big batch and ask about the posts that matter. Needs X connected.
| Argument | Type | Description |
|---|---|---|
| ids* | array | numeric X post ids (max 25) — the last part of each post URL |
| 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. |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| endDate | string | YYYY-MM-DD or a UTC timestamp; defaults to now |
| startDate | string |
THE X ANALYTICS TOOL THAT WORKS — impressions, likes, reposts, replies, quotes and bookmarks for any post, PLUS the advertiser numbers (link clicks, profile clicks, engagements) for YOUR OWN posts published in the last 30 days. X serves those private metrics on this same lookup with the user-context connection you already have; that is X's own design, not a workaround. Use this rather than x_post_insights, whose endpoint family X has retired. If a post is deleted, protected or suspended, X answers with no data at all and this says so — that is MISSING DATA, never zero engagement, and must never be reported as a measured zero. Costs a small number of credits (X bills per API read). Needs X connected.
| Argument | Type | Description |
|---|---|---|
| id* | string | the numeric X post id — the last part of the post URL |
| 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. |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| publishedAt | number | epoch ms the post went out, if known — lets the private owned-post metrics be requested only inside X's 30-day window instead of costing a refused call |
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.
| Argument | Type | Description |
|---|---|---|
| query* | string | same operator syntax as search_x |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| granularity | string | default day |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"query"
],
"properties": {
"query": {
"type": "string",
"description": "same operator syntax as search_x"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"granularity": {
"enum": [
"minute",
"hour",
"day"
],
"type": "string",
"description": "default day"
}
}
}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.
| Argument | Type | Description |
|---|---|---|
| woeid | string | numeric Where-On-Earth id; default 1 = worldwide |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| maxTrends | number | 1–50, default 25 |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"woeid": {
"type": "string",
"description": "numeric Where-On-Earth id; default 1 = worldwide"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"maxTrends": {
"type": "number",
"description": "1–50, default 25"
}
}
}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.
| Argument | Type | Description |
|---|---|---|
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| username* | string | the handle, with or without the @ |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"username"
],
"properties": {
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"username": {
"type": "string",
"description": "the handle, with or without the @"
}
}
}THE ONLY PLACE YOUTUBE PUBLISHES THUMBNAIL IMPRESSIONS AND THUMBNAIL CTR. This is a different API from youtube_channel_report — YouTube’s bulk Reporting API — and for a product that generates thumbnails it is the number that says whether the thumbnail actually worked. Reports: thumbnails (impressions + CTR per video per day) · thumbnails_by_source (the same, split by traffic source, traffic source DETAIL, device and OS) · cards (per-card impressions, clicks and click rate by card_id) · end_screens (per end-screen element) · traffic_source (with the UNCAPPED traffic_source_detail — youtube_channel_report caps that at 25 rows) · basic. IT IS SCHEDULED, NOT ON-DEMAND, AND THIS IS THE ONE THING YOU MUST EXPLAIN TO THE USER: the first call SCHEDULES a job and returns NO DATA. YouTube then writes one CSV per 24-hour Pacific day — the first within 48 hours — plus a backfill of the 30 days before scheduling, and files expire after 60 days. It can NEVER answer about a period before the job existed, so "we have no thumbnail history yet" is a real and correct answer on day one. An unknown report name is refused with the list. Zero rows means missing data for that window, never zero impressions. Read-only, 0 credits.
| Argument | Type | Description |
|---|---|---|
| days | number | how many recent daily files to read (1–14, default 7) |
| since | string | YYYY-MM-DD — only files whose data starts on or after this |
| until | string | YYYY-MM-DD — only files whose data starts before this |
| report | string | default thumbnails |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
Read the brand’s connected YouTube channel — title + subscriber / view / video counts (for reporting), and whether CUSTOM THUMBNAILS are on (customThumbnails: enabled | needs_verification | unavailable | unknown). When they are not on, the reply carries YouTube’s own step-by-step to turn them on (a phone verification in YouTube Studio), so check this before promising a custom thumbnail. Needs a connected YouTube channel.
| Argument | Type | Description |
|---|---|---|
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
}
}
}The YouTube Analytics reports that say WHERE views came from, WHO watched and WHERE they stopped watching — the questions youtube_channel (totals) and youtube_video_insights (one video, flat) cannot answer. Pick a report: day / month (time series) · country / province (US states) / city / dma (geography) · trafficSource (search vs browse vs suggested vs shorts feed vs external — the single most useful one for judging a thumbnail and title) · trafficSourceDetail (the actual search terms, inside ONE source — pass parent, e.g. "YT_SEARCH") · playbackLocation / playbackLocationDetail (which sites embedded it) · device / operatingSystem · demographics (age + gender) · sharingService · subscribedStatus · audienceRetention (the drop-off CURVE, 100 points across ONE video — the read that tells you whether the hook held and exactly when people left) · topVideos (the channel’s best in the window) · contentType (SHORTS vs long-form vs live; contentType:"shorts" narrows most reports to Shorts, and engagedViews — views past the first frame — is the honest Shorts number). Scope it to one or more videoIds, or omit for the whole channel. An unknown report name is refused WITH the list rather than quietly swapped. TWO THINGS TO SAY OUT LOUD WHEN REPORTING: demographics returns viewerPercentage and NOTHING else — YouTube publishes no absolute demographic counts, so never convert it into a number of viewers — and a capped report (city 250, topVideos 200, the *Detail reports 25) is the TOP N, not the whole set. Zero rows means missing data for that window, never zero views. Read-only, 0 credits.
| Argument | Type | Description |
|---|---|---|
| limit | number | rows, within YouTube’s own cap for that report |
| parent | string | required by the *Detail reports: the ONE parent to drill into, e.g. "YT_SEARCH" / "SUBSCRIBER" / "RELATED_VIDEO" for trafficSourceDetail, "EMBEDDED" for playbackLocationDetail |
Per-VIDEO performance for a video on the connected channel — views, estimated minutes watched, average view duration, average view PERCENTAGE (the retention number that tells you whether the hook held), likes, comments, shares and subscribers gained. Use it for "how did that video do", "which upload performed best", or to judge an ad before spending more behind it. youtube_channel only returns channel-wide totals and cannot answer this. Defaults to the last 28 days; pass startDate/endDate (YYYY-MM-DD) for another window. Read-only, 0 credits. Needs a connected YouTube channel.
| Argument | 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. |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| endDate | string | YYYY-MM-DD, default today |
| videoId* | string | the YouTube video id (the v= part of the watch URL, or the videoId returned by post_to_youtube) |
| startDate | string | YYYY-MM-DD, default 28 days ago |
We read every tool's name, description and inputs the way the model will, looking for instructions hidden from you. A clean result means no warning signs, not a guarantee. Scanned Nov 6, 2026.
Takes a password, API key or token as an argument, so the secret passes through the model's context and chat logs.
Fix: Read credentials from the server's own configuration (environment, OAuth) instead of a tool argument.
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"channel"
],
"properties": {
"limit": {
"type": "number",
"description": "how many posts this page (default 50, max 200)"
},
"cursor": {
"type": "string",
"description": "resume from a previous run"
},
"channel": {
"enum": [
"facebook",
"instagram",
"threads",
"youtube",
"tiktok",
"pinterest",
"bluesky"
],
"type": "string",
"description": "which channel to import from"
},
"confirm": {
"type": "boolean",
"description": "actually import — omit for a dry run that only quotes the cost"
},
"accountRef": {
"type": "string",
"description": "which Page / account, when the profile has more than one"
}
}
}{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"pack": {
"type": "string",
"description": "the pack id to buy (e.g. pack-2k) — omit to list the available packs first"
},
"confirm": {
"type": "boolean",
"description": "set true to actually charge the saved card for `pack` (required for the one-click charge; ignored on the checkout-link path)"
},
"quote_token": {
"type": "string",
"description": "the quoteToken returned by the quote step — REQUIRED (with confirm:true) to charge; it binds the exact pack + price you quoted (10-minute validity) and makes a retried confirm idempotent"
}
}
}| string |
| the connector id: applovin_ads, stripe, intercom, mailchimp, klaviyo, vercel, openai_ads, apple_ads, bluesky, telegram, bing_webmaster, posthog, mixpanel, amplitude, wordpress, slack, discord, webhook |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"provider"
],
"properties": {
"fields": {
"type": "object",
"description": "that provider's own field names and values, e.g. {\"apiKey\":\"…\"}; the names for each provider are in the description",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
},
"provider": {
"type": "string",
"description": "the connector id: applovin_ads, stripe, intercom, mailchimp, klaviyo, vercel, openai_ads, apple_ads, bluesky, telegram, bing_webmaster, posthog, mixpanel, amplitude, wordpress, slack, discord, webhook"
}
}
}| previewText |
| string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"templateId",
"subject"
],
"properties": {
"name": {
"type": "string"
},
"listIds": {
"type": "array",
"items": {
"type": "string"
}
},
"replyTo": {
"type": "string"
},
"subject": {
"type": "string"
},
"fromEmail": {
"type": "string"
},
"fromLabel": {
"type": "string"
},
"excludeIds": {
"type": "array",
"items": {
"type": "string"
}
},
"segmentIds": {
"type": "array",
"items": {
"type": "string"
}
},
"templateId": {
"type": "string"
},
"previewText": {
"type": "string"
}
}
}| headline |
| string |
| imageAlt | string |
| background | string | #RRGGBB |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"body": {
"type": "string"
},
"html": {
"type": "string",
"description": "your own full HTML instead of the layout"
},
"name": {
"type": "string"
},
"text": {
"type": "string",
"description": "plain-text version"
},
"image": {
"type": "string",
"description": "a Library id, Hermoso or upload_file URL, or a public image URL"
},
"accent": {
"type": "string",
"description": "#RRGGBB"
},
"ctaUrl": {
"type": "string"
},
"ctaText": {
"type": "string"
},
"headline": {
"type": "string"
},
"imageAlt": {
"type": "string"
},
"background": {
"type": "string",
"description": "#RRGGBB"
}
}
}| announce | boolean | ALSO publish the Page post that shares the event. LinkedIn makes an event reachable only once a post shares it (an unannounced one stopped being readable within a minute, measured 2026-10-27). PUBLIC — show the user the copy and get a yes first |
| startsAt* | string | ISO 8601, must be in the future — LinkedIn rejects an event that has already begun |
| leadFormId | string | or attach your own APPROVED lead form instead of registrationForm |
| description | string | event description shown on the event page |
| announceText | string | the copy of the announcing post |
| venueDetails | string | room, floor, parking… |
| discoveryMode | string | LISTED (shown in event search) or URL_ONLY (reachable by link only) |
| organizationId | string | the Page — omit if only one is shared with this profile |
| privacyPolicyUrl | string | privacy policy link; required with registrationForm:true |
| registrationForm | boolean | attach a LinkedIn-built registration form (needs privacyPolicyUrl) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"name",
"startsAt"
],
"properties": {
"url": {
"type": "string",
"description": "external: where it streams or where people join; in_person: an info page"
},
"name": {
"type": "string",
"description": "the event title"
},
"endsAt": {
"type": "string",
"description": "ISO 8601, after startsAt; defaults to one hour after the start"
},
"format": {
"enum": [
"external",
"in_person",
"live"
],
"type": "string",
"description": "inferred from url / address when omitted; live needs LinkedIn Live access"
},
"address": {
"type": "object",
"required": [
"city",
"country"
],
"properties": {
"city": {
"type": "string",
"description": "venue city"
},
"line1": {
"type": "string",
"description": "venue street address, first line (in_person events)"
},
"region": {
"type": "string",
"description": "venue state or region"
},
"country": {
"type": "string",
"description": "2-letter code, e.g. US"
},
"postalCode": {
"type": "string",
"description": "venue postal or ZIP code"
}
},
"description": "in_person venue"
},
"announce": {
"type": "boolean",
"description": "ALSO publish the Page post that shares the event. LinkedIn makes an event reachable only once a post shares it (an unannounced one stopped being readable within a minute, measured 2026-10-27). PUBLIC — show the user the copy and get a yes first"
},
"startsAt": {
"type": "string",
"description": "ISO 8601, must be in the future — LinkedIn rejects an event that has already begun"
},
"leadFormId": {
"type": "string",
"description": "or attach your own APPROVED lead form instead of registrationForm"
},
"description": {
"type": "string",
"description": "event description shown on the event page"
},
"announceText": {
"type": "string",
"description": "the copy of the announcing post"
},
"venueDetails": {
"type": "string",
"description": "room, floor, parking…"
},
"discoveryMode": {
"enum": [
"LISTED",
"URL_ONLY"
],
"type": "string",
"description": "LISTED (shown in event search) or URL_ONLY (reachable by link only)"
},
"organizationId": {
"type": "string",
"description": "the Page — omit if only one is shared with this profile"
},
"privacyPolicyUrl": {
"type": "string",
"description": "privacy policy link; required with registrationForm:true"
},
"registrationForm": {
"type": "boolean",
"description": "attach a LinkedIn-built registration form (needs privacyPolicyUrl)"
}
}
}| string |
| background | string | #RRGGBB |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"body": {
"type": "string"
},
"html": {
"type": "string",
"description": "your own full HTML instead of the layout"
},
"name": {
"type": "string"
},
"image": {
"type": "string",
"description": "a Library id, or a Hermoso or upload_file URL"
},
"accent": {
"type": "string",
"description": "#RRGGBB"
},
"ctaUrl": {
"type": "string"
},
"ctaText": {
"type": "string"
},
"headline": {
"type": "string"
},
"imageAlt": {
"type": "string"
},
"background": {
"type": "string",
"description": "#RRGGBB"
}
}
}| pixelId |
| string |
| Meta Pixel for offsite conversion attribution |
| startTime | string | ISO time, now or within 30 days |
| adAccountId* | string | ad account id (act_… or digits) |
| dailyBudgetUsd | number | daily budget in the ad account's currency, minimum 30; set this or lifetimeBudgetUsd, not both |
| lifetimeBudgetUsd | number | total budget in the ad account's currency across the campaign, minimum 30; needs endTime |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"adAccountId",
"name"
],
"properties": {
"name": {
"type": "string",
"description": "campaign name, shown in Meta and in list_messenger_marketing_campaigns"
},
"dryRun": {
"type": "boolean",
"description": "true = show the exact request body and create nothing"
},
"pageId": {
"type": "string",
"description": "the sending Page; defaults to the one shared Page"
},
"endTime": {
"type": "string",
"description": "ISO time the campaign stops sending"
},
"pixelId": {
"type": "string",
"description": "Meta Pixel for offsite conversion attribution"
},
"startTime": {
"type": "string",
"description": "ISO time, now or within 30 days"
},
"adAccountId": {
"type": "string",
"description": "ad account id (act_… or digits)"
},
"dailyBudgetUsd": {
"type": "number",
"description": "daily budget in the ad account's currency, minimum 30; set this or lifetimeBudgetUsd, not both"
},
"lifetimeBudgetUsd": {
"type": "number",
"description": "total budget in the ad account's currency across the campaign, minimum 30; needs endTime"
}
}
}| the owning business portfolio by NAME instead of by id, exactly as list_meta_catalogs prints it (e.g. "DMDB Group"). Unknown or ambiguous names are refused with the real ids rather than guessed. Give this or businessId. |
| allowDuplicateName | boolean | create it even though the portfolio already has a catalog with this name. Off by default: Meta would build an indistinguishable twin without complaining. |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"name"
],
"properties": {
"name": {
"type": "string",
"description": "what to call the catalog"
},
"vertical": {
"enum": [
"adoptable_pets",
"apps_and_software",
"articles_and_publications",
"commerce",
"destinations",
"flights",
"generic",
"home_listings",
"hotels",
"local_service_businesses",
"media_titles",
"offer_items",
"services",
"offline_commerce",
"transactable_items",
"vehicles"
],
"type": "string",
"description": "the catalog’s industry (default commerce). PERMANENT — Meta publishes no way to change it after creation."
},
"businessId": {
"type": "string",
"description": "numeric business-portfolio id that will OWN the catalog (from list_meta_catalogs). Meta will not let a catalog move to another portfolio later. A portfolio NAME is accepted here too and is resolved server-side."
},
"businessName": {
"type": "string",
"description": "the owning business portfolio by NAME instead of by id, exactly as list_meta_catalogs prints it (e.g. \"DMDB Group\"). Unknown or ambiguous names are refused with the real ids rather than guessed. Give this or businessId."
},
"allowDuplicateName": {
"type": "boolean",
"description": "create it even though the portfolio already has a catalog with this name. Off by default: Meta would build an indistinguishable twin without complaining."
}
}
}{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"chatId"
],
"properties": {
"name": {
"type": "string",
"description": "what this link is for — the campaign name; up to 32 characters, and the whole point of the tool"
},
"chatId": {
"type": "string",
"description": "the channel’s @username or numeric chat id"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"expiresAt": {
"type": "string",
"description": "ISO 8601 timestamp after which the link stops working"
},
"memberLimit": {
"type": "number",
"description": "how many people may join through it; cannot be combined with requireApproval"
},
"requireApproval": {
"type": "boolean",
"description": "each join waits for an admin; cannot be combined with memberLimit"
}
}
}| wabaId | string | the WhatsApp Business Account id, from list_whatsapp_accounts. Optional when exactly one account is shared with this profile; required when several are, and refused by name for one that is not shared |
| category* | string | MARKETING = promotional, UTILITY = transactional follow-up, AUTHENTICATION = one-time passcode. It sets the price and the review policy. |
| language* | string | the language code, e.g. en_US, es_MX, pt_BR — Meta treats each as its own template |
| components* | array | Meta’s component array. A BODY is the only REQUIRED one: [{ type: "BODY", text: "Your order {{1}} has shipped." }]. Optional HEADER (text max 60), FOOTER (max 60) and BUTTONS (up to 10 in total). Body text max 1024. A carousel is { type: “CAROUSEL”, cards: [{ components: [{ type: “HEADER”, format: “IMAGE” }, { type: “BODY”, text: … }] }, …] } — 2–10 cards, all with the same components, card body max 160, up to 2 buttons each. AUTHENTICATION templates have their OWN shape (Meta presets the text): [{ type: "BODY", add_security_recommendation: true }, { type: "FOOTER", code_expiration_minutes: 10 }, { type: "BUTTONS", buttons: [{ type: "OTP", otp_type: "COPY_CODE", text: "Copy code" }] }] — no body `text` (refused, never dropped), expiry 1–90 minutes, otp_type COPY_CODE or ONE_TAP (ONE_TAP also needs supported_apps: [{ package_name, signature_hash }], up to 5). |
| parameterFormat | string | POSITIONAL (default, {{1}}) or NAMED |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"name",
"language",
"category",
"components"
],
"properties": {
"name": {
"type": "string",
"description": "lowercase letters, digits and underscores only, e.g. \"order_shipped\" (max 512)"
},
"wabaId": {
"type": "string",
"description": "the WhatsApp Business Account id, from list_whatsapp_accounts. Optional when exactly one account is shared with this profile; required when several are, and refused by name for one that is not shared"
},
"category": {
"enum": [
"MARKETING",
"UTILITY",
"AUTHENTICATION"
],
"type": "string",
"description": "MARKETING = promotional, UTILITY = transactional follow-up, AUTHENTICATION = one-time passcode. It sets the price and the review policy."
},
"language": {
"type": "string",
"description": "the language code, e.g. en_US, es_MX, pt_BR — Meta treats each as its own template"
},
"components": {
"type": "array",
"items": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"description": "Meta’s component array. A BODY is the only REQUIRED one: [{ type: \"BODY\", text: \"Your order {{1}} has shipped.\" }]. Optional HEADER (text max 60), FOOTER (max 60) and BUTTONS (up to 10 in total). Body text max 1024. A carousel is { type: “CAROUSEL”, cards: [{ components: [{ type: “HEADER”, format: “IMAGE” }, { type: “BODY”, text: … }] }, …] } — 2–10 cards, all with the same components, card body max 160, up to 2 buttons each. AUTHENTICATION templates have their OWN shape (Meta presets the text): [{ type: \"BODY\", add_security_recommendation: true }, { type: \"FOOTER\", code_expiration_minutes: 10 }, { type: \"BUTTONS\", buttons: [{ type: \"OTP\", otp_type: \"COPY_CODE\", text: \"Copy code\" }] }] — no body `text` (refused, never dropped), expiry 1–90 minutes, otp_type COPY_CODE or ONE_TAP (ONE_TAP also needs supported_apps: [{ package_name, signature_hash }], up to 5)."
},
"parameterFormat": {
"type": "string",
"description": "POSITIONAL (default, {{1}}) or NAMED"
}
}
}| which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| confirm | boolean | REQUIRED true — deletion is permanent and cannot be undone |
| confirmText | string | the post's own text as the unconfirmed call reported it — the first 40 characters is enough. Required once the post has any likes, reposts, replies or quotes. A post with no text asks for its cid instead. |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"uri"
],
"properties": {
"uri": {
"type": "string",
"description": "the post's AT-URI (at://did:plc:…/app.bsky.feed.post/…) as post_to_bluesky returned it, the handle form of the same URI for the CONNECTED account only (at://<its handle>/app.bsky.feed.post/…), or just its record key (3mtc4n3fibn2x)"
},
"brand": {
"type": "string",
"description": "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."
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"confirm": {
"type": "boolean",
"description": "REQUIRED true — deletion is permanent and cannot be undone"
},
"confirmText": {
"type": "string",
"description": "the post's own text as the unconfirmed call reported it — the first 40 characters is enough. Required once the post has any likes, reposts, replies or quotes. A post with no text asks for its cid instead."
}
}
}{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"brand"
],
"properties": {
"brand": {
"type": "string",
"description": "profile id or exact name from list_brands"
},
"confirm": {
"type": "boolean",
"description": "REQUIRED true — this destroys the whole profile and cannot be undone"
},
"confirmName": {
"type": "string",
"description": "the workspace's EXACT name, required when it is not empty — copy it from the inventory this tool returned, after the user has agreed to it"
},
"confirmConnectors": {
"type": "number",
"description": "the number of connected accounts the inventory reported, required when there is at least one — the user must specifically agree to losing them, because reconnecting each needs a browser and no agent can do it"
}
}
}{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"catalogId"
],
"properties": {
"confirm": {
"type": "boolean",
"description": "true once the user has seen exactly what this destroys and said yes"
},
"catalogId": {
"type": "string",
"description": "numeric catalog id from list_meta_catalogs"
},
"confirmName": {
"type": "string",
"description": "the catalog’s exact name, echoed back — required for any catalog that holds products or sets"
},
"confirmChildren": {
"type": "number",
"description": "the total number of products + product sets it holds, echoed back"
},
"allowDeleteWithLiveProductSet": {
"type": "boolean",
"description": "Meta BLOCKS the delete by default when a product set is live. Setting this forces it through, which changes what a campaign that is spending RIGHT NOW shows. Never set it without saying so."
}
}
}| the number of Pins the unconfirmed call reported on the board |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"boardId"
],
"properties": {
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"boardId": {
"type": "string",
"description": "numeric board id from list_pinterest_boards"
},
"confirm": {
"type": "boolean",
"description": "REQUIRED true — the board and its Pins are gone for good"
},
"confirmName": {
"type": "string",
"description": "the board’s EXACT name as the unconfirmed call reported it"
},
"confirmChildren": {
"type": "number",
"description": "the number of Pins the unconfirmed call reported on the board"
}
}
}{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"pinId"
],
"properties": {
"brand": {
"type": "string",
"description": "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."
},
"pinId": {
"type": "string",
"description": "numeric Pin id from list_pinterest_pins"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"confirm": {
"type": "boolean",
"description": "REQUIRED true — deletion is permanent"
},
"confirmName": {
"type": "string",
"description": "the Pin’s EXACT title as the unconfirmed call reported it — required once it has saves or has been promoted"
}
}
}| number |
| the message id post_to_telegram returned (also the number at the end of a t.me link) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"chatId",
"messageId"
],
"properties": {
"brand": {
"type": "string",
"description": "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."
},
"chatId": {
"type": "string",
"description": "the chat the message is in — the same @username or numeric id it was posted with"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"confirm": {
"type": "boolean",
"description": "REQUIRED true — Telegram has no trash and no undelete"
},
"messageId": {
"type": "number",
"description": "the message id post_to_telegram returned (also the number at the end of a t.me link)"
}
}
}| confirmName |
| string |
| the post’s exact text as the unconfirmed call reported it — required once it has any likes, replies or reposts |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"postId"
],
"properties": {
"brand": {
"type": "string",
"description": "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."
},
"postId": {
"type": "string",
"description": "post id from list_threads_posts"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"confirm": {
"type": "boolean",
"description": "REQUIRED true — deletion is permanent; only set it after the user has explicitly agreed"
},
"confirmName": {
"type": "string",
"description": "the post’s exact text as the unconfirmed call reported it — required once it has any likes, replies or reposts"
}
}
}| confirmTitle |
| string |
| the video's EXACT title as the unconfirmed call reported it — required once the video is public or has any views or comments |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"videoId"
],
"properties": {
"brand": {
"type": "string",
"description": "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."
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"confirm": {
"type": "boolean",
"description": "REQUIRED true — deletion is permanent and cannot be undone"
},
"videoId": {
"type": "string",
"description": "the YouTube video id (from list_youtube_videos)"
},
"confirmTitle": {
"type": "string",
"description": "the video's EXACT title as the unconfirmed call reported it — required once the video is public or has any views or comments"
}
}
}| 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. |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"limit": {
"type": "number",
"description": "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."
},
"channel": {
"type": "string",
"description": "restrict to one channel: facebook, instagram, threads, x, linkedin, youtube, tiktok, pinterest, bluesky"
},
"converting": {
"type": "boolean",
"description": "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."
}
}
}| string |
| TELEGRAM — which chat, group or channel the copy goes to (@username or numeric id) |
| ideaId | string | short id of the content-plan idea this post came from |
| pageId | string | FACEBOOK / INSTAGRAM / THREADS — which connected Page (list_meta_pages) |
| recipe | string | the post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format |
| boardId | string | PINTEREST — the board for the copy (list_pinterest_boards) |
| message | string | a different caption for the copy |
| captions | object | per-channel caption overrides for the copy |
| channels | array | post the copy to these channels instead of the original’s |
| imageUrl | string | replacement image for the copy: a Hermoso render URL or an upload_file URL |
| timezone | string | IANA zone for a wall-clock `at` or the queue |
| useQueue | boolean | instead of naming a time, take the brand’s next free posting slot |
| videoUrl | string | replacement video for the copy: a Hermoso render URL or an upload_file URL |
| imageUrls | array | CAROUSEL — an ORDERED list of image URLs published as ONE swipeable post |
| locationId | string | GOOGLE BUSINESS — which listing (list_business_locations) |
| visibility | string | public (default), unlisted or private (YouTube), or draft (TikTok or an unpublished Facebook Page post) |
| whopExperienceId | string | WHOP — which forum or chat the copy goes to (exp_…, from list_whop_destinations). |
| linkedinOrganizationId | string | LINKEDIN — publish the copy as this company Page (list_linkedin_pages) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"id"
],
"properties": {
"at": {
"type": "string",
"description": "when the copy goes out: local wall clock (2026-10-30T10:00), UTC or epoch ms (default: an hour from now)"
},
"id": {
"type": "string",
"description": "the post to copy, from list_scheduled"
},
"link": {
"type": "string",
"description": "destination URL for the copy (Pinterest, Facebook text post or Google Business post)"
},
"brand": {
"type": "string",
"description": "WHICH PROFILE this post is in — id or exact name from list_brands; needed when it is not the profile this connection is pinned to. A name that matches no profile, or two, is REFUSED."
},
"title": {
"type": "string",
"description": "Pinterest or YouTube headline for the copy, max 100 characters"
},
"chatId": {
"type": "string",
"description": "TELEGRAM — which chat, group or channel the copy goes to (@username or numeric id)"
},
"ideaId": {
"type": "string",
"description": "short id of the content-plan idea this post came from"
},
"pageId": {
"type": "string",
"description": "FACEBOOK / INSTAGRAM / THREADS — which connected Page (list_meta_pages)"
},
"recipe": {
"type": "string",
"description": "the post's FORMAT id, e.g. \"slideshow\" or \"imessage_chat\" — post_performance groups by it, so reuse one id per format"
},
"boardId": {
"type": "string",
"description": "PINTEREST — the board for the copy (list_pinterest_boards)"
},
"message": {
"type": "string",
"description": "a different caption for the copy"
},
"captions": {
"type": "object",
"description": "per-channel caption overrides for the copy",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
},
"channels": {
"type": "array",
"items": {
"enum": [
"facebook",
"instagram",
"threads",
"tiktok",
"youtube",
"linkedin",
"x",
"pinterest",
"google_business",
"bluesky",
"telegram",
"wordpress",
"discord",
"slack",
"whop"
],
"type": "string"
},
"description": "post the copy to these channels instead of the original’s"
},
"imageUrl": {
"type": "string",
"description": "replacement image for the copy: a Hermoso render URL or an upload_file URL"
},
"timezone": {
"type": "string",
"description": "IANA zone for a wall-clock `at` or the queue"
},
"useQueue": {
"type": "boolean",
"description": "instead of naming a time, take the brand’s next free posting slot"
},
"videoUrl": {
"type": "string",
"description": "replacement video for the copy: a Hermoso render URL or an upload_file URL"
},
"imageUrls": {
"type": "array",
"items": {
"type": "string"
},
"description": "CAROUSEL — an ORDERED list of image URLs published as ONE swipeable post"
},
"locationId": {
"type": "string",
"description": "GOOGLE BUSINESS — which listing (list_business_locations)"
},
"visibility": {
"enum": [
"public",
"unlisted",
"private",
"draft"
],
"type": "string",
"description": "public (default), unlisted or private (YouTube), or draft (TikTok or an unpublished Facebook Page post)"
},
"whopExperienceId": {
"type": "string",
"description": "WHOP — which forum or chat the copy goes to (exp_…, from list_whop_destinations)."
},
"linkedinOrganizationId": {
"type": "string",
"description": "LINKEDIN — publish the copy as this company Page (list_linkedin_pages)"
}
}
}| messageId* | number | from post_to_telegram, or the number at the end of a t.me link |
| parseMode | string | 'HTML' to format the new text with Telegram's HTML tags; omit for plain text |
| linkPreview | boolean | false to suppress the link preview |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"chatId",
"messageId"
],
"properties": {
"text": {
"type": "string",
"description": "the new text, for a post with no media"
},
"brand": {
"type": "string",
"description": "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."
},
"chatId": {
"type": "string",
"description": "the channel’s @username or numeric chat id"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"buttons": {
"type": "array",
"items": {
"anyOf": [
{
"type": "object",
"required": [
"text",
"url"
],
"properties": {
"url": {
"type": "string"
},
"text": {
"type": "string"
},
"style": {
"enum": [
"danger",
"success",
"primary"
],
"type": "string"
}
}
},
{
"type": "array",
"items": {
"type": "object",
"required": [
"text",
"url"
],
"properties": {
"url": {
"type": "string"
},
"text": {
"type": "string"
},
"style": {
"enum": [
"danger",
"success",
"primary"
],
"type": "string"
}
}
}
}
]
},
"description": "URL buttons under the post. An edit REPLACES the buttons with what it sends, so pass them again to keep them; the reply reads back the buttons Telegram now shows."
},
"caption": {
"type": "string",
"description": "the new caption, for a post carrying a photo/video/album"
},
"messageId": {
"type": "number",
"description": "from post_to_telegram, or the number at the end of a t.me link"
},
"parseMode": {
"enum": [
"HTML"
],
"type": "string",
"description": "'HTML' to format the new text with Telegram's HTML tags; omit for plain text"
},
"linkPreview": {
"type": "boolean",
"description": "false to suppress the link preview"
}
}
}| the numeric X post id — the last part of the post URL. Any id in the post’s edit chain works; Hermoso resolves the newest one. |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"postId",
"text"
],
"properties": {
"text": {
"type": "string",
"description": "the FULL new text of the post. It replaces the old text entirely. Same length rule as a new post: 280 characters, or up to 25,000 on an X Premium account."
},
"brand": {
"type": "string",
"description": "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."
},
"postId": {
"type": "string",
"description": "the numeric X post id — the last part of the post URL. Any id in the post’s edit chain works; Hermoso resolves the newest one."
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
}
}
}| next page: the cursor value from the previous reply |
| gender | string | creator gender filter: male or female |
| pageId | string | Facebook Page id linked to the brand's Instagram; from list_meta_pages |
| states | array | state or region codes; needs exactly one country |
| account | string | direct Instagram account (@handle or id) when several are connected; from list_connector_accounts |
| language | string | creator language filter, e.g. en |
| username | string | one creator handle; no other filter allowed with it |
| ageBucket | string | creator age bucket filter, e.g. 25_to_34 |
| countries | array | 2-letter ISO codes |
| interests | array | up to 5 of Meta’s categories, e.g. BEAUTY, FASHION, FITNESS_AND_WORKOUTS, FOOD_AND_DRINK, TRAVEL_AND_LEISURE_ACTIVITIES |
| similarTo | array | up to 5 creator handles to find look-alikes of |
| maxEngaged | number | 2000, 10000, 50000 or 100000 |
| minEngaged | number | 0, 2000, 10000, 50000 or 100000 |
| hasPortfolio | boolean | true = creators with a portfolio only |
| maxFollowers | number | 10000, 25000, 50000, 75000, 100000, 250000 or 1000000 |
| minFollowers | number | 0, 10000, 25000, 50000, 75000, 100000, 250000 or 1000000 |
| audienceGender | string | gender of the creator's audience: male or female |
| audienceStates | array | state or region codes the creator's audience must match |
| followerGrowth | string | follower growth band: creators in the top 10, 30 or 50 percent |
| lastPostWithin | string | only creators who posted within the last 7, 30 or 90 days |
| recommendation | string | Meta's recommendation ranking for the results; omit for a plain search |
| audienceDevices | array | device filter for the creator's audience: ios and/or android |
| verifiedAccount | boolean | true = verified accounts only |
| customAudienceId | string | from list_instagram_marketplace_audiences |
| audienceAgeBucket | string | age bucket of the creator's audience, e.g. 25_to_34 |
| audienceCountries | array | 2-letter ISO codes of countries the creator's audience must match |
| featuredInPaidAds | boolean | true = creators featured in paid ads only |
| reelsInteractionRate | number | minimum reels interaction rate, as a plain number |
| hasPublicContactEmail | boolean | true = creators with a public contact email only |
| excludeMessagedCreators | boolean | true = hide creators this brand has already messaged |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"limit": {
"type": "number",
"description": "1 to 50"
},
"query": {
"type": "string",
"description": "keywords, e.g. \"skincare\" or \"trail running\""
},
"cursor": {
"type": "string",
"description": "next page: the cursor value from the previous reply"
},
"gender": {
"enum": [
"male",
"female"
],
"type": "string",
"description": "creator gender filter: male or female"
},
"pageId": {
"type": "string",
"description": "Facebook Page id linked to the brand's Instagram; from list_meta_pages"
},
"states": {
"type": "array",
"items": {
"type": "string"
},
"description": "state or region codes; needs exactly one country"
},
"account": {
"type": "string",
"description": "direct Instagram account (@handle or id) when several are connected; from list_connector_accounts"
},
"language": {
"type": "string",
"description": "creator language filter, e.g. en"
},
"username": {
"type": "string",
"description": "one creator handle; no other filter allowed with it"
},
"ageBucket": {
"enum": [
"18_to_24",
"25_to_34",
"35_to_44",
"45_to_54",
"55_to_64",
"65_and_above"
],
"type": "string",
"description": "creator age bucket filter, e.g. 25_to_34"
},
"countries": {
"type": "array",
"items": {
"type": "string"
},
"description": "2-letter ISO codes"
},
"interests": {
"type": "array",
"items": {
"type": "string"
},
"description": "up to 5 of Meta’s categories, e.g. BEAUTY, FASHION, FITNESS_AND_WORKOUTS, FOOD_AND_DRINK, TRAVEL_AND_LEISURE_ACTIVITIES"
},
"similarTo": {
"type": "array",
"items": {
"type": "string"
},
"description": "up to 5 creator handles to find look-alikes of"
},
"maxEngaged": {
"type": "number",
"description": "2000, 10000, 50000 or 100000"
},
"minEngaged": {
"type": "number",
"description": "0, 2000, 10000, 50000 or 100000"
},
"hasPortfolio": {
"type": "boolean",
"description": "true = creators with a portfolio only"
},
"maxFollowers": {
"type": "number",
"description": "10000, 25000, 50000, 75000, 100000, 250000 or 1000000"
},
"minFollowers": {
"type": "number",
"description": "0, 10000, 25000, 50000, 75000, 100000, 250000 or 1000000"
},
"audienceGender": {
"enum": [
"male",
"female"
],
"type": "string",
"description": "gender of the creator's audience: male or female"
},
"audienceStates": {
"type": "array",
"items": {
"type": "string"
},
"description": "state or region codes the creator's audience must match"
},
"followerGrowth": {
"enum": [
"top_10_percent",
"top_30_percent",
"top_50_percent"
],
"type": "string",
"description": "follower growth band: creators in the top 10, 30 or 50 percent"
},
"lastPostWithin": {
"enum": [
"last_7_days",
"last_30_days",
"last_90_days"
],
"type": "string",
"description": "only creators who posted within the last 7, 30 or 90 days"
},
"recommendation": {
"enum": [
"most_relevant_for_me",
"high_ad_performance",
"most_ads_experience",
"similar_brands",
"similar_audience",
"interested_in_collaboration"
],
"type": "string",
"description": "Meta's recommendation ranking for the results; omit for a plain search"
},
"audienceDevices": {
"type": "array",
"items": {
"enum": [
"ios",
"android"
],
"type": "string"
},
"description": "device filter for the creator's audience: ios and/or android"
},
"verifiedAccount": {
"type": "boolean",
"description": "true = verified accounts only"
},
"customAudienceId": {
"type": "string",
"description": "from list_instagram_marketplace_audiences"
},
"audienceAgeBucket": {
"enum": [
"18_to_24",
"25_to_34",
"35_to_44",
"45_to_54",
"55_to_64",
"65_and_above"
],
"type": "string",
"description": "age bucket of the creator's audience, e.g. 25_to_34"
},
"audienceCountries": {
"type": "array",
"items": {
"type": "string"
},
"description": "2-letter ISO codes of countries the creator's audience must match"
},
"featuredInPaidAds": {
"type": "boolean",
"description": "true = creators featured in paid ads only"
},
"reelsInteractionRate": {
"type": "number",
"description": "minimum reels interaction rate, as a plain number"
},
"hasPublicContactEmail": {
"type": "boolean",
"description": "true = creators with a public contact email only"
},
"excludeMessagedCreators": {
"type": "boolean",
"description": "true = hide creators this brand has already messaged"
}
}
}{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"url": {
"type": "string",
"description": "the story url from the same inbox item"
},
"brand": {
"type": "string",
"description": "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."
},
"pageId": {
"type": "string",
"description": "one Facebook Page"
},
"account": {
"type": "string",
"description": "one Instagram account: an @handle or id"
},
"storyId": {
"type": "string",
"description": "the story id from the inbox item, or a media id"
},
"repliedAt": {
"type": "string",
"description": "when the reply arrived, the item’s `at`; a reply older than 24 hours means the Story has expired"
}
}
}| metrics | array | account metrics (default: views, reach, accounts_engaged, total_interactions, likes, comments, shares, saves, profile_links_taps). Add follower_demographics or engaged_audience_demographics for the audience, which also needs a breakdown. |
| breakdown | array | contact_button_type / follow_type / media_product_type for account metrics; age / city / country / gender for the demographic metrics (exactly one) |
| timeframe | string | window for the demographic metrics only (default this_month = the last 30 days). engaged_audience_demographics takes ONLY this_month or this_week — Meta retired the other four for it from API v20; follower_demographics takes all six |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"since": {
"type": "string",
"description": "YYYY-MM-DD window start"
},
"until": {
"type": "string",
"description": "YYYY-MM-DD window end"
},
"pageId": {
"type": "string",
"description": "Facebook Page id the Instagram account is linked to — omit when only one Page is connected"
},
"period": {
"enum": [
"day",
"week",
"days_28"
],
"type": "string",
"description": "aggregation for reach, the one time-series metric (default day)"
},
"account": {
"type": "string",
"description": "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."
},
"metrics": {
"type": "array",
"items": {
"type": "string"
},
"description": "account metrics (default: views, reach, accounts_engaged, total_interactions, likes, comments, shares, saves, profile_links_taps). Add follower_demographics or engaged_audience_demographics for the audience, which also needs a breakdown."
},
"breakdown": {
"type": "array",
"items": {
"type": "string"
},
"description": "contact_button_type / follow_type / media_product_type for account metrics; age / city / country / gender for the demographic metrics (exactly one)"
},
"timeframe": {
"enum": [
"last_14_days",
"last_30_days",
"last_90_days",
"prev_month",
"this_month",
"this_week"
],
"type": "string",
"description": "window for the demographic metrics only (default this_month = the last 30 days). engaged_audience_demographics takes ONLY this_month or this_week — Meta retired the other four for it from API v20; follower_demographics takes all six"
}
}
}| pageId | string | Facebook Page id — omit when only one Page is connected. Meta requires the lookup to be made AS a professional account, so the Page must have an Instagram account linked. |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"handle"
],
"properties": {
"posts": {
"type": "integer",
"maximum": 50,
"minimum": 0,
"description": "how many of the account’s recent posts to read with like / comment / view counts and links (0–50, default 0). First-party and free"
},
"cursor": {
"type": "string",
"description": "next page of posts — the cursor from the previous reply"
},
"handle": {
"type": "string",
"description": "the Instagram handle — \"fellowproducts\", \"@fellowproducts\" or an instagram.com profile link all work. Meta matches it EXACTLY and does not strip a leading @, so Hermoso normalises it first."
},
"pageId": {
"type": "string",
"description": "Facebook Page id — omit when only one Page is connected. Meta requires the lookup to be made AS a professional account, so the Page must have an Instagram account linked."
}
}
}| one Story id, from list_meta_stories |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"days": {
"type": "number",
"description": "how far back (1-400, default 30)"
},
"brand": {
"type": "string",
"description": "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": {
"type": "number",
"description": "rows, newest first (1-100, default 25)"
},
"pageId": {
"type": "string",
"description": "one Facebook Page, for its linked Instagram"
},
"account": {
"type": "string",
"description": "one Instagram account: an @handle or id"
},
"storyId": {
"type": "string",
"description": "one Story id, from list_meta_stories"
}
}
}| statistics |
| array |
| Klaviyo statistic names, e.g. opens_unique, click_rate, conversion_value |
| campaignIds | array |
| conversionMetric | string | metric name or id; default 'Placed Order' |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"of": {
"enum": [
"campaigns",
"flows"
],
"type": "string"
},
"end": {
"type": "string"
},
"fresh": {
"type": "boolean"
},
"start": {
"type": "string"
},
"channel": {
"enum": [
"email",
"sms",
"push-notification",
"whatsapp"
],
"type": "string"
},
"flowIds": {
"type": "array",
"items": {
"type": "string"
}
},
"timeframe": {
"enum": [
"last_12_months",
"last_30_days",
"last_365_days",
"last_3_months",
"last_7_days",
"last_90_days",
"last_month",
"last_week",
"last_year",
"this_month",
"this_week",
"this_year",
"today",
"yesterday"
],
"type": "string"
},
"statistics": {
"type": "array",
"items": {
"type": "string"
},
"description": "Klaviyo statistic names, e.g. opens_unique, click_rate, conversion_value"
},
"campaignIds": {
"type": "array",
"items": {
"type": "string"
}
},
"conversionMetric": {
"type": "string",
"description": "metric name or id; default 'Placed Order'"
}
}
}| organizationId | string | numeric Page id from list_linkedin_pages — omit only when exactly one Page is shared with this profile |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"endDate": {
"type": "string",
"description": "YYYY-MM-DD, default today"
},
"postUrns": {
"type": "array",
"items": {
"type": "string"
},
"description": "urn:li:share:… / urn:li:ugcPost:… — switches to per-post lifetime numbers instead of the Page total"
},
"startDate": {
"type": "string",
"description": "YYYY-MM-DD, default 28 days ago (LinkedIn keeps 12 months)"
},
"demographics": {
"type": "boolean",
"description": "also return WHO follows the Page and who visits it (all-time, LinkedIn’s lifetime statistics): the top seniorities, industries, job functions, countries, regions and company sizes, each named — the core B2B \"who is our audience\" answer"
},
"organizationId": {
"type": "string",
"description": "numeric Page id from list_linkedin_pages — omit only when exactly one Page is shared with this profile"
}
}
}{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"kind": {
"enum": [
"direct",
"group"
],
"type": "string",
"description": "direct or group conversations only"
},
"limit": {
"type": "number",
"description": "how many conversations, 1–100 (default 25)"
},
"cursor": {
"type": "string",
"description": "walk further back — pass the cursor from a previous call"
},
"status": {
"enum": [
"request",
"accepted"
],
"type": "string",
"description": "\"request\" = from someone this account does not follow"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"readState": {
"enum": [
"unread"
],
"type": "string",
"description": "only conversations with unread messages"
}
}
}| account |
| string |
| which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"brand": {
"type": "string",
"description": "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": {
"type": "number",
"description": "how many posts, 1–100 (default 25). 100 is Bluesky's own maximum."
},
"cursor": {
"type": "string",
"description": "nextCursor from a previous call — a short page is NOT end-of-feed"
},
"filter": {
"type": "string",
"description": "posts_no_replies | posts_with_media | posts_with_replies | posts_and_author_threads"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
}
}
}| locations/{id} — from list_business_locations |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"showAll": {
"type": "boolean",
"description": "true = send Google's showAll flag for the full attribute list"
},
"pageSize": {
"type": "number",
"description": "attributes per page; default 200"
},
"pageToken": {
"type": "string",
"description": "next page token from the previous reply"
},
"regionCode": {
"type": "string",
"description": "ISO 3166-1 alpha-2 country code"
},
"categoryName": {
"type": "string",
"description": "categories/gcid:… — from list_business_categories"
},
"languageCode": {
"type": "string",
"description": "BCP 47 language code; default en"
},
"locationName": {
"type": "string",
"description": "locations/{id} — from list_business_locations"
}
}
}{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"regionCode"
],
"properties": {
"view": {
"enum": [
"BASIC",
"FULL"
],
"type": "string",
"description": "FULL adds service types and valid attributes"
},
"filter": {
"type": "string",
"description": "search the display names, e.g. “coffee”"
},
"pageSize": {
"type": "number",
"description": "categories per page"
},
"pageToken": {
"type": "string",
"description": "next page token from the previous reply"
},
"regionCode": {
"type": "string",
"description": "ISO 3166-1 alpha-2 country code — US, GB, CA, AU"
},
"languageCode": {
"type": "string",
"description": "BCP 47, default en"
}
}
}| include trashed files (default false) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"query": {
"type": "string",
"description": "only files whose name contains this"
},
"folderId": {
"type": "string",
"description": "list the contents of this folder id"
},
"pageSize": {
"type": "number",
"description": "rows per page (1–200, default 50)"
},
"pageToken": {
"type": "string",
"description": "cursor from a previous call"
},
"recursive": {
"type": "boolean",
"description": "with folderId: the whole tree"
},
"onlyFolders": {
"type": "boolean",
"description": "list folders only"
},
"includeTrashed": {
"type": "boolean",
"description": "include trashed files (default false)"
}
}
}| sources | array | Limit to these sources: facebook, instagram, threads, meta_dm, instagram_dm, meta_webhook, threads_mention, youtube, google_business, x, bluesky, bluesky_mention, bluesky_dm, x_dm, telegram_dm, intercom. Omit for every connected channel. |
| videoId | string | Restrict YouTube to one video. |
| conversationId | string | Restrict DMs to one conversation (from list_meta_conversations, list_bluesky_convos or list_x_dms). Required for Bluesky and X DMs; on X reading one costs credits. |
| unansweredOnly | boolean | Only items with no reply from the brand yet. |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"limit": {
"type": "number",
"description": "Max items per source (default 25)."
},
"pageId": {
"type": "string",
"description": "Facebook Page id (list_meta_pages) when the brand has several Pages connected: reads that Page's Messenger and Instagram DMs and comments only. Omit to read every connected Page."
},
"postId": {
"type": "string",
"description": "Restrict Facebook/Instagram to one post or media id (Meta reads comments per post)."
},
"sources": {
"type": "array",
"items": {
"type": "string"
},
"description": "Limit to these sources: facebook, instagram, threads, meta_dm, instagram_dm, meta_webhook, threads_mention, youtube, google_business, x, bluesky, bluesky_mention, bluesky_dm, x_dm, telegram_dm, intercom. Omit for every connected channel."
},
"videoId": {
"type": "string",
"description": "Restrict YouTube to one video."
},
"conversationId": {
"type": "string",
"description": "Restrict DMs to one conversation (from list_meta_conversations, list_bluesky_convos or list_x_dms). Required for Bluesky and X DMs; on X reading one costs credits."
},
"unansweredOnly": {
"type": "boolean",
"description": "Only items with no reply from the brand yet."
}
}
}| inbox view; default open |
| string | the customer's exact email |
| limit | number | 1-50, default 20 |
| tagId | string | Intercom tag id to filter by, from the Intercom tags list |
| cursor | string | nextCursor from the previous answer |
| teamId | string | the team id it is assigned to (list_intercom_teammates) |
| unread | boolean | true = only conversations no teammate has read |
| channel | string | where the conversation started; conversation is the Messenger |
| tagName | string | a tag name, resolved to its id; use instead of tagId |
| customer | string | part of the customer's name or email |
| priority | boolean | true = only priority conversations, false = only non-priority |
| contactId | string | an Intercom contact id |
| assigneeId | string | the teammate id it is assigned to (list_intercom_teammates) |
| attributes | object | { name: value } custom attributes of the conversation |
| createdAfter | string | date |
| updatedAfter | string | date such as 2026-10-01; replaces days |
| createdBefore | string | date |
| updatedBefore | string | date |
| contactAttributes | object | { name: value } custom attributes of the customer's contact record |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"app": {
"type": "string",
"description": "the product the customer wrote from: their contact's current_app attribute, for example Hermoso"
},
"days": {
"type": "number",
"description": "1-365, default 30: conversations updated in the last N days"
},
"text": {
"type": "string",
"description": "words that appear in the first message"
},
"view": {
"enum": [
"open",
"unassigned",
"waiting",
"snoozed",
"closed",
"recent",
"all"
],
"type": "string",
"description": "inbox view; default open"
},
"email": {
"type": "string",
"description": "the customer's exact email"
},
"limit": {
"type": "number",
"description": "1-50, default 20"
},
"tagId": {
"type": "string",
"description": "Intercom tag id to filter by, from the Intercom tags list"
},
"cursor": {
"type": "string",
"description": "nextCursor from the previous answer"
},
"teamId": {
"type": "string",
"description": "the team id it is assigned to (list_intercom_teammates)"
},
"unread": {
"type": "boolean",
"description": "true = only conversations no teammate has read"
},
"channel": {
"enum": [
"conversation",
"email",
"facebook",
"instagram",
"phone_call",
"phone_switch",
"push",
"sms",
"twitter",
"whatsapp"
],
"type": "string",
"description": "where the conversation started; conversation is the Messenger"
},
"tagName": {
"type": "string",
"description": "a tag name, resolved to its id; use instead of tagId"
},
"customer": {
"type": "string",
"description": "part of the customer's name or email"
},
"priority": {
"type": "boolean",
"description": "true = only priority conversations, false = only non-priority"
},
"contactId": {
"type": "string",
"description": "an Intercom contact id"
},
"assigneeId": {
"type": "string",
"description": "the teammate id it is assigned to (list_intercom_teammates)"
},
"attributes": {
"type": "object",
"description": "{ name: value } custom attributes of the conversation",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
},
"createdAfter": {
"type": "string",
"description": "date"
},
"updatedAfter": {
"type": "string",
"description": "date such as 2026-10-01; replaces days"
},
"createdBefore": {
"type": "string",
"description": "date"
},
"updatedBefore": {
"type": "string",
"description": "date"
},
"contactAttributes": {
"type": "object",
"description": "{ name: value } custom attributes of the customer's contact record",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
}
}
}| adAccountId | string | read forms owned by an AD ACCOUNT instead of a Page |
| formVersion | number | default 1 |
| testLeadsOnly | boolean | true returns ONLY test submissions |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"limit": {
"type": "number",
"description": "per page, max 100"
},
"since": {
"type": "string",
"description": "ISO date or epoch milliseconds"
},
"start": {
"type": "number",
"description": "offset for the next page"
},
"until": {
"type": "string",
"description": "end of the time window, ISO date or epoch milliseconds; omit for now"
},
"formId": {
"type": "string",
"description": "only this form (from list_linkedin_lead_forms)"
},
"pageId": {
"type": "string",
"description": "the company Page that owns the form — from list_linkedin_pages; omit when one Page is shared"
},
"leadType": {
"enum": [
"SPONSORED",
"COMPANY",
"EVENT",
"ORGANIZATION_PRODUCT"
],
"type": "string",
"description": "defaults by owner: SPONSORED for an ad account, COMPANY (organic Page form) for a Page; EVENT for event forms. LinkedIn refuses SPONSORED on a Page owner"
},
"adAccountId": {
"type": "string",
"description": "read forms owned by an AD ACCOUNT instead of a Page"
},
"formVersion": {
"type": "number",
"description": "default 1"
},
"testLeadsOnly": {
"type": "boolean",
"description": "true returns ONLY test submissions"
}
}
}{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"postId"
],
"properties": {
"limit": {
"type": "number",
"description": "how many comments (1–50, default 25)"
},
"cursor": {
"type": "string",
"description": "the cursor from a previous call. A post with more comments than one page comes back with hasMore + a truncationNote — counts or sentiment drawn from ONE page describe a sample, not the conversation."
},
"pageId": {
"type": "string",
"description": "Page id — omit when only one Page is connected"
},
"postId": {
"type": "string",
"description": "post/media id — or a COMMENT id, which returns that comment’s replies"
},
"account": {
"type": "string",
"description": "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."
}
}
}| string |
| which Instagram account — an @handle or id from list_connector_accounts("instagram"). The way a DIRECT (Instagram Login) account is reached; omit for the Page-linked one. |
| platform | string | omit to read both — Messenger and Instagram Direct are separate inboxes |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"limit": {
"type": "number",
"description": "threads per surface (1–100, default 25)"
},
"folder": {
"enum": [
"inbox",
"other",
"page_done",
"spam",
"pending",
"archived"
],
"type": "string",
"description": "narrow to ONE folder. Omit to read inbox + other, which is almost always what you want. \"other\" IS the message-requests folder. A folder Meta does not recognise is REFUSED rather than forwarded, because Meta answers an unknown folder with the DEFAULT inbox — so a plausible-looking spelling like \"requests\" would hand back the ordinary inbox and be reported as \"no message requests\"."
},
"pageId": {
"type": "string",
"description": "Facebook Page id — omit when only one Page is connected"
},
"account": {
"type": "string",
"description": "which Instagram account — an @handle or id from list_connector_accounts(\"instagram\"). The way a DIRECT (Instagram Login) account is reached; omit for the Page-linked one."
},
"platform": {
"enum": [
"MESSENGER",
"INSTAGRAM"
],
"type": "string",
"description": "omit to read both — Messenger and Instagram Direct are separate inboxes"
}
}
}| creatorIds | string | comma-separated NUMERIC Instagram account ids to CHECK against the TAG-APPROVAL list. Meta publishes no way to list that edge — `user_ids` is required on it and the response is filtered to exactly the ids given — so without this the reply says so instead of reporting a read that failed. Handles do not work here (Meta publishes no username form for this parameter); the ad-permission list is the one that takes a @handle. Creators already on the ad-permission list are checked automatically. |
| creatorUsername | string | narrow the AD-PERMISSION list to one creator handle. It does not affect the tag-approval list, which is addressed by numeric id only. |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"pageId": {
"type": "string",
"description": "Facebook Page id — omit when only one Page is connected. Both lists live on its linked Instagram account."
},
"creatorIds": {
"type": "string",
"description": "comma-separated NUMERIC Instagram account ids to CHECK against the TAG-APPROVAL list. Meta publishes no way to list that edge — `user_ids` is required on it and the response is filtered to exactly the ids given — so without this the reply says so instead of reporting a read that failed. Handles do not work here (Meta publishes no username form for this parameter); the ad-permission list is the one that takes a @handle. Creators already on the ad-permission list are checked automatically."
},
"creatorUsername": {
"type": "string",
"description": "narrow the AD-PERMISSION list to one creator handle. It does not affect the tag-approval list, which is addressed by numeric id only."
}
}
}| string |
| 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. |
| includeUnpublished | boolean | Facebook only — also return unpublished drafts (hidden by default) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"brand": {
"type": "string",
"description": "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": {
"type": "number",
"description": "how many posts (default 25, max 100)"
},
"cursor": {
"type": "string",
"description": "paging cursor returned by a previous call"
},
"pageId": {
"type": "string",
"description": "which connected Page — omit when the profile has only one"
},
"tagged": {
"type": "boolean",
"description": "INSTAGRAM — list posts OTHER accounts tagged the brand in (UGC, creator posts, partner mentions), with each author’s @handle, instead of the brand’s own posts. Free."
},
"target": {
"enum": [
"facebook",
"instagram"
],
"type": "string",
"description": "default facebook; 'instagram' reads the Page's linked IG business account"
},
"account": {
"type": "string",
"description": "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."
},
"includeUnpublished": {
"type": "boolean",
"description": "Facebook only — also return unpublished drafts (hidden by default)"
}
}
}| channel |
| string |
| default all |
| includeEnded | boolean | also list Stories that ended, with their kept numbers |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"days": {
"type": "number",
"description": "how far back includeEnded goes (1-400, default 30)"
},
"brand": {
"type": "string",
"description": "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": {
"type": "number",
"description": "rows, newest first (1-100, default 25)"
},
"pageId": {
"type": "string",
"description": "one Facebook Page, and its linked Instagram"
},
"account": {
"type": "string",
"description": "one Instagram account: an @handle or id"
},
"channel": {
"enum": [
"instagram",
"facebook",
"all"
],
"type": "string",
"description": "default all"
},
"includeEnded": {
"type": "boolean",
"description": "also list Stories that ended, with their kept numbers"
}
}
}| string |
| product group id from what:product_groups, used with products and product_counts |
| processingResultId | string | processing result id from what:processing_results, used with item_issues |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"what": {
"enum": [
"catalogs",
"feeds",
"feed",
"processing_results",
"item_issues",
"product_groups",
"product_counts",
"products",
"items_batch"
],
"type": "string",
"description": "which view to read; default catalogs"
},
"feedId": {
"type": "string",
"description": "feed id from what:feeds, used with feed, processing_results and product_groups"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"batchId": {
"type": "string",
"description": "batch id returned by update_pinterest_catalog_items, used with items_batch"
},
"catalogId": {
"type": "string",
"description": "catalog id from what:catalogs, used with feeds and product groups"
},
"adAccountId": {
"type": "string",
"description": "optional — act as this ad account’s owner via Pinterest Business Access (must be shared with this profile)"
},
"productGroupId": {
"type": "string",
"description": "product group id from what:product_groups, used with products and product_counts"
},
"processingResultId": {
"type": "string",
"description": "processing result id from what:processing_results, used with item_issues"
}
}
}{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"id": {
"type": "string",
"description": "one post id from this list: returns that post in full, every caption and setting included"
},
"brand": {
"type": "string",
"description": "WHICH PROFILE to list — the id or exact name from list_brands. Needed when the post lives in a profile this connection is not pinned to: a post you can CREATE in a profile must be manageable there too, without switching the whole connection. A name that matches no profile, or two, is REFUSED."
},
"fired": {
"type": "number",
"description": "how many already-fired posts to list, most recent last (default 15, max 200)"
},
"channel": {
"type": "string",
"description": "only posts that include this channel, e.g. \"pinterest\" or \"x\""
},
"upcoming": {
"type": "number",
"description": "how many queued posts to list, soonest first (default 25, max 200)"
}
}
}| regions | array | two-letter country codes matched against the posting author's registration country, e.g. US |
| maxCount | number | 1 to 100; TikTok defaults this one to 10 |
| sortField | string | CREATE_TIME, LIKES, COMMENTS or SHARES |
| sortOrder | string | ASC or DESC |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"days": {
"type": "number",
"description": "1 to 90. Default 90"
},
"brand": {
"type": "string",
"description": "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."
},
"cursor": {
"type": "number",
"description": "pagination cursor from the previous reply to fetch the next page"
},
"fields": {
"type": "array",
"items": {
"type": "string"
},
"description": "fields to return; omit for every field TikTok publishes"
},
"hashtag": {
"type": "string",
"description": "one ENABLED tag, spelled exactly as enabled. The match is case-sensitive"
},
"regions": {
"type": "array",
"items": {
"type": "string"
},
"description": "two-letter country codes matched against the posting author's registration country, e.g. US"
},
"maxCount": {
"type": "number",
"description": "1 to 100; TikTok defaults this one to 10"
},
"sortField": {
"enum": [
"CREATE_TIME",
"LIKES",
"COMMENTS",
"SHARES"
],
"type": "string",
"description": "CREATE_TIME, LIKES, COMMENTS or SHARES"
},
"sortOrder": {
"enum": [
"ASC",
"DESC"
],
"type": "string",
"description": "ASC or DESC"
}
}
}{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"videoId",
"commentId"
],
"properties": {
"cursor": {
"type": "number",
"description": "pagination cursor from the previous reply's cursor field"
},
"status": {
"enum": [
"PUBLIC",
"ALL"
],
"type": "string",
"description": "PUBLIC or ALL (ALL includes hidden replies)"
},
"videoId": {
"type": "string",
"description": "TikTok requires the post id alongside the comment id"
},
"maxCount": {
"type": "number",
"description": "number of replies to return in this page"
},
"commentId": {
"type": "string",
"description": "from list_tiktok_comments"
},
"sortField": {
"enum": [
"likes",
"replies",
"create_time"
],
"type": "string",
"description": "likes, replies or create_time"
},
"sortOrder": {
"enum": [
"asc",
"desc"
],
"type": "string",
"description": "asc or desc"
}
}
}| string |
| asc or desc |
| commentIds | array | filter to specific comment ids; TikTok caps this at 30 |
| includeReplies | boolean | attach up to THREE replies per comment — not all of them |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"videoId"
],
"properties": {
"cursor": {
"type": "number",
"description": "pagination cursor from the previous reply's cursor field"
},
"status": {
"enum": [
"PUBLIC",
"ALL"
],
"type": "string",
"description": "default ALL, which INCLUDES hidden comments"
},
"videoId": {
"type": "string",
"description": "the TikTok post id (`item_id`) — the last path segment of a tiktok.com/@handle/video/<id> URL"
},
"maxCount": {
"type": "number",
"description": "number of comments to return in this page"
},
"sortField": {
"enum": [
"likes",
"replies",
"create_time"
],
"type": "string",
"description": "likes, replies or create_time"
},
"sortOrder": {
"enum": [
"asc",
"desc"
],
"type": "string",
"description": "asc or desc"
},
"commentIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "filter to specific comment ids; TikTok caps this at 30"
},
"includeReplies": {
"type": "boolean",
"description": "attach up to THREE replies per comment — not all of them"
}
}
}| string |
| ASC or DESC |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"days": {
"type": "number",
"description": "1 to 90. Default 90"
},
"cursor": {
"type": "number",
"description": "pagination cursor from the previous reply to fetch the next page"
},
"fields": {
"type": "array",
"items": {
"type": "string"
},
"description": "fields to return; omit for every field TikTok publishes"
},
"regions": {
"type": "array",
"items": {
"type": "string"
},
"description": "two-letter codes; filters on the commenting author's registration country"
},
"maxCount": {
"type": "number",
"description": "1 to 100; TikTok defaults this one to 10"
},
"sortField": {
"enum": [
"VIDEO_LIKES",
"COMMENT_CREATE_TIME",
"COMMENT_LIKES"
],
"type": "string",
"description": "VIDEO_LIKES, COMMENT_CREATE_TIME or COMMENT_LIKES"
},
"sortOrder": {
"enum": [
"ASC",
"DESC"
],
"type": "string",
"description": "ASC or DESC"
}
}
}| maxCount | number | 1 to 100 |
| sortField | string | CREATE_TIME, LIKES, COMMENTS or SHARES |
| sortOrder | string | ASC or DESC |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"days": {
"type": "number",
"description": "look-back window, 1 to 90 (TikTok indexes nothing older). Default 90"
},
"cursor": {
"type": "number",
"description": "pagination cursor from the previous reply to fetch the next page"
},
"fields": {
"type": "array",
"items": {
"type": "string"
},
"description": "defaults to every field TikTok publishes; item_id, create_time, video_link, caption, likes, comments, shares, thumbnail_url, views, reach, creator_handle_name"
},
"regions": {
"type": "array",
"items": {
"type": "string"
},
"description": "two-letter codes to filter the MENTIONING author's registration country, e.g. ['US','GB']. TikTok publishes 162 of them and answers an unknown one with an empty result, so an unpublished code is refused here instead"
},
"maxCount": {
"type": "number",
"description": "1 to 100"
},
"sortField": {
"enum": [
"CREATE_TIME",
"LIKES",
"COMMENTS",
"SHARES"
],
"type": "string",
"description": "CREATE_TIME, LIKES, COMMENTS or SHARES"
},
"sortOrder": {
"enum": [
"ASC",
"DESC"
],
"type": "string",
"description": "ASC or DESC"
}
}
}{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"brand": {
"type": "string",
"description": "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": {
"type": "number",
"description": "1-20, default 10 (ignored when videoIds is given)"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"videoIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "read these specific TikTok video ids instead of listing recent ones — up to 20 per call"
}
}
}{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"name": {
"type": "string",
"description": "filter to one template name (Meta matches it exactly)"
},
"limit": {
"type": "number",
"description": "how many (1–200, default 50)"
},
"status": {
"type": "string",
"description": "filter to one review status, e.g. APPROVED or PENDING"
},
"wabaId": {
"type": "string",
"description": "the WhatsApp Business Account id, from list_whatsapp_accounts. Optional when exactly one account is shared with this profile; required when several are, and refused by name for one that is not shared"
},
"language": {
"type": "string",
"description": "filter to one language code, e.g. en_US"
}
}
}| paginationToken | string | next page token from the previous reply |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"id": {
"type": "string",
"description": "one broadcast id (alphanumeric, up to 24 characters)"
},
"ids": {
"type": "string",
"description": "comma-separated broadcast ids (live kind, up to 100)"
},
"kind": {
"enum": [
"all",
"live",
"scheduled"
],
"type": "string",
"description": "all (default), live or scheduled"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"maxResults": {
"type": "number",
"description": "1-100, default 25"
},
"newestStart": {
"type": "string",
"description": "scheduled kind: latest start, ISO 8601 or epoch ms"
},
"oldestStart": {
"type": "string",
"description": "scheduled kind: earliest start, ISO 8601 or epoch ms"
},
"paginationToken": {
"type": "string",
"description": "next page token from the previous reply"
}
}
}| default 'conversations' |
| inbox | string | message_requests: X's Priority tab (default) or the filtered low-quality one |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| endTime | string | latest message before this ISO 8601 time |
| isMuted | boolean | true only muted, false only not muted |
| startTime | string | latest message at or after this ISO 8601 time |
| inviteLink | string | an x.com/i/chat/group_join/... link, for view invite |
| isArchived | boolean | true only archived, false only not archived |
| maxResults | number | 1-100 (message_requests 1-50), default 10; every conversation or request returned is billed |
| unreadOnly | boolean | only unread conversations |
| inviteToken | string | the 10-character token, with id, for view invite |
| withMembers | boolean | also resolve member/admin @handles (each one billed) |
| withSenders | boolean | message_requests: resolve who sent each request (default true, each sender billed) |
| participantIds | string | 1-10 numeric X account ids, comma-separated, all in the chat |
| paginationToken | string | nextToken from a previous call |
| participantQuery | string | name or @username prefix, 1-50 characters |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"id": {
"type": "string",
"description": "one conversation; for admin_requests, the group (g + digits)"
},
"type": {
"enum": [
"direct",
"group"
],
"type": "string",
"description": "only 1:1 or only group conversations"
},
"view": {
"enum": [
"conversations",
"admin_requests",
"invite",
"message_requests"
],
"type": "string",
"description": "default 'conversations'"
},
"inbox": {
"enum": [
"priority",
"low_quality"
],
"type": "string",
"description": "message_requests: X's Priority tab (default) or the filtered low-quality one"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"endTime": {
"type": "string",
"description": "latest message before this ISO 8601 time"
},
"isMuted": {
"type": "boolean",
"description": "true only muted, false only not muted"
},
"startTime": {
"type": "string",
"description": "latest message at or after this ISO 8601 time"
},
"inviteLink": {
"type": "string",
"description": "an x.com/i/chat/group_join/... link, for view invite"
},
"isArchived": {
"type": "boolean",
"description": "true only archived, false only not archived"
},
"maxResults": {
"type": "number",
"description": "1-100 (message_requests 1-50), default 10; every conversation or request returned is billed"
},
"unreadOnly": {
"type": "boolean",
"description": "only unread conversations"
},
"inviteToken": {
"type": "string",
"description": "the 10-character token, with id, for view invite"
},
"withMembers": {
"type": "boolean",
"description": "also resolve member/admin @handles (each one billed)"
},
"withSenders": {
"type": "boolean",
"description": "message_requests: resolve who sent each request (default true, each sender billed)"
},
"participantIds": {
"type": "string",
"description": "1-10 numeric X account ids, comma-separated, all in the chat"
},
"paginationToken": {
"type": "string",
"description": "nextToken from a previous call"
},
"participantQuery": {
"type": "string",
"description": "name or @username prefix, 1-50 characters"
}
}
}| read just the conversation with this NUMERIC X account id (not an @handle) |
| conversationId | string | read just this conversation (from a previous call). One-to-one ids look like "12-345"; group ids are one long number. |
| paginationToken | string | nextToken from a previous call, to page further back. There is no since/until filter at X — this is the only way to walk history. |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"eventTypes": {
"type": "array",
"items": {
"type": "string"
},
"description": "MessageCreate (default), ParticipantsJoin, ParticipantsLeave. The join/leave events carry no message and are billed like any other, so the default is messages only."
},
"maxResults": {
"type": "number",
"description": "how many messages to pull, 1-100 (default 10) — EVERY ONE IS BILLED, and omitting it would make X return 100"
},
"participantId": {
"type": "string",
"description": "read just the conversation with this NUMERIC X account id (not an @handle)"
},
"conversationId": {
"type": "string",
"description": "read just this conversation (from a previous call). One-to-one ids look like \"12-345\"; group ids are one long number."
},
"paginationToken": {
"type": "string",
"description": "nextToken from a previous call, to page further back. There is no since/until filter at X — this is the only way to walk history."
}
}
}{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"mediaId"
],
"properties": {
"tags": {
"type": "array",
"items": {
"type": "object",
"required": [
"product_id"
],
"properties": {
"x": {
"type": "number",
"description": "photo only: horizontal position as a fraction, 0.0 (left) to 1.0 (right); required on a photo"
},
"y": {
"type": "number",
"description": "photo only: vertical position as a fraction, 0.0 (top) to 1.0 (bottom); required on a photo"
},
"product_id": {
"type": "string",
"description": "catalog product id to tag on the post"
}
}
},
"description": "ADD or MOVE these tags. Omit to just read. ON A PHOTO each tag is {product_id, x, y}: x and y are FRACTIONS of the image, 0.0 (left/top) to 1.0 (right/bottom), 0.5,0.5 is the middle, and both are required. ON A REEL OR VIDEO it is {product_id} ALONE: Instagram takes no position on a video tag, so coordinates sent for one are not used and the reply says so. Max 20 on a photo or feed video, 30 on a Reel. A carousel is tagged on its SLIDES: pass the slide’s own media id."
},
"pageId": {
"type": "string",
"description": "Facebook Page id — omit when only one Page is connected"
},
"mediaId": {
"type": "string",
"description": "the numeric Instagram media id, from list_instagram_media"
}
}
}| snoozeUntil | string | ISO date-time in the future, for snooze |
| conversationId* | string | Intercom conversation id, from list_intercom_conversations |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"conversationId",
"action"
],
"properties": {
"action": {
"enum": [
"close",
"snooze",
"open",
"assign"
],
"type": "string",
"description": "close, snooze (needs snoozeUntil), open or assign"
},
"teamId": {
"type": "string",
"description": "team id to assign to, from list_intercom_teammates"
},
"confirm": {
"type": "boolean",
"description": "true once the user agreed to this change"
},
"message": {
"type": "string",
"description": "optional, close or assign only: the customer sees it"
},
"unassign": {
"type": "boolean",
"description": "true = remove the current assignee"
},
"assignToMe": {
"type": "boolean",
"description": "true = assign to the connected teammate"
},
"assigneeId": {
"type": "string",
"description": "teammate id to assign to, from list_intercom_teammates"
},
"snoozeUntil": {
"type": "string",
"description": "ISO date-time in the future, for snooze"
},
"conversationId": {
"type": "string",
"description": "Intercom conversation id, from list_intercom_conversations"
}
}
}| the creator’s Instagram @username, or their numeric Instagram account id. The ad-permission list accepts either; the tag-approval list takes numeric ids ONLY, because Meta publishes no username form for its parameter — use instagram_profile to turn the handle into that id. An id that did not come from Meta is rejected with “An unknown error has occurred.” (HTTP 500, code 1), which names no cause and looks like an outage rather than a bad argument. |
| allowAds | boolean | the Partnership Ads permission list — whose content this profile may run as an advert |
| creatorIds | array | for `allowTagging`, which accepts several NUMERIC ids at once. A handle here is refused by name rather than sent and rejected by Meta — run instagram_profile on each handle first and pass the ids it returns. |
| allowTagging | boolean | the tag-approval list — who may tag this brand as a paid partner on their own organic post |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"creator"
],
"properties": {
"pageId": {
"type": "string",
"description": "Facebook Page id — omit when only one Page is connected"
},
"remove": {
"type": "boolean",
"description": "revoke instead of add"
},
"creator": {
"type": "string",
"description": "the creator’s Instagram @username, or their numeric Instagram account id. The ad-permission list accepts either; the tag-approval list takes numeric ids ONLY, because Meta publishes no username form for its parameter — use instagram_profile to turn the handle into that id. An id that did not come from Meta is rejected with “An unknown error has occurred.” (HTTP 500, code 1), which names no cause and looks like an outage rather than a bad argument."
},
"allowAds": {
"type": "boolean",
"description": "the Partnership Ads permission list — whose content this profile may run as an advert"
},
"creatorIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "for `allowTagging`, which accepts several NUMERIC ids at once. A handle here is refused by name rather than sent and rejected by Meta — run instagram_profile on each handle first and pass the ids it returns."
},
"allowTagging": {
"type": "boolean",
"description": "the tag-approval list — who may tag this brand as a paid partner on their own organic post"
}
}
}| string |
| which Page to use — needed when the post id has no page prefix, or when the profile has several Pages and you are deleting an Instagram post |
| postId* | string | the post id returned by post_to_meta — for Instagram, the media id from list_instagram_media |
| target | string | default facebook |
| confirm | boolean | REQUIRED true to delete (permanent) |
| message | string | the new post text (action:"edit" on facebook) |
| videoUrl | string | action:"cover" with coverAtMs — the Hermoso file the video was published from, to cut the frame from (else Facebook’s own copy is used) |
| coverAtMs | number | action:"cover" — the new cover frame, in milliseconds from the start (Facebook only; Instagram, LinkedIn and Pinterest cannot change a published cover) |
| confirmName | string | Instagram only: the post’s exact caption line, exactly as the unconfirmed call reported it — required once the post has any likes or comments |
| coverImageUrl | string | action:"cover" — a Hermoso-hosted picture as the new cover, instead of a frame (a render, or any file through upload_file) |
| confirmChildren | number | Instagram only: how many carousel cards the delete also destroys, as the unconfirmed call reported |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"postId",
"action"
],
"properties": {
"brand": {
"type": "string",
"description": "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."
},
"action": {
"enum": [
"edit",
"delete",
"cover"
],
"type": "string",
"description": "edit the text (FB only), delete the post, or cover: replace the cover of a published Facebook video or Reel (postId = its video id)"
},
"pageId": {
"type": "string",
"description": "which Page to use — needed when the post id has no page prefix, or when the profile has several Pages and you are deleting an Instagram post"
},
"postId": {
"type": "string",
"description": "the post id returned by post_to_meta — for Instagram, the media id from list_instagram_media"
},
"target": {
"enum": [
"facebook",
"threads",
"instagram"
],
"type": "string",
"description": "default facebook"
},
"confirm": {
"type": "boolean",
"description": "REQUIRED true to delete (permanent)"
},
"message": {
"type": "string",
"description": "the new post text (action:\"edit\" on facebook)"
},
"videoUrl": {
"type": "string",
"description": "action:\"cover\" with coverAtMs — the Hermoso file the video was published from, to cut the frame from (else Facebook’s own copy is used)"
},
"coverAtMs": {
"type": "number",
"description": "action:\"cover\" — the new cover frame, in milliseconds from the start (Facebook only; Instagram, LinkedIn and Pinterest cannot change a published cover)"
},
"confirmName": {
"type": "string",
"description": "Instagram only: the post’s exact caption line, exactly as the unconfirmed call reported it — required once the post has any likes or comments"
},
"coverImageUrl": {
"type": "string",
"description": "action:\"cover\" — a Hermoso-hosted picture as the new cover, instead of a frame (a render, or any file through upload_file)"
},
"confirmChildren": {
"type": "number",
"description": "Instagram only: how many carousel cards the delete also destroys, as the unconfirmed call reported"
}
}
}| location | string | the URL Pinterest downloads the feed from |
| schedule | object | when Pinterest downloads the feed: {time HH:MM, timezone} |
| catalogId | string | catalog to attach the feed to, from list_pinterest_catalogs |
| adAccountId | string | optional — act as this ad account’s owner via Pinterest Business Access (must be shared with this profile) |
| credentials | object | only if the feed URL needs a login |
| defaultLocale | string | e.g. en-US — required on create |
| defaultCountry | string | two-letter, e.g. US — required on create |
| defaultCurrency | string | currency for items that omit one, e.g. USD |
| defaultAvailability | string | availability for items that omit it: IN_STOCK, OUT_OF_STOCK or PREORDER |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"name": {
"type": "string",
"description": "feed name; required on create"
},
"action": {
"enum": [
"create",
"update",
"delete"
],
"type": "string",
"description": "default create"
},
"feedId": {
"type": "string",
"description": "feed to update or delete, from list_pinterest_catalogs what:feeds"
},
"format": {
"enum": [
"CSV",
"TSV",
"XML",
"INTEGRATION"
],
"type": "string",
"description": "feed file format: CSV, TSV, XML or INTEGRATION"
},
"status": {
"enum": [
"ACTIVE",
"INACTIVE"
],
"type": "string",
"description": "ACTIVE or INACTIVE"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"confirm": {
"type": "boolean",
"description": "required true for delete"
},
"location": {
"type": "string",
"description": "the URL Pinterest downloads the feed from"
},
"schedule": {
"type": "object",
"required": [
"time",
"timezone"
],
"properties": {
"time": {
"type": "string",
"description": "download time in 24-hour HH:MM, e.g. 06:00"
},
"timezone": {
"type": "string",
"description": "IANA timezone for the download time, e.g. America/New_York"
}
},
"description": "when Pinterest downloads the feed: {time HH:MM, timezone}"
},
"catalogId": {
"type": "string",
"description": "catalog to attach the feed to, from list_pinterest_catalogs"
},
"adAccountId": {
"type": "string",
"description": "optional — act as this ad account’s owner via Pinterest Business Access (must be shared with this profile)"
},
"credentials": {
"type": "object",
"required": [
"username",
"password"
],
"properties": {
"password": {
"type": "string",
"description": "password for the feed URL; only if the URL needs one"
},
"username": {
"type": "string",
"description": "login for the feed URL; only if the URL needs one"
}
},
"description": "only if the feed URL needs a login"
},
"defaultLocale": {
"type": "string",
"description": "e.g. en-US — required on create"
},
"defaultCountry": {
"type": "string",
"description": "two-letter, e.g. US — required on create"
},
"defaultCurrency": {
"type": "string",
"description": "currency for items that omit one, e.g. USD"
},
"defaultAvailability": {
"enum": [
"IN_STOCK",
"OUT_OF_STOCK",
"PREORDER"
],
"type": "string",
"description": "availability for items that omit it: IN_STOCK, OUT_OF_STOCK or PREORDER"
}
}
}| boolean |
| true = mark the product group as featured |
| adAccountId | string | optional — act as this ad account’s owner via Pinterest Business Access (must be shared with this profile) |
| description | string | product group description |
| productGroupId | string | product group to update or delete |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"name": {
"type": "string",
"description": "product group name; required on create"
},
"action": {
"enum": [
"create",
"update",
"delete"
],
"type": "string",
"description": "default create"
},
"feedId": {
"type": "string",
"description": "feed the group draws products from; required on create"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"confirm": {
"type": "boolean",
"description": "required true for delete"
},
"filters": {
"type": "object",
"description": "Pinterest product-group filters: {\"all_of\":[{\"BRAND\":{\"values\":[\"Acme\"]}},{\"MIN_PRICE\":{\"values\":10}}]} or {\"any_of\":[…]}. Keys: AVAILABILITY, BRAND, CONDITION, CURRENCY, CUSTOM_LABEL_0-4, CUSTOM_NUMBER_0-4, GENDER, GOOGLE_PRODUCT_CATEGORY_0-6, ITEM_GROUP_ID, ITEM_ID, MAX_PRICE, MEDIA_TYPE, MIN_PRICE, PINTEREST_PRODUCT_CATEGORIES, PRODUCT_GROUP, PRODUCT_TYPE_0-4, TITLE_KEYWORDS — an unknown key is refused by name",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"isFeatured": {
"type": "boolean",
"description": "true = mark the product group as featured"
},
"adAccountId": {
"type": "string",
"description": "optional — act as this ad account’s owner via Pinterest Business Access (must be shared with this profile)"
},
"description": {
"type": "string",
"description": "product group description"
},
"productGroupId": {
"type": "string",
"description": "product group to update or delete"
}
}
}| the filled-cell count an unconfirmed delete reported |
| spreadsheetId | string | Google spreadsheet id; use this or sheetUrl |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"action"
],
"properties": {
"tab": {
"type": "string",
"description": "which tab — its title or numeric sheetId (rename / delete)"
},
"title": {
"type": "string",
"description": "the name for the new tab (action:\"add\")"
},
"action": {
"enum": [
"add",
"rename",
"delete"
],
"type": "string",
"description": "add, rename or delete a tab"
},
"confirm": {
"type": "boolean",
"description": "true deletes a tab that holds data; needs confirmCells"
},
"newTitle": {
"type": "string",
"description": "what to rename the tab to (action:\"rename\")"
},
"sheetUrl": {
"type": "string",
"description": "Google Sheets link, instead of spreadsheetId"
},
"confirmCells": {
"type": "number",
"description": "the filled-cell count an unconfirmed delete reported"
},
"spreadsheetId": {
"type": "string",
"description": "Google spreadsheet id; use this or sheetUrl"
}
}
}| locale | string | broadcast language locale, e.g. en |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| confirm | boolean | true only after the user approved this exact public or irreversible action |
| startAt | string | ISO 8601 or epoch ms |
| isLocked | boolean | true = lock the broadcast (X's isLocked flag) |
| skipPost | boolean | publish: true skips the post X would make announcing it |
| sourceId | string | stream source id from manage_x_stream_source (schedule, create) |
| chatOption | string | X chat permission option (numeric string) |
| description | string | broadcast description |
| manualPublish | boolean | required on schedule: true waits for go_live, false auto-publishes at startAt |
| thumbnailMediaId | string | pre-live slate media id |
| availableForReplay | boolean | true = keep the broadcast available as a replay |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"action"
],
"properties": {
"id": {
"type": "string",
"description": "broadcast id, for every action but schedule and create"
},
"endAt": {
"type": "string",
"description": "ISO 8601 or epoch ms; X requires one on update"
},
"title": {
"type": "string",
"description": "broadcast title"
},
"action": {
"enum": [
"schedule",
"update",
"go_live",
"cancel",
"create",
"publish",
"end",
"delete"
],
"type": "string",
"description": "schedule, update, go_live, cancel, create, publish, end or delete"
},
"locale": {
"type": "string",
"description": "broadcast language locale, e.g. en"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"confirm": {
"type": "boolean",
"description": "true only after the user approved this exact public or irreversible action"
},
"startAt": {
"type": "string",
"description": "ISO 8601 or epoch ms"
},
"isLocked": {
"type": "boolean",
"description": "true = lock the broadcast (X's isLocked flag)"
},
"skipPost": {
"type": "boolean",
"description": "publish: true skips the post X would make announcing it"
},
"sourceId": {
"type": "string",
"description": "stream source id from manage_x_stream_source (schedule, create)"
},
"chatOption": {
"type": "string",
"description": "X chat permission option (numeric string)"
},
"description": {
"type": "string",
"description": "broadcast description"
},
"manualPublish": {
"type": "boolean",
"description": "required on schedule: true waits for go_live, false auto-publishes at startAt"
},
"thumbnailMediaId": {
"type": "string",
"description": "pre-live slate media id"
},
"availableForReplay": {
"type": "boolean",
"description": "true = keep the broadcast available as a replay"
}
}
}| title |
| string |
| enable_invite: the group name shown on the link preview (stored unencrypted) |
| action* | string | conversation action; the tool description lists each one and what it needs |
| userId | string | NUMERIC X account id (approve_admin_request, reject_admin_request) |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| confirm | boolean | true only after the user approved this exact delete / leave / removal / link shutdown |
| userIds | array | NUMERIC X account ids (add_admins, remove_admins, remove_members, reject_join_requests) |
| adminOnly | object | admin_settings: true = only admins may |
| avatarUrl | string | enable_invite: https image for the preview |
| expiresAt | string | enable_invite: ISO 8601 or epoch ms; omit for a link that does not expire |
| disappearAfter | string | set_disappearing: '24h', '7d', '90m' or a number of seconds |
| welcomeMessage | string | enable_invite: welcome text on the invite preview; stored unencrypted |
| blockScreenshots | boolean | screen_capture: true = block screenshots of the chat |
| notifyOnScreenshot | boolean | screen_capture: true = notify everyone when someone takes a screenshot |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"action",
"id"
],
"properties": {
"id": {
"type": "string",
"description": "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)"
},
"title": {
"type": "string",
"description": "enable_invite: the group name shown on the link preview (stored unencrypted)"
},
"action": {
"enum": [
"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"
],
"type": "string",
"description": "conversation action; the tool description lists each one and what it needs"
},
"userId": {
"type": "string",
"description": "NUMERIC X account id (approve_admin_request, reject_admin_request)"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"confirm": {
"type": "boolean",
"description": "true only after the user approved this exact delete / leave / removal / link shutdown"
},
"userIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "NUMERIC X account ids (add_admins, remove_admins, remove_members, reject_join_requests)"
},
"adminOnly": {
"type": "object",
"properties": {
"addMember": {
"type": "boolean",
"description": "admin_settings: true = only admins may add members"
},
"startCall": {
"type": "boolean",
"description": "admin_settings: true = only admins may start calls"
},
"sendMessage": {
"type": "boolean",
"description": "admin_settings: true = only admins may send messages (announcement mode)"
},
"editGroupInfo": {
"type": "boolean",
"description": "admin_settings: true = only admins may edit group info"
},
"editMessageTtl": {
"type": "boolean",
"description": "admin_settings: true = only admins may change the disappearing-message timer"
},
"blockScreenCapture": {
"type": "boolean",
"description": "admin_settings: true = only admins may control screen capture"
}
},
"description": "admin_settings: true = only admins may"
},
"avatarUrl": {
"type": "string",
"description": "enable_invite: https image for the preview"
},
"expiresAt": {
"type": "string",
"description": "enable_invite: ISO 8601 or epoch ms; omit for a link that does not expire"
},
"disappearAfter": {
"type": "string",
"description": "set_disappearing: '24h', '7d', '90m' or a number of seconds"
},
"welcomeMessage": {
"type": "string",
"description": "enable_invite: welcome text on the invite preview; stored unencrypted"
},
"blockScreenshots": {
"type": "boolean",
"description": "screen_capture: true = block screenshots of the chat"
},
"notifyOnScreenshot": {
"type": "boolean",
"description": "screen_capture: true = notify everyone when someone takes a screenshot"
}
}
}{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"id": {
"type": "string",
"description": "source id, for get, rename and delete"
},
"name": {
"type": "string",
"description": "label, for create and rename"
},
"action": {
"enum": [
"list",
"get",
"create",
"rename",
"delete",
"region"
],
"type": "string",
"description": "default 'list'"
},
"region": {
"type": "string",
"description": "create only: 'atla' or 'pdxa', or an AWS region X maps onto one"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"confirm": {
"type": "boolean",
"description": "true only after the user approved deleting this source"
}
}
}| videoId |
| string |
| required for upload; strongly recommended for update and delete so the track can be resolved and read back |
| language | string | BCP-47 code such as en, es, pt-BR. Required for upload. |
| captionId | string | required for update and delete (from list_youtube_captions) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"action"
],
"properties": {
"name": {
"type": "string",
"description": "a label shown in YouTube’s caption picker"
},
"text": {
"type": "string",
"description": "the caption file body — SRT or WebVTT. Required for upload."
},
"draft": {
"type": "boolean",
"description": "true keeps the track unpublished while you review it"
},
"action": {
"enum": [
"upload",
"update",
"delete"
],
"type": "string",
"description": "upload | update | delete"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"confirm": {
"type": "boolean",
"description": "required to delete"
},
"videoId": {
"type": "string",
"description": "required for upload; strongly recommended for update and delete so the track can be resolved and read back"
},
"language": {
"type": "string",
"description": "BCP-47 code such as en, es, pt-BR. Required for upload."
},
"captionId": {
"type": "string",
"description": "required for update and delete (from list_youtube_captions)"
}
}
}| action | string | defaults to 'list' |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| confirm | boolean | must be true to actually delete |
| channels | array | channel ids to feature. Required for multipleChannels |
| position | number | zero-based position on the homepage. Leave it off and YouTube places the section |
| playlists | array | playlist ids, from list_youtube_playlists. Required for singlePlaylist (exactly one) and multiplePlaylists |
| sectionId | string | for update and delete — from action:'list' |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"hl": {
"type": "string",
"description": "language for the returned titles, e.g. \"en\""
},
"type": {
"type": "string",
"description": "singlePlaylist | multiplePlaylists | popularUploads | recentUploads | likes | allPlaylists | likedPlaylists | recentPosts | recentActivity | liveEvents | upcomingEvents | completedEvents | multipleChannels | postedVideos | postedPlaylists | subscriptions. Required to create or update"
},
"style": {
"enum": [
"horizontalRow",
"verticalList"
],
"type": "string",
"description": "leave it off to let YouTube choose"
},
"title": {
"type": "string",
"description": "the heading, and only multiplePlaylists and multipleChannels take one — YouTube writes the heading for every other type"
},
"action": {
"enum": [
"list",
"create",
"update",
"delete"
],
"type": "string",
"description": "defaults to 'list'"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"confirm": {
"type": "boolean",
"description": "must be true to actually delete"
},
"channels": {
"type": "array",
"items": {
"type": "string"
},
"description": "channel ids to feature. Required for multipleChannels"
},
"position": {
"type": "number",
"description": "zero-based position on the homepage. Leave it off and YouTube places the section"
},
"playlists": {
"type": "array",
"items": {
"type": "string"
},
"description": "playlist ids, from list_youtube_playlists. Required for singlePlaylist (exactly one) and multiplePlaylists"
},
"sectionId": {
"type": "string",
"description": "for update and delete — from action:'list'"
}
}
}| description | string | on update, omit to leave unchanged |
| confirmTitle | string | the playlist’s EXACT title — required to delete one that is public or non-empty |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"action"
],
"properties": {
"title": {
"type": "string",
"description": "required on create; on update, omit to leave unchanged"
},
"action": {
"enum": [
"create",
"update",
"delete"
],
"type": "string",
"description": "create | update | delete"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"confirm": {
"type": "boolean",
"description": "required to delete"
},
"privacy": {
"enum": [
"public",
"unlisted",
"private"
],
"type": "string",
"description": "default private on create"
},
"playlistId": {
"type": "string",
"description": "required for update and delete"
},
"description": {
"type": "string",
"description": "on update, omit to leave unchanged"
},
"confirmTitle": {
"type": "string",
"description": "the playlist’s EXACT title — required to delete one that is public or non-empty"
}
}
}{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"postId"
],
"properties": {
"brand": {
"type": "string",
"description": "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."
},
"pageId": {
"type": "string",
"description": "Page id — omit when only one Page is connected"
},
"postId": {
"type": "string",
"description": "post/media id returned by post_to_meta"
},
"target": {
"enum": [
"facebook",
"instagram"
],
"type": "string",
"description": "which metric set to ask for; an Instagram media id is detected and read as Instagram either way (default: Facebook for a Facebook id)"
},
"account": {
"type": "string",
"description": "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."
}
}
}| required for remove_message |
| username | string | X handle, resolved to the id for you |
| messageId | string | chat message id to remove; with mute, also removes that message |
| broadcastId | string | broadcast id; for remove_message, mute and unmute |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"action"
],
"properties": {
"until": {
"type": "string",
"description": "mute end, ISO 8601 or epoch ms"
},
"action": {
"enum": [
"remove_message",
"mute",
"unmute",
"list_moderators",
"add_moderator",
"remove_moderator",
"list_mutes",
"mute_everywhere",
"unmute_everywhere"
],
"type": "string",
"description": "remove_message, mute, unmute, list_moderators, add_moderator, remove_moderator, list_mutes, mute_everywhere or unmute_everywhere"
},
"userId": {
"type": "string",
"description": "numeric X account id"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"confirm": {
"type": "boolean",
"description": "required for remove_message"
},
"username": {
"type": "string",
"description": "X handle, resolved to the id for you"
},
"messageId": {
"type": "string",
"description": "chat message id to remove; with mute, also removes that message"
},
"broadcastId": {
"type": "string",
"description": "broadcast id; for remove_message, mute and unmute"
}
}
}{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"action",
"commentId"
],
"properties": {
"action": {
"enum": [
"publish",
"hold",
"reject",
"spam",
"delete"
],
"type": "string",
"description": "publish | hold | reject | spam | delete"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"confirm": {
"type": "boolean",
"description": "required for delete"
},
"banAuthor": {
"type": "boolean",
"description": "reject only — also auto-reject every future comment from this author"
},
"commentId": {
"type": "string",
"description": "id of the comment (from list_youtube_comments)"
}
}
}| YYYY-MM-DD, default today |
| video | boolean | scope:"pin" only — true when the Pin is a VIDEO, which has its own metric set |
| sortBy | string | top_pins / top_video_pins: the metric to rank by (default the first metric) |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| appTypes | string | app type to report on: ALL, MOBILE, TABLET or WEB |
| splitField | string | account: NO_SPLIT | APP_TYPE | OWNED_CONTENT | SOURCE | PIN_FORMAT |
| metricTypes | array | which metrics; omit for all of the ones valid at this scope. Unknown values are refused with the valid list. |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"limit": {
"type": "number",
"description": "top_pins / top_video_pins: how many (1–50, default 10)"
},
"pinId": {
"type": "string",
"description": "required for scope:\"pin\" — the id post_to_pinterest returned"
},
"scope": {
"enum": [
"account",
"top_pins",
"top_video_pins",
"pin"
],
"type": "string",
"description": "default account"
},
"since": {
"type": "string",
"description": "YYYY-MM-DD, default 30 days ago; Pinterest allows at most 90 days back"
},
"until": {
"type": "string",
"description": "YYYY-MM-DD, default today"
},
"video": {
"type": "boolean",
"description": "scope:\"pin\" only — true when the Pin is a VIDEO, which has its own metric set"
},
"sortBy": {
"type": "string",
"description": "top_pins / top_video_pins: the metric to rank by (default the first metric)"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"appTypes": {
"enum": [
"ALL",
"MOBILE",
"TABLET",
"WEB"
],
"type": "string",
"description": "app type to report on: ALL, MOBILE, TABLET or WEB"
},
"splitField": {
"type": "string",
"description": "account: NO_SPLIT | APP_TYPE | OWNED_CONTENT | SOURCE | PIN_FORMAT"
},
"metricTypes": {
"type": "array",
"items": {
"type": "string"
},
"description": "which metrics; omit for all of the ones valid at this scope. Unknown values are refused with the valid list."
}
}
}| Pinterest ad account id; omit when only one is shared with this profile |
| campaignIds | array | campaign ids to report on; needed for scope campaign |
| granularity | string | time bucket: TOTAL, DAY, HOUR, WEEK or MONTH |
| targetingTypes* | array | REQUIRED — e.g. KEYWORD, AGE_BUCKET, GENDER, LOCATION, PLACEMENT, MEDIA_TYPE, TARGETED_INTEREST, PINNER_INTEREST, COUNTRY, REGION |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"targetingTypes"
],
"properties": {
"adIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "ad ids to report on; needed for scope ad"
},
"scope": {
"enum": [
"account",
"campaign",
"adGroup",
"ad"
],
"type": "string",
"description": "default account"
},
"since": {
"type": "string",
"description": "start date, YYYY-MM-DD; at most 90 days back"
},
"until": {
"type": "string",
"description": "end date, YYYY-MM-DD"
},
"columns": {
"type": "array",
"items": {
"type": "string"
},
"description": "Pinterest metric column names to return"
},
"adGroupIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "ad group ids to report on; needed for scope adGroup"
},
"adAccountId": {
"type": "string",
"description": "Pinterest ad account id; omit when only one is shared with this profile"
},
"campaignIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "campaign ids to report on; needed for scope campaign"
},
"granularity": {
"enum": [
"TOTAL",
"DAY",
"HOUR",
"WEEK",
"MONTH"
],
"type": "string",
"description": "time bucket: TOTAL, DAY, HOUR, WEEK or MONTH"
},
"targetingTypes": {
"type": "array",
"items": {
"type": "string"
},
"description": "REQUIRED — e.g. KEYWORD, AGE_BUCKET, GENDER, LOCATION, PLACEMENT, MEDIA_TYPE, TARGETED_INTEREST, PINNER_INTEREST, COUNTRY, REGION"
}
}
}| genders |
| array |
| keywords: male, female, unknown. product categories: MALE, FEMALE, UNSPECIFIED |
| interest | string | topics: ALL, ANIMALS, ARCHITECTURE, ART, BEAUTY, DIY_AND_CRAFTS, EDUCATION, EVENT_PLANNING, FASHION, FOOD_AND_DRINKS, GARDENING, HEALTH, HOME_DECOR, PARENTING, TRAVEL, WEDDING |
| interests | array | keywords: animals, architecture, art, beauty, childrens_fashion, design, diy_and_crafts, education, electronics, entertainment, event_planning, finance, food_and_drinks, gardening, health, home_decor, mens_fashion, parenting, quotes, sport, travel, vehicles, wedding, womens_fashion |
| trendType | string | keywords: default growing |
| verticals | array | shopping verticals for product categories: FASHION, HOME_DECOR or BEAUTY |
| engagementType | string | engagement measure to report: ENGAGEMENT, OUTBOUND_CLICK or SAVE |
| lookbackWindow | any | trend lookback in days: 90, 180, 365 or 730 |
| includeKeywords | array | keywords: only trends containing one of these (max 50) |
| productCategories | array | product_category_details: category names from product_categories, e.g. AREA_RUGS |
| includeDemographics | boolean | true = include demographics in the result |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"ages": {
"type": "array",
"items": {
"enum": [
"18-24",
"25-34",
"35-44",
"45-49",
"50-54",
"55-64",
"65+"
],
"type": "string"
},
"description": "age bands to filter keyword trends by, e.g. 25-34"
},
"kind": {
"enum": [
"keywords",
"topics",
"product_categories",
"product_category_details"
],
"type": "string",
"description": "what to list: keywords (default), topics, product_categories or product_category_details"
},
"limit": {
"type": "number",
"description": "keywords: 1–50"
},
"region": {
"type": "string",
"description": "keywords: US, CA, DE, FR, ES, IT, DE+AT+CH, GB+IE, IT+ES+PT+GR+MT, PL+RO+HU+SK+CZ, SE+DK+FI+NO, NL+BE+LU, AR, BR, CO, MX, MX+AR+CO+CL, AU+NZ. topics / product categories: US, GB+IE, CA. Default US; \"UK\" means GB+IE."
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"genders": {
"type": "array",
"items": {
"type": "string"
},
"description": "keywords: male, female, unknown. product categories: MALE, FEMALE, UNSPECIFIED"
},
"interest": {
"type": "string",
"description": "topics: ALL, ANIMALS, ARCHITECTURE, ART, BEAUTY, DIY_AND_CRAFTS, EDUCATION, EVENT_PLANNING, FASHION, FOOD_AND_DRINKS, GARDENING, HEALTH, HOME_DECOR, PARENTING, TRAVEL, WEDDING"
},
"interests": {
"type": "array",
"items": {
"type": "string"
},
"description": "keywords: animals, architecture, art, beauty, childrens_fashion, design, diy_and_crafts, education, electronics, entertainment, event_planning, finance, food_and_drinks, gardening, health, home_decor, mens_fashion, parenting, quotes, sport, travel, vehicles, wedding, womens_fashion"
},
"trendType": {
"enum": [
"growing",
"monthly",
"yearly",
"seasonal"
],
"type": "string",
"description": "keywords: default growing"
},
"verticals": {
"type": "array",
"items": {
"enum": [
"FASHION",
"HOME_DECOR",
"BEAUTY"
],
"type": "string"
},
"description": "shopping verticals for product categories: FASHION, HOME_DECOR or BEAUTY"
},
"engagementType": {
"enum": [
"ENGAGEMENT",
"OUTBOUND_CLICK",
"SAVE"
],
"type": "string",
"description": "engagement measure to report: ENGAGEMENT, OUTBOUND_CLICK or SAVE"
},
"lookbackWindow": {
"anyOf": [
{
"type": "number",
"const": 90
},
{
"type": "number",
"const": 180
},
{
"type": "number",
"const": 365
},
{
"type": "number",
"const": 730
}
],
"description": "trend lookback in days: 90, 180, 365 or 730"
},
"includeKeywords": {
"type": "array",
"items": {
"type": "string"
},
"description": "keywords: only trends containing one of these (max 50)"
},
"productCategories": {
"type": "array",
"items": {
"type": "string"
},
"description": "product_category_details: category names from product_categories, e.g. AREA_RUGS"
},
"includeDemographics": {
"type": "boolean",
"description": "true = include demographics in the result"
}
}
}| string |
| restrict to one channel |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"axis": {
"enum": [
"hook",
"subject",
"recipe",
"channel",
"media",
"hour"
],
"type": "string",
"description": "what to group by — default hook; recipe = the format of the creative"
},
"days": {
"type": "number",
"description": "look back N days (1-730) over the whole history; omit for the recent posts only"
},
"brand": {
"type": "string",
"description": "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": {
"type": "string",
"description": "restrict to one channel"
}
}
}| string |
| The post's angle |
| text* | string | The post, up to 300 characters / 3000 UTF-8 bytes. |
| 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. |
| langs | array | BCP-47 language tags, e.g. ['en']. |
| ideaId | string | short id of the content-plan idea this post came from |
| recipe | string | the post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format |
| 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 bluesky account connected (several and none named is refused by name, never guessed); omit when there is one. |
| altText | any | Alt text — an ARRAY, one per image in the same order, or a single STRING to describe every image with it. WRITE ONE: Bluesky’s own lexicon makes `alt` a REQUIRED property of every image, so a post without it is undescribed by design rather than by omission, and Bluesky users expect it. No maximum length is published, so nothing is truncated. |
| subject | string | what the post is about |
| captions | array | Up to 20 WebVTT caption tracks: [{lang:'en', url:'https://…/en.vtt'}] or [{lang:'en', content:'WEBVTT\n\n00:00…'}]. Each file is capped at 20000 bytes. |
| linkCard | any | Rich link card (`app.bsky.embed.external`). OMIT for the default (a card is built automatically when the post has a URL and no media). `false` never builds one. `true` builds one from the first URL in the text. An object {uri,title,description,thumbUrl} overrides any field — supply BOTH title and description and the page is never fetched. Cannot be combined with imageUrls/videoUrl: a post has ONE embed, so an explicit linkCard beside media is refused. |
| videoAlt | string | Alt text describing the video, for accessibility. |
| videoUrl | string | One public MP4 URL. Cannot be combined with imageUrls. Bluesky transcodes it, which takes a minute or two; a URL, not a local file path; use upload_file for local files |
| imageUrls | array | Up to 10 public image URLs to attach (1–4 a standard image post, 5–10 a Bluesky gallery; more than 10 is refused, never cut). Cannot be combined with videoUrl. |
| platformCover | boolean | VIDEO COVER. Bluesky has no cover setting and shows the video’s FIRST frame, so by default Hermoso checks it and, only when that frame is blank (a template ad’s empty opening card), sends Bluesky a copy with the first frame replaced by the video’s best frame. Your Library file is never changed. true = send the file exactly as it is. |
| allowDuplicate | boolean | post it even though an identical post was just made |
| idempotencyKey | string | any stable string: a repeat within 24h returns the original post instead of posting again |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"text"
],
"properties": {
"hook": {
"type": "string",
"description": "The post's angle"
},
"text": {
"type": "string",
"description": "The post, up to 300 characters / 3000 UTF-8 bytes."
},
"brand": {
"type": "string",
"description": "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."
},
"langs": {
"type": "array",
"items": {
"type": "string"
},
"description": "BCP-47 language tags, e.g. ['en']."
},
"ideaId": {
"type": "string",
"description": "short id of the content-plan idea this post came from"
},
"recipe": {
"type": "string",
"description": "the post's FORMAT id, e.g. \"slideshow\" or \"imessage_chat\" — post_performance groups by it, so reuse one id per format"
},
"account": {
"type": "string",
"description": "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 bluesky account connected (several and none named is refused by name, never guessed); omit when there is one."
},
"altText": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
],
"description": "Alt text — an ARRAY, one per image in the same order, or a single STRING to describe every image with it. WRITE ONE: Bluesky’s own lexicon makes `alt` a REQUIRED property of every image, so a post without it is undescribed by design rather than by omission, and Bluesky users expect it. No maximum length is published, so nothing is truncated."
},
"subject": {
"type": "string",
"description": "what the post is about"
},
"captions": {
"type": "array",
"items": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"description": "Up to 20 WebVTT caption tracks: [{lang:'en', url:'https://…/en.vtt'}] or [{lang:'en', content:'WEBVTT\\n\\n00:00…'}]. Each file is capped at 20000 bytes."
},
"linkCard": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
],
"description": "Rich link card (`app.bsky.embed.external`). OMIT for the default (a card is built automatically when the post has a URL and no media). `false` never builds one. `true` builds one from the first URL in the text. An object {uri,title,description,thumbUrl} overrides any field — supply BOTH title and description and the page is never fetched. Cannot be combined with imageUrls/videoUrl: a post has ONE embed, so an explicit linkCard beside media is refused."
},
"videoAlt": {
"type": "string",
"description": "Alt text describing the video, for accessibility."
},
"videoUrl": {
"type": "string",
"description": "One public MP4 URL. Cannot be combined with imageUrls. Bluesky transcodes it, which takes a minute or two; a URL, not a local file path; use upload_file for local files"
},
"imageUrls": {
"type": "array",
"items": {
"type": "string"
},
"description": "Up to 10 public image URLs to attach (1–4 a standard image post, 5–10 a Bluesky gallery; more than 10 is refused, never cut). Cannot be combined with videoUrl."
},
"platformCover": {
"type": "boolean",
"description": "VIDEO COVER. Bluesky has no cover setting and shows the video’s FIRST frame, so by default Hermoso checks it and, only when that frame is blank (a template ad’s empty opening card), sends Bluesky a copy with the first frame replaced by the video’s best frame. Your Library file is never changed. true = send the file exactly as it is."
},
"allowDuplicate": {
"type": "boolean",
"description": "post it even though an identical post was just made"
},
"idempotencyKey": {
"type": "string",
"description": "any stable string: a repeat within 24h returns the original post instead of posting again"
}
}
}| post without a notification |
| channel | string | HERMOSO BOT: channel id or #name (list_discord_channels); unset = the default channel |
| replyTo | string | HERMOSO BOT: reply to this message id |
| subject | string | what the post is about |
| imageUrl | string |
| mentions | boolean | let @everyone / @here / role mentions ping people (off by default) |
| threadId | string | HERMOSO BOT: post inside this existing thread |
| username | string | webhook posts only: a different display name than the webhook's own |
| videoUrl | string |
| imageUrls | array | up to 10 files in one message |
| threadName | string | required when the channel is a FORUM channel: the new thread title |
| startThread | string | HERMOSO BOT: start a thread under the new post with this title |
| discordServer | string | HERMOSO BOT: which Discord server (id or name) when the bot is in several |
| allowDuplicate | boolean | post it even though an identical post was just made |
| idempotencyKey | string | any stable string: a repeat within 24h returns the original post instead of posting again |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"hook": {
"type": "string",
"description": "The post's angle"
},
"text": {
"type": "string",
"description": "the announcement, ≤2000 characters"
},
"brand": {
"type": "string",
"description": "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."
},
"ideaId": {
"type": "string",
"description": "short id of the content-plan idea this post came from"
},
"recipe": {
"type": "string",
"description": "the post's FORMAT id, e.g. \"slideshow\" or \"imessage_chat\" — post_performance groups by it, so reuse one id per format"
},
"silent": {
"type": "boolean",
"description": "post without a notification"
},
"channel": {
"type": "string",
"description": "HERMOSO BOT: channel id or #name (list_discord_channels); unset = the default channel"
},
"replyTo": {
"type": "string",
"description": "HERMOSO BOT: reply to this message id"
},
"subject": {
"type": "string",
"description": "what the post is about"
},
"imageUrl": {
"type": "string"
},
"mentions": {
"type": "boolean",
"description": "let @everyone / @here / role mentions ping people (off by default)"
},
"threadId": {
"type": "string",
"description": "HERMOSO BOT: post inside this existing thread"
},
"username": {
"type": "string",
"description": "webhook posts only: a different display name than the webhook's own"
},
"videoUrl": {
"type": "string"
},
"imageUrls": {
"type": "array",
"items": {
"type": "string"
},
"description": "up to 10 files in one message"
},
"threadName": {
"type": "string",
"description": "required when the channel is a FORUM channel: the new thread title"
},
"startThread": {
"type": "string",
"description": "HERMOSO BOT: start a thread under the new post with this title"
},
"discordServer": {
"type": "string",
"description": "HERMOSO BOT: which Discord server (id or name) when the bot is in several"
},
"allowDuplicate": {
"type": "boolean",
"description": "post it even though an identical post was just made"
},
"idempotencyKey": {
"type": "string",
"description": "any stable string: a repeat within 24h returns the original post instead of posting again"
}
}
}| recipe | string | the post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format |
| endDate | string | YYYY-MM-DD, defaults to startDate |
| 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. |
| summary | string | the body text of the Post |
| imageUrl | string | a Hermoso render image URL (or an upload_file url) to show on the Post |
| startDate | string | YYYY-MM-DD — REQUIRED for EVENT and OFFER |
| topicType | string | default STANDARD |
| actionType | string | the button on the Post |
| couponCode | string | OFFER only |
| locationId | string | which listing, e.g. 'locations/123' from list_business_locations — only needed when the account manages more than one |
| languageCode | string | BCP-47 language of the Post, default 'en' |
| redeemOnlineUrl | string | OFFER only — this is the link Google actually uses on an offer |
| termsConditions | string | OFFER only |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"hook": {
"type": "string",
"description": "WHAT ANGLE this post is built on, recorded only at publish time. Omit it and this post never votes on which hook works."
},
"link": {
"type": "string",
"description": "the URL the button opens — not for CALL, and ignored on an OFFER"
},
"brand": {
"type": "string",
"description": "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."
},
"title": {
"type": "string",
"description": "headline — REQUIRED for EVENT and OFFER"
},
"ideaId": {
"type": "string",
"description": "short id of the content-plan idea this post came from"
},
"recipe": {
"type": "string",
"description": "the post's FORMAT id, e.g. \"slideshow\" or \"imessage_chat\" — post_performance groups by it, so reuse one id per format"
},
"endDate": {
"type": "string",
"description": "YYYY-MM-DD, defaults to startDate"
},
"subject": {
"type": "string",
"description": "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."
},
"summary": {
"type": "string",
"description": "the body text of the Post"
},
"imageUrl": {
"type": "string",
"description": "a Hermoso render image URL (or an upload_file url) to show on the Post"
},
"startDate": {
"type": "string",
"description": "YYYY-MM-DD — REQUIRED for EVENT and OFFER"
},
"topicType": {
"enum": [
"STANDARD",
"EVENT",
"OFFER",
"ALERT"
],
"type": "string",
"description": "default STANDARD"
},
"actionType": {
"enum": [
"BOOK",
"ORDER",
"SHOP",
"LEARN_MORE",
"SIGN_UP",
"CALL"
],
"type": "string",
"description": "the button on the Post"
},
"couponCode": {
"type": "string",
"description": "OFFER only"
},
"locationId": {
"type": "string",
"description": "which listing, e.g. 'locations/123' from list_business_locations — only needed when the account manages more than one"
},
"languageCode": {
"type": "string",
"description": "BCP-47 language of the Post, default 'en'"
},
"redeemOnlineUrl": {
"type": "string",
"description": "OFFER only — this is the link Google actually uses on an offer"
},
"termsConditions": {
"type": "string",
"description": "OFFER only"
}
}
}| short id of the content-plan idea this post came from |
| recipe | string | the post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format |
| altText | any | image alt text — a string for every image, or an array per slide |
| linkUrl | string | a LINK post with a real preview card. Not with media. |
| 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. |
| imageUrl | string | a Hermoso-hosted image URL to attach (≤12MB) — a Hermoso render, or ANY file of the user’s own put through upload_file first. An arbitrary external host is refused (we fetch the bytes ourselves); a URL, not a local file path; use upload_file for local files |
| mentions | array | TAG COMPANIES: write "@Name" in the text where each tag goes and list each company here — its vanity name (the part after linkedin.com/company/), its company page URL, or {organizationId, name}. Hermoso resolves it on LinkedIn and LinkedIn renders a real clickable mention; a company with no "@" spot in the text is refused rather than appended. |
| videoUrl | string | a Hermoso-hosted video (a render, or the user’s footage via upload_file). LinkedIn processes it before publishing, which takes a minute; a URL, not a local file path; use upload_file for local files |
| imageUrls | array | a MULTI-IMAGE post: 2–20 Hermoso-hosted images in order, one post the reader swipes through (LinkedIn’s MultiImage API takes a member author on w_member_social). Images only. |
| linkTitle | string | the card headline — LinkedIn never scrapes the page, so without it the card is unlabelled |
| visibility | string | default PUBLIC |
| captionsSrt | string | SRT caption CONTENT for a videoUrl post (English only, attached during upload) |
| documentUrl | string | a DOCUMENT post — a Hermoso-hosted PDF / PPTX / DOCX (upload_file first) that readers swipe through page by page in the feed; LinkedIn takes up to 100MB and 300 pages. One kind of media per post: not with an image, video, slides or linkUrl. |
| documentTitle | string | the title shown on the document (defaults to the file name) |
| 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. |
| idempotencyKey | string | SAFE RETRIES — any stable string: a repeat of the SAME publish within 24h returns the ORIGINAL post id instead of posting again (an identical publish is also auto-recognised for 10 minutes). On a timeout or an ambiguous error, CALL AGAIN WITH THE SAME KEY: it reports the original post or publishes it once. It never posts twice. |
| linkDescription | string | link card description; LinkedIn does not read the page, so set it here |
| linkThumbnailUrl | string | Hermoso-hosted image for the card |
| videoThumbnailUrl | string | cover image for a videoUrl post (Hermoso-hosted) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"text"
],
"properties": {
"hook": {
"type": "string",
"description": "WHAT ANGLE this post is built on, recorded only at publish time. Omit it and this post never votes on which hook works."
},
"text": {
"type": "string",
"description": "the post text"
},
"brand": {
"type": "string",
"description": "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."
},
"title": {
"type": "string",
"description": "video title"
},
"ideaId": {
"type": "string",
"description": "short id of the content-plan idea this post came from"
},
"recipe": {
"type": "string",
"description": "the post's FORMAT id, e.g. \"slideshow\" or \"imessage_chat\" — post_performance groups by it, so reuse one id per format"
},
"altText": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
],
"description": "image alt text — a string for every image, or an array per slide"
},
"linkUrl": {
"type": "string",
"description": "a LINK post with a real preview card. Not with media."
},
"subject": {
"type": "string",
"description": "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."
},
"imageUrl": {
"type": "string",
"description": "a Hermoso-hosted image URL to attach (≤12MB) — a Hermoso render, or ANY file of the user’s own put through upload_file first. An arbitrary external host is refused (we fetch the bytes ourselves); a URL, not a local file path; use upload_file for local files"
},
"mentions": {
"type": "array",
"items": {},
"description": "TAG COMPANIES: write \"@Name\" in the text where each tag goes and list each company here — its vanity name (the part after linkedin.com/company/), its company page URL, or {organizationId, name}. Hermoso resolves it on LinkedIn and LinkedIn renders a real clickable mention; a company with no \"@\" spot in the text is refused rather than appended."
},
"videoUrl": {
"type": "string",
"description": "a Hermoso-hosted video (a render, or the user’s footage via upload_file). LinkedIn processes it before publishing, which takes a minute; a URL, not a local file path; use upload_file for local files"
},
"imageUrls": {
"type": "array",
"items": {
"type": "string"
},
"description": "a MULTI-IMAGE post: 2–20 Hermoso-hosted images in order, one post the reader swipes through (LinkedIn’s MultiImage API takes a member author on w_member_social). Images only."
},
"linkTitle": {
"type": "string",
"description": "the card headline — LinkedIn never scrapes the page, so without it the card is unlabelled"
},
"visibility": {
"enum": [
"PUBLIC",
"CONNECTIONS"
],
"type": "string",
"description": "default PUBLIC"
},
"captionsSrt": {
"type": "string",
"description": "SRT caption CONTENT for a videoUrl post (English only, attached during upload)"
},
"documentUrl": {
"type": "string",
"description": "a DOCUMENT post — a Hermoso-hosted PDF / PPTX / DOCX (upload_file first) that readers swipe through page by page in the feed; LinkedIn takes up to 100MB and 300 pages. One kind of media per post: not with an image, video, slides or linkUrl."
},
"documentTitle": {
"type": "string",
"description": "the title shown on the document (defaults to the file name)"
},
"allowDuplicate": {
"type": "boolean",
"description": "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."
},
"idempotencyKey": {
"type": "string",
"description": "SAFE RETRIES — any stable string: a repeat of the SAME publish within 24h returns the ORIGINAL post id instead of posting again (an identical publish is also auto-recognised for 10 minutes). On a timeout or an ambiguous error, CALL AGAIN WITH THE SAME KEY: it reports the original post or publishes it once. It never posts twice."
},
"linkDescription": {
"type": "string",
"description": "link card description; LinkedIn does not read the page, so set it here"
},
"linkThumbnailUrl": {
"type": "string",
"description": "Hermoso-hosted image for the card"
},
"videoThumbnailUrl": {
"type": "string",
"description": "cover image for a videoUrl post (Hermoso-hosted)"
}
}
}| string |
| the post text |
| 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. |
| title | string | video title |
| ideaId | string | short id of the content-plan idea this post came from |
| recipe | string | the post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format |
| altText | any | accessibility alt text (max 4086 characters, ~120 recommended). A STRING describes every image; an ARRAY describes each slide of a multi-image post separately, in slide order — LinkedIn stores altText per image, and their own sample request carries a different one on each. Also works on a personal-profile post (post_to_linkedin). |
| linkUrl | string | publish a LINK POST — LinkedIn renders a real preview card for this URL instead of leaving a bare link in the text. Mutually exclusive with imageUrl / videoUrl / imageUrls: LinkedIn’s content field is a union, so combining them is refused by name rather than one being dropped. |
| 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. |
| imageUrl | string | a Hermoso-hosted image URL — a render (list_library), or ANY image of the user’s own passed through upload_file first. An arbitrary external host is refused. |
| mentions | array | TAG COMPANIES: write "@Name" in the text where each tag goes and list each company here — its vanity name (the part after linkedin.com/company/), its company page URL, or {organizationId, name}. Hermoso resolves it on LinkedIn and LinkedIn renders a real clickable mention; a company with no "@" spot in the text is refused rather than appended. |
| videoUrl | string | a Hermoso-hosted video URL — a render, or the user’s own footage via upload_file. LinkedIn processes it before publishing, which takes a minute. |
| coverAtMs | number | THE VIDEO COVER of a videoUrl post, as ONE frame: milliseconds from the start (7000 = the frame at 7s). That frame is uploaded as LinkedIn’s video thumbnail (only possible while the video uploads). videoThumbnailUrl wins. |
| imageUrls | array | CAROUSEL — an ORDERED list of image (and, where the channel allows, video) URLs published as ONE post the viewer swipes through. THIS IS NOT “post several” — it is a single post with several slides, which is what a multi-slide creative (a listicle, a “1/6 · SWIPE” deck) actually needs; publishing only its first slide tells the viewer to swipe at something that cannot. The ORDER is the product. Limits per channel: Instagram 2–20 (images, videos or a mix), Threads 2–20 (mix allowed), Facebook 2+ (Meta publishes no documented maximum; Hermoso caps the upload fan-out at 30 and says so), LinkedIn company Pages 2–20 (images only), Pinterest 2–5 (images only), TikTok up to 35. One url here is simply an ordinary single post. Anything a channel cannot do is REFUSED with the real reason — nothing is ever quietly downgraded to one slide. |
| linkTitle | string | the headline ON the preview card. LINKEDIN NEVER SCRAPES THE PAGE — their Posts API disables URL scraping for API partners outright — so if you do not pass this the card renders UNLABELLED. Fetch the page’s own title and pass it. |
| visibility | string | default PUBLIC |
| captionsSrt | string | CLOSED CAPTIONS for a videoUrl post — the SubRip (.srt) CONTENT itself, cue numbers and `00:00:00,000 --> 00:00:02,000` timing lines included, NOT a URL and NOT the plain script (a file with no timings is refused, because LinkedIn would accept it and then silently never show it). Most of LinkedIn is watched with the sound off, so an uncaptioned video is one most of the feed never hears. LinkedIn allows ONE caption file per video and ENGLISH ONLY; it can be attached only WHILE the video is uploaded, never added to a published post; and it is processed asynchronously, so the reply confirms it was UPLOADED and never that it is visible yet. Requires videoUrl — passing it on an image, carousel or link post is refused by name. |
| documentUrl | string | a DOCUMENT post — a Hermoso-hosted PDF / PPTX / DOCX (upload_file first) that readers swipe through page by page in the feed; LinkedIn takes up to 100MB and 300 pages. One kind of media per post: not with an image, video, slides or linkUrl. |
| firstComment | string | FIRST COMMENT — posted on the new post AS THE PAGE right after it publishes. Needs the LinkedIn comments authorization on this brand (provider linkedin_community). Up to 2,000 characters, refused rather than cut. If the comment fails the post stays live and firstComment in the result says why. |
| documentTitle | string | the title shown on the document (defaults to the file name) |
| 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 (LinkedIn’s video thumbnail upload). true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins. |
| 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. |
| idempotencyKey | string | SAFE RETRIES — any stable string: a repeat of the SAME publish within 24h returns the ORIGINAL post id instead of posting again (an identical publish is also auto-recognised for 10 minutes). On a timeout or an ambiguous error, CALL AGAIN WITH THE SAME KEY: it reports the original post or publishes it once. It never posts twice. |
| organizationId | string | numeric Page id from list_linkedin_pages |
| targetAudience | object | LINKEDIN COMPANY PAGE POST ONLY — show the post only to Page followers matching these facets. LinkedIn requires the matching audience to be over 300 followers and refuses a smaller one. Personal-profile posts cannot be targeted. |
| linkDescription | string | the sub-line on the preview card. Same rule as linkTitle: absent means blank, because LinkedIn will not fetch it. |
| linkThumbnailUrl | string | a Hermoso-hosted image used as the card’s picture (uploaded to LinkedIn for you). Without it the card has no image. |
| videoThumbnailUrl | string | the COVER IMAGE for a videoUrl post — a Hermoso-hosted image (a render, or any picture of the user’s via upload_file). Without it LinkedIn adds a system-generated thumbnail, which on an ad is usually whatever the first frame happens to be. Like captions this can only be set WHILE the video is uploaded, never afterwards. Requires videoUrl. This is NOT linkThumbnailUrl, which is the picture on a link-preview card. |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"text"
],
"properties": {
"hook": {
"type": "string",
"description": "WHAT ANGLE this post is built on, recorded only at publish time. Omit it and this post never votes on which hook works."
},
"text": {
"type": "string",
"description": "the post text"
},
"brand": {
"type": "string",
"description": "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."
},
"title": {
"type": "string",
"description": "video title"
},
"ideaId": {
"type": "string",
"description": "short id of the content-plan idea this post came from"
},
"recipe": {
"type": "string",
"description": "the post's FORMAT id, e.g. \"slideshow\" or \"imessage_chat\" — post_performance groups by it, so reuse one id per format"
},
"altText": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
],
"description": "accessibility alt text (max 4086 characters, ~120 recommended). A STRING describes every image; an ARRAY describes each slide of a multi-image post separately, in slide order — LinkedIn stores altText per image, and their own sample request carries a different one on each. Also works on a personal-profile post (post_to_linkedin)."
},
"linkUrl": {
"type": "string",
"description": "publish a LINK POST — LinkedIn renders a real preview card for this URL instead of leaving a bare link in the text. Mutually exclusive with imageUrl / videoUrl / imageUrls: LinkedIn’s content field is a union, so combining them is refused by name rather than one being dropped."
},
"subject": {
"type": "string",
"description": "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."
},
"imageUrl": {
"type": "string",
"description": "a Hermoso-hosted image URL — a render (list_library), or ANY image of the user’s own passed through upload_file first. An arbitrary external host is refused."
},
"mentions": {
"type": "array",
"items": {},
"description": "TAG COMPANIES: write \"@Name\" in the text where each tag goes and list each company here — its vanity name (the part after linkedin.com/company/), its company page URL, or {organizationId, name}. Hermoso resolves it on LinkedIn and LinkedIn renders a real clickable mention; a company with no \"@\" spot in the text is refused rather than appended."
},
"videoUrl": {
"type": "string",
"description": "a Hermoso-hosted video URL — a render, or the user’s own footage via upload_file. LinkedIn processes it before publishing, which takes a minute."
},
"coverAtMs": {
"type": "number",
"description": "THE VIDEO COVER of a videoUrl post, as ONE frame: milliseconds from the start (7000 = the frame at 7s). That frame is uploaded as LinkedIn’s video thumbnail (only possible while the video uploads). videoThumbnailUrl wins."
},
"imageUrls": {
"type": "array",
"items": {
"type": "string"
},
"description": "CAROUSEL — an ORDERED list of image (and, where the channel allows, video) URLs published as ONE post the viewer swipes through. THIS IS NOT “post several” — it is a single post with several slides, which is what a multi-slide creative (a listicle, a “1/6 · SWIPE” deck) actually needs; publishing only its first slide tells the viewer to swipe at something that cannot. The ORDER is the product. Limits per channel: Instagram 2–20 (images, videos or a mix), Threads 2–20 (mix allowed), Facebook 2+ (Meta publishes no documented maximum; Hermoso caps the upload fan-out at 30 and says so), LinkedIn company Pages 2–20 (images only), Pinterest 2–5 (images only), TikTok up to 35. One url here is simply an ordinary single post. Anything a channel cannot do is REFUSED with the real reason — nothing is ever quietly downgraded to one slide."
},
"linkTitle": {
"type": "string",
"description": "the headline ON the preview card. LINKEDIN NEVER SCRAPES THE PAGE — their Posts API disables URL scraping for API partners outright — so if you do not pass this the card renders UNLABELLED. Fetch the page’s own title and pass it."
},
"visibility": {
"enum": [
"PUBLIC",
"CONNECTIONS"
],
"type": "string",
"description": "default PUBLIC"
},
"captionsSrt": {
"type": "string",
"description": "CLOSED CAPTIONS for a videoUrl post — the SubRip (.srt) CONTENT itself, cue numbers and `00:00:00,000 --> 00:00:02,000` timing lines included, NOT a URL and NOT the plain script (a file with no timings is refused, because LinkedIn would accept it and then silently never show it). Most of LinkedIn is watched with the sound off, so an uncaptioned video is one most of the feed never hears. LinkedIn allows ONE caption file per video and ENGLISH ONLY; it can be attached only WHILE the video is uploaded, never added to a published post; and it is processed asynchronously, so the reply confirms it was UPLOADED and never that it is visible yet. Requires videoUrl — passing it on an image, carousel or link post is refused by name."
},
"documentUrl": {
"type": "string",
"description": "a DOCUMENT post — a Hermoso-hosted PDF / PPTX / DOCX (upload_file first) that readers swipe through page by page in the feed; LinkedIn takes up to 100MB and 300 pages. One kind of media per post: not with an image, video, slides or linkUrl."
},
"firstComment": {
"type": "string",
"description": "FIRST COMMENT — posted on the new post AS THE PAGE right after it publishes. Needs the LinkedIn comments authorization on this brand (provider linkedin_community). Up to 2,000 characters, refused rather than cut. If the comment fails the post stays live and firstComment in the result says why."
},
"documentTitle": {
"type": "string",
"description": "the title shown on the document (defaults to the file name)"
},
"platformCover": {
"type": "boolean",
"description": "VIDEO COVER. Omit it (the default) and Hermoso sets the video’s best frame — the same frame as its Library thumbnail — as the cover (LinkedIn’s video thumbnail upload). true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins."
},
"allowDuplicate": {
"type": "boolean",
"description": "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."
},
"idempotencyKey": {
"type": "string",
"description": "SAFE RETRIES — any stable string: a repeat of the SAME publish within 24h returns the ORIGINAL post id instead of posting again (an identical publish is also auto-recognised for 10 minutes). On a timeout or an ambiguous error, CALL AGAIN WITH THE SAME KEY: it reports the original post or publishes it once. It never posts twice."
},
"organizationId": {
"type": "string",
"description": "numeric Page id from list_linkedin_pages"
},
"targetAudience": {
"type": "object",
"properties": {
"degrees": {
"type": "array",
"items": {
"type": "string"
},
"description": "education degree values to narrow the followers shown the post; company Page posts only"
},
"industries": {
"type": "array",
"items": {
"type": "string"
},
"description": "LinkedIn industry URNs or ids to narrow the followers shown the post; company Page posts only"
},
"seniorities": {
"type": "array",
"items": {
"type": "string"
},
"description": "LinkedIn seniority URNs or ids to narrow the followers shown the post; company Page posts only"
},
"geoLocations": {
"type": "array",
"items": {
"type": "string"
},
"description": "LinkedIn geo URNs or ids of Page followers to show the post to; company Page posts only"
},
"jobFunctions": {
"type": "array",
"items": {
"type": "string"
},
"description": "LinkedIn job function URNs or ids to narrow the followers shown the post; company Page posts only"
},
"fieldsOfStudy": {
"type": "array",
"items": {
"type": "string"
},
"description": "fields of study to narrow the followers shown the post; company Page posts only"
},
"organizations": {
"type": "array",
"items": {
"type": "string"
},
"description": "organization URNs or ids to narrow the followers shown the post; company Page posts only"
},
"staffCountRanges": {
"type": "array",
"items": {
"enum": [
"SIZE_1",
"SIZE_2_TO_10",
"SIZE_11_TO_50",
"SIZE_51_TO_200",
"SIZE_201_TO_500",
"SIZE_501_TO_1000",
"SIZE_1001_TO_5000",
"SIZE_5001_TO_10000",
"SIZE_10001_OR_MORE"
],
"type": "string"
},
"description": "company size bands to narrow the followers shown the post; company Page posts only"
}
},
"description": "LINKEDIN COMPANY PAGE POST ONLY — show the post only to Page followers matching these facets. LinkedIn requires the matching audience to be over 300 followers and refuses a smaller one. Personal-profile posts cannot be targeted."
},
"linkDescription": {
"type": "string",
"description": "the sub-line on the preview card. Same rule as linkTitle: absent means blank, because LinkedIn will not fetch it."
},
"linkThumbnailUrl": {
"type": "string",
"description": "a Hermoso-hosted image used as the card’s picture (uploaded to LinkedIn for you). Without it the card has no image."
},
"videoThumbnailUrl": {
"type": "string",
"description": "the COVER IMAGE for a videoUrl post — a Hermoso-hosted image (a render, or any picture of the user’s via upload_file). Without it LinkedIn adds a system-generated thumbnail, which on an ad is usually whatever the first frame happens to be. Like captions this can only be set WHILE the video is uploaded, never afterwards. Requires videoUrl. This is NOT linkThumbnailUrl, which is the picture on a link-preview card."
}
}
}| 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. |
| place | string | FACEBOOK — tag a location on the Page post. |
| story | boolean | INSTAGRAM OR FACEBOOK PAGE STORY — publish this as a 24-hour Story instead of a feed post. One image OR one video: Instagram has no carousel story, so a carousel is REFUSED BY NAME rather than quietly posted to the feed. Link stickers are not available on a Story posted here. An Instagram Story keeps its caption: people see the start under the Story in the mobile app and tap for the rest. It takes NO collaborators, product tags or trialReel — any of those is refused by name and nothing is posted. FACEBOOK PAGE STORIES WORK TOO (target facebook): one image, or one vertical 9:16 video of 3–90 seconds, no caption (a message is refused by name), not schedulable natively — use schedule_post to queue one; the reply reads the story back from the Page. |
| ideaId | string | short id of the content-plan idea this post came from |
| pageId | string | target Page id (from list_meta_pages). Omit when the brand has one Page. With several and none named the call refuses and lists them by name and id: ask the user which Page, never pick one yourself. |
| recipe | string | the post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format |
| target | string | 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. |
| altText | any | ACCESSIBILITY — the description screen readers announce, and what the platform otherwise auto-generates badly or not at all. Describe what is actually IN the picture, never the caption. ONE STRING describes the picture; on a CAROUSEL it describes EVERY slide. Pass an ARRAY of strings instead to describe each slide separately, aligned to the slide order — use that on a multi-slide post, because one sentence read out over six different pictures is wrong for five of them. More descriptions than pictures is refused rather than dropped. WHERE IT LANDS, per Meta’s own docs: INSTAGRAM image posts and the IMAGE slides of an Instagram carousel (up to 1000 characters; dropped rather than sent on a Reel or a video slide, which Meta do not support); FACEBOOK Page photos including every photo of an album, via alt_text_custom (Meta publish no length for it, so nothing is truncated); THREADS on a single-image or single-video post ONLY — Meta document no way to attach alt text to a Threads CAROUSEL slide, so a Threads carousel publishes undescribed and the reply says so rather than risking the whole post on a guess. (Meta’s AI disclosure defaults from PROVENANCE: a Hermoso render is declared is_ai_generated; media that came through upload_file or an external URL, i.e. the user’s own photos or footage, is NOT. Pass `aiGenerated` to force it either way.) |
| audioId | string | INSTAGRAM REEL — put one of Instagram’s OWN licensed music tracks under the Reel: the `id` search_instagram_audio returns. Instagram mixes it in when the Reel is published; nothing is downloaded or re-rendered. REELS ONLY, and only on an Instagram account connected through Meta (a Facebook Page with a linked Instagram), which is Instagram’s own rule — the Instagram connector cannot take it and is refused by name. |
| message | string | post text / caption |
| 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. |
| 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. |
| 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. |
| imageUrl | string | public https URL, a data: URI, or a Hermoso /generated path (upload_file gives you one for a local file); a URL, not a local file path; use upload_file for local files |
| 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. |
| topicTag | string | THREADS ONLY — one topic tag for discovery, 1–50 characters. A leading # is stripped for you; Threads refuses "." and "&". |
| userTags | array | INSTAGRAM USER TAGS — tag people (public accounts) on the post; each is notified. Usernames, or { username, x, y } with x/y from 0 to 1 = where the tag sits on the IMAGE (a tag without a position sits at the centre). A Reel tag takes no position; a story position is optional. Not on a carousel (refused by name). |
| videoUrl | string | public https URL, data: URI, or /generated path — FB video post / IG Reel; a URL, not a local file path; use upload_file for local files |
| audioName | string | INSTAGRAM REEL — the name of the Reel’s audio track, which is what viewers tap through to. REELS ONLY. |
| coverAtMs | number | THE VIDEO COVER for every target of this post, as ONE frame: milliseconds from the start (7000 = the frame at 7s). Instagram gets it as thumb_offset, Facebook as its uploaded cover (a Reel’s preferred thumbnail). Instagram’s own thumbOffset, or a Hermoso-hosted coverUrl, also becomes the Facebook cover. |
| imageUrls | array | CAROUSEL — an ORDERED list of image (and, where the channel allows, video) URLs published as ONE post the viewer swipes through. THIS IS NOT “post several” — it is a single post with several slides, which is what a multi-slide creative (a listicle, a “1/6 · SWIPE” deck) actually needs; publishing only its first slide tells the viewer to swipe at something that cannot. The ORDER is the product. Limits per channel: Instagram 2–20 (images, videos or a mix), Threads 2–20 (mix allowed), Facebook 2+ (Meta publishes no documented maximum; Hermoso caps the upload fan-out at 30 and says so), LinkedIn company Pages 2–20 (images only), Pinterest 2–5 (images only), TikTok up to 35. One url here is simply an ordinary single post. Anything a channel cannot do is REFUSED with the real reason — nothing is ever quietly downgraded to one slide. |
| placeName | string | the name the user saw for that place; a different business is refused |
| trialReel | string | 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. |
| locationId | string | TAG A PLACE. THREADS: a place id from search_threads_locations. A non-numeric value is refused rather than sent. |
| 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. |
| threadsGif | string | THREADS ONLY — a GIPHY GIF id (the id in a giphy.com/gifs/… link) attached to a TEXT Threads post. GIPHY is the only provider Threads takes. |
| aiGenerated | boolean | INSTAGRAM / FACEBOOK REEL / THREADS — Meta’s is_ai_generated self-disclosure (Threads: is_made_with_ai, the AI info label; a text-only Threads post is declared only when you pass true). OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, media that came through upload_file or from an external URL (the user’s own photographs or footage) is NOT — a real photo must never carry Instagram’s “AI info” label. Pass true or false only to override: false strips the label from something Hermoso would otherwise declare, true declares a render the user uploaded themselves. |
| audioVolume | integer | INSTAGRAM REEL — how loud that track plays, 0–100 (Instagram’s default 100). Needs audioId. |
| 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. |
| productTags | array | 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. |
| quotePostId | string | THREADS ONLY — the id of a post to QUOTE. A quote post is a distinct post type, not a formatting option. |
| 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. |
| 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. |
| videoVolume | integer | INSTAGRAM REEL — how loud the video’s own sound plays under the track, 0–100 (Instagram’s default 100; 0 = the track alone). Needs audioId. |
| callToAction | string | 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. |
| countryCodes | array | THREADS ONLY — restrict the post to these ISO 3166-1 alpha-2 countries. Warning: This is an ALLOWLIST, so the post becomes INVISIBLE in every country not named — it narrows reach, it does not target. |
| firstComment | string | FIRST COMMENT — posted on the new post as the same account right after it publishes (Facebook or Instagram; not Threads, not a story, not an unpublished post). Up to 2,000 characters, refused rather than cut. If the comment fails the post stays live and firstComment in the result says why. |
| replyControl | string | THREADS ONLY — who may reply. Default is everyone. |
| collaborators | array | INSTAGRAM COLLAB — up to 3 Instagram usernames invited to CO-AUTHOR this post. Once one accepts, the post appears on THEIR profile too, with both handles in the header and the likes and comments shared. Pass handles only ("hermosoai"), not profile links; a leading @ is fine. INSTAGRAM ONLY — Facebook and Threads have no collab post at all and are refused BY NAME rather than silently dropping the co-authors — and never on a Story. AN INVITE IS NOT A CO-POST: publishing SENDS a request the other account must accept in their Instagram notifications, and until they do the post is on this brand’s profile ALONE; they may also decline, and Instagram sends no notification either way. So never report the post as live on both accounts — read the invite status back out of the reply, and use instagram_collaborators later to find out whether they accepted. |
| instagramPoll | array | INSTAGRAM — attach a POLL: 2 to 4 answers of 1–25 characters each, and the caption IS the question (so a caption is required). Feed photos, carousels and Reels only (never a story), one caption add-on per post (not with instagramCommentPrompt), and only on an account connected through Meta (a Facebook Page with a linked Instagram). WRITE-ONCE: Instagram cannot add, change or remove it after publishing, so show the user the answers first. |
| 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 (Instagram Reel: thumb_offset; Facebook video/Reel: an uploaded cover image) — and on Threads, which has no cover setting, a blank first frame is replaced on a copy sent to Threads only. true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins. |
| 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. |
| idempotencyKey | string | SAFE RETRIES — any stable string: a repeat of the SAME publish within 24h returns the ORIGINAL post id instead of posting again (an identical publish is also auto-recognised for 10 minutes). On a timeout or an ambiguous error, CALL AGAIN WITH THE SAME KEY: it reports the original post or publishes it once. It never posts twice. |
| 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. |
| 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. |
| paidPartnership | boolean | INSTAGRAM — the PAID PARTNERSHIP label. A COMPLIANCE DECLARATION: set it whenever the post is sponsored, gifted or otherwise paid for. Opt-in and never inferred — it is the poster’s own statement about their commercial relationship. |
| threadsLongText | string | THREADS ONLY — LONG-FORM text, up to 10,000 characters, attached under a TEXT-only Threads post (Threads' text attachment). The way to publish more than Threads' 500-character post: keep a short lead in the message and put the full text here. Not with an image, video or carousel. |
| callToActionLink | string | FACEBOOK — where the button goes, when that is not the post’s own `link`. |
| 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. |
| threadsGhostPost | boolean | THREADS ONLY — a GHOST post: text only, Threads archives it automatically after 24 hours (a flash offer, a 24h-only note). No other Threads option can ride it. |
| 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. |
| instagramPollExtended | boolean | INSTAGRAM — give that poll Instagram’s extended voting duration instead of its default 3 days (Instagram’s changelog says it then stays open indefinitely). Only with instagramPoll. |
| instagramCommentPrompt | boolean | INSTAGRAM — make the caption a COMMENT PROMPT that people answer in the comments. Same limits as instagramPoll, and never together with it. |
| brandedContentSponsorIds | array | 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. |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"hook": {
"type": "string",
"description": "WHAT ANGLE this post is built on, recorded only at publish time. Omit it and this post never votes on which hook works."
},
"link": {
"type": "string",
"description": "a URL to attach (FB text post only)"
},
"async": {
"type": "boolean",
"description": "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."
},
"brand": {
"type": "string",
"description": "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."
},
"place": {
"type": "string",
"description": "FACEBOOK — tag a location on the Page post."
},
"story": {
"type": "boolean",
"description": "INSTAGRAM OR FACEBOOK PAGE STORY — publish this as a 24-hour Story instead of a feed post. One image OR one video: Instagram has no carousel story, so a carousel is REFUSED BY NAME rather than quietly posted to the feed. Link stickers are not available on a Story posted here. An Instagram Story keeps its caption: people see the start under the Story in the mobile app and tap for the rest. It takes NO collaborators, product tags or trialReel — any of those is refused by name and nothing is posted. FACEBOOK PAGE STORIES WORK TOO (target facebook): one image, or one vertical 9:16 video of 3–90 seconds, no caption (a message is refused by name), not schedulable natively — use schedule_post to queue one; the reply reads the story back from the Page."
},
"ideaId": {
"type": "string",
"description": "short id of the content-plan idea this post came from"
},
"pageId": {
"type": "string",
"description": "target Page id (from list_meta_pages). Omit when the brand has one Page. With several and none named the call refuses and lists them by name and id: ask the user which Page, never pick one yourself."
},
"recipe": {
"type": "string",
"description": "the post's FORMAT id, e.g. \"slideshow\" or \"imessage_chat\" — post_performance groups by it, so reuse one id per format"
},
"target": {
"enum": [
"facebook",
"instagram",
"threads"
],
"type": "string",
"description": "default facebook; instagram -> the Page’s linked IG; threads -> the brand’s connected Threads account"
},
"account": {
"type": "string",
"description": "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."
},
"altText": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
],
"description": "ACCESSIBILITY — the description screen readers announce, and what the platform otherwise auto-generates badly or not at all. Describe what is actually IN the picture, never the caption. ONE STRING describes the picture; on a CAROUSEL it describes EVERY slide. Pass an ARRAY of strings instead to describe each slide separately, aligned to the slide order — use that on a multi-slide post, because one sentence read out over six different pictures is wrong for five of them. More descriptions than pictures is refused rather than dropped. WHERE IT LANDS, per Meta’s own docs: INSTAGRAM image posts and the IMAGE slides of an Instagram carousel (up to 1000 characters; dropped rather than sent on a Reel or a video slide, which Meta do not support); FACEBOOK Page photos including every photo of an album, via alt_text_custom (Meta publish no length for it, so nothing is truncated); THREADS on a single-image or single-video post ONLY — Meta document no way to attach alt text to a Threads CAROUSEL slide, so a Threads carousel publishes undescribed and the reply says so rather than risking the whole post on a guess. (Meta’s AI disclosure defaults from PROVENANCE: a Hermoso render is declared is_ai_generated; media that came through upload_file or an external URL, i.e. the user’s own photos or footage, is NOT. Pass `aiGenerated` to force it either way.)"
},
"audioId": {
"type": "string",
"description": "INSTAGRAM REEL — put one of Instagram’s OWN licensed music tracks under the Reel: the `id` search_instagram_audio returns. Instagram mixes it in when the Reel is published; nothing is downloaded or re-rendered. REELS ONLY, and only on an Instagram account connected through Meta (a Facebook Page with a linked Instagram), which is Instagram’s own rule — the Instagram connector cannot take it and is refused by name."
},
"message": {
"type": "string",
"description": "post text / caption"
},
"subject": {
"type": "string",
"description": "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."
},
"audience": {
"type": "object",
"properties": {
"cities": {
"type": "array",
"items": {
"type": "string"
},
"description": "Meta location keys for cities"
},
"minAge": {
"anyOf": [
{
"type": "number",
"const": 13
},
{
"type": "number",
"const": 15
},
{
"type": "number",
"const": 18
},
{
"type": "number",
"const": 21
},
{
"type": "number",
"const": 25
}
],
"description": "Facebook Page post audience: minimum age, one of 13, 15, 18, 21 or 25"
},
"regions": {
"type": "array",
"items": {
"type": "string"
},
"description": "Meta location keys for regions/states"
},
"countries": {
"type": "array",
"items": {
"type": "string"
},
"description": "two-letter codes, e.g. [\"CA\",\"US\"]"
}
},
"description": "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."
},
"coverUrl": {
"type": "string",
"description": "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."
},
"imageUrl": {
"type": "string",
"description": "public https URL, a data: URI, or a Hermoso /generated path (upload_file gives you one for a local file); a URL, not a local file path; use upload_file for local files"
},
"linkName": {
"type": "string",
"description": "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."
},
"topicTag": {
"type": "string",
"description": "THREADS ONLY — one topic tag for discovery, 1–50 characters. A leading # is stripped for you; Threads refuses \".\" and \"&\"."
},
"userTags": {
"type": "array",
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "object",
"required": [
"username"
],
"properties": {
"x": {
"type": "number",
"maximum": 1,
"minimum": 0
},
"y": {
"type": "number",
"maximum": 1,
"minimum": 0
},
"username": {
"type": "string"
}
}
}
]
},
"description": "INSTAGRAM USER TAGS — tag people (public accounts) on the post; each is notified. Usernames, or { username, x, y } with x/y from 0 to 1 = where the tag sits on the IMAGE (a tag without a position sits at the centre). A Reel tag takes no position; a story position is optional. Not on a carousel (refused by name)."
},
"videoUrl": {
"type": "string",
"description": "public https URL, data: URI, or /generated path — FB video post / IG Reel; a URL, not a local file path; use upload_file for local files"
},
"audioName": {
"type": "string",
"description": "INSTAGRAM REEL — the name of the Reel’s audio track, which is what viewers tap through to. REELS ONLY."
},
"coverAtMs": {
"type": "number",
"description": "THE VIDEO COVER for every target of this post, as ONE frame: milliseconds from the start (7000 = the frame at 7s). Instagram gets it as thumb_offset, Facebook as its uploaded cover (a Reel’s preferred thumbnail). Instagram’s own thumbOffset, or a Hermoso-hosted coverUrl, also becomes the Facebook cover."
},
"imageUrls": {
"type": "array",
"items": {
"type": "string"
},
"description": "CAROUSEL — an ORDERED list of image (and, where the channel allows, video) URLs published as ONE post the viewer swipes through. THIS IS NOT “post several” — it is a single post with several slides, which is what a multi-slide creative (a listicle, a “1/6 · SWIPE” deck) actually needs; publishing only its first slide tells the viewer to swipe at something that cannot. The ORDER is the product. Limits per channel: Instagram 2–20 (images, videos or a mix), Threads 2–20 (mix allowed), Facebook 2+ (Meta publishes no documented maximum; Hermoso caps the upload fan-out at 30 and says so), LinkedIn company Pages 2–20 (images only), Pinterest 2–5 (images only), TikTok up to 35. One url here is simply an ordinary single post. Anything a channel cannot do is REFUSED with the real reason — nothing is ever quietly downgraded to one slide."
},
"placeName": {
"type": "string",
"description": "the name the user saw for that place; a different business is refused"
},
"trialReel": {
"enum": [
"MANUAL",
"SS_PERFORMANCE"
],
"type": "string",
"description": "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."
},
"locationId": {
"type": "string",
"description": "TAG A PLACE. THREADS: a place id from search_threads_locations. A non-numeric value is refused rather than sent."
},
"scheduleAt": {
"type": "string",
"description": "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."
},
"threadsGif": {
"type": "string",
"description": "THREADS ONLY — a GIPHY GIF id (the id in a giphy.com/gifs/… link) attached to a TEXT Threads post. GIPHY is the only provider Threads takes."
},
"aiGenerated": {
"type": "boolean",
"description": "INSTAGRAM / FACEBOOK REEL / THREADS — Meta’s is_ai_generated self-disclosure (Threads: is_made_with_ai, the AI info label; a text-only Threads post is declared only when you pass true). OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, media that came through upload_file or from an external URL (the user’s own photographs or footage) is NOT — a real photo must never carry Instagram’s “AI info” label. Pass true or false only to override: false strips the label from something Hermoso would otherwise declare, true declares a render the user uploaded themselves."
},
"audioVolume": {
"type": "integer",
"maximum": 100,
"minimum": 0,
"description": "INSTAGRAM REEL — how loud that track plays, 0–100 (Instagram’s default 100). Needs audioId."
},
"linkPicture": {
"type": "string",
"description": "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."
},
"productTags": {
"type": "array",
"items": {},
"description": "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."
},
"quotePostId": {
"type": "string",
"description": "THREADS ONLY — the id of a post to QUOTE. A quote post is a distinct post type, not a formatting option."
},
"shareToFeed": {
"type": "boolean",
"description": "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."
},
"thumbOffset": {
"type": "number",
"description": "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."
},
"videoVolume": {
"type": "integer",
"maximum": 100,
"minimum": 0,
"description": "INSTAGRAM REEL — how loud the video’s own sound plays under the track, 0–100 (Instagram’s default 100; 0 = the track alone). Needs audioId."
},
"callToAction": {
"enum": [
"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"
],
"type": "string",
"description": "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."
},
"countryCodes": {
"type": "array",
"items": {
"type": "string"
},
"description": "THREADS ONLY — restrict the post to these ISO 3166-1 alpha-2 countries. Warning: This is an ALLOWLIST, so the post becomes INVISIBLE in every country not named — it narrows reach, it does not target."
},
"firstComment": {
"type": "string",
"description": "FIRST COMMENT — posted on the new post as the same account right after it publishes (Facebook or Instagram; not Threads, not a story, not an unpublished post). Up to 2,000 characters, refused rather than cut. If the comment fails the post stays live and firstComment in the result says why."
},
"replyControl": {
"enum": [
"everyone",
"accounts_you_follow",
"mentioned_only",
"parent_post_author_only",
"followers_only"
],
"type": "string",
"description": "THREADS ONLY — who may reply. Default is everyone."
},
"collaborators": {
"type": "array",
"items": {
"type": "string"
},
"description": "INSTAGRAM COLLAB — up to 3 Instagram usernames invited to CO-AUTHOR this post. Once one accepts, the post appears on THEIR profile too, with both handles in the header and the likes and comments shared. Pass handles only (\"hermosoai\"), not profile links; a leading @ is fine. INSTAGRAM ONLY — Facebook and Threads have no collab post at all and are refused BY NAME rather than silently dropping the co-authors — and never on a Story. AN INVITE IS NOT A CO-POST: publishing SENDS a request the other account must accept in their Instagram notifications, and until they do the post is on this brand’s profile ALONE; they may also decline, and Instagram sends no notification either way. So never report the post as live on both accounts — read the invite status back out of the reply, and use instagram_collaborators later to find out whether they accepted."
},
"instagramPoll": {
"type": "array",
"items": {
"type": "string"
},
"description": "INSTAGRAM — attach a POLL: 2 to 4 answers of 1–25 characters each, and the caption IS the question (so a caption is required). Feed photos, carousels and Reels only (never a story), one caption add-on per post (not with instagramCommentPrompt), and only on an account connected through Meta (a Facebook Page with a linked Instagram). WRITE-ONCE: Instagram cannot add, change or remove it after publishing, so show the user the answers first."
},
"platformCover": {
"type": "boolean",
"description": "VIDEO COVER. Omit it (the default) and Hermoso sets the video’s best frame — the same frame as its Library thumbnail — as the cover (Instagram Reel: thumb_offset; Facebook video/Reel: an uploaded cover image) — and on Threads, which has no cover setting, a blank first frame is replaced on a copy sent to Threads only. true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins."
},
"allowDuplicate": {
"type": "boolean",
"description": "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."
},
"idempotencyKey": {
"type": "string",
"description": "SAFE RETRIES — any stable string: a repeat of the SAME publish within 24h returns the ORIGINAL post id instead of posting again (an identical publish is also auto-recognised for 10 minutes). On a timeout or an ambiguous error, CALL AGAIN WITH THE SAME KEY: it reports the original post or publishes it once. It never posts twice."
},
"linkAttachment": {
"type": "string",
"description": "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."
},
"linkDescription": {
"type": "string",
"description": "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."
},
"paidPartnership": {
"type": "boolean",
"description": "INSTAGRAM — the PAID PARTNERSHIP label. A COMPLIANCE DECLARATION: set it whenever the post is sponsored, gifted or otherwise paid for. Opt-in and never inferred — it is the poster’s own statement about their commercial relationship."
},
"threadsLongText": {
"type": "string",
"description": "THREADS ONLY — LONG-FORM text, up to 10,000 characters, attached under a TEXT-only Threads post (Threads' text attachment). The way to publish more than Threads' 500-character post: keep a short lead in the message and put the full text here. Not with an image, video or carousel."
},
"callToActionLink": {
"type": "string",
"description": "FACEBOOK — where the button goes, when that is not the post’s own `link`."
},
"crossreshareToIg": {
"type": "boolean",
"description": "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."
},
"threadsGhostPost": {
"type": "boolean",
"description": "THREADS ONLY — a GHOST post: text only, Threads archives it automatically after 24 hours (a flash offer, a 24h-only note). No other Threads option can ride it."
},
"crossreshareDarkMode": {
"type": "boolean",
"description": "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."
},
"instagramPollExtended": {
"type": "boolean",
"description": "INSTAGRAM — give that poll Instagram’s extended voting duration instead of its default 3 days (Instagram’s changelog says it then stays open indefinitely). Only with instagramPoll."
},
"instagramCommentPrompt": {
"type": "boolean",
"description": "INSTAGRAM — make the caption a COMMENT PROMPT that people answer in the comments. Same limits as instagramPoll, and never together with it."
},
"brandedContentSponsorIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "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."
}
},
"additionalProperties": {}
}| recipe |
| string |
| the post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format |
| 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 pinterest account connected (several and none named is refused by name, never guessed); omit when there is one. |
| altText | any | accessibility alt text, max 500 characters. PIN-LEVEL: Pinterest’s API has no per-item alt text at all, so on a CAROUSEL the FIRST description is used for the whole Pin and the reply states that the others were not sent. |
| boardId* | string | numeric board id from list_pinterest_boards — the user picks it, never guess |
| 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. |
| imageUrl | string | a Hermoso render image URL (or an upload_file url); a URL, not a local file path; use upload_file for local files |
| videoUrl | string | a Hermoso render video URL — takes 1–2 minutes to ingest; a URL, not a local file path; use upload_file for local files |
| coverAtMs | number | THE VIDEO COVER of a video Pin, as ONE frame: milliseconds from the start (7000 = the frame at 7s). That frame is cut and sent as the Pin cover image. coverImageUrl wins. |
| imageUrls | array | CAROUSEL — an ORDERED list of image (and, where the channel allows, video) URLs published as ONE post the viewer swipes through. THIS IS NOT “post several” — it is a single post with several slides, which is what a multi-slide creative (a listicle, a “1/6 · SWIPE” deck) actually needs; publishing only its first slide tells the viewer to swipe at something that cannot. The ORDER is the product. Limits per channel: Instagram 2–20 (images, videos or a mix), Threads 2–20 (mix allowed), Facebook 2+ (Meta publishes no documented maximum; Hermoso caps the upload fan-out at 30 and says so), LinkedIn company Pages 2–20 (images only), Pinterest 2–5 (images only), TikTok up to 35. One url here is simply an ordinary single post. Anything a channel cannot do is REFUSED with the real reason — nothing is ever quietly downgraded to one slide. |
| slideText | array | PINTEREST CAROUSEL ONLY — per-slide title, description and destination LINK, one object per slide in slide order. This is the ONLY genuine per-slide caption on any channel Hermoso publishes to: slide 4 can send people to the product ON slide 4, where every other platform gives a carousel one shared caption. Omit any field to leave it unset; the Pin’s own title/description/link still describe the Pin as a whole. Pinterest publishes no length limit on these, so nothing is truncated. More entries than slides is refused rather than dropped. |
| aiGenerated | boolean | Pinterest’s AI disclosure (ai_disclosures AI_MODIFIED). OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, an image or video that came through upload_file or an external URL (the user’s own) is NOT. Pass true or false only to override. |
| description | string | Pin description, max 800 characters — this is what Pinterest search reads |
| coverImageUrl | string | video Pins only — a render to use as the cover frame |
| 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 (on Pinterest the frame rides as the cover image). true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins. |
| 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. |
| boardSectionId | string | optional section within the board |
| idempotencyKey | string | SAFE RETRIES — any stable string: a repeat of the SAME publish within 24h returns the ORIGINAL post id instead of posting again (an identical publish is also auto-recognised for 10 minutes). On a timeout or an ambiguous error, CALL AGAIN WITH THE SAME KEY: it reports the original post or publishes it once. It never posts twice. |
| syntheticPerformer | boolean | ALSO declare SYNTHETIC_PERFORMER — the Pin shows a realistic AI-generated person. Only with an AI disclosure; never guessed, so pass true only when the user says the person is AI-made. |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"boardId"
],
"properties": {
"hook": {
"type": "string",
"description": "WHAT ANGLE this post is built on, recorded only at publish time. Omit it and this post never votes on which hook works."
},
"link": {
"type": "string",
"description": "destination URL the Pin clicks through to"
},
"brand": {
"type": "string",
"description": "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."
},
"title": {
"type": "string",
"description": "Pin title, max 100 characters"
},
"ideaId": {
"type": "string",
"description": "short id of the content-plan idea this post came from"
},
"recipe": {
"type": "string",
"description": "the post's FORMAT id, e.g. \"slideshow\" or \"imessage_chat\" — post_performance groups by it, so reuse one id per format"
},
"account": {
"type": "string",
"description": "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 pinterest account connected (several and none named is refused by name, never guessed); omit when there is one."
},
"altText": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
],
"description": "accessibility alt text, max 500 characters. PIN-LEVEL: Pinterest’s API has no per-item alt text at all, so on a CAROUSEL the FIRST description is used for the whole Pin and the reply states that the others were not sent."
},
"boardId": {
"type": "string",
"description": "numeric board id from list_pinterest_boards — the user picks it, never guess"
},
"subject": {
"type": "string",
"description": "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."
},
"imageUrl": {
"type": "string",
"description": "a Hermoso render image URL (or an upload_file url); a URL, not a local file path; use upload_file for local files"
},
"videoUrl": {
"type": "string",
"description": "a Hermoso render video URL — takes 1–2 minutes to ingest; a URL, not a local file path; use upload_file for local files"
},
"coverAtMs": {
"type": "number",
"description": "THE VIDEO COVER of a video Pin, as ONE frame: milliseconds from the start (7000 = the frame at 7s). That frame is cut and sent as the Pin cover image. coverImageUrl wins."
},
"imageUrls": {
"type": "array",
"items": {
"type": "string"
},
"description": "CAROUSEL — an ORDERED list of image (and, where the channel allows, video) URLs published as ONE post the viewer swipes through. THIS IS NOT “post several” — it is a single post with several slides, which is what a multi-slide creative (a listicle, a “1/6 · SWIPE” deck) actually needs; publishing only its first slide tells the viewer to swipe at something that cannot. The ORDER is the product. Limits per channel: Instagram 2–20 (images, videos or a mix), Threads 2–20 (mix allowed), Facebook 2+ (Meta publishes no documented maximum; Hermoso caps the upload fan-out at 30 and says so), LinkedIn company Pages 2–20 (images only), Pinterest 2–5 (images only), TikTok up to 35. One url here is simply an ordinary single post. Anything a channel cannot do is REFUSED with the real reason — nothing is ever quietly downgraded to one slide."
},
"slideText": {
"type": "array",
"items": {
"type": "object",
"properties": {
"link": {
"type": "string",
"description": "carousel slide destination URL for this slide"
},
"title": {
"type": "string",
"description": "carousel slide title; one object per slide, in slide order"
},
"description": {
"type": "string",
"description": "carousel slide description for this slide"
}
}
},
"description": "PINTEREST CAROUSEL ONLY — per-slide title, description and destination LINK, one object per slide in slide order. This is the ONLY genuine per-slide caption on any channel Hermoso publishes to: slide 4 can send people to the product ON slide 4, where every other platform gives a carousel one shared caption. Omit any field to leave it unset; the Pin’s own title/description/link still describe the Pin as a whole. Pinterest publishes no length limit on these, so nothing is truncated. More entries than slides is refused rather than dropped."
},
"aiGenerated": {
"type": "boolean",
"description": "Pinterest’s AI disclosure (ai_disclosures AI_MODIFIED). OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, an image or video that came through upload_file or an external URL (the user’s own) is NOT. Pass true or false only to override."
},
"description": {
"type": "string",
"description": "Pin description, max 800 characters — this is what Pinterest search reads"
},
"coverImageUrl": {
"type": "string",
"description": "video Pins only — a render to use as the cover frame"
},
"platformCover": {
"type": "boolean",
"description": "VIDEO COVER. Omit it (the default) and Hermoso sets the video’s best frame — the same frame as its Library thumbnail — as the cover (on Pinterest the frame rides as the cover image). true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins."
},
"allowDuplicate": {
"type": "boolean",
"description": "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."
},
"boardSectionId": {
"type": "string",
"description": "optional section within the board"
},
"idempotencyKey": {
"type": "string",
"description": "SAFE RETRIES — any stable string: a repeat of the SAME publish within 24h returns the ORIGINAL post id instead of posting again (an identical publish is also auto-recognised for 10 minutes). On a timeout or an ambiguous error, CALL AGAIN WITH THE SAME KEY: it reports the original post or publishes it once. It never posts twice."
},
"syntheticPerformer": {
"type": "boolean",
"description": "ALSO declare SYNTHETIC_PERFORMER — the Pin shows a realistic AI-generated person. Only with an AI disclosure; never guessed, so pass true only when the user says the person is AI-made."
}
}
}| recipe | string | the post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format |
| altText | string | alt text for the images |
| channel | string | HERMOSO APP: channel id or #name (list_slack_channels); unset = the default channel |
| subject | string | what the post is about |
| imageUrl | string |
| threadTs | string | HERMOSO APP: reply in this thread (the parent message ts) |
| videoUrl | string |
| imageUrls | array |
| unfurlLinks | boolean | false = no link previews |
| allowDuplicate | boolean | post it even though an identical post was just made |
| idempotencyKey | string | any stable string: a repeat within 24h returns the original post instead of posting again |
| replyBroadcast | boolean | HERMOSO APP: also show the thread reply in the channel |
| slackWorkspace | string | HERMOSO APP: which Slack workspace (id or name) when the app is in several |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"hook": {
"type": "string",
"description": "The post's angle"
},
"text": {
"type": "string"
},
"brand": {
"type": "string",
"description": "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."
},
"title": {
"type": "string",
"description": "a header line above the text"
},
"ideaId": {
"type": "string",
"description": "short id of the content-plan idea this post came from"
},
"postAt": {
"type": "string",
"description": "HERMOSO APP: schedule in Slack itself for this ISO time (≤120 days, text only; use schedule_post for files)"
},
"recipe": {
"type": "string",
"description": "the post's FORMAT id, e.g. \"slideshow\" or \"imessage_chat\" — post_performance groups by it, so reuse one id per format"
},
"altText": {
"type": "string",
"description": "alt text for the images"
},
"channel": {
"type": "string",
"description": "HERMOSO APP: channel id or #name (list_slack_channels); unset = the default channel"
},
"subject": {
"type": "string",
"description": "what the post is about"
},
"imageUrl": {
"type": "string"
},
"threadTs": {
"type": "string",
"description": "HERMOSO APP: reply in this thread (the parent message ts)"
},
"videoUrl": {
"type": "string"
},
"imageUrls": {
"type": "array",
"items": {
"type": "string"
}
},
"unfurlLinks": {
"type": "boolean",
"description": "false = no link previews"
},
"allowDuplicate": {
"type": "boolean",
"description": "post it even though an identical post was just made"
},
"idempotencyKey": {
"type": "string",
"description": "any stable string: a repeat within 24h returns the original post instead of posting again"
},
"replyBroadcast": {
"type": "boolean",
"description": "HERMOSO APP: also show the thread reply in the channel"
},
"slackWorkspace": {
"type": "string",
"description": "HERMOSO APP: which Slack workspace (id or name) when the app is in several"
}
}
}| 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. |
| chatId* | string | REQUIRED — the destination: a public channel's @username, or the numeric chat id. Never guessed; ask the user, or use list_telegram_chats. |
| ideaId | string | short id of the content-plan idea this post came from |
| recipe | string | the post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format |
| silent | boolean | deliver without a notification sound (Telegram’s disable_notification). This is NOT a visibility setting — the message is just as visible. |
| 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 telegram account connected (several and none named is refused by name, never guessed); omit when there is one. |
| buttons | array | URL BUTTONS under the post, Telegram's call to action: [{text:'Shop now', url:'https://…'}] one per row, or rows [[a,b],[c]]. URL buttons only (https://, http:// or tg://), optional style danger|success|primary. A single photo, a video or a text post can carry them; an ALBUM cannot (Telegram's sendMediaGroup takes no buttons), so that combination is refused. |
| subject | string | what the post is about |
| imageUrl | string | one image (≤10MB after upload); a URL, not a local file path; use upload_file for local files |
| videoUrl | string | one video (≤50MB). Passed alongside imageUrls it joins the album as one more item; a URL, not a local file path; use upload_file for local files |
| coverAtMs | number | THE VIDEO COVER in the chat, as ONE frame: milliseconds from the start (7000 = the frame at 7s). Sent as Telegram’s cover image; beats platformCover. |
| imageUrls | array | an ALBUM of 2–10 media, in order. Photos and videos may be mixed — Telegram allows it. |
| parseMode | string | 'HTML' to format the text with Telegram's HTML: <b>, <i>, <u>, <s>, <a href="…">, <code>, <pre>, <blockquote>, <tg-spoiler>. Omit for plain text (the default). In HTML mode a literal <, > or & in the copy must be written < > &, which is why it is never turned on by itself. Markdown (**bold**) is NOT parsed; convert it to HTML tags. |
| 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 (Telegram’s in-chat video cover). true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins. |
| allowDuplicate | boolean | post it even though an identical post was just made |
| disablePreview | boolean | suppress the link-preview card on a text-only post. Default is Telegram’s own behaviour (previews on). |
| idempotencyKey | string | any stable string: a repeat within 24h returns the original post instead of posting again |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"chatId"
],
"properties": {
"hook": {
"type": "string",
"description": "The post's angle"
},
"text": {
"type": "string",
"description": "the message. ≤4096 characters on its own; ≤1024 once any image or video is attached."
},
"brand": {
"type": "string",
"description": "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."
},
"chatId": {
"type": "string",
"description": "REQUIRED — the destination: a public channel's @username, or the numeric chat id. Never guessed; ask the user, or use list_telegram_chats."
},
"ideaId": {
"type": "string",
"description": "short id of the content-plan idea this post came from"
},
"recipe": {
"type": "string",
"description": "the post's FORMAT id, e.g. \"slideshow\" or \"imessage_chat\" — post_performance groups by it, so reuse one id per format"
},
"silent": {
"type": "boolean",
"description": "deliver without a notification sound (Telegram’s disable_notification). This is NOT a visibility setting — the message is just as visible."
},
"account": {
"type": "string",
"description": "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 telegram account connected (several and none named is refused by name, never guessed); omit when there is one."
},
"buttons": {
"type": "array",
"items": {
"anyOf": [
{
"type": "object",
"required": [
"text",
"url"
],
"properties": {
"url": {
"type": "string"
},
"text": {
"type": "string"
},
"style": {
"enum": [
"danger",
"success",
"primary"
],
"type": "string"
}
}
},
{
"type": "array",
"items": {
"type": "object",
"required": [
"text",
"url"
],
"properties": {
"url": {
"type": "string"
},
"text": {
"type": "string"
},
"style": {
"enum": [
"danger",
"success",
"primary"
],
"type": "string"
}
}
}
}
]
},
"description": "URL BUTTONS under the post, Telegram's call to action: [{text:'Shop now', url:'https://…'}] one per row, or rows [[a,b],[c]]. URL buttons only (https://, http:// or tg://), optional style danger|success|primary. A single photo, a video or a text post can carry them; an ALBUM cannot (Telegram's sendMediaGroup takes no buttons), so that combination is refused."
},
"subject": {
"type": "string",
"description": "what the post is about"
},
"imageUrl": {
"type": "string",
"description": "one image (≤10MB after upload); a URL, not a local file path; use upload_file for local files"
},
"videoUrl": {
"type": "string",
"description": "one video (≤50MB). Passed alongside imageUrls it joins the album as one more item; a URL, not a local file path; use upload_file for local files"
},
"coverAtMs": {
"type": "number",
"description": "THE VIDEO COVER in the chat, as ONE frame: milliseconds from the start (7000 = the frame at 7s). Sent as Telegram’s cover image; beats platformCover."
},
"imageUrls": {
"type": "array",
"items": {
"type": "string"
},
"description": "an ALBUM of 2–10 media, in order. Photos and videos may be mixed — Telegram allows it."
},
"parseMode": {
"enum": [
"HTML"
],
"type": "string",
"description": "'HTML' to format the text with Telegram's HTML: <b>, <i>, <u>, <s>, <a href=\"…\">, <code>, <pre>, <blockquote>, <tg-spoiler>. Omit for plain text (the default). In HTML mode a literal <, > or & in the copy must be written < > &, which is why it is never turned on by itself. Markdown (**bold**) is NOT parsed; convert it to HTML tags."
},
"platformCover": {
"type": "boolean",
"description": "VIDEO COVER. Omit it (the default) and Hermoso sets the video’s best frame — the same frame as its Library thumbnail — as the cover (Telegram’s in-chat video cover). true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins."
},
"allowDuplicate": {
"type": "boolean",
"description": "post it even though an identical post was just made"
},
"disablePreview": {
"type": "boolean",
"description": "suppress the link-preview card on a text-only post. Default is Telegram’s own behaviour (previews on)."
},
"idempotencyKey": {
"type": "string",
"description": "any stable string: a repeat within 24h returns the original post instead of posting again"
}
}
}| title |
| string |
| the caption — hashtags go here (video ≤2200 chars, photo post ≤4000) |
| ideaId | string | short id of the content-plan idea this post came from |
| recipe | string | the post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format |
| 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. |
| privacy | string | REQUIRED for destination:"post", for photos and video alike. Must be one the creator actually allows — read them from tiktok_creator_info, never guess. |
| 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. |
| videoUrl | string | the video to post — a Hermoso render URL or an upload_file url. Omit for a photo post; a URL, not a local file path; use upload_file for local files |
| imageUrls | array | a PHOTO POST: 1–35 image URLs in slide order. One url = a single-image photo post. Do not combine with videoUrl. |
| yourBrand | boolean | discloses that this promotes the creator’s own brand |
| coverIndex | number | photo posts: which slide is the cover, 0-based. Default 0 (the first slide). |
| photoTitle | string | photo posts only: a short title above the caption (≤90 chars). Defaults to the caption’s first line. |
| 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. |
| destination | string | "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. |
| disableDuet | boolean | video only — TikTok has no duet on a photo post |
| autoAddMusic | boolean | photo posts only: let TikTok add a recommended track (default true — a silent slideshow reads as broken) |
| disableStitch | boolean | video only — TikTok has no stitch on a photo post |
| 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. |
| brandedContent | boolean | discloses a paid partnership — cannot be combined with SELF_ONLY privacy |
| disableComment | boolean | true = turn comments off on this post |
| coverTimestampMs | number | video only: which frame to use as the cover, in ms |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"hook": {
"type": "string",
"description": "WHAT ANGLE this post is built on, recorded only at publish time. Omit it and this post never votes on which hook works."
},
"brand": {
"type": "string",
"description": "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."
},
"title": {
"type": "string",
"description": "the caption — hashtags go here (video ≤2200 chars, photo post ≤4000)"
},
"ideaId": {
"type": "string",
"description": "short id of the content-plan idea this post came from"
},
"recipe": {
"type": "string",
"description": "the post's FORMAT id, e.g. \"slideshow\" or \"imessage_chat\" — post_performance groups by it, so reuse one id per format"
},
"account": {
"type": "string",
"description": "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."
},
"privacy": {
"enum": [
"PUBLIC_TO_EVERYONE",
"MUTUAL_FOLLOW_FRIENDS",
"FOLLOWER_OF_CREATOR",
"SELF_ONLY"
],
"type": "string",
"description": "REQUIRED for destination:\"post\", for photos and video alike. Must be one the creator actually allows — read them from tiktok_creator_info, never guess."
},
"subject": {
"type": "string",
"description": "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."
},
"videoUrl": {
"type": "string",
"description": "the video to post — a Hermoso render URL or an upload_file url. Omit for a photo post; a URL, not a local file path; use upload_file for local files"
},
"imageUrls": {
"type": "array",
"items": {
"type": "string"
},
"description": "a PHOTO POST: 1–35 image URLs in slide order. One url = a single-image photo post. Do not combine with videoUrl."
},
"yourBrand": {
"type": "boolean",
"description": "discloses that this promotes the creator’s own brand"
},
"coverIndex": {
"type": "number",
"description": "photo posts: which slide is the cover, 0-based. Default 0 (the first slide)."
},
"photoTitle": {
"type": "string",
"description": "photo posts only: a short title above the caption (≤90 chars). Defaults to the caption’s first line."
},
"aiGenerated": {
"type": "boolean",
"description": "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."
},
"destination": {
"enum": [
"post",
"draft"
],
"type": "string",
"description": "\"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."
},
"disableDuet": {
"type": "boolean",
"description": "video only — TikTok has no duet on a photo post"
},
"autoAddMusic": {
"type": "boolean",
"description": "photo posts only: let TikTok add a recommended track (default true — a silent slideshow reads as broken)"
},
"disableStitch": {
"type": "boolean",
"description": "video only — TikTok has no stitch on a photo post"
},
"platformCover": {
"type": "boolean",
"description": "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."
},
"brandedContent": {
"type": "boolean",
"description": "discloses a paid partnership — cannot be combined with SELF_ONLY privacy"
},
"disableComment": {
"type": "boolean",
"description": "true = turn comments off on this post"
},
"coverTimestampMs": {
"type": "number",
"description": "video only: which frame to use as the cover, in ms"
}
}
}| the body: plain text, light markdown or HTML |
| 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. |
| title* | string | REQUIRED — the post title |
| ideaId | string | short id of the content-plan idea this post came from |
| recipe | string | the post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format |
| status | string | default publish |
| altText | string | alt text for the images |
| excerpt | string | the summary under the title in lists and search |
| subject | string | what the post is about |
| imageUrl | string | one image — becomes the featured image |
| videoUrl | string | one video, embedded in the post |
| imageUrls | array | several images: the first is featured, the rest go inline |
| categories | array | category names or ids (a missing name is created) |
| allowDuplicate | boolean | post it even though an identical post was just made |
| idempotencyKey | string | any stable string: a repeat within 24h returns the original post instead of posting again |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"title"
],
"properties": {
"date": {
"type": "string",
"description": "ISO time to publish at; a future date schedules it on WordPress itself"
},
"hook": {
"type": "string",
"description": "The post's angle"
},
"slug": {
"type": "string",
"description": "the end of the post URL"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "tag names or ids (a missing name is created)"
},
"text": {
"type": "string",
"description": "the body: plain text, light markdown or HTML"
},
"brand": {
"type": "string",
"description": "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."
},
"title": {
"type": "string",
"description": "REQUIRED — the post title"
},
"ideaId": {
"type": "string",
"description": "short id of the content-plan idea this post came from"
},
"recipe": {
"type": "string",
"description": "the post's FORMAT id, e.g. \"slideshow\" or \"imessage_chat\" — post_performance groups by it, so reuse one id per format"
},
"status": {
"enum": [
"publish",
"draft",
"pending",
"private",
"future"
],
"type": "string",
"description": "default publish"
},
"altText": {
"type": "string",
"description": "alt text for the images"
},
"excerpt": {
"type": "string",
"description": "the summary under the title in lists and search"
},
"subject": {
"type": "string",
"description": "what the post is about"
},
"imageUrl": {
"type": "string",
"description": "one image — becomes the featured image"
},
"videoUrl": {
"type": "string",
"description": "one video, embedded in the post"
},
"imageUrls": {
"type": "array",
"items": {
"type": "string"
},
"description": "several images: the first is featured, the rest go inline"
},
"categories": {
"type": "array",
"items": {
"type": "string"
},
"description": "category names or ids (a missing name is created)"
},
"allowDuplicate": {
"type": "boolean",
"description": "post it even though an identical post was just made"
},
"idempotencyKey": {
"type": "string",
"description": "any stable string: a repeat within 24h returns the original post instead of posting again"
}
}
}| WHAT ANGLE this post is built on, recorded only at publish time. Omit it and this post never votes on which hook works. |
| poll | object | run a poll on the post. X does not allow a poll and media on the same post, and a poll cannot ride on a thread. |
| text | string | the post text. 280 characters without X Premium, up to 25,000 with it — write the full thing, it is never truncated. With `thread` also set, this is the FIRST post and the thread entries follow it (a thread whose first entry is already this text is not posted twice). |
| 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. |
| dryRun | boolean | true = post NOTHING and return what this exact post would cost (costCredits). Free. |
| ideaId | string | short id of the content-plan idea this post came from |
| recipe | string | the post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format |
| thread | array | a thread: each string is one post, published in order, each replying to the previous (if `text` is also set it is posted first and these follow). Max 25 including `text`. Each part follows the same length rule as `text`, and on an X Premium account ONE long post is usually both better reading and cheaper than a thread. |
| 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 x account connected (several and none named is refused by name, never guessed); omit when there is one. |
| altText | any | accessibility description of the attached media, max 1000 characters — write one whenever you attach a render. Costs a small extra amount: X bills one metadata write PER media. With SEVERAL media, pass an ARRAY aligned to their order — X attaches alt text per media id, and a single string describes only the FIRST one (X renders up to four media as a GRID, not a carousel, so one sentence would be wrong for the other three). |
| 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. |
| imageUrl | string | alias of mediaUrl for an IMAGE — same as passing it as mediaUrl |
| mediaUrl | string | a Hermoso render (image or video) to attach to the first post — pass its served URL, or an upload_file url for external media; a URL, not a local file path; use upload_file for local files |
| videoCta | string | VIDEO posts only — a button on the video; needs videoCtaUrl (https) |
| videoUrl | string | alias of mediaUrl for a VIDEO — same as passing it as mediaUrl |
| mediaUrls | array | UP TO FOUR Hermoso-hosted media attached to ONE post — X’s own schema caps media_ids at 4. X renders them as a GRID: every image visible at once, nothing to swipe to. That is NOT a carousel, and a numbered "1/6 · SWIPE" slide deck must still not be sent here — it would publish as a grid and the "swipe" instruction would make no sense. Order decides the layout. On a thread the media rides the FIRST post; give each later part its own post to attach more. Mutually exclusive with mediaUrl, and more than 4 is refused before anything is uploaded; a URL, not a local file path; use upload_file for local files |
| replyToId | string | numeric id of an existing X post to reply to. X ONLY ACCEPTS AN API REPLY TO A POST THAT MENTIONS THIS ACCOUNT OR THAT THIS ACCOUNT WROTE (X policy since Feb 2026, every tier below Enterprise): a cold reply under a stranger's post is refused by X with "You can only reply to or quote posts where you are mentioned or are the author" and nothing is posted. Reply to those from x.com itself; use this for replies in your own threads and to people who tagged you. |
| dmDeepLink | string | boolean | a "Message us" button: true for the connected account, or https://x.com/messages/compose?recipient_id=<id>. TEXT posts only — X refuses it alongside media. |
| videoTitle | string | VIDEO posts only — a headline under the video, max 70 (measured: X drops it on an image post, so it is refused there) |
| communityId | string | publish into an X COMMUNITY instead of the main timeline — the number in the community’s own URL (x.com/i/communities/<id>). The connected account must be a MEMBER of it; X answers a non-member and a non-existent id with the same refusal and does not separate them. |
| quotePostId | string | numeric id of a post to QUOTE — X renders the quoted post inside yours. This is NOT replyToId: a reply sits under the original in its thread, a quote stands alone on your own timeline with the original embedded, which is the one you want for commentary. X makes a quote mutually exclusive with media and with a poll (their own schema), and a quote is billed at the higher LINK rate because X appends the quoted post’s t.co URL whatever your text says. Same X rule as replyToId: a quote of a post that does not mention this account is refused by X. |
| videoCtaUrl | string | https link the video button opens; used with videoCta |
| platformCover | boolean | VIDEO COVER. X has no cover setting and shows the video’s FIRST frame, so by default Hermoso checks it and, only when that frame is blank (a template ad’s empty opening card), sends X a copy with the first frame replaced by the video’s best frame. Your Library file is never changed. true = send the file exactly as it is. |
| replySettings | string | restrict who can reply — omit for everyone, which is the right default for a brand post |
| taggedUserIds | array | tag up to 10 people (numeric X user ids, from x_user) in the post’s media |
| paidPartnership | boolean | label the post a PAID PARTNERSHIP on X — the same disclosure Hermoso already ships for TikTok. OPT-IN ONLY: set it when the post is sponsored, gifted or otherwise paid for, and never assume it on the user’s behalf. |
| videoDescription | string | VIDEO posts only — a line under the title, max 200 |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"hook": {
"type": "string",
"description": "WHAT ANGLE this post is built on, recorded only at publish time. Omit it and this post never votes on which hook works."
},
"poll": {
"type": "object",
"required": [
"options"
],
"properties": {
"options": {
"type": "array",
"items": {
"type": "string"
},
"description": "2-4 choices, max 25 characters each"
},
"durationMinutes": {
"type": "number",
"description": "5 to 10080 minutes (7 days); default 1440 = one day"
}
},
"description": "run a poll on the post. X does not allow a poll and media on the same post, and a poll cannot ride on a thread."
},
"text": {
"type": "string",
"description": "the post text. 280 characters without X Premium, up to 25,000 with it — write the full thing, it is never truncated. With `thread` also set, this is the FIRST post and the thread entries follow it (a thread whose first entry is already this text is not posted twice)."
},
"brand": {
"type": "string",
"description": "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."
},
"dryRun": {
"type": "boolean",
"description": "true = post NOTHING and return what this exact post would cost (costCredits). Free."
},
"ideaId": {
"type": "string",
"description": "short id of the content-plan idea this post came from"
},
"recipe": {
"type": "string",
"description": "the post's FORMAT id, e.g. \"slideshow\" or \"imessage_chat\" — post_performance groups by it, so reuse one id per format"
},
"thread": {
"type": "array",
"items": {
"type": "string"
},
"description": "a thread: each string is one post, published in order, each replying to the previous (if `text` is also set it is posted first and these follow). Max 25 including `text`. Each part follows the same length rule as `text`, and on an X Premium account ONE long post is usually both better reading and cheaper than a thread."
},
"account": {
"type": "string",
"description": "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 x account connected (several and none named is refused by name, never guessed); omit when there is one."
},
"altText": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
],
"description": "accessibility description of the attached media, max 1000 characters — write one whenever you attach a render. Costs a small extra amount: X bills one metadata write PER media. With SEVERAL media, pass an ARRAY aligned to their order — X attaches alt text per media id, and a single string describes only the FIRST one (X renders up to four media as a GRID, not a carousel, so one sentence would be wrong for the other three)."
},
"subject": {
"type": "string",
"description": "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."
},
"imageUrl": {
"type": "string",
"description": "alias of mediaUrl for an IMAGE — same as passing it as mediaUrl"
},
"mediaUrl": {
"type": "string",
"description": "a Hermoso render (image or video) to attach to the first post — pass its served URL, or an upload_file url for external media; a URL, not a local file path; use upload_file for local files"
},
"videoCta": {
"enum": [
"visit_site",
"watch_now"
],
"type": "string",
"description": "VIDEO posts only — a button on the video; needs videoCtaUrl (https)"
},
"videoUrl": {
"type": "string",
"description": "alias of mediaUrl for a VIDEO — same as passing it as mediaUrl"
},
"mediaUrls": {
"type": "array",
"items": {
"type": "string"
},
"description": "UP TO FOUR Hermoso-hosted media attached to ONE post — X’s own schema caps media_ids at 4. X renders them as a GRID: every image visible at once, nothing to swipe to. That is NOT a carousel, and a numbered \"1/6 · SWIPE\" slide deck must still not be sent here — it would publish as a grid and the \"swipe\" instruction would make no sense. Order decides the layout. On a thread the media rides the FIRST post; give each later part its own post to attach more. Mutually exclusive with mediaUrl, and more than 4 is refused before anything is uploaded; a URL, not a local file path; use upload_file for local files"
},
"replyToId": {
"type": "string",
"description": "numeric id of an existing X post to reply to. X ONLY ACCEPTS AN API REPLY TO A POST THAT MENTIONS THIS ACCOUNT OR THAT THIS ACCOUNT WROTE (X policy since Feb 2026, every tier below Enterprise): a cold reply under a stranger's post is refused by X with \"You can only reply to or quote posts where you are mentioned or are the author\" and nothing is posted. Reply to those from x.com itself; use this for replies in your own threads and to people who tagged you."
},
"dmDeepLink": {
"type": [
"string",
"boolean"
],
"description": "a \"Message us\" button: true for the connected account, or https://x.com/messages/compose?recipient_id=<id>. TEXT posts only — X refuses it alongside media."
},
"videoTitle": {
"type": "string",
"description": "VIDEO posts only — a headline under the video, max 70 (measured: X drops it on an image post, so it is refused there)"
},
"communityId": {
"type": "string",
"description": "publish into an X COMMUNITY instead of the main timeline — the number in the community’s own URL (x.com/i/communities/<id>). The connected account must be a MEMBER of it; X answers a non-member and a non-existent id with the same refusal and does not separate them."
},
"quotePostId": {
"type": "string",
"description": "numeric id of a post to QUOTE — X renders the quoted post inside yours. This is NOT replyToId: a reply sits under the original in its thread, a quote stands alone on your own timeline with the original embedded, which is the one you want for commentary. X makes a quote mutually exclusive with media and with a poll (their own schema), and a quote is billed at the higher LINK rate because X appends the quoted post’s t.co URL whatever your text says. Same X rule as replyToId: a quote of a post that does not mention this account is refused by X."
},
"videoCtaUrl": {
"type": "string",
"description": "https link the video button opens; used with videoCta"
},
"platformCover": {
"type": "boolean",
"description": "VIDEO COVER. X has no cover setting and shows the video’s FIRST frame, so by default Hermoso checks it and, only when that frame is blank (a template ad’s empty opening card), sends X a copy with the first frame replaced by the video’s best frame. Your Library file is never changed. true = send the file exactly as it is."
},
"replySettings": {
"enum": [
"following",
"mentionedUsers",
"subscribers",
"verified"
],
"type": "string",
"description": "restrict who can reply — omit for everyone, which is the right default for a brand post"
},
"taggedUserIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "tag up to 10 people (numeric X user ids, from x_user) in the post’s media"
},
"paidPartnership": {
"type": "boolean",
"description": "label the post a PAID PARTNERSHIP on X — the same disclosure Hermoso already ships for TikTok. OPT-IN ONLY: set it when the post is sponsored, gifted or otherwise paid for, and never assume it on the user’s behalf."
},
"videoDescription": {
"type": "string",
"description": "VIDEO posts only — a line under the title, max 200"
}
}
}| 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. |
| title | string | REQUIRED in practice — the headline every search result and the Shorts feed shows (≤100 chars); an upload with no title is refused |
| ideaId | string | short id of the content-plan idea this post came from |
| recipe | string | the post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format |
| 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 youtube account connected (several and none named is refused by name, never guessed); omit when there is one. |
| license | string | YouTube licence: youtube (standard) or creativeCommon |
| privacy | string | default public (live + searchable on the channel); unlisted = link-only (the ad-ready setting, or to add YouTube music in Studio first — only when the user asks); private = eyes-only (cannot run as an ad) |
| 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. |
| videoUrl* | string | the video to post — a Hermoso render URL or an upload_file url; a URL, not a local file path; use upload_file for local files |
| coverAtMs | number | THE VIDEO COVER (the custom thumbnail), as ONE frame: milliseconds from the start (7000 = the frame at 7s). That frame is cut from the upload and set with thumbnails.set. thumbnailUrl wins; this beats platformCover. |
| publishAt | string | SCHEDULE the publish — ISO 8601, e.g. "2026-09-01T15:00:00Z", and it must be in the future. YouTube only allows this on a PRIVATE video and makes it PUBLIC at that moment, so pass privacy:"private" (or leave privacy unset) — asking for a scheduled "unlisted" or "public" post is refused rather than half-honoured. |
| categoryId | string | YouTube category id, NUMERIC — default "22" (People & Blogs), which is wrong for most ads. 1 Film & Animation · 2 Autos & Vehicles · 10 Music · 15 Pets & Animals · 17 Sports · 19 Travel & Events · 20 Gaming · 22 People & Blogs · 23 Comedy · 24 Entertainment · 25 News & Politics · 26 Howto & Style · 27 Education · 28 Science & Technology · 29 Nonprofits & Activism. The assignable set is region-specific, so the value is forwarded as given and YouTube has the last word. |
| embeddable | boolean | true = allow embedding on other sites |
| aiGenerated | boolean | YouTube’s “altered or synthetic content” declaration (containsSyntheticMedia). OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, a video the user uploaded through upload_file or from an external URL is NOT — real footage must not carry the label. true/false overrides. |
| description | string | REQUIRED in practice — what the video shows, a line about the brand, a link and a few #hashtags (≤5000 chars); YouTube search, suggested videos and the Shorts feed rank on it, so an upload with no description (and no caption) is refused |
| brandPartner | string | sponsoring brand's channel id (UC…) or @handle — gives that brand access to run the video as a partnership ad; "" unlinks (update only) |
| thumbnailUrl | string | The custom thumbnail: a Hermoso-hosted image. Omit it and a representative frame of the video is set, free; "auto" keeps YouTube’s pick. Custom thumbnails need a verified channel. |
| localizations | object | translated title/description per language, e.g. {"es":{"title":"…"}}; needs defaultLanguage |
| 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 (YouTube custom thumbnail; the same as thumbnailUrl:"auto" when true). true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins. |
| recordingDate | string | YYYY-MM-DD |
| defaultLanguage | string | language of title/description, e.g. "en" |
| notifySubscribers | boolean | THE DEFAULT FOLLOWS PRIVACY. privacy:"public" NOTIFIES the channel's subscribers — that is YouTube's own default and normally what someone publishing publicly wants. privacy:"unlisted" and "private" do NOT: the video is not on the channel, so announcing it is nonsense, and a blast to somebody's whole subscriber list cannot be undone. A scheduled publish (publishAt) is PRIVATE at upload, so it does not notify either — pass true to announce one. An explicit value ALWAYS wins in both directions: true announces an unlisted/private upload, false publishes publicly and quietly. The reply reports which way it went and why. |
| publicStatsViewable | boolean | true = show public view and like counts |
| defaultAudioLanguage | string | spoken language, e.g. "es" |
| paidProductPlacement | boolean | true = contains PAID product placement (sponsored) — YouTube shows the disclosure; legally required for sponsored content |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"videoUrl"
],
"properties": {
"hook": {
"type": "string",
"description": "WHAT ANGLE this post is built on, recorded only at publish time. Omit it and this post never votes on which hook works."
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "up to 30 tags"
},
"brand": {
"type": "string",
"description": "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."
},
"title": {
"type": "string",
"description": "REQUIRED in practice — the headline every search result and the Shorts feed shows (≤100 chars); an upload with no title is refused"
},
"ideaId": {
"type": "string",
"description": "short id of the content-plan idea this post came from"
},
"recipe": {
"type": "string",
"description": "the post's FORMAT id, e.g. \"slideshow\" or \"imessage_chat\" — post_performance groups by it, so reuse one id per format"
},
"account": {
"type": "string",
"description": "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 youtube account connected (several and none named is refused by name, never guessed); omit when there is one."
},
"license": {
"enum": [
"youtube",
"creativeCommon"
],
"type": "string",
"description": "YouTube licence: youtube (standard) or creativeCommon"
},
"privacy": {
"enum": [
"private",
"unlisted",
"public"
],
"type": "string",
"description": "default public (live + searchable on the channel); unlisted = link-only (the ad-ready setting, or to add YouTube music in Studio first — only when the user asks); private = eyes-only (cannot run as an ad)"
},
"subject": {
"type": "string",
"description": "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."
},
"videoUrl": {
"type": "string",
"description": "the video to post — a Hermoso render URL or an upload_file url; a URL, not a local file path; use upload_file for local files"
},
"coverAtMs": {
"type": "number",
"description": "THE VIDEO COVER (the custom thumbnail), as ONE frame: milliseconds from the start (7000 = the frame at 7s). That frame is cut from the upload and set with thumbnails.set. thumbnailUrl wins; this beats platformCover."
},
"publishAt": {
"type": "string",
"description": "SCHEDULE the publish — ISO 8601, e.g. \"2026-09-01T15:00:00Z\", and it must be in the future. YouTube only allows this on a PRIVATE video and makes it PUBLIC at that moment, so pass privacy:\"private\" (or leave privacy unset) — asking for a scheduled \"unlisted\" or \"public\" post is refused rather than half-honoured."
},
"categoryId": {
"type": "string",
"description": "YouTube category id, NUMERIC — default \"22\" (People & Blogs), which is wrong for most ads. 1 Film & Animation · 2 Autos & Vehicles · 10 Music · 15 Pets & Animals · 17 Sports · 19 Travel & Events · 20 Gaming · 22 People & Blogs · 23 Comedy · 24 Entertainment · 25 News & Politics · 26 Howto & Style · 27 Education · 28 Science & Technology · 29 Nonprofits & Activism. The assignable set is region-specific, so the value is forwarded as given and YouTube has the last word."
},
"embeddable": {
"type": "boolean",
"description": "true = allow embedding on other sites"
},
"aiGenerated": {
"type": "boolean",
"description": "YouTube’s “altered or synthetic content” declaration (containsSyntheticMedia). OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, a video the user uploaded through upload_file or from an external URL is NOT — real footage must not carry the label. true/false overrides."
},
"description": {
"type": "string",
"description": "REQUIRED in practice — what the video shows, a line about the brand, a link and a few #hashtags (≤5000 chars); YouTube search, suggested videos and the Shorts feed rank on it, so an upload with no description (and no caption) is refused"
},
"brandPartner": {
"type": "string",
"description": "sponsoring brand's channel id (UC…) or @handle — gives that brand access to run the video as a partnership ad; \"\" unlinks (update only)"
},
"thumbnailUrl": {
"type": "string",
"description": "The custom thumbnail: a Hermoso-hosted image. Omit it and a representative frame of the video is set, free; \"auto\" keeps YouTube’s pick. Custom thumbnails need a verified channel."
},
"localizations": {
"type": "object",
"description": "translated title/description per language, e.g. {\"es\":{\"title\":\"…\"}}; needs defaultLanguage",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"description": {
"type": "string"
}
}
}
},
"platformCover": {
"type": "boolean",
"description": "VIDEO COVER. Omit it (the default) and Hermoso sets the video’s best frame — the same frame as its Library thumbnail — as the cover (YouTube custom thumbnail; the same as thumbnailUrl:\"auto\" when true). true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins."
},
"recordingDate": {
"type": "string",
"description": "YYYY-MM-DD"
},
"defaultLanguage": {
"type": "string",
"description": "language of title/description, e.g. \"en\""
},
"notifySubscribers": {
"type": "boolean",
"description": "THE DEFAULT FOLLOWS PRIVACY. privacy:\"public\" NOTIFIES the channel's subscribers — that is YouTube's own default and normally what someone publishing publicly wants. privacy:\"unlisted\" and \"private\" do NOT: the video is not on the channel, so announcing it is nonsense, and a blast to somebody's whole subscriber list cannot be undone. A scheduled publish (publishAt) is PRIVATE at upload, so it does not notify either — pass true to announce one. An explicit value ALWAYS wins in both directions: true announces an unlisted/private upload, false publishes publicly and quietly. The reply reports which way it went and why."
},
"publicStatsViewable": {
"type": "boolean",
"description": "true = show public view and like counts"
},
"defaultAudioLanguage": {
"type": "string",
"description": "spoken language, e.g. \"es\""
},
"paidProductPlacement": {
"type": "boolean",
"description": "true = contains PAID product placement (sponsored) — YouTube shows the disclosure; legally required for sponsored content"
}
}
}| 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. |
| title* | string | the Article title — X requires one and refuses a draft without it. This is what shows on the timeline card. |
| ideaId | string | short id of the content-plan idea this post came from |
| recipe | string | the post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| publish | boolean | default true. Pass false to save it as a DRAFT in the account’s X Articles composer instead — nothing becomes public, the user can review and publish it from X, and it does not spend one of the five daily publishes. |
| 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. |
| headings | string | how headings are rendered. “blocks” (default) uses X’s own heading block types for # and ## headings; ### and deeper become bold lines, because X Articles refuse a third-level heading (the reply says so). “text” renders every heading as a BOLD standalone paragraph instead: the words survive and the heading structure does not, and the reply says so. |
| allowLossy | boolean | publish even though part of the source cannot be represented on X, rendering those parts as plain text. OFF by default and it should usually stay off — silently publishing a user’s copy with formatting missing is worse than refusing and telling them. |
| coverImageUrl | string | optional cover picture for the Article — a Hermoso render URL or an upload_file url. Must be a STILL image; X Article covers are not videos. |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"title",
"body"
],
"properties": {
"body": {
"type": "string",
"description": "the article body, as markdown or plain prose. Markdown headings, lists, quotes, links, emphasis, ``` code fences and | pipe | tables | are all converted to X’s own Article structure."
},
"hook": {
"type": "string",
"description": "WHAT ANGLE this post is built on, recorded only at publish time. Omit it and this post never votes on which hook works."
},
"brand": {
"type": "string",
"description": "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."
},
"title": {
"type": "string",
"description": "the Article title — X requires one and refuses a draft without it. This is what shows on the timeline card."
},
"ideaId": {
"type": "string",
"description": "short id of the content-plan idea this post came from"
},
"recipe": {
"type": "string",
"description": "the post's FORMAT id, e.g. \"slideshow\" or \"imessage_chat\" — post_performance groups by it, so reuse one id per format"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"publish": {
"type": "boolean",
"description": "default true. Pass false to save it as a DRAFT in the account’s X Articles composer instead — nothing becomes public, the user can review and publish it from X, and it does not spend one of the five daily publishes."
},
"subject": {
"type": "string",
"description": "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."
},
"headings": {
"enum": [
"blocks",
"text"
],
"type": "string",
"description": "how headings are rendered. “blocks” (default) uses X’s own heading block types for # and ## headings; ### and deeper become bold lines, because X Articles refuse a third-level heading (the reply says so). “text” renders every heading as a BOLD standalone paragraph instead: the words survive and the heading structure does not, and the reply says so."
},
"allowLossy": {
"type": "boolean",
"description": "publish even though part of the source cannot be represented on X, rendering those parts as plain text. OFF by default and it should usually stay off — silently publishing a user’s copy with formatting missing is worse than refusing and telling them."
},
"coverImageUrl": {
"type": "string",
"description": "optional cover picture for the Article — a Hermoso render URL or an upload_file url. Must be a STILL image; X Article covers are not videos."
}
}
}{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"productId"
],
"properties": {
"alt": {
"type": "string",
"description": "alt text for accessibility and SEO; defaults to a generic credit"
},
"kind": {
"enum": [
"image",
"video"
],
"type": "string",
"description": "only when the link does not say (no file extension and no content type)"
},
"imageUrl": {
"type": "string",
"description": "older name for mediaUrl; still accepted"
},
"mediaUrl": {
"type": "string",
"description": "any public https image or video URL: a Hermoso render works, and so does ANY file of your own brought in with upload_file"
},
"productId": {
"type": "string",
"description": "gid://shopify/Product/… from list_shopify_products"
}
}
}| string |
| ascending (default) or descending |
| databaseId | string | database id or link (list_notion_pages type:"database") |
| dataSourceId | string | data source id from an earlier read, as an alternative to databaseId |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"limit": {
"type": "number",
"description": "1-100, default 50"
},
"match": {
"type": "object",
"description": "{\"Column\": value} — equality for status/select/checkbox/number/date, contains for text/multi-select",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"cursor": {
"type": "string",
"description": "nextCursor from the previous page of rows"
},
"filter": {
"type": "object",
"description": "a raw Notion filter object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"sortBy": {
"type": "string",
"description": "column name to sort by, or created_time or last_edited_time"
},
"archived": {
"type": "boolean",
"description": "read archived rows instead"
},
"direction": {
"enum": [
"ascending",
"descending"
],
"type": "string",
"description": "ascending (default) or descending"
},
"databaseId": {
"type": "string",
"description": "database id or link (list_notion_pages type:\"database\")"
},
"dataSourceId": {
"type": "string",
"description": "data source id from an earlier read, as an alternative to databaseId"
}
}
}| conversationId* | string | from list_meta_conversations |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"conversationId"
],
"properties": {
"limit": {
"type": "number",
"description": "messages (1–100, default 25)"
},
"pageId": {
"type": "string",
"description": "Facebook Page that owns the DM thread; omit when only one Page is connected"
},
"account": {
"type": "string",
"description": "which Instagram account — an @handle or id from list_connector_accounts(\"instagram\"). The way a DIRECT (Instagram Login) account is reached; omit for the Page-linked one."
},
"platform": {
"enum": [
"MESSENGER",
"INSTAGRAM"
],
"type": "string",
"description": "so the Instagram 20-message ceiling can be stated when it applies"
},
"conversationId": {
"type": "string",
"description": "from list_meta_conversations"
}
}
}| humanAgent | boolean | HUMAN AGENT — true when a PERSON on the team is personally answering a customer's question more than 24 hours after they last wrote (up to 7 days): Meta's HUMAN_AGENT tag. Never for automated, promotional or unrelated messages. Meta grants the tag only after approving Hermoso's Human Agent feature (App Review, pending); until then Meta refuses it and the reply says so. |
| recipientId* | string | the PSID (Messenger) or IGSID (Instagram) to reply to — `replyTo` from read_meta_conversation |
| conversationId | string | strongly recommended: it is what lets the 24-hour window be checked BEFORE sending rather than discovered by a refusal |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"recipientId",
"text"
],
"properties": {
"text": {
"type": "string",
"description": "the reply"
},
"pageId": {
"type": "string",
"description": "Facebook Page that owns the conversation; omit when only one Page is connected"
},
"account": {
"type": "string",
"description": "which Instagram account — an @handle or id from list_connector_accounts(\"instagram\"). The way a DIRECT (Instagram Login) account is reached; omit for the Page-linked one."
},
"platform": {
"enum": [
"MESSENGER",
"INSTAGRAM"
],
"type": "string",
"description": "MESSENGER or INSTAGRAM; the channel the conversation is on"
},
"humanAgent": {
"type": "boolean",
"description": "HUMAN AGENT — true when a PERSON on the team is personally answering a customer's question more than 24 hours after they last wrote (up to 7 days): Meta's HUMAN_AGENT tag. Never for automated, promotional or unrelated messages. Meta grants the tag only after approving Hermoso's Human Agent feature (App Review, pending); until then Meta refuses it and the reply says so."
},
"recipientId": {
"type": "string",
"description": "the PSID (Messenger) or IGSID (Instagram) to reply to — `replyTo` from read_meta_conversation"
},
"conversationId": {
"type": "string",
"description": "strongly recommended: it is what lets the 24-hour window be checked BEFORE sending rather than discovered by a refusal"
}
}
}{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"videoId",
"commentId"
],
"properties": {
"text": {
"type": "string",
"description": "≤1,200 characters (UTF-8)"
},
"videoId": {
"type": "string",
"description": "the post the comment sits on — TikTok requires it"
},
"imageUri": {
"type": "string",
"description": "from upload_tiktok_comment_image"
},
"imageUrl": {
"type": "string",
"description": "a public image URL — TikTok accepts this on a REPLY only, never on a new comment"
},
"commentId": {
"type": "string",
"description": "from list_tiktok_comments"
},
"imageWidth": {
"type": "number",
"description": "pixel width of the image sent as imageUri (from upload_tiktok_comment_image)"
},
"imageHeight": {
"type": "number",
"description": "pixel height of the image sent as imageUri"
}
}
}| string |
| WHICH PROFILE this post is in — id or exact name from list_brands; needed when it is not the profile this connection is pinned to. A name that matches no profile, or two, is REFUSED. |
| event | object | GOOGLE BUSINESS — replaces the whole event record {title, startDate, startTime, endDate, endTime}. |
| offer | object | GOOGLE BUSINESS — replaces the whole offer record {couponCode, redeemOnlineUrl, termsConditions}. |
| place | string | FACEBOOK — replaces the tagged place (the numeric id of its Facebook Page); an empty string removes it. |
| story | boolean | INSTAGRAM — true makes it a 24-hour Story, false an ordinary feed post. One image or one video, no carousel. |
| title | string | PINTEREST / YOUTUBE — replace the headline; "" clears it and goes back to deriving one from the caption |
| chatId | string | TELEGRAM — send it to a different chat, group or channel (@username or numeric id). It can be changed but never cleared: telegram cannot publish without one. |
| ideaId | string | short id of the content-plan idea this post came from |
| pageId | string | FACEBOOK / INSTAGRAM / THREADS — publish from a different connected Page (list_meta_pages) |
| recipe | string | the post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format |
| silent | boolean | TELEGRAM / DISCORD — no notification. |
| thread | array | X — replaces the WHOLE thread; an explicit [] drops back to a single post using the caption. |
| wpSlug | string | WORDPRESS — the post slug |
| wpTags | array | WORDPRESS — tags (names or ids) |
| altText | any | ACCESSIBILITY — replace the screen-reader description(s). A STRING describes every slide; an ARRAY describes them one at a time in slide order and REPLACES the whole list. "" clears it. |
| audioId | string | INSTAGRAM REEL — put one of Instagram’s OWN licensed music tracks under the Reel: the `id` search_instagram_audio returns. Instagram mixes it in when the Reel is published; nothing is downloaded or re-rendered. REELS ONLY; an empty string removes it, and only on an Instagram account connected through Meta (a Facebook Page with a linked Instagram), which is Instagram’s own rule — the Instagram connector cannot take it and is refused by name. |
| boardId | string | PINTEREST — move the Pin to a different board (list_pinterest_boards) |
| buttons | array | TELEGRAM ONLY — replace the URL buttons ([] removes them). |
| excerpt | string | WORDPRESS — the excerpt under the title |
| message | string | replace the caption used for every channel that has no override |
| audience | object | FACEBOOK — replaces who can see the Page post {countries, regions, cities, minAge}; {} removes the limit. |
| captions | object | replaces the WHOLE per-channel caption map — send every override you want to keep, not just the new one |
| channels | array | replaces the channel list |
| coverUrl | string | INSTAGRAM REEL — replaces the cover image url; an empty string removes it. |
| imageUrl | string | swap the image; "" removes it |
| linkName | string | FACEBOOK — replaces the link preview headline; an empty string removes the override. |
| timezone | string | IANA zone for a wall-clock `at` |
| topicTag | string | THREADS ONLY — one topic tag, without the leading #. |
| userTags | array | INSTAGRAM USER TAGS — tag people (public accounts) on the post; each is notified. Usernames, or { username, x, y } with x/y from 0 to 1 = where the tag sits on the IMAGE (a tag without a position sits at the centre). A Reel tag takes no position; a story position is optional. Not on a carousel (refused by name). |
| videoUrl | string | swap the video; "" removes it |
| whopPoll | array | WHOP: poll options, 2 to 10 |
| xArticle | object | X: replaces the X Article (title, headings); {} makes it an ordinary X post again. |
| audioName | string | INSTAGRAM REEL — replaces the audio track name; an empty string removes it. |
| coverAtMs | number | THE VIDEO COVER on every channel, as one frame in milliseconds (see schedule_post). |
| imageUrls | array | replace the CAROUSEL slides, in order; an empty array [] drops the carousel and goes back to a single image. Omit to leave the slides exactly as they are. The edited item is re-checked against the same carousel rules the create passed, so adding a channel that cannot swipe is refused now rather than posting slide 1 later. |
| parseMode | string | TELEGRAM ONLY — 'HTML' for Telegram's HTML tags. |
| placeName | string | the name the user saw for that place; a different business is refused |
| slideText | array | PINTEREST CAROUSEL ONLY — per-slide title / description / link, aligned to slide order. |
| topicType | string | GOOGLE BUSINESS — the kind of Post; EVENT and OFFER both require `event`. |
| trialReel | string | INSTAGRAM — replaces the trial-reel setting on a queued Reel (MANUAL or SS_PERFORMANCE); an explicit "" turns the trial off and it goes out as an ordinary Reel. Only takes effect while the post is still queued — a Reel already published cannot be converted into a trial. |
| yourBrand | boolean | TIKTOK — the own-brand disclosure; false turns it off. |
| actionType | string | GOOGLE BUSINESS — the call-to-action button; "" clears it. |
| locationId | string | GOOGLE BUSINESS — a different listing (list_business_locations) |
| madeWithAi | boolean | X — the AI-media label; false turns it off. |
| threadName | string | DISCORD: forum thread title |
| threadsGif | string | THREADS ONLY — a GIPHY GIF id (the id in a giphy.com/gifs/… link) attached to a TEXT Threads post. GIPHY is the only provider Threads takes. |
| visibility | string | NOTE: changing this without also naming visibilityByChannel clears any per-channel overrides, so "make it all draft" is not a no-op |
| whopPinned | boolean | WHOP: pin the forum post |
| aiGenerated | boolean | AI-CONTENT DISCLOSURE (Instagram / Facebook Reel is_ai_generated, TikTok is_aigc, YouTube containsSyntheticMedia, Pinterest ai_disclosures, Threads is_made_with_ai). OMIT IT and the value already on the post stays; a post that never had one is decided from provenance at publish: a Hermoso render is declared AI-generated, media that came through upload_file or from an external URL (the user’s own photos or footage) is NOT. Pass true or false only to override. |
| audioVolume | integer | INSTAGRAM REEL — how loud that track plays, 0–100 (Instagram’s default 100). Needs audioId. |
| communityId | string | X — the community to publish into; an empty string goes back to the main timeline. |
| description | string | YOUTUBE — replace the video description; "" clears it and the caption is used. |
| disableDuet | boolean | TIKTOK VIDEO ONLY — block Duets. |
| linkPicture | string | FACEBOOK — replaces the link preview image url; an empty string removes the override. |
| quotePostId | string | THREADS ONLY — the id of the Threads post this one quotes. |
| shareToFeed | boolean | INSTAGRAM REEL — whether the Reel also shows in the Feed grid. |
| thumbOffset | number | INSTAGRAM REEL — replaces the cover frame, in milliseconds; 0 removes it. Never together with coverUrl. |
| videoVolume | integer | INSTAGRAM REEL — how loud the video’s own sound plays under the track, 0–100 (Instagram’s default 100; 0 = the track alone). Needs audioId. |
| whopMention | boolean | WHOP: notify every member |
| brandPartner | string | YOUTUBE: sponsoring brand channel (UC… or @handle); "" removes it |
| callToAction | string | FACEBOOK — replaces the button on the Page post; "" removes it. |
| countryCodes | array | THREADS ONLY — two-letter country codes limiting who can see the post. |
| firstComment | string | FIRST COMMENT — posted on each new post as the same account right after it publishes, on Facebook, Instagram and LinkedIn company Pages (LinkedIn needs its comments authorization, provider linkedin_community). Other channels publish without it and their result row says so. Up to 2,000 characters, refused rather than cut; never on a story. A failed comment never undoes the post. |
| optimizeCopy | boolean | fit the shared caption to each channel’s own rules when you schedule it (the fitted caption is stored on the scheduled post, so what you scheduled is what publishes) wherever no per-channel caption was written; a channel with its own caption is left exactly as written. Send false to switch it off on this item. |
| privacyLevel | string | TIKTOK — WHICH PRIVACY LEVEL the post goes out at, in TikTok’s own vocabulary. TikTok requires the USER to choose this from the levels their own account allows: call tiktok_creator_info, show them the real options, and pass back the one they picked — never a default and never a guess, which TikTok refuses at init. Omit it and the post falls back to the coarse `visibility` (private -> SELF_ONLY, otherwise PUBLIC_TO_EVERYONE), which cannot express MUTUAL_FOLLOW_FRIENDS or FOLLOWER_OF_CREATOR at all. It must agree with the TikTok visibility (SELF_ONLY is the private one) and it is refused on a TikTok DRAFT, which carries no post info. |
| replyControl | string | THREADS ONLY — who may reply. |
| slackChannel | string | SLACK app: channel id or #name (default: the brand's) |
| thumbnailUrl | string | YOUTUBE: replace the custom thumbnail; "" goes back to a frame of the video, "auto" to YouTube’s pick. |
| wpCategories | array | WORDPRESS — categories (names or ids) |
| xQuotePostId | string | X — the post this one QUOTES; an empty string removes the quote. Named apart from the Threads `quotePostId` on this same schedule. |
| collaborators | array | INSTAGRAM — replaces the WHOLE collab list (up to 3 usernames); an explicit [] removes the co-authors and the post goes out as an ordinary single-author post. Only takes effect if the post has not fired yet — an invite already sent cannot be withdrawn from here. |
| coverImageUrl | string | THE VIDEO COVER on every channel, as a Hermoso-hosted picture (see schedule_post). |
| disableStitch | boolean | TIKTOK VIDEO ONLY — block Stitches. |
| discordServer | string | DISCORD bot: which server |
| instagramPoll | array | INSTAGRAM — attach a POLL: 2 to 4 answers of 1–25 characters each, and the caption IS the question (so a caption is required). Feed photos, carousels and Reels only (never a story), one caption add-on per post (not with instagramCommentPrompt), and only on an account connected through Meta (a Facebook Page with a linked Instagram). WRITE-ONCE: Instagram cannot add, change or remove it after publishing, so show the user the answers first. |
| 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 on every channel that allows one — and on X, Threads and Bluesky, which have none, a blank first frame is replaced on a copy sent there only. true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins. |
| replySettings | string | X — who may reply; "" goes back to everyone. |
| slackThreadTs | string | SLACK app: reply in this thread (parent ts) |
| brandedContent | boolean | TIKTOK — the paid-partnership disclosure; false turns it off. |
| disableComment | boolean | TIKTOK — comments off on this post. |
| disablePreview | boolean | TELEGRAM ONLY — no link preview. |
| discordChannel | string | DISCORD bot: channel id or #name (default: the brand's) |
| discordReplyTo | string | DISCORD bot: reply to this message id |
| linkAttachment | string | THREADS ONLY — a full http(s) URL rendered as a link card on a TEXT-ONLY post. The only way a Threads post carries a destination. |
| slackWorkspace | string | SLACK app: which workspace |
| targetAudience | object | LINKEDIN COMPANY PAGE — replaces who sees the post; {} removes the limit. The matching audience must be over 300 followers. |
| whopVisibility | string | WHOP: who can see the forum post |
| discordMentions | boolean | DISCORD: let mentions ping |
| linkDescription | string | FACEBOOK — replaces the link preview description; an empty string removes the override. |
| paidPartnership | boolean | INSTAGRAM AND X — the paid-partnership label; false turns it off. |
| threadsLongText | string | THREADS ONLY — LONG-FORM text, up to 10,000 characters, attached under a TEXT-only Threads post (Threads' text attachment). The way to publish more than Threads' 500-character post: keep a short lead in the message and put the full text here. Not with an image, video or carousel. |
| callToActionLink | string | FACEBOOK — replaces where the button goes; an empty string falls back to the post link. |
| coverTimestampMs | number | TIKTOK VIDEO ONLY — cover frame in milliseconds. |
| crossreshareToIg | boolean | THREADS ONLY — also share to Instagram as a Story when it fires; false turns it off. Refused on a carousel. |
| threadsGhostPost | boolean | THREADS ONLY — a GHOST post: text only, Threads archives it automatically after 24 hours (a flash offer, a 24h-only note). No other Threads option can ride it. |
| whopExperienceId | string | WHOP — move it to a different forum or chat (exp_…, from list_whop_destinations). |
| whopPaywallCents | integer | WHOP: forum-post paywall in cents (needs a title) |
| commercialContent | boolean | TIKTOK — the COMMERCIAL CONTENT DISCLOSURE toggle: true when this post promotes a brand, product or service at all. TikTok requires at least one of yourBrand / brandedContent once it is on, and refuses a post that declares itself commercial without naming which kind. Either disclosure already implies it. |
| discordStartThread | string | DISCORD bot: open a thread under the post, this title |
| instagramLocationId | string | INSTAGRAM — replaces the tagged place (the numeric id of its Facebook Page); an empty string removes it. Not locationId, which is the Google Business listing. |
| visibilityByChannel | object | per-channel visibility override, e.g. {tiktok: draft}; values public, unlisted, private or draft |
| whopPaywallCurrency | string | WHOP: paywall currency |
| crossreshareDarkMode | boolean | THREADS ONLY — dark-mode that Instagram Story. Needs crossreshareToIg. |
| paidProductPlacement | boolean | YOUTUBE: paid product placement (shows the disclosure) — false removes it |
| instagramPollExtended | boolean | INSTAGRAM — give that poll Instagram’s extended voting duration instead of its default 3 days (Instagram’s changelog says it then stays open indefinitely). Only with instagramPoll. |
| instagramCommentPrompt | boolean | INSTAGRAM — make the caption a COMMENT PROMPT that people answer in the comments. Same limits as instagramPoll, and never together with it. |
| linkedinOrganizationId | string | LINKEDIN — target a different company Page, or "" to post as the connected person instead |
| brandedContentSponsorIds | array | INSTAGRAM — replaces the sponsor user ids behind the paid-partnership label (at most 2); [] removes them. |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"id"
],
"properties": {
"at": {
"type": "string",
"description": "the new time: local wall clock (2026-10-30T10:00, read in `timezone` or the brand’s zone on that date), UTC (…Z) or epoch ms. Future, at most 365 days out."
},
"id": {
"type": "string",
"description": "the scheduled post id from list_scheduled"
},
"link": {
"type": "string",
"description": "destination URL for a Pinterest Pin, Facebook text post or Google Business post"
},
"poll": {
"type": "object",
"required": [
"options"
],
"properties": {
"options": {
"type": "array",
"items": {
"type": "string"
},
"description": "X poll choices: 2 to 4 options, max 25 characters each"
},
"durationMinutes": {
"type": "number",
"description": "X poll length in minutes, 5 to 10080; default 1440 (one day)"
}
},
"description": "X — replaces the poll; an empty options list removes it."
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "YOUTUBE — replaces the WHOLE tag list; an empty array [] clears the tags."
},
"brand": {
"type": "string",
"description": "WHICH PROFILE this post is in — id or exact name from list_brands; needed when it is not the profile this connection is pinned to. A name that matches no profile, or two, is REFUSED."
},
"event": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Google Business event title; required for an event or offer post"
},
"endDate": {
"type": "string",
"description": "event end date, e.g. 2026-11-01"
},
"endTime": {
"type": "string",
"description": "event end time, e.g. 20:00"
},
"startDate": {
"type": "string",
"description": "event start date, e.g. 2026-11-01"
},
"startTime": {
"type": "string",
"description": "event start time, e.g. 18:00"
}
},
"description": "GOOGLE BUSINESS — replaces the whole event record {title, startDate, startTime, endDate, endTime}."
},
"offer": {
"type": "object",
"properties": {
"couponCode": {
"type": "string",
"description": "Google Business offer coupon code"
},
"redeemOnlineUrl": {
"type": "string",
"description": "link where the offer is redeemed online"
},
"termsConditions": {
"type": "string",
"description": "terms text shown with the offer"
}
},
"description": "GOOGLE BUSINESS — replaces the whole offer record {couponCode, redeemOnlineUrl, termsConditions}."
},
"place": {
"type": "string",
"description": "FACEBOOK — replaces the tagged place (the numeric id of its Facebook Page); an empty string removes it."
},
"story": {
"type": "boolean",
"description": "INSTAGRAM — true makes it a 24-hour Story, false an ordinary feed post. One image or one video, no carousel."
},
"title": {
"type": "string",
"description": "PINTEREST / YOUTUBE — replace the headline; \"\" clears it and goes back to deriving one from the caption"
},
"chatId": {
"type": "string",
"description": "TELEGRAM — send it to a different chat, group or channel (@username or numeric id). It can be changed but never cleared: telegram cannot publish without one."
},
"ideaId": {
"type": "string",
"description": "short id of the content-plan idea this post came from"
},
"pageId": {
"type": "string",
"description": "FACEBOOK / INSTAGRAM / THREADS — publish from a different connected Page (list_meta_pages)"
},
"recipe": {
"type": "string",
"description": "the post's FORMAT id, e.g. \"slideshow\" or \"imessage_chat\" — post_performance groups by it, so reuse one id per format"
},
"silent": {
"type": "boolean",
"description": "TELEGRAM / DISCORD — no notification."
},
"thread": {
"type": "array",
"items": {
"type": "string"
},
"description": "X — replaces the WHOLE thread; an explicit [] drops back to a single post using the caption."
},
"wpSlug": {
"type": "string",
"description": "WORDPRESS — the post slug"
},
"wpTags": {
"type": "array",
"items": {
"type": "string"
},
"description": "WORDPRESS — tags (names or ids)"
},
"altText": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
],
"description": "ACCESSIBILITY — replace the screen-reader description(s). A STRING describes every slide; an ARRAY describes them one at a time in slide order and REPLACES the whole list. \"\" clears it."
},
"audioId": {
"type": "string",
"description": "INSTAGRAM REEL — put one of Instagram’s OWN licensed music tracks under the Reel: the `id` search_instagram_audio returns. Instagram mixes it in when the Reel is published; nothing is downloaded or re-rendered. REELS ONLY; an empty string removes it, and only on an Instagram account connected through Meta (a Facebook Page with a linked Instagram), which is Instagram’s own rule — the Instagram connector cannot take it and is refused by name."
},
"boardId": {
"type": "string",
"description": "PINTEREST — move the Pin to a different board (list_pinterest_boards)"
},
"buttons": {
"type": "array",
"items": {
"anyOf": [
{
"type": "object",
"required": [
"text",
"url"
],
"properties": {
"url": {
"type": "string"
},
"text": {
"type": "string"
},
"style": {
"enum": [
"danger",
"success",
"primary"
],
"type": "string"
}
}
},
{
"type": "array",
"items": {
"type": "object",
"required": [
"text",
"url"
],
"properties": {
"url": {
"type": "string"
},
"text": {
"type": "string"
},
"style": {
"enum": [
"danger",
"success",
"primary"
],
"type": "string"
}
}
}
}
]
},
"description": "TELEGRAM ONLY — replace the URL buttons ([] removes them)."
},
"excerpt": {
"type": "string",
"description": "WORDPRESS — the excerpt under the title"
},
"message": {
"type": "string",
"description": "replace the caption used for every channel that has no override"
},
"audience": {
"type": "object",
"properties": {
"cities": {
"type": "array",
"items": {
"type": "string"
},
"description": "Facebook Page post audience: Meta numeric city keys (the ads location search returns them)"
},
"minAge": {
"type": "number",
"description": "Facebook Page post audience: minimum age, one of 13, 15, 18, 21 or 25"
},
"regions": {
"type": "array",
"items": {
"type": "string"
},
"description": "Facebook Page post audience: Meta numeric region keys (the ads location search returns them)"
},
"countries": {
"type": "array",
"items": {
"type": "string"
},
"description": "Facebook Page post audience: two-letter country codes, e.g. CA, US"
}
},
"description": "FACEBOOK — replaces who can see the Page post {countries, regions, cities, minAge}; {} removes the limit."
},
"captions": {
"type": "object",
"description": "replaces the WHOLE per-channel caption map — send every override you want to keep, not just the new one",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
},
"channels": {
"type": "array",
"items": {
"enum": [
"facebook",
"instagram",
"threads",
"tiktok",
"youtube",
"linkedin",
"x",
"pinterest",
"google_business",
"bluesky",
"telegram",
"wordpress",
"discord",
"slack",
"whop"
],
"type": "string"
},
"description": "replaces the channel list"
},
"coverUrl": {
"type": "string",
"description": "INSTAGRAM REEL — replaces the cover image url; an empty string removes it."
},
"imageUrl": {
"type": "string",
"description": "swap the image; \"\" removes it"
},
"linkName": {
"type": "string",
"description": "FACEBOOK — replaces the link preview headline; an empty string removes the override."
},
"timezone": {
"type": "string",
"description": "IANA zone for a wall-clock `at`"
},
"topicTag": {
"type": "string",
"description": "THREADS ONLY — one topic tag, without the leading #."
},
"userTags": {
"type": "array",
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "object",
"required": [
"username"
],
"properties": {
"x": {
"type": "number",
"maximum": 1,
"minimum": 0
},
"y": {
"type": "number",
"maximum": 1,
"minimum": 0
},
"username": {
"type": "string"
}
}
}
]
},
"description": "INSTAGRAM USER TAGS — tag people (public accounts) on the post; each is notified. Usernames, or { username, x, y } with x/y from 0 to 1 = where the tag sits on the IMAGE (a tag without a position sits at the centre). A Reel tag takes no position; a story position is optional. Not on a carousel (refused by name)."
},
"videoUrl": {
"type": "string",
"description": "swap the video; \"\" removes it"
},
"whopPoll": {
"type": "array",
"items": {
"type": "string"
},
"description": "WHOP: poll options, 2 to 10"
},
"xArticle": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "X Article headline; the post text becomes the article body in markdown"
},
"headings": {
"enum": [
"blocks",
"text"
],
"type": "string",
"description": "blocks (default) keeps real headings; text turns headings into bold lines"
}
},
"description": "X: replaces the X Article (title, headings); {} makes it an ordinary X post again."
},
"audioName": {
"type": "string",
"description": "INSTAGRAM REEL — replaces the audio track name; an empty string removes it."
},
"coverAtMs": {
"type": "number",
"description": "THE VIDEO COVER on every channel, as one frame in milliseconds (see schedule_post)."
},
"imageUrls": {
"type": "array",
"items": {
"type": "string"
},
"description": "replace the CAROUSEL slides, in order; an empty array [] drops the carousel and goes back to a single image. Omit to leave the slides exactly as they are. The edited item is re-checked against the same carousel rules the create passed, so adding a channel that cannot swipe is refused now rather than posting slide 1 later."
},
"parseMode": {
"enum": [
"HTML"
],
"type": "string",
"description": "TELEGRAM ONLY — 'HTML' for Telegram's HTML tags."
},
"placeName": {
"type": "string",
"description": "the name the user saw for that place; a different business is refused"
},
"slideText": {
"type": "array",
"items": {
"type": "object",
"properties": {
"link": {
"type": "string",
"description": "Pinterest carousel slide: destination URL for this slide"
},
"title": {
"type": "string",
"description": "Pinterest carousel slide: title for this slide"
},
"description": {
"type": "string",
"description": "Pinterest carousel slide: description for this slide"
}
}
},
"description": "PINTEREST CAROUSEL ONLY — per-slide title / description / link, aligned to slide order."
},
"topicType": {
"enum": [
"STANDARD",
"EVENT",
"OFFER",
"ALERT"
],
"type": "string",
"description": "GOOGLE BUSINESS — the kind of Post; EVENT and OFFER both require `event`."
},
"trialReel": {
"enum": [
"MANUAL",
"SS_PERFORMANCE",
""
],
"type": "string",
"description": "INSTAGRAM — replaces the trial-reel setting on a queued Reel (MANUAL or SS_PERFORMANCE); an explicit \"\" turns the trial off and it goes out as an ordinary Reel. Only takes effect while the post is still queued — a Reel already published cannot be converted into a trial."
},
"yourBrand": {
"type": "boolean",
"description": "TIKTOK — the own-brand disclosure; false turns it off."
},
"actionType": {
"enum": [
"BOOK",
"ORDER",
"SHOP",
"LEARN_MORE",
"SIGN_UP",
"CALL"
],
"type": "string",
"description": "GOOGLE BUSINESS — the call-to-action button; \"\" clears it."
},
"locationId": {
"type": "string",
"description": "GOOGLE BUSINESS — a different listing (list_business_locations)"
},
"madeWithAi": {
"type": "boolean",
"description": "X — the AI-media label; false turns it off."
},
"threadName": {
"type": "string",
"description": "DISCORD: forum thread title"
},
"threadsGif": {
"type": "string",
"description": "THREADS ONLY — a GIPHY GIF id (the id in a giphy.com/gifs/… link) attached to a TEXT Threads post. GIPHY is the only provider Threads takes."
},
"visibility": {
"enum": [
"public",
"unlisted",
"private",
"draft"
],
"type": "string",
"description": "NOTE: changing this without also naming visibilityByChannel clears any per-channel overrides, so \"make it all draft\" is not a no-op"
},
"whopPinned": {
"type": "boolean",
"description": "WHOP: pin the forum post"
},
"aiGenerated": {
"type": "boolean",
"description": "AI-CONTENT DISCLOSURE (Instagram / Facebook Reel is_ai_generated, TikTok is_aigc, YouTube containsSyntheticMedia, Pinterest ai_disclosures, Threads is_made_with_ai). OMIT IT and the value already on the post stays; a post that never had one is decided from provenance at publish: a Hermoso render is declared AI-generated, media that came through upload_file or from an external URL (the user’s own photos or footage) is NOT. Pass true or false only to override."
},
"audioVolume": {
"type": "integer",
"maximum": 100,
"minimum": 0,
"description": "INSTAGRAM REEL — how loud that track plays, 0–100 (Instagram’s default 100). Needs audioId."
},
"communityId": {
"type": "string",
"description": "X — the community to publish into; an empty string goes back to the main timeline."
},
"description": {
"type": "string",
"description": "YOUTUBE — replace the video description; \"\" clears it and the caption is used."
},
"disableDuet": {
"type": "boolean",
"description": "TIKTOK VIDEO ONLY — block Duets."
},
"linkPicture": {
"type": "string",
"description": "FACEBOOK — replaces the link preview image url; an empty string removes the override."
},
"quotePostId": {
"type": "string",
"description": "THREADS ONLY — the id of the Threads post this one quotes."
},
"shareToFeed": {
"type": "boolean",
"description": "INSTAGRAM REEL — whether the Reel also shows in the Feed grid."
},
"thumbOffset": {
"type": "number",
"description": "INSTAGRAM REEL — replaces the cover frame, in milliseconds; 0 removes it. Never together with coverUrl."
},
"videoVolume": {
"type": "integer",
"maximum": 100,
"minimum": 0,
"description": "INSTAGRAM REEL — how loud the video’s own sound plays under the track, 0–100 (Instagram’s default 100; 0 = the track alone). Needs audioId."
},
"whopMention": {
"type": "boolean",
"description": "WHOP: notify every member"
},
"brandPartner": {
"type": "string",
"description": "YOUTUBE: sponsoring brand channel (UC… or @handle); \"\" removes it"
},
"callToAction": {
"enum": [
"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",
""
],
"type": "string",
"description": "FACEBOOK — replaces the button on the Page post; \"\" removes it."
},
"countryCodes": {
"type": "array",
"items": {
"type": "string"
},
"description": "THREADS ONLY — two-letter country codes limiting who can see the post."
},
"firstComment": {
"type": "string",
"description": "FIRST COMMENT — posted on each new post as the same account right after it publishes, on Facebook, Instagram and LinkedIn company Pages (LinkedIn needs its comments authorization, provider linkedin_community). Other channels publish without it and their result row says so. Up to 2,000 characters, refused rather than cut; never on a story. A failed comment never undoes the post."
},
"optimizeCopy": {
"type": "boolean",
"description": "fit the shared caption to each channel’s own rules when you schedule it (the fitted caption is stored on the scheduled post, so what you scheduled is what publishes) wherever no per-channel caption was written; a channel with its own caption is left exactly as written. Send false to switch it off on this item."
},
"privacyLevel": {
"enum": [
"PUBLIC_TO_EVERYONE",
"MUTUAL_FOLLOW_FRIENDS",
"FOLLOWER_OF_CREATOR",
"SELF_ONLY"
],
"type": "string",
"description": "TIKTOK — WHICH PRIVACY LEVEL the post goes out at, in TikTok’s own vocabulary. TikTok requires the USER to choose this from the levels their own account allows: call tiktok_creator_info, show them the real options, and pass back the one they picked — never a default and never a guess, which TikTok refuses at init. Omit it and the post falls back to the coarse `visibility` (private -> SELF_ONLY, otherwise PUBLIC_TO_EVERYONE), which cannot express MUTUAL_FOLLOW_FRIENDS or FOLLOWER_OF_CREATOR at all. It must agree with the TikTok visibility (SELF_ONLY is the private one) and it is refused on a TikTok DRAFT, which carries no post info."
},
"replyControl": {
"enum": [
"everyone",
"accounts_you_follow",
"mentioned_only",
"parent_post_author_only",
"followers_only"
],
"type": "string",
"description": "THREADS ONLY — who may reply."
},
"slackChannel": {
"type": "string",
"description": "SLACK app: channel id or #name (default: the brand's)"
},
"thumbnailUrl": {
"type": "string",
"description": "YOUTUBE: replace the custom thumbnail; \"\" goes back to a frame of the video, \"auto\" to YouTube’s pick."
},
"wpCategories": {
"type": "array",
"items": {
"type": "string"
},
"description": "WORDPRESS — categories (names or ids)"
},
"xQuotePostId": {
"type": "string",
"description": "X — the post this one QUOTES; an empty string removes the quote. Named apart from the Threads `quotePostId` on this same schedule."
},
"collaborators": {
"type": "array",
"items": {
"type": "string"
},
"description": "INSTAGRAM — replaces the WHOLE collab list (up to 3 usernames); an explicit [] removes the co-authors and the post goes out as an ordinary single-author post. Only takes effect if the post has not fired yet — an invite already sent cannot be withdrawn from here."
},
"coverImageUrl": {
"type": "string",
"description": "THE VIDEO COVER on every channel, as a Hermoso-hosted picture (see schedule_post)."
},
"disableStitch": {
"type": "boolean",
"description": "TIKTOK VIDEO ONLY — block Stitches."
},
"discordServer": {
"type": "string",
"description": "DISCORD bot: which server"
},
"instagramPoll": {
"type": "array",
"items": {
"type": "string"
},
"description": "INSTAGRAM — attach a POLL: 2 to 4 answers of 1–25 characters each, and the caption IS the question (so a caption is required). Feed photos, carousels and Reels only (never a story), one caption add-on per post (not with instagramCommentPrompt), and only on an account connected through Meta (a Facebook Page with a linked Instagram). WRITE-ONCE: Instagram cannot add, change or remove it after publishing, so show the user the answers first."
},
"platformCover": {
"type": "boolean",
"description": "VIDEO COVER. Omit it (the default) and Hermoso sets the video’s best frame — the same frame as its Library thumbnail — as the cover on every channel that allows one — and on X, Threads and Bluesky, which have none, a blank first frame is replaced on a copy sent there only. true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins."
},
"replySettings": {
"enum": [
"following",
"mentionedUsers",
"subscribers",
"verified"
],
"type": "string",
"description": "X — who may reply; \"\" goes back to everyone."
},
"slackThreadTs": {
"type": "string",
"description": "SLACK app: reply in this thread (parent ts)"
},
"brandedContent": {
"type": "boolean",
"description": "TIKTOK — the paid-partnership disclosure; false turns it off."
},
"disableComment": {
"type": "boolean",
"description": "TIKTOK — comments off on this post."
},
"disablePreview": {
"type": "boolean",
"description": "TELEGRAM ONLY — no link preview."
},
"discordChannel": {
"type": "string",
"description": "DISCORD bot: channel id or #name (default: the brand's)"
},
"discordReplyTo": {
"type": "string",
"description": "DISCORD bot: reply to this message id"
},
"linkAttachment": {
"type": "string",
"description": "THREADS ONLY — a full http(s) URL rendered as a link card on a TEXT-ONLY post. The only way a Threads post carries a destination."
},
"slackWorkspace": {
"type": "string",
"description": "SLACK app: which workspace"
},
"targetAudience": {
"type": "object",
"properties": {
"degrees": {
"type": "array",
"items": {
"type": "string"
},
"description": "LinkedIn degree facet: numeric id or urn:li:degree value from search_linkedin_ads_targeting"
},
"industries": {
"type": "array",
"items": {
"type": "string"
},
"description": "LinkedIn industry facet: numeric id or urn:li:industry value from search_linkedin_ads_targeting"
},
"seniorities": {
"type": "array",
"items": {
"type": "string"
},
"description": "LinkedIn seniority facet: numeric id or urn:li:seniority value from search_linkedin_ads_targeting"
},
"geoLocations": {
"type": "array",
"items": {
"type": "string"
},
"description": "LinkedIn geo facet: numeric id or urn:li:geo value from search_linkedin_ads_targeting"
},
"jobFunctions": {
"type": "array",
"items": {
"type": "string"
},
"description": "LinkedIn job function facet: numeric id or urn:li:function value from search_linkedin_ads_targeting"
},
"fieldsOfStudy": {
"type": "array",
"items": {
"type": "string"
},
"description": "LinkedIn field of study facet: numeric id or urn:li:fieldOfStudy value from search_linkedin_ads_targeting"
},
"organizations": {
"type": "array",
"items": {
"type": "string"
},
"description": "LinkedIn school or organization facet: numeric id or urn:li:organization value from search_linkedin_ads_targeting"
},
"staffCountRanges": {
"type": "array",
"items": {
"type": "string"
},
"description": "LinkedIn company size bands, e.g. SIZE_11_TO_50"
}
},
"description": "LINKEDIN COMPANY PAGE — replaces who sees the post; {} removes the limit. The matching audience must be over 300 followers."
},
"whopVisibility": {
"enum": [
"members_only",
"globally_visible"
],
"type": "string",
"description": "WHOP: who can see the forum post"
},
"discordMentions": {
"type": "boolean",
"description": "DISCORD: let mentions ping"
},
"linkDescription": {
"type": "string",
"description": "FACEBOOK — replaces the link preview description; an empty string removes the override."
},
"paidPartnership": {
"type": "boolean",
"description": "INSTAGRAM AND X — the paid-partnership label; false turns it off."
},
"threadsLongText": {
"type": "string",
"description": "THREADS ONLY — LONG-FORM text, up to 10,000 characters, attached under a TEXT-only Threads post (Threads' text attachment). The way to publish more than Threads' 500-character post: keep a short lead in the message and put the full text here. Not with an image, video or carousel."
},
"callToActionLink": {
"type": "string",
"description": "FACEBOOK — replaces where the button goes; an empty string falls back to the post link."
},
"coverTimestampMs": {
"type": "number",
"description": "TIKTOK VIDEO ONLY — cover frame in milliseconds."
},
"crossreshareToIg": {
"type": "boolean",
"description": "THREADS ONLY — also share to Instagram as a Story when it fires; false turns it off. Refused on a carousel."
},
"threadsGhostPost": {
"type": "boolean",
"description": "THREADS ONLY — a GHOST post: text only, Threads archives it automatically after 24 hours (a flash offer, a 24h-only note). No other Threads option can ride it."
},
"whopExperienceId": {
"type": "string",
"description": "WHOP — move it to a different forum or chat (exp_…, from list_whop_destinations)."
},
"whopPaywallCents": {
"type": "integer",
"maximum": 9007199254740991,
"minimum": 0,
"description": "WHOP: forum-post paywall in cents (needs a title)"
},
"commercialContent": {
"type": "boolean",
"description": "TIKTOK — the COMMERCIAL CONTENT DISCLOSURE toggle: true when this post promotes a brand, product or service at all. TikTok requires at least one of yourBrand / brandedContent once it is on, and refuses a post that declares itself commercial without naming which kind. Either disclosure already implies it."
},
"discordStartThread": {
"type": "string",
"description": "DISCORD bot: open a thread under the post, this title"
},
"instagramLocationId": {
"type": "string",
"description": "INSTAGRAM — replaces the tagged place (the numeric id of its Facebook Page); an empty string removes it. Not locationId, which is the Google Business listing."
},
"visibilityByChannel": {
"type": "object",
"description": "per-channel visibility override, e.g. {tiktok: draft}; values public, unlisted, private or draft",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
},
"whopPaywallCurrency": {
"type": "string",
"description": "WHOP: paywall currency"
},
"crossreshareDarkMode": {
"type": "boolean",
"description": "THREADS ONLY — dark-mode that Instagram Story. Needs crossreshareToIg."
},
"paidProductPlacement": {
"type": "boolean",
"description": "YOUTUBE: paid product placement (shows the disclosure) — false removes it"
},
"instagramPollExtended": {
"type": "boolean",
"description": "INSTAGRAM — give that poll Instagram’s extended voting duration instead of its default 3 days (Instagram’s changelog says it then stays open indefinitely). Only with instagramPoll."
},
"instagramCommentPrompt": {
"type": "boolean",
"description": "INSTAGRAM — make the caption a COMMENT PROMPT that people answer in the comments. Same limits as instagramPoll, and never together with it."
},
"linkedinOrganizationId": {
"type": "string",
"description": "LINKEDIN — target a different company Page, or \"\" to post as the connected person instead"
},
"brandedContentSponsorIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "INSTAGRAM — replaces the sponsor user ids behind the paid-partnership label (at most 2); [] removes them."
}
}
}| chatId | string | CORRECT THE TELEGRAM DESTINATION on retry — the @username or numeric id of the chat. A post aimed at a chat the bot is not in fails every time it is retried until this changes. |
| pageId | string | CORRECT THE PAGE on retry — which connected Facebook Page (and its linked Instagram/Threads) publishes, from list_meta_pages. |
| boardId | string | CORRECT THE BOARD on retry — the Pinterest board the Pin goes on, from list_pinterest_boards. A Pin aimed at a board Pinterest refuses fails the same way on every retry until this is changed. |
| message | string | CORRECT THE CAPTION on retry — use this when the original was refused for length or content. Anything not named here is copied from the original post. |
| captions | object | CORRECT ONE CHANNEL’S CAPTION on retry, e.g. { "x": "..." } when only that channel refused the text. |
| channels | array | retry only these channels (default: every channel that did not publish) |
| locationId | string | CORRECT THE LISTING on retry — which Google Business Profile location, e.g. "locations/123" from list_business_locations. |
| allowDuplicate | boolean | ONLY for a channel you have checked by hand and confirmed the post is genuinely NOT there. It bypasses the double-post protection and can publish a second public copy, so never set it to work around a refusal you have not investigated. |
| whopExperienceId | string | WHOP — correct the forum or chat on retry (exp_…, from list_whop_destinations). |
| linkedinOrganizationId | string | CORRECT THE LINKEDIN AUTHOR on retry — the company Page id from list_linkedin_pages. Set it to an empty string to fall back to the personal profile. |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"id"
],
"properties": {
"at": {
"type": "string",
"description": "hold the retry until a later time — ISO timestamp or epoch milliseconds. Leave it out to retry immediately, which is almost always what you want. A time you name here must be at least a minute from now, exactly like any other scheduled post."
},
"id": {
"type": "string",
"description": "the scheduled post id from list_scheduled"
},
"brand": {
"type": "string",
"description": "WHICH PROFILE this post is in — id or exact name from list_brands; needed when it is not the profile this connection is pinned to. A name that matches no profile, or two, is REFUSED."
},
"chatId": {
"type": "string",
"description": "CORRECT THE TELEGRAM DESTINATION on retry — the @username or numeric id of the chat. A post aimed at a chat the bot is not in fails every time it is retried until this changes."
},
"pageId": {
"type": "string",
"description": "CORRECT THE PAGE on retry — which connected Facebook Page (and its linked Instagram/Threads) publishes, from list_meta_pages."
},
"boardId": {
"type": "string",
"description": "CORRECT THE BOARD on retry — the Pinterest board the Pin goes on, from list_pinterest_boards. A Pin aimed at a board Pinterest refuses fails the same way on every retry until this is changed."
},
"message": {
"type": "string",
"description": "CORRECT THE CAPTION on retry — use this when the original was refused for length or content. Anything not named here is copied from the original post."
},
"captions": {
"type": "object",
"description": "CORRECT ONE CHANNEL’S CAPTION on retry, e.g. { \"x\": \"...\" } when only that channel refused the text.",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
},
"channels": {
"type": "array",
"items": {
"enum": [
"facebook",
"instagram",
"threads",
"tiktok",
"youtube",
"linkedin",
"x",
"pinterest",
"google_business",
"bluesky",
"telegram",
"wordpress",
"discord",
"slack",
"whop"
],
"type": "string"
},
"description": "retry only these channels (default: every channel that did not publish)"
},
"locationId": {
"type": "string",
"description": "CORRECT THE LISTING on retry — which Google Business Profile location, e.g. \"locations/123\" from list_business_locations."
},
"allowDuplicate": {
"type": "boolean",
"description": "ONLY for a channel you have checked by hand and confirmed the post is genuinely NOT there. It bypasses the double-post protection and can publish a second public copy, so never set it to work around a refusal you have not investigated."
},
"whopExperienceId": {
"type": "string",
"description": "WHOP — correct the forum or chat on retry (exp_…, from list_whop_destinations)."
},
"linkedinOrganizationId": {
"type": "string",
"description": "CORRECT THE LINKEDIN AUTHOR on retry — the company Page id from list_linkedin_pages. Set it to an empty string to fall back to the personal profile."
}
}
}| learn |
| array |
| notes for future batches, e.g. "too salesy". Free. |
| dryRun | boolean | default TRUE (a preview: nothing rendered, nothing queued; the copy it writes bills its model cost). false renders fresh creative and schedules or drafts it per the mode. |
| approve | array | REVIEW: draft ids to schedule, or ["all"]. A draft whose time has passed moves to the next free posting slot. |
| discard | array | REVIEW: draft ids to delete, or ["all"]. Nothing is posted for them. |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"edit": {
"type": "array",
"items": {
"type": "object",
"required": [
"id"
],
"properties": {
"at": {
"type": "string",
"description": "ISO time, at least 5 minutes ahead"
},
"id": {
"type": "string",
"description": "draft id to change, from get_post_refill"
},
"title": {
"type": "string",
"description": "Pinterest or YouTube headline for the draft"
},
"message": {
"type": "string",
"description": "replacement caption for the draft"
},
"captions": {
"type": "object",
"description": "per-channel caption overrides for the draft, e.g. {instagram: ...}",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
},
"channels": {
"type": "array",
"items": {
"type": "string"
},
"description": "narrow to some of the draft’s own channels"
}
}
},
"description": "REVIEW: change drafts before approving them. Edited copy is screened by the voice rules."
},
"redo": {
"type": "object",
"required": [
"id"
],
"properties": {
"id": {
"type": "string",
"description": "draft id to remake, from get_post_refill"
},
"change": {
"enum": [
"both",
"caption",
"picture"
],
"type": "string",
"description": "caption keeps the picture"
},
"confirm": {
"type": "boolean",
"description": "omit for the price only"
},
"feedback": {
"type": "string",
"description": "what to change, e.g. too salesy; saved for later batches when remember is on"
},
"remember": {
"type": "boolean",
"description": "save the note too (default true)"
}
},
"description": "remake one draft from feedback; paid, so without confirm:true it returns only the price"
},
"force": {
"type": "boolean",
"description": "plan a preview even while autoposting is switched off — useful for showing someone what it would do before they turn it on."
},
"learn": {
"type": "array",
"items": {
"type": "string"
},
"description": "notes for future batches, e.g. \"too salesy\". Free."
},
"dryRun": {
"type": "boolean",
"description": "default TRUE (a preview: nothing rendered, nothing queued; the copy it writes bills its model cost). false renders fresh creative and schedules or drafts it per the mode."
},
"approve": {
"type": "array",
"items": {
"type": "string"
},
"description": "REVIEW: draft ids to schedule, or [\"all\"]. A draft whose time has passed moves to the next free posting slot."
},
"discard": {
"type": "array",
"items": {
"type": "string"
},
"description": "REVIEW: draft ids to delete, or [\"all\"]. Nothing is posted for them."
}
}
}| source |
| string |
| "generated" (default) = an AI-made person; "upload" / "social" = a REAL person |
| useAnyway | boolean | Only for a creator whose saved photo was flagged too unclear to cast: true casts the current photo as it is, no new image needed |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"name"
],
"properties": {
"look": {
"type": "string",
"description": "their canonical wardrobe/appearance in words — reused to hold the look steady across ads"
},
"name": {
"type": "string",
"description": "what to call this creator (e.g. “Sarah”) — list_creators and the app’s picker match on it"
},
"brand": {
"type": "string",
"description": "profile id/name from list_brands, this call only"
},
"image": {
"type": "string",
"description": "REQUIRED except with useAnyway. public https url of the portrait (an existing render’s url, or any public photo). Not a local file path — upload it with upload_file first and save the url that returns"
},
"poses": {
"type": "array",
"items": {
"type": "string"
},
"description": "up to 4 extra full-body / angle plates of the SAME person (public urls) — they make a wider shot hold the identity"
},
"voice": {
"type": "string",
"description": "a default voice name for this persona (engines + voices are in hermoso_capabilities)"
},
"source": {
"enum": [
"generated",
"upload",
"social"
],
"type": "string",
"description": "\"generated\" (default) = an AI-made person; \"upload\" / \"social\" = a REAL person"
},
"useAnyway": {
"type": "boolean",
"description": "Only for a creator whose saved photo was flagged too unclear to cast: true casts the current photo as it is, no new image needed"
}
}
}| enabled | boolean | false pauses the automation without deleting it |
| message | object | what the person receives: {text, buttons}; on X the text is the public reply, up to 280 characters |
| trigger | object | what starts the rule: {type, keywords, match, scope, mediaId, ...}; keywords are case-insensitive |
| followGate | object | Instagram only: followers get the content after tapping a button |
| cooldownHours | number | DM/ref rules: answer one person at most once per this many hours (default 24) |
| publicReplies | array | Instagram/Facebook comment rules: public replies under the comment, rotated |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"id": {
"type": "string",
"description": "rule id to edit; omit to create a new automation"
},
"name": {
"type": "string",
"description": "name of the automation, shown in the list"
},
"dryRun": {
"type": "boolean",
"description": "log what would be sent without sending"
},
"account": {
"type": "string",
"description": "the account on that channel: @handle (Instagram, X) or Page name/id (Facebook); omit when only one is connected"
},
"channel": {
"enum": [
"instagram",
"facebook",
"x"
],
"type": "string",
"description": "instagram (default), facebook (a Facebook Page: comments on its posts and ads, plus Messenger) or x"
},
"enabled": {
"type": "boolean",
"description": "false pauses the automation without deleting it"
},
"message": {
"type": "object",
"required": [
"text"
],
"properties": {
"text": {
"type": "string",
"description": "what they receive; on X the public reply (280) or, for a dm rule, the DM"
},
"buttons": {
"type": "array",
"items": {
"type": "object",
"required": [
"title",
"url"
],
"properties": {
"url": {
"type": "string",
"description": "https link"
},
"title": {
"type": "string",
"description": "up to 20 characters"
}
}
},
"description": "up to 3 link buttons (Instagram, Facebook; X has none)"
}
},
"description": "what the person receives: {text, buttons}; on X the text is the public reply, up to 280 characters"
},
"trigger": {
"type": "object",
"required": [
"type"
],
"properties": {
"ref": {
"type": "string",
"description": "ig.me/m/<handle>?ref=<ref> or m.me/<page>?ref=<ref>"
},
"type": {
"enum": [
"comment",
"dm",
"story_reply",
"ref",
"mention"
],
"type": "string",
"description": "comment (all; on X a reply under your post), dm (all), story_reply (Instagram), ref (ig.me/m.me link: Instagram, Facebook), mention (X)"
},
"match": {
"enum": [
"contains",
"exact"
],
"type": "string",
"description": "contains (keyword appears in the text) or exact (whole text must equal the keyword)"
},
"scope": {
"enum": [
"post",
"next",
"any"
],
"type": "string",
"description": "comment rules: one post (mediaId), the NEXT post you publish, or every post"
},
"mediaId": {
"type": "string",
"description": "Instagram media id, Facebook post id (an ad: its effective_object_story_id) or X post id"
},
"keywords": {
"type": "array",
"items": {
"type": "string"
},
"description": "case-insensitive words/phrases; empty = any comment (DM and mention rules need one)"
},
"mediaType": {
"enum": [
"REELS",
"FEED"
],
"type": "string",
"description": "Instagram scope \"next\" only"
}
},
"description": "what starts the rule: {type, keywords, match, scope, mediaId, ...}; keywords are case-insensitive"
},
"followGate": {
"type": "object",
"required": [
"enabled"
],
"properties": {
"ask": {
"type": "string",
"description": "message asking the person to follow the account"
},
"button": {
"type": "string",
"description": "label of the button the person taps once they follow"
},
"enabled": {
"type": "boolean",
"description": "true = non-followers see the follow prompt before the content (Instagram only)"
},
"notFollowing": {
"type": "string",
"description": "message sent to someone who does not follow yet"
}
},
"description": "Instagram only: followers get the content after tapping a button"
},
"cooldownHours": {
"type": "number",
"description": "DM/ref rules: answer one person at most once per this many hours (default 24)"
},
"publicReplies": {
"type": "array",
"items": {
"type": "string"
},
"description": "Instagram/Facebook comment rules: public replies under the comment, rotated"
}
}
}| object |
| send the content only to followers: they tap a button first |
| cooldownHours | number | DM/story/ref rules: answer one person at most once per this many hours (default 24) |
| publicReplies | array | comment rules: public replies under the comment, rotated (e.g. "Sent! Check your DMs") |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"id": {
"type": "string",
"description": "rule id to edit; omit to create a new automation"
},
"name": {
"type": "string",
"description": "name of the automation, shown in the list"
},
"dryRun": {
"type": "boolean",
"description": "log what would be sent without sending"
},
"account": {
"type": "string",
"description": "@handle or id of the Instagram account; omit when only one is connected"
},
"enabled": {
"type": "boolean",
"description": "false pauses the automation without deleting it"
},
"message": {
"type": "object",
"required": [
"text"
],
"properties": {
"text": {
"type": "string",
"description": "the DM they receive"
},
"buttons": {
"type": "array",
"items": {
"type": "object",
"required": [
"title",
"url"
],
"properties": {
"url": {
"type": "string",
"description": "https link"
},
"title": {
"type": "string",
"description": "up to 20 characters"
}
}
},
"description": "up to 3 link buttons"
}
},
"description": "what the person receives: {text, buttons}; up to 3 link buttons"
},
"trigger": {
"type": "object",
"required": [
"type"
],
"properties": {
"ref": {
"type": "string",
"description": "ref rules: the ref in ig.me/m/<handle>?ref=<ref>"
},
"type": {
"enum": [
"comment",
"dm",
"story_reply",
"ref"
],
"type": "string",
"description": "comment (keyword on a post comment), dm (keyword in a DM), story_reply, or ref (ig.me link)"
},
"match": {
"enum": [
"contains",
"exact"
],
"type": "string",
"description": "contains (keyword appears in the text) or exact (whole text must equal the keyword)"
},
"scope": {
"enum": [
"post",
"next",
"any"
],
"type": "string",
"description": "comment rules: one post (mediaId), the NEXT post you publish, or every post"
},
"mediaId": {
"type": "string",
"description": "the post for scope \"post\" (list_instagram_media gives ids)"
},
"keywords": {
"type": "array",
"items": {
"type": "string"
},
"description": "case-insensitive words/phrases; empty = any comment/story reply (DM rules need one)"
},
"mediaType": {
"enum": [
"REELS",
"FEED"
],
"type": "string",
"description": "scope \"next\": only the next reel, or only the next feed post"
}
},
"description": "what starts the rule: {type, keywords, match, scope, ...}; keywords are case-insensitive"
},
"followGate": {
"type": "object",
"required": [
"enabled"
],
"properties": {
"ask": {
"type": "string",
"description": "message asking the person to follow the account"
},
"button": {
"type": "string",
"description": "label of the button the person taps once they follow"
},
"enabled": {
"type": "boolean",
"description": "true = non-followers see the follow prompt before the content"
},
"notFollowing": {
"type": "string",
"description": "message sent to someone who does not follow yet"
}
},
"description": "send the content only to followers: they tap a button first"
},
"cooldownHours": {
"type": "number",
"description": "DM/story/ref rules: answer one person at most once per this many hours (default 24)"
},
"publicReplies": {
"type": "array",
"items": {
"type": "string"
},
"description": "comment rules: public replies under the comment, rotated (e.g. \"Sent! Check your DMs\")"
}
}
}{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"icon": {
"type": "string",
"description": "one emoji"
},
"title": {
"type": "string",
"description": "title of the new Notion page"
},
"content": {
"type": "string",
"description": "markdown body"
},
"databaseId": {
"type": "string",
"description": "database id or link: saves as a new row"
},
"dataSourceId": {
"type": "string",
"description": "data source id to save into as a new row; pass only one of parentPageId, databaseId, dataSourceId"
},
"parentPageId": {
"type": "string",
"description": "page id or link to save under"
},
"includeSchedule": {
"type": "boolean",
"description": "add the scheduled posts"
},
"swipefileCollection": {
"type": "string",
"description": "swipefile collection name or id"
}
}
}| Argument | Type | Description |
|---|---|---|
| at | string | when to post: local wall clock, no offset (2026-10-30T10:00), read in `timezone` or the brand’s zone ON THAT DATE so a series survives a clock change; or UTC (…Z) / epoch ms. Future, at most 365 days out. Give this OR useQueue, never both. |
| hook | string | WHAT ANGLE this post is built on, recorded only at publish time. Omit it and this post never votes on which hook works. |
| link | string | a link to attach (Facebook) |
| poll | object | X — attach a poll: {options:["…","…"], durationMinutes}. 2–4 options of at most 25 characters each; voting runs 5–10080 minutes (7 days), default 1440. X makes a poll MUTUALLY EXCLUSIVE with media, so an item carrying an image or video is refused — schedule the poll as its own X-only post. |
| tags | array | YOUTUBE — up to 30 search tags for the video (plain words, no #). |
| 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. |
| event | object | GOOGLE BUSINESS — required for an EVENT or OFFER post: {title, startDate:"YYYY-MM-DD", endDate, startTime:"HH:MM", endTime}. `title` is the EVENT’s headline, a different thing from the post `title` (which is the Pinterest/YouTube one). Google documents its TimeInterval as needing all four date/time parts to be valid, so send the times whenever you know them. |
| offer | object | GOOGLE BUSINESS — OFFER posts only: {couponCode, redeemOnlineUrl, termsConditions}. redeemOnlineUrl is where an offer actually sends people, since the button link is ignored on an Offer. |
| place | string | FACEBOOK — tag a location on the Page post. |
| story | boolean | INSTAGRAM OR FACEBOOK PAGE STORY — publish this as a 24-hour Story instead of a feed post. One image OR one video: Instagram has no carousel story, so a carousel is REFUSED BY NAME rather than quietly posted to the feed. Link stickers are not available on a Story posted here. An Instagram Story keeps its caption: people see the start under the Story in the mobile app and tap for the rest. It takes NO collaborators, product tags or trialReel — any of those is refused by name and nothing is posted. FACEBOOK PAGE STORIES WORK TOO (target facebook): one image, or one vertical 9:16 video of 3–90 seconds, no caption (a message is refused by name), not schedulable natively — use schedule_post to queue one; the reply reads the story back from the Page. |
| title | string | PINTEREST / YOUTUBE — the headline, max 100 characters. Pinterest shows it in search and under the pin; YouTube requires one. Leave it out and Hermoso derives one from that channel’s caption (first sentence, cut on a word boundary, trailing hashtags dropped) — set a real one whenever the caption does not open with a usable headline. |
| chatId | string | TELEGRAM — REQUIRED whenever telegram is a channel: WHICH chat, group or channel the bot posts to. A public channel’s @username (@yourchannel) or the numeric id (a group is negative; a supergroup or channel starts with -100). There is no default and there cannot be one — the Telegram Bot API publishes no method that lists a bot’s chats — so scheduling telegram without one is refused up front. list_telegram_chats finds ids for chats that have messaged the bot in the last 24 hours. |
| ideaId | string | short id of the content-plan idea this post came from |
| pageId | string | FACEBOOK / INSTAGRAM / THREADS — which connected Facebook Page (and its linked Instagram) publishes, from list_meta_pages. Needed when the profile has more than one Page connected: a Facebook post with several and no id is refused WHEN YOU SCHEDULE IT, with the Pages listed by name and id, so ask the user which Page and schedule again with its id. It is never sent from a guessed Page. |
| recipe | string | the post's FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format |
| silent | boolean | TELEGRAM / DISCORD — no notification. |
| thread | array | X — publish a THREAD, one entry per post, each replying to the one before (at most 25). 280 characters per part without X Premium, up to 25,000 with it; nothing is truncated. It REPLACES the X caption: with a thread set, `message`/`captions.x` is not sent to X at all. A thread cannot carry a poll. |
| wpSlug | string | WORDPRESS — the post slug |
| wpTags | array | WORDPRESS — tags (names or ids) |
| altText | any | ACCESSIBILITY — the screen-reader description of the attached image. ONE STRING describes the picture; on a CAROUSEL it describes EVERY slide. Pass an ARRAY of strings instead to describe each slide separately, aligned to the slide order — use that on a multi-slide post, because one sentence read out over six different pictures is wrong for five of them. More descriptions than pictures is refused rather than dropped. Write one whenever the post carries an image: describe what is IN the picture, never a repeat of the caption, which a screen reader already reads. CARRIED BY: X (max 1000, one per media), Pinterest (max 500 — PIN-LEVEL only, since its API has no per-item alt text, so slide 1’s description is used for the whole Pin and the result says the others were not sent), LinkedIn COMPANY PAGES (max 4086, one per slide), INSTAGRAM image posts and image slides (max 1000), FACEBOOK photos and albums, and BLUESKY, whose lexicon makes it REQUIRED on every image. The schedule is REFUSED if the LONGEST description exceeds the tightest of the channels on it, rather than truncated on the way out. NOT CARRIED, and none of these is a refusal — the post still publishes, just undescribed there, and the per-channel result says which: TikTok (its photo post has no alt field at any level), a THREADS CAROUSEL, an INSTAGRAM Reel or video slide, and a LinkedIn PERSONAL-profile post. |
| audioId | string | INSTAGRAM REEL — put one of Instagram’s OWN licensed music tracks under the Reel: the `id` search_instagram_audio returns. Instagram mixes it in when the Reel is published; nothing is downloaded or re-rendered. REELS ONLY, and only on an Instagram account connected through Meta (a Facebook Page with a linked Instagram), which is Instagram’s own rule — the Instagram connector cannot take it and is refused by name. |
| boardId | string | PINTEREST — REQUIRED whenever pinterest is a channel: the board the Pin goes on, from list_pinterest_boards. The user picks it; a Pin on the wrong board is a public mistake. Scheduling pinterest without one is refused immediately. |
| buttons | array | TELEGRAM ONLY — URL buttons under the post, as post_to_telegram takes them; never on an album. |
| excerpt | string | WORDPRESS — the excerpt under the title |
| message | string | the caption/text used for every channel unless overridden in captions |
| 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. |
| accounts | object | WHICH accounts of a multi-account channel to post to, e.g. { "tiktok": ["@a", "@b"] } or { "tiktok": "all" } — one row per account at fire time, each with its own result. Omit for channels with one account (several and none named is refused by name). |
| 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. |
| captions | object | per-channel caption overrides, e.g. { "instagram": "…", "threads": "…" } — platforms want different lengths and hashtag conventions |
| channels* | array | one or more channels to post to at that time |
| 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. |
| imageUrl | string | a Hermoso render URL (a /generated path, or what upload_file returned), a data: URI, or a public https URL. NOTE: only Facebook/Instagram/Threads accept an arbitrary public URL — X, TikTok, YouTube, LinkedIn, Pinterest and Google Business re-host the bytes and REFUSE anything that is not a Hermoso render, so run an external file through upload_file first and schedule that url; a URL, not a local file path; use upload_file for local files |
| 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. |
| timezone | string | IANA zone (e.g. "Africa/Cairo") a wall-clock `at` or the queue is read in; overrides the brand’s saved zone for this one post. Without either, UTC. |
| topicTag | string | THREADS ONLY — one topic tag for the post, without the leading #. |
| useQueue | boolean | instead of naming a minute, drop this into the brand’s POSTING QUEUE: Hermoso takes the earliest of its saved posting times that is still free (skipping any slot another queued post already holds). This is the natural answer to “just queue it” / “post it at my next opening”. Mutually exclusive with `at` — passing both is refused rather than one being silently preferred. If the profile has no posting times set, or every slot for the next 90 days is taken, it is refused by name and nothing is scheduled. |
| userTags | array | INSTAGRAM USER TAGS — tag people (public accounts) on the post; each is notified. Usernames, or { username, x, y } with x/y from 0 to 1 = where the tag sits on the IMAGE (a tag without a position sits at the centre). A Reel tag takes no position; a story position is optional. Not on a carousel (refused by name). |
| videoUrl | string | a Hermoso render URL (a /generated path, or what upload_file returned), a data: URI, or a public https URL — required for youtube, and for tiktok unless you pass an imageUrl (TikTok takes a photo post too). Same origin rule as imageUrl: everything except Facebook/Instagram/Threads REFUSES a non-Hermoso URL, so pass external video through upload_file first; a URL, not a local file path; use upload_file for local files |
| whopPoll | array | WHOP FORUM POSTS ONLY — a poll: 2 to 10 options, in order. |
| xArticle | object | X: publish the X item as a long-form X ARTICLE. title is its headline, the X text (message or captions.x) its markdown body, the image its cover. Refused now if the markdown has formatting X cannot hold. X allows about 5 Articles a day; one that fires into that cap fails with the reset time and can be retried. |
| audioName | string | INSTAGRAM REEL — the name of the Reel’s audio track, which is what viewers tap through to. REELS ONLY. |
| coverAtMs | number | THE VIDEO COVER on EVERY channel of this post, as ONE frame: milliseconds from the start (7000 = the frame at 7s). Instagram, TikTok, Facebook, LinkedIn Page, Pinterest, Telegram and YouTube all get that frame; X, Threads and Bluesky have no cover setting. A channel’s own field (thumbOffset, coverTimestampMs, coverUrl) wins there and otherwise counts as this. |
| imageUrls | array | CAROUSEL — an ORDERED list of image URLs to publish as ONE swipeable post on every channel that supports it (Instagram 2–20, Threads 2–20, Facebook, LinkedIn company Pages 2–20, Pinterest 2–5, TikTok up to 35 as a photo post). Use this whenever the creative is a multi-slide deck: a scheduled post carrying only slide 1 of a “1/6 · SWIPE” set is a broken ad that nobody is watching when it fires. THE ORDER IS THE PRODUCT. A channel on this schedule that cannot do carousels — X, YouTube, Google Business Profile — is REFUSED NOW, with the reason, so you can drop it or give it its own single image; it is never quietly downgraded hours later. |
| parseMode | string | TELEGRAM ONLY — 'HTML' for Telegram's HTML tags; omit for plain text. |
| placeName | string | the name the user saw for that place; a different business is refused |
| slideText | array | PINTEREST CAROUSEL ONLY — per-slide title / description / link, aligned to slide order. Every other platform takes ONE caption for the whole carousel. |
| topicType | string | GOOGLE BUSINESS — the KIND of Post. STANDARD is the default; EVENT and OFFER both REQUIRE `event` (title + start date), and OFFER also takes `offer`. |
| trialReel | string | INSTAGRAM TRIAL REEL — publish this Reel to NON-FOLLOWERS ONLY at first (Instagram allows trials only on accounts above its follower threshold — about 1,000 followers; an ineligible account is refused by name and nothing is posted), so a hook can be tested on a cold audience without spending it on the people who already follow the brand; Instagram shows it to followers only if it graduates. MANUAL = the creator graduates it by hand in the Instagram app; SS_PERFORMANCE = Instagram graduates it automatically if it performs. REELS ONLY and INSTAGRAM ONLY: an image, a carousel, or a Facebook/Threads channel is REFUSED BY NAME rather than quietly published as an ordinary post — a trial that silently goes to every follower is the exact opposite of what was asked for, so Instagram must be one of the `channels` and the item must carry a video. Omit it for a normal Reel. |
| yourBrand | boolean | TIKTOK — the OWN-BRAND disclosure (brand_organic_toggle): true when the post promotes the creator’s own business. TikTok asks for at least one of this and brandedContent once a post is commercial. |
| actionType | string | GOOGLE BUSINESS — the call-to-action button. Every button except CALL needs `link` (CALL dials the number on the listing and takes none). Google IGNORES the button link on an OFFER post — put the destination in offer.redeemOnlineUrl. Omit and a post carrying a link gets LEARN_MORE. |
| locationId | string | GOOGLE BUSINESS PROFILE — which listing, e.g. 'locations/123' from list_business_locations. Needed when the account manages more than one storefront; it is never chosen for the user. |
| madeWithAi | boolean | X — X’s AI-media label on this post. Opt-in: X treats it as the poster’s own claim about their media, so it is never set on the user’s behalf. |
| threadName | string | DISCORD: forum thread title |
| threadsGif | string | THREADS ONLY — a GIPHY GIF id (the id in a giphy.com/gifs/… link) attached to a TEXT Threads post. GIPHY is the only provider Threads takes. |
| visibility | string | how it should be published — DEFAULT 'public' (live). Only pass something else if the user explicitly asked to stage/hide it. Not every channel supports every value; an impossible combination is refused when you schedule it, with the reason. |
| whopPinned | boolean | WHOP FORUM POSTS ONLY — pin it to the top of the forum. |
| aiGenerated | boolean | AI-CONTENT DISCLOSURE (Instagram / Facebook Reel is_ai_generated, TikTok is_aigc, YouTube containsSyntheticMedia, Pinterest ai_disclosures, Threads is_made_with_ai). OMIT IT and Hermoso decides from provenance: a Hermoso render is declared AI-generated, media that came through upload_file or from an external URL (the user’s own photos or footage) is NOT. Pass true or false only to override. |
| audioVolume | integer | INSTAGRAM REEL — how loud that track plays, 0–100 (Instagram’s default 100). Needs audioId. |
| communityId | string | X — publish into an X COMMUNITY instead of the main timeline: the number in the community’s own URL (x.com/i/communities/<id>). The connected account must be a MEMBER of it. |
| description | string | YOUTUBE: the video DESCRIPTION, max 5000 characters, carrying the links, the CTA and what YouTube search reads. Omit it and the caption is used. |
| disableDuet | boolean | TIKTOK VIDEO ONLY — block Duets. TikTok’s photo-post API has no Duets, so this is refused on a photo/slideshow item rather than silently dropped. |
| 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. |
| quotePostId | string | THREADS ONLY — the id of the Threads post this one quotes. |
| 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. |
| 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. |
| videoVolume | integer | INSTAGRAM REEL — how loud the video’s own sound plays under the track, 0–100 (Instagram’s default 100; 0 = the track alone). Needs audioId. |
| whopMention | boolean | WHOP FORUM POSTS ONLY — notify every member (off by default). |
| brandPartner | string | YOUTUBE: sponsoring brand channel (UC… or @handle) |
| callToAction | string | 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. |
| countryCodes | array | THREADS ONLY — two-letter country codes to limit who can see the post. Omit to show it everywhere. |
| firstComment | string | FIRST COMMENT — posted on each new post as the same account right after it publishes, on Facebook, Instagram and LinkedIn company Pages (LinkedIn needs its comments authorization, provider linkedin_community). Other channels publish without it and their result row says so. Up to 2,000 characters, refused rather than cut; never on a story. A failed comment never undoes the post. |
| optimizeCopy | boolean | RECOMMENDED when one caption goes to several channels: fit the shared caption to each channel’s own rules when you schedule it (the fitted caption is stored on the scheduled post, so what you scheduled is what publishes) wherever no per-channel caption was written. The angle and every claim stay the author’s; a channel with its own caption is left exactly as written. Off by default so nobody’s words are rewritten unasked. |
| privacyLevel | string | TIKTOK — WHICH PRIVACY LEVEL the post goes out at, in TikTok’s own vocabulary. TikTok requires the USER to choose this from the levels their own account allows: call tiktok_creator_info, show them the real options, and pass back the one they picked — never a default and never a guess, which TikTok refuses at init. Omit it and the post falls back to the coarse `visibility` (private -> SELF_ONLY, otherwise PUBLIC_TO_EVERYONE), which cannot express MUTUAL_FOLLOW_FRIENDS or FOLLOWER_OF_CREATOR at all. It must agree with the TikTok visibility (SELF_ONLY is the private one) and it is refused on a TikTok DRAFT, which carries no post info. |
| replyControl | string | THREADS ONLY — who may reply. Omit for Threads' own default (everyone). |
| slackChannel | string | SLACK app: channel id or #name (default: the brand's) |
| thumbnailUrl | string | YOUTUBE: the custom thumbnail, a Hermoso-hosted image. Omit it and a frame of the video is used; "auto" keeps YouTube’s pick. |
| wpCategories | array | WORDPRESS — categories (names or ids) |
| xQuotePostId | string | X — the numeric id of an X post this one QUOTES: the last part of its URL. NAMED xQuotePostId, NOT quotePostId, because `quotePostId` on this same schedule belongs to THREADS. Billed at X’s higher LINK rate. |
| collaborators | array | INSTAGRAM — a COLLAB post: up to 3 Instagram usernames invited to CO-AUTHOR it, so it appears on their profile too once they accept, with both handles in the header and the engagement shared. Handles only ("hermosoai"); a leading @ is fine. Instagram must be one of the `channels` — asking for collaborators on a schedule Instagram is not on is REFUSED now rather than discovered when it fires, and the other channels in a mixed schedule simply publish without co-authors. THE INVITE IS SENT WHEN THE POST FIRES, not when you schedule it, and it is PENDING until the other account accepts in their notifications; check with instagram_collaborators afterwards rather than telling the user it is live on both profiles. |
| coverImageUrl | string | THE VIDEO COVER as a picture instead of a frame — a Hermoso-hosted image (upload_file). Every channel that takes a cover image gets it (Instagram, Facebook, LinkedIn Page, Pinterest, Telegram, YouTube); TikTok takes only a frame. Never together with coverAtMs. |
| disableStitch | boolean | TIKTOK VIDEO ONLY — block Stitches. Same photo-post rule as disableDuet. |
| discordServer | string | DISCORD bot: which server |
| instagramPoll | array | INSTAGRAM — attach a POLL: 2 to 4 answers of 1–25 characters each, and the caption IS the question (so a caption is required). Feed photos, carousels and Reels only (never a story), one caption add-on per post (not with instagramCommentPrompt), and only on an account connected through Meta (a Facebook Page with a linked Instagram). WRITE-ONCE: Instagram cannot add, change or remove it after publishing, so show the user the answers first. |
| 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 on every channel that allows one (Instagram, Facebook, TikTok direct posts, LinkedIn Pages, Pinterest, Telegram, YouTube) — and on X, Threads and Bluesky, which have none, a blank first frame is replaced on a copy sent there only. true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins. |
| replySettings | string | X — who may reply. Omit for everyone, which is the right default for a brand post. |
| slackThreadTs | string | SLACK app: reply in this thread (parent ts) |
| brandedContent | boolean | TIKTOK — the PAID-PARTNERSHIP disclosure (TikTok’s brand_content_toggle): true when this post promotes a THIRD-PARTY business. It is a compliance declaration, not a preference — set it whenever the post is sponsored. TikTok only accepts branded content on a public or friends-only post, so it cannot ride a private/SELF_ONLY or draft TikTok item and the schedule is refused with the reason. |
| disableComment | boolean | TIKTOK — turn comments off on this post. |
| disablePreview | boolean | TELEGRAM ONLY — no link preview. |
| discordChannel | string | DISCORD bot: channel id or #name (default: the brand's) |
| discordReplyTo | string | DISCORD bot: reply to this message id |
| linkAttachment | string | THREADS ONLY — a full http(s) URL rendered as a link card. This is the ONLY way a Threads post carries a destination, and Threads attaches it to TEXT-ONLY posts (a post with media cannot also carry a card). |
| slackWorkspace | string | SLACK app: which workspace |
| targetAudience | object | LINKEDIN COMPANY PAGE POST ONLY — show the post only to Page followers matching these facets. LinkedIn requires the matching audience to be over 300 followers and refuses a smaller one. Personal-profile posts cannot be targeted. |
| whopVisibility | string | WHOP FORUM POSTS ONLY — who can see it; unset is the forum’s own default. |
| discordMentions | boolean | DISCORD: let mentions ping |
| 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. |
| paidPartnership | boolean | INSTAGRAM AND X — the PAID PARTNERSHIP label, a compliance declaration: set it when the post is sponsored, gifted or otherwise paid for. OPT-IN ONLY, never assume it on the user’s behalf. On Instagram, brandedContentSponsorIds names the brands behind it. |
| threadsLongText | string | THREADS ONLY — LONG-FORM text, up to 10,000 characters, attached under a TEXT-only Threads post (Threads' text attachment). The way to publish more than Threads' 500-character post: keep a short lead in the message and put the full text here. Not with an image, video or carousel. |
| callToActionLink | string | FACEBOOK — where the button goes, when that is not the post’s own `link`. |
| coverTimestampMs | number | TIKTOK VIDEO ONLY — which frame TikTok uses as the cover, in milliseconds from the start. Omit and Hermoso uses the video’s best frame (platformCover:true leaves it to TikTok, which uses the first frame). |
| crossreshareToIg | boolean | THREADS ONLY — when this fires, ALSO share it to the linked Instagram account as a STORY. Refused on a Threads carousel. No confirmation exists that the Story was created, so the result says it was requested. |
| threadsGhostPost | boolean | THREADS ONLY — a GHOST post: text only, Threads archives it automatically after 24 hours (a flash offer, a 24h-only note). No other Threads option can ride it. |
| whopExperienceId | string | WHOP — which forum or chat in the whop (exp_…, from list_whop_destinations). Omit it when the whop has only one; with several and none named the post is refused at fire time, never guessed. |
| whopPaywallCents | integer | WHOP FORUM POSTS ONLY — a paywall in cents (500 = $5.00): readers pay to see the body; needs a title. |
| commercialContent | boolean | TIKTOK — the COMMERCIAL CONTENT DISCLOSURE toggle: true when the post promotes a brand, product or service at all. TikTok requires at least one of yourBrand / brandedContent alongside it, and a post declaring itself commercial without naming which kind is refused. Setting either disclosure already implies this, so it is only needed to be explicit. |
| discordStartThread | string | DISCORD bot: open a thread under the post, this title |
| instagramLocationId | string | INSTAGRAM — tag a place (called locationId on post_to_meta; locationId here is the Google Business listing). A non-numeric value is refused rather than sent. |
| visibilityByChannel | object | override visibility for one channel, e.g. { "tiktok": "draft" } to go live everywhere but stage TikTok for review |
| whopPaywallCurrency | string | WHOP — the paywall currency, a 3-letter code (default usd). |
| crossreshareDarkMode | boolean | THREADS ONLY — render that Instagram Story in dark mode. Needs crossreshareToIg. |
| paidProductPlacement | boolean | YOUTUBE: paid product placement (shows the disclosure) |
| instagramPollExtended | boolean | INSTAGRAM — give that poll Instagram’s extended voting duration instead of its default 3 days (Instagram’s changelog says it then stays open indefinitely). Only with instagramPoll. |
| instagramCommentPrompt | boolean | INSTAGRAM — make the caption a COMMENT PROMPT that people answer in the comments. Same limits as instagramPoll, and never together with it. |
| linkedinOrganizationId | string | LINKEDIN — publish as a COMPANY PAGE instead of the connected personal profile. The organization id from list_linkedin_pages. Omit and it posts as the person: an unset id means the profile, never “probably the company”. A Page can also carry VIDEO, which a personal profile cannot. |
| brandedContentSponsorIds | array | 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. |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"channels"
],
"properties": {
"at": {
"type": "string",
"description": "when to post: local wall clock, no offset (2026-10-30T10:00), read in `timezone` or the brand’s zone ON THAT DATE so a series survives a clock change; or UTC (…Z) / epoch ms. Future, at most 365 days out. Give this OR useQueue, never both."
},
"hook": {
"type": "string",
"description": "WHAT ANGLE this post is built on, recorded only at publish time. Omit it and this post never votes on which hook works."
},
"link": {
"type": "string",
"description": "a link to attach (Facebook)"
},
"poll": {
"type": "object",
"required": [
"options"
],
"properties": {
"options": {
"type": "array",
"items": {
"type": "string"
},
"description": "X poll choices: 2 to 4 options, max 25 characters each"
},
"durationMinutes": {
"type": "number",
"description": "X poll length in minutes, 5 to 10080; default 1440 (one day)"
}
},
"description": "X — attach a poll: {options:[\"…\",\"…\"], durationMinutes}. 2–4 options of at most 25 characters each; voting runs 5–10080 minutes (7 days), default 1440. X makes a poll MUTUALLY EXCLUSIVE with media, so an item carrying an image or video is refused — schedule the poll as its own X-only post."
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "YOUTUBE — up to 30 search tags for the video (plain words, no #)."
},
"brand": {
"type": "string",
"description": "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."
},
"event": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Google Business event title; required for an event or offer post"
},
"endDate": {
"type": "string",
"description": "event end date, e.g. 2026-11-01"
},
"endTime": {
"type": "string",
"description": "event end time, e.g. 20:00"
},
"startDate": {
"type": "string",
"description": "event start date, e.g. 2026-11-01"
},
"startTime": {
"type": "string",
"description": "event start time, e.g. 18:00"
}
},
"description": "GOOGLE BUSINESS — required for an EVENT or OFFER post: {title, startDate:\"YYYY-MM-DD\", endDate, startTime:\"HH:MM\", endTime}. `title` is the EVENT’s headline, a different thing from the post `title` (which is the Pinterest/YouTube one). Google documents its TimeInterval as needing all four date/time parts to be valid, so send the times whenever you know them."
},
"offer": {
"type": "object",
"properties": {
"couponCode": {
"type": "string",
"description": "Google Business offer coupon code"
},
"redeemOnlineUrl": {
"type": "string",
"description": "link where the offer is redeemed online"
},
"termsConditions": {
"type": "string",
"description": "terms text shown with the offer"
}
},
"description": "GOOGLE BUSINESS — OFFER posts only: {couponCode, redeemOnlineUrl, termsConditions}. redeemOnlineUrl is where an offer actually sends people, since the button link is ignored on an Offer."
},
"place": {
"type": "string",
"description": "FACEBOOK — tag a location on the Page post."
},
"story": {
"type": "boolean",
"description": "INSTAGRAM OR FACEBOOK PAGE STORY — publish this as a 24-hour Story instead of a feed post. One image OR one video: Instagram has no carousel story, so a carousel is REFUSED BY NAME rather than quietly posted to the feed. Link stickers are not available on a Story posted here. An Instagram Story keeps its caption: people see the start under the Story in the mobile app and tap for the rest. It takes NO collaborators, product tags or trialReel — any of those is refused by name and nothing is posted. FACEBOOK PAGE STORIES WORK TOO (target facebook): one image, or one vertical 9:16 video of 3–90 seconds, no caption (a message is refused by name), not schedulable natively — use schedule_post to queue one; the reply reads the story back from the Page."
},
"title": {
"type": "string",
"description": "PINTEREST / YOUTUBE — the headline, max 100 characters. Pinterest shows it in search and under the pin; YouTube requires one. Leave it out and Hermoso derives one from that channel’s caption (first sentence, cut on a word boundary, trailing hashtags dropped) — set a real one whenever the caption does not open with a usable headline."
},
"chatId": {
"type": "string",
"description": "TELEGRAM — REQUIRED whenever telegram is a channel: WHICH chat, group or channel the bot posts to. A public channel’s @username (@yourchannel) or the numeric id (a group is negative; a supergroup or channel starts with -100). There is no default and there cannot be one — the Telegram Bot API publishes no method that lists a bot’s chats — so scheduling telegram without one is refused up front. list_telegram_chats finds ids for chats that have messaged the bot in the last 24 hours."
},
"ideaId": {
"type": "string",
"description": "short id of the content-plan idea this post came from"
},
"pageId": {
"type": "string",
"description": "FACEBOOK / INSTAGRAM / THREADS — which connected Facebook Page (and its linked Instagram) publishes, from list_meta_pages. Needed when the profile has more than one Page connected: a Facebook post with several and no id is refused WHEN YOU SCHEDULE IT, with the Pages listed by name and id, so ask the user which Page and schedule again with its id. It is never sent from a guessed Page."
},
"recipe": {
"type": "string",
"description": "the post's FORMAT id, e.g. \"slideshow\" or \"imessage_chat\" — post_performance groups by it, so reuse one id per format"
},
"silent": {
"type": "boolean",
"description": "TELEGRAM / DISCORD — no notification."
},
"thread": {
"type": "array",
"items": {
"type": "string"
},
"description": "X — publish a THREAD, one entry per post, each replying to the one before (at most 25). 280 characters per part without X Premium, up to 25,000 with it; nothing is truncated. It REPLACES the X caption: with a thread set, `message`/`captions.x` is not sent to X at all. A thread cannot carry a poll."
},
"wpSlug": {
"type": "string",
"description": "WORDPRESS — the post slug"
},
"wpTags": {
"type": "array",
"items": {
"type": "string"
},
"description": "WORDPRESS — tags (names or ids)"
},
"altText": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
],
"description": "ACCESSIBILITY — the screen-reader description of the attached image. ONE STRING describes the picture; on a CAROUSEL it describes EVERY slide. Pass an ARRAY of strings instead to describe each slide separately, aligned to the slide order — use that on a multi-slide post, because one sentence read out over six different pictures is wrong for five of them. More descriptions than pictures is refused rather than dropped. Write one whenever the post carries an image: describe what is IN the picture, never a repeat of the caption, which a screen reader already reads. CARRIED BY: X (max 1000, one per media), Pinterest (max 500 — PIN-LEVEL only, since its API has no per-item alt text, so slide 1’s description is used for the whole Pin and the result says the others were not sent), LinkedIn COMPANY PAGES (max 4086, one per slide), INSTAGRAM image posts and image slides (max 1000), FACEBOOK photos and albums, and BLUESKY, whose lexicon makes it REQUIRED on every image. The schedule is REFUSED if the LONGEST description exceeds the tightest of the channels on it, rather than truncated on the way out. NOT CARRIED, and none of these is a refusal — the post still publishes, just undescribed there, and the per-channel result says which: TikTok (its photo post has no alt field at any level), a THREADS CAROUSEL, an INSTAGRAM Reel or video slide, and a LinkedIn PERSONAL-profile post."
},
"audioId": {
"type": "string",
"description": "INSTAGRAM REEL — put one of Instagram’s OWN licensed music tracks under the Reel: the `id` search_instagram_audio returns. Instagram mixes it in when the Reel is published; nothing is downloaded or re-rendered. REELS ONLY, and only on an Instagram account connected through Meta (a Facebook Page with a linked Instagram), which is Instagram’s own rule — the Instagram connector cannot take it and is refused by name."
},
"boardId": {
"type": "string",
"description": "PINTEREST — REQUIRED whenever pinterest is a channel: the board the Pin goes on, from list_pinterest_boards. The user picks it; a Pin on the wrong board is a public mistake. Scheduling pinterest without one is refused immediately."
},
"buttons": {
"type": "array",
"items": {
"anyOf": [
{
"type": "object",
"required": [
"text",
"url"
],
"properties": {
"url": {
"type": "string"
},
"text": {
"type": "string"
},
"style": {
"enum": [
"danger",
"success",
"primary"
],
"type": "string"
}
}
},
{
"type": "array",
"items": {
"type": "object",
"required": [
"text",
"url"
],
"properties": {
"url": {
"type": "string"
},
"text": {
"type": "string"
},
"style": {
"enum": [
"danger",
"success",
"primary"
],
"type": "string"
}
}
}
}
]
},
"description": "TELEGRAM ONLY — URL buttons under the post, as post_to_telegram takes them; never on an album."
},
"excerpt": {
"type": "string",
"description": "WORDPRESS — the excerpt under the title"
},
"message": {
"type": "string",
"description": "the caption/text used for every channel unless overridden in captions"
},
"subject": {
"type": "string",
"description": "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."
},
"accounts": {
"type": "object",
"description": "WHICH accounts of a multi-account channel to post to, e.g. { \"tiktok\": [\"@a\", \"@b\"] } or { \"tiktok\": \"all\" } — one row per account at fire time, each with its own result. Omit for channels with one account (several and none named is refused by name).",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"anyOf": [
{
"type": "array",
"items": {
"type": "string"
}
},
{
"type": "string",
"const": "all"
}
]
}
},
"audience": {
"type": "object",
"properties": {
"cities": {
"type": "array",
"items": {
"type": "string"
},
"description": "Meta location keys for cities"
},
"minAge": {
"anyOf": [
{
"type": "number",
"const": 13
},
{
"type": "number",
"const": 15
},
{
"type": "number",
"const": 18
},
{
"type": "number",
"const": 21
},
{
"type": "number",
"const": 25
}
],
"description": "Facebook Page post audience: minimum age, one of 13, 15, 18, 21 or 25"
},
"regions": {
"type": "array",
"items": {
"type": "string"
},
"description": "Meta location keys for regions/states"
},
"countries": {
"type": "array",
"items": {
"type": "string"
},
"description": "two-letter codes, e.g. [\"CA\",\"US\"]"
}
},
"description": "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."
},
"captions": {
"type": "object",
"description": "per-channel caption overrides, e.g. { \"instagram\": \"…\", \"threads\": \"…\" } — platforms want different lengths and hashtag conventions",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
},
"channels": {
"type": "array",
"items": {
"enum": [
"facebook",
"instagram",
"threads",
"tiktok",
"youtube",
"linkedin",
"x",
"pinterest",
"google_business",
"bluesky",
"telegram",
"wordpress",
"discord",
"slack",
"whop"
],
"type": "string"
},
"description": "one or more channels to post to at that time"
},
"coverUrl": {
"type": "string",
"description": "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."
},
"imageUrl": {
"type": "string",
"description": "a Hermoso render URL (a /generated path, or what upload_file returned), a data: URI, or a public https URL. NOTE: only Facebook/Instagram/Threads accept an arbitrary public URL — X, TikTok, YouTube, LinkedIn, Pinterest and Google Business re-host the bytes and REFUSE anything that is not a Hermoso render, so run an external file through upload_file first and schedule that url; a URL, not a local file path; use upload_file for local files"
},
"linkName": {
"type": "string",
"description": "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."
},
"timezone": {
"type": "string",
"description": "IANA zone (e.g. \"Africa/Cairo\") a wall-clock `at` or the queue is read in; overrides the brand’s saved zone for this one post. Without either, UTC."
},
"topicTag": {
"type": "string",
"description": "THREADS ONLY — one topic tag for the post, without the leading #."
},
"useQueue": {
"type": "boolean",
"description": "instead of naming a minute, drop this into the brand’s POSTING QUEUE: Hermoso takes the earliest of its saved posting times that is still free (skipping any slot another queued post already holds). This is the natural answer to “just queue it” / “post it at my next opening”. Mutually exclusive with `at` — passing both is refused rather than one being silently preferred. If the profile has no posting times set, or every slot for the next 90 days is taken, it is refused by name and nothing is scheduled."
},
"userTags": {
"type": "array",
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "object",
"required": [
"username"
],
"properties": {
"x": {
"type": "number",
"maximum": 1,
"minimum": 0
},
"y": {
"type": "number",
"maximum": 1,
"minimum": 0
},
"username": {
"type": "string"
}
}
}
]
},
"description": "INSTAGRAM USER TAGS — tag people (public accounts) on the post; each is notified. Usernames, or { username, x, y } with x/y from 0 to 1 = where the tag sits on the IMAGE (a tag without a position sits at the centre). A Reel tag takes no position; a story position is optional. Not on a carousel (refused by name)."
},
"videoUrl": {
"type": "string",
"description": "a Hermoso render URL (a /generated path, or what upload_file returned), a data: URI, or a public https URL — required for youtube, and for tiktok unless you pass an imageUrl (TikTok takes a photo post too). Same origin rule as imageUrl: everything except Facebook/Instagram/Threads REFUSES a non-Hermoso URL, so pass external video through upload_file first; a URL, not a local file path; use upload_file for local files"
},
"whopPoll": {
"type": "array",
"items": {
"type": "string"
},
"description": "WHOP FORUM POSTS ONLY — a poll: 2 to 10 options, in order."
},
"xArticle": {
"type": "object",
"required": [
"title"
],
"properties": {
"title": {
"type": "string",
"description": "X Article headline; the post text becomes the article body in markdown"
},
"headings": {
"enum": [
"blocks",
"text"
],
"type": "string",
"description": "blocks (default) keeps real headings; text turns headings into bold lines"
}
},
"description": "X: publish the X item as a long-form X ARTICLE. title is its headline, the X text (message or captions.x) its markdown body, the image its cover. Refused now if the markdown has formatting X cannot hold. X allows about 5 Articles a day; one that fires into that cap fails with the reset time and can be retried."
},
"audioName": {
"type": "string",
"description": "INSTAGRAM REEL — the name of the Reel’s audio track, which is what viewers tap through to. REELS ONLY."
},
"coverAtMs": {
"type": "number",
"description": "THE VIDEO COVER on EVERY channel of this post, as ONE frame: milliseconds from the start (7000 = the frame at 7s). Instagram, TikTok, Facebook, LinkedIn Page, Pinterest, Telegram and YouTube all get that frame; X, Threads and Bluesky have no cover setting. A channel’s own field (thumbOffset, coverTimestampMs, coverUrl) wins there and otherwise counts as this."
},
"imageUrls": {
"type": "array",
"items": {
"type": "string"
},
"description": "CAROUSEL — an ORDERED list of image URLs to publish as ONE swipeable post on every channel that supports it (Instagram 2–20, Threads 2–20, Facebook, LinkedIn company Pages 2–20, Pinterest 2–5, TikTok up to 35 as a photo post). Use this whenever the creative is a multi-slide deck: a scheduled post carrying only slide 1 of a “1/6 · SWIPE” set is a broken ad that nobody is watching when it fires. THE ORDER IS THE PRODUCT. A channel on this schedule that cannot do carousels — X, YouTube, Google Business Profile — is REFUSED NOW, with the reason, so you can drop it or give it its own single image; it is never quietly downgraded hours later."
},
"parseMode": {
"enum": [
"HTML"
],
"type": "string",
"description": "TELEGRAM ONLY — 'HTML' for Telegram's HTML tags; omit for plain text."
},
"placeName": {
"type": "string",
"description": "the name the user saw for that place; a different business is refused"
},
"slideText": {
"type": "array",
"items": {
"type": "object",
"properties": {
"link": {
"type": "string",
"description": "Pinterest carousel slide: destination URL for this slide"
},
"title": {
"type": "string",
"description": "Pinterest carousel slide: title for this slide"
},
"description": {
"type": "string",
"description": "Pinterest carousel slide: description for this slide"
}
}
},
"description": "PINTEREST CAROUSEL ONLY — per-slide title / description / link, aligned to slide order. Every other platform takes ONE caption for the whole carousel."
},
"topicType": {
"enum": [
"STANDARD",
"EVENT",
"OFFER",
"ALERT"
],
"type": "string",
"description": "GOOGLE BUSINESS — the KIND of Post. STANDARD is the default; EVENT and OFFER both REQUIRE `event` (title + start date), and OFFER also takes `offer`."
},
"trialReel": {
"enum": [
"MANUAL",
"SS_PERFORMANCE"
],
"type": "string",
"description": "INSTAGRAM TRIAL REEL — publish this Reel to NON-FOLLOWERS ONLY at first (Instagram allows trials only on accounts above its follower threshold — about 1,000 followers; an ineligible account is refused by name and nothing is posted), so a hook can be tested on a cold audience without spending it on the people who already follow the brand; Instagram shows it to followers only if it graduates. MANUAL = the creator graduates it by hand in the Instagram app; SS_PERFORMANCE = Instagram graduates it automatically if it performs. REELS ONLY and INSTAGRAM ONLY: an image, a carousel, or a Facebook/Threads channel is REFUSED BY NAME rather than quietly published as an ordinary post — a trial that silently goes to every follower is the exact opposite of what was asked for, so Instagram must be one of the `channels` and the item must carry a video. Omit it for a normal Reel."
},
"yourBrand": {
"type": "boolean",
"description": "TIKTOK — the OWN-BRAND disclosure (brand_organic_toggle): true when the post promotes the creator’s own business. TikTok asks for at least one of this and brandedContent once a post is commercial."
},
"actionType": {
"enum": [
"BOOK",
"ORDER",
"SHOP",
"LEARN_MORE",
"SIGN_UP",
"CALL"
],
"type": "string",
"description": "GOOGLE BUSINESS — the call-to-action button. Every button except CALL needs `link` (CALL dials the number on the listing and takes none). Google IGNORES the button link on an OFFER post — put the destination in offer.redeemOnlineUrl. Omit and a post carrying a link gets LEARN_MORE."
},
"locationId": {
"type": "string",
"description": "GOOGLE BUSINESS PROFILE — which listing, e.g. 'locations/123' from list_business_locations. Needed when the account manages more than one storefront; it is never chosen for the user."
},
"madeWithAi": {
"type": "boolean",
"description": "X — X’s AI-media label on this post. Opt-in: X treats it as the poster’s own claim about their media, so it is never set on the user’s behalf."
},
"threadName": {
"type": "string",
"description": "DISCORD: forum thread title"
},
"threadsGif": {
"type": "string",
"description": "THREADS ONLY — a GIPHY GIF id (the id in a giphy.com/gifs/… link) attached to a TEXT Threads post. GIPHY is the only provider Threads takes."
},
"visibility": {
"enum": [
"public",
"unlisted",
"private",
"draft"
],
"type": "string",
"description": "how it should be published — DEFAULT 'public' (live). Only pass something else if the user explicitly asked to stage/hide it. Not every channel supports every value; an impossible combination is refused when you schedule it, with the reason."
},
"whopPinned": {
"type": "boolean",
"description": "WHOP FORUM POSTS ONLY — pin it to the top of the forum."
},
"aiGenerated": {
"type": "boolean",
"description": "AI-CONTENT DISCLOSURE (Instagram / Facebook Reel is_ai_generated, TikTok is_aigc, YouTube containsSyntheticMedia, Pinterest ai_disclosures, Threads is_made_with_ai). OMIT IT and Hermoso decides from provenance: a Hermoso render is declared AI-generated, media that came through upload_file or from an external URL (the user’s own photos or footage) is NOT. Pass true or false only to override."
},
"audioVolume": {
"type": "integer",
"maximum": 100,
"minimum": 0,
"description": "INSTAGRAM REEL — how loud that track plays, 0–100 (Instagram’s default 100). Needs audioId."
},
"communityId": {
"type": "string",
"description": "X — publish into an X COMMUNITY instead of the main timeline: the number in the community’s own URL (x.com/i/communities/<id>). The connected account must be a MEMBER of it."
},
"description": {
"type": "string",
"description": "YOUTUBE: the video DESCRIPTION, max 5000 characters, carrying the links, the CTA and what YouTube search reads. Omit it and the caption is used."
},
"disableDuet": {
"type": "boolean",
"description": "TIKTOK VIDEO ONLY — block Duets. TikTok’s photo-post API has no Duets, so this is refused on a photo/slideshow item rather than silently dropped."
},
"linkPicture": {
"type": "string",
"description": "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."
},
"quotePostId": {
"type": "string",
"description": "THREADS ONLY — the id of the Threads post this one quotes."
},
"shareToFeed": {
"type": "boolean",
"description": "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."
},
"thumbOffset": {
"type": "number",
"description": "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."
},
"videoVolume": {
"type": "integer",
"maximum": 100,
"minimum": 0,
"description": "INSTAGRAM REEL — how loud the video’s own sound plays under the track, 0–100 (Instagram’s default 100; 0 = the track alone). Needs audioId."
},
"whopMention": {
"type": "boolean",
"description": "WHOP FORUM POSTS ONLY — notify every member (off by default)."
},
"brandPartner": {
"type": "string",
"description": "YOUTUBE: sponsoring brand channel (UC… or @handle)"
},
"callToAction": {
"enum": [
"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"
],
"type": "string",
"description": "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."
},
"countryCodes": {
"type": "array",
"items": {
"type": "string"
},
"description": "THREADS ONLY — two-letter country codes to limit who can see the post. Omit to show it everywhere."
},
"firstComment": {
"type": "string",
"description": "FIRST COMMENT — posted on each new post as the same account right after it publishes, on Facebook, Instagram and LinkedIn company Pages (LinkedIn needs its comments authorization, provider linkedin_community). Other channels publish without it and their result row says so. Up to 2,000 characters, refused rather than cut; never on a story. A failed comment never undoes the post."
},
"optimizeCopy": {
"type": "boolean",
"description": "RECOMMENDED when one caption goes to several channels: fit the shared caption to each channel’s own rules when you schedule it (the fitted caption is stored on the scheduled post, so what you scheduled is what publishes) wherever no per-channel caption was written. The angle and every claim stay the author’s; a channel with its own caption is left exactly as written. Off by default so nobody’s words are rewritten unasked."
},
"privacyLevel": {
"enum": [
"PUBLIC_TO_EVERYONE",
"MUTUAL_FOLLOW_FRIENDS",
"FOLLOWER_OF_CREATOR",
"SELF_ONLY"
],
"type": "string",
"description": "TIKTOK — WHICH PRIVACY LEVEL the post goes out at, in TikTok’s own vocabulary. TikTok requires the USER to choose this from the levels their own account allows: call tiktok_creator_info, show them the real options, and pass back the one they picked — never a default and never a guess, which TikTok refuses at init. Omit it and the post falls back to the coarse `visibility` (private -> SELF_ONLY, otherwise PUBLIC_TO_EVERYONE), which cannot express MUTUAL_FOLLOW_FRIENDS or FOLLOWER_OF_CREATOR at all. It must agree with the TikTok visibility (SELF_ONLY is the private one) and it is refused on a TikTok DRAFT, which carries no post info."
},
"replyControl": {
"enum": [
"everyone",
"accounts_you_follow",
"mentioned_only",
"parent_post_author_only",
"followers_only"
],
"type": "string",
"description": "THREADS ONLY — who may reply. Omit for Threads' own default (everyone)."
},
"slackChannel": {
"type": "string",
"description": "SLACK app: channel id or #name (default: the brand's)"
},
"thumbnailUrl": {
"type": "string",
"description": "YOUTUBE: the custom thumbnail, a Hermoso-hosted image. Omit it and a frame of the video is used; \"auto\" keeps YouTube’s pick."
},
"wpCategories": {
"type": "array",
"items": {
"type": "string"
},
"description": "WORDPRESS — categories (names or ids)"
},
"xQuotePostId": {
"type": "string",
"description": "X — the numeric id of an X post this one QUOTES: the last part of its URL. NAMED xQuotePostId, NOT quotePostId, because `quotePostId` on this same schedule belongs to THREADS. Billed at X’s higher LINK rate."
},
"collaborators": {
"type": "array",
"items": {
"type": "string"
},
"description": "INSTAGRAM — a COLLAB post: up to 3 Instagram usernames invited to CO-AUTHOR it, so it appears on their profile too once they accept, with both handles in the header and the engagement shared. Handles only (\"hermosoai\"); a leading @ is fine. Instagram must be one of the `channels` — asking for collaborators on a schedule Instagram is not on is REFUSED now rather than discovered when it fires, and the other channels in a mixed schedule simply publish without co-authors. THE INVITE IS SENT WHEN THE POST FIRES, not when you schedule it, and it is PENDING until the other account accepts in their notifications; check with instagram_collaborators afterwards rather than telling the user it is live on both profiles."
},
"coverImageUrl": {
"type": "string",
"description": "THE VIDEO COVER as a picture instead of a frame — a Hermoso-hosted image (upload_file). Every channel that takes a cover image gets it (Instagram, Facebook, LinkedIn Page, Pinterest, Telegram, YouTube); TikTok takes only a frame. Never together with coverAtMs."
},
"disableStitch": {
"type": "boolean",
"description": "TIKTOK VIDEO ONLY — block Stitches. Same photo-post rule as disableDuet."
},
"discordServer": {
"type": "string",
"description": "DISCORD bot: which server"
},
"instagramPoll": {
"type": "array",
"items": {
"type": "string"
},
"description": "INSTAGRAM — attach a POLL: 2 to 4 answers of 1–25 characters each, and the caption IS the question (so a caption is required). Feed photos, carousels and Reels only (never a story), one caption add-on per post (not with instagramCommentPrompt), and only on an account connected through Meta (a Facebook Page with a linked Instagram). WRITE-ONCE: Instagram cannot add, change or remove it after publishing, so show the user the answers first."
},
"platformCover": {
"type": "boolean",
"description": "VIDEO COVER. Omit it (the default) and Hermoso sets the video’s best frame — the same frame as its Library thumbnail — as the cover on every channel that allows one (Instagram, Facebook, TikTok direct posts, LinkedIn Pages, Pinterest, Telegram, YouTube) — and on X, Threads and Bluesky, which have none, a blank first frame is replaced on a copy sent there only. true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins."
},
"replySettings": {
"enum": [
"following",
"mentionedUsers",
"subscribers",
"verified"
],
"type": "string",
"description": "X — who may reply. Omit for everyone, which is the right default for a brand post."
},
"slackThreadTs": {
"type": "string",
"description": "SLACK app: reply in this thread (parent ts)"
},
"brandedContent": {
"type": "boolean",
"description": "TIKTOK — the PAID-PARTNERSHIP disclosure (TikTok’s brand_content_toggle): true when this post promotes a THIRD-PARTY business. It is a compliance declaration, not a preference — set it whenever the post is sponsored. TikTok only accepts branded content on a public or friends-only post, so it cannot ride a private/SELF_ONLY or draft TikTok item and the schedule is refused with the reason."
},
"disableComment": {
"type": "boolean",
"description": "TIKTOK — turn comments off on this post."
},
"disablePreview": {
"type": "boolean",
"description": "TELEGRAM ONLY — no link preview."
},
"discordChannel": {
"type": "string",
"description": "DISCORD bot: channel id or #name (default: the brand's)"
},
"discordReplyTo": {
"type": "string",
"description": "DISCORD bot: reply to this message id"
},
"linkAttachment": {
"type": "string",
"description": "THREADS ONLY — a full http(s) URL rendered as a link card. This is the ONLY way a Threads post carries a destination, and Threads attaches it to TEXT-ONLY posts (a post with media cannot also carry a card)."
},
"slackWorkspace": {
"type": "string",
"description": "SLACK app: which workspace"
},
"targetAudience": {
"type": "object",
"properties": {
"degrees": {
"type": "array",
"items": {
"type": "string"
},
"description": "LinkedIn degree facet: numeric id or urn:li:degree value from search_linkedin_ads_targeting"
},
"industries": {
"type": "array",
"items": {
"type": "string"
},
"description": "LinkedIn industry facet: numeric id or urn:li:industry value from search_linkedin_ads_targeting"
},
"seniorities": {
"type": "array",
"items": {
"type": "string"
},
"description": "LinkedIn seniority facet: numeric id or urn:li:seniority value from search_linkedin_ads_targeting"
},
"geoLocations": {
"type": "array",
"items": {
"type": "string"
},
"description": "LinkedIn geo facet: numeric id or urn:li:geo value from search_linkedin_ads_targeting"
},
"jobFunctions": {
"type": "array",
"items": {
"type": "string"
},
"description": "LinkedIn job function facet: numeric id or urn:li:function value from search_linkedin_ads_targeting"
},
"fieldsOfStudy": {
"type": "array",
"items": {
"type": "string"
},
"description": "LinkedIn field of study facet: numeric id or urn:li:fieldOfStudy value from search_linkedin_ads_targeting"
},
"organizations": {
"type": "array",
"items": {
"type": "string"
},
"description": "LinkedIn school or organization facet: numeric id or urn:li:organization value from search_linkedin_ads_targeting"
},
"staffCountRanges": {
"type": "array",
"items": {
"enum": [
"SIZE_1",
"SIZE_2_TO_10",
"SIZE_11_TO_50",
"SIZE_51_TO_200",
"SIZE_201_TO_500",
"SIZE_501_TO_1000",
"SIZE_1001_TO_5000",
"SIZE_5001_TO_10000",
"SIZE_10001_OR_MORE"
],
"type": "string"
},
"description": "LinkedIn company size bands, e.g. SIZE_11_TO_50"
}
},
"description": "LINKEDIN COMPANY PAGE POST ONLY — show the post only to Page followers matching these facets. LinkedIn requires the matching audience to be over 300 followers and refuses a smaller one. Personal-profile posts cannot be targeted."
},
"whopVisibility": {
"enum": [
"members_only",
"globally_visible"
],
"type": "string",
"description": "WHOP FORUM POSTS ONLY — who can see it; unset is the forum’s own default."
},
"discordMentions": {
"type": "boolean",
"description": "DISCORD: let mentions ping"
},
"linkDescription": {
"type": "string",
"description": "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."
},
"paidPartnership": {
"type": "boolean",
"description": "INSTAGRAM AND X — the PAID PARTNERSHIP label, a compliance declaration: set it when the post is sponsored, gifted or otherwise paid for. OPT-IN ONLY, never assume it on the user’s behalf. On Instagram, brandedContentSponsorIds names the brands behind it."
},
"threadsLongText": {
"type": "string",
"description": "THREADS ONLY — LONG-FORM text, up to 10,000 characters, attached under a TEXT-only Threads post (Threads' text attachment). The way to publish more than Threads' 500-character post: keep a short lead in the message and put the full text here. Not with an image, video or carousel."
},
"callToActionLink": {
"type": "string",
"description": "FACEBOOK — where the button goes, when that is not the post’s own `link`."
},
"coverTimestampMs": {
"type": "number",
"description": "TIKTOK VIDEO ONLY — which frame TikTok uses as the cover, in milliseconds from the start. Omit and Hermoso uses the video’s best frame (platformCover:true leaves it to TikTok, which uses the first frame)."
},
"crossreshareToIg": {
"type": "boolean",
"description": "THREADS ONLY — when this fires, ALSO share it to the linked Instagram account as a STORY. Refused on a Threads carousel. No confirmation exists that the Story was created, so the result says it was requested."
},
"threadsGhostPost": {
"type": "boolean",
"description": "THREADS ONLY — a GHOST post: text only, Threads archives it automatically after 24 hours (a flash offer, a 24h-only note). No other Threads option can ride it."
},
"whopExperienceId": {
"type": "string",
"description": "WHOP — which forum or chat in the whop (exp_…, from list_whop_destinations). Omit it when the whop has only one; with several and none named the post is refused at fire time, never guessed."
},
"whopPaywallCents": {
"type": "integer",
"maximum": 9007199254740991,
"minimum": 0,
"description": "WHOP FORUM POSTS ONLY — a paywall in cents (500 = $5.00): readers pay to see the body; needs a title."
},
"commercialContent": {
"type": "boolean",
"description": "TIKTOK — the COMMERCIAL CONTENT DISCLOSURE toggle: true when the post promotes a brand, product or service at all. TikTok requires at least one of yourBrand / brandedContent alongside it, and a post declaring itself commercial without naming which kind is refused. Setting either disclosure already implies this, so it is only needed to be explicit."
},
"discordStartThread": {
"type": "string",
"description": "DISCORD bot: open a thread under the post, this title"
},
"instagramLocationId": {
"type": "string",
"description": "INSTAGRAM — tag a place (called locationId on post_to_meta; locationId here is the Google Business listing). A non-numeric value is refused rather than sent."
},
"visibilityByChannel": {
"type": "object",
"description": "override visibility for one channel, e.g. { \"tiktok\": \"draft\" } to go live everywhere but stage TikTok for review",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
},
"whopPaywallCurrency": {
"type": "string",
"description": "WHOP — the paywall currency, a 3-letter code (default usd)."
},
"crossreshareDarkMode": {
"type": "boolean",
"description": "THREADS ONLY — render that Instagram Story in dark mode. Needs crossreshareToIg."
},
"paidProductPlacement": {
"type": "boolean",
"description": "YOUTUBE: paid product placement (shows the disclosure)"
},
"instagramPollExtended": {
"type": "boolean",
"description": "INSTAGRAM — give that poll Instagram’s extended voting duration instead of its default 3 days (Instagram’s changelog says it then stays open indefinitely). Only with instagramPoll."
},
"instagramCommentPrompt": {
"type": "boolean",
"description": "INSTAGRAM — make the caption a COMMENT PROMPT that people answer in the comments. Same limits as instagramPoll, and never together with it."
},
"linkedinOrganizationId": {
"type": "string",
"description": "LINKEDIN — publish as a COMPANY PAGE instead of the connected personal profile. The organization id from list_linkedin_pages. Omit and it posts as the person: an unset id means the profile, never “probably the company”. A Page can also carry VIDEO, which a personal profile cannot."
},
"brandedContentSponsorIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "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."
}
}
}| pageId | string | Facebook Page id — omit when only one Page is connected. Its linked Instagram account is what Meta charges the hashtag budget against. |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"tag"
],
"properties": {
"tag": {
"type": "string",
"description": "the hashtag — \"skincare\", \"#skincare\" or an instagram.com/explore/tags/… link all work. Instagram matches it EXACTLY and tags carry no spaces or punctuation."
},
"edge": {
"enum": [
"recent",
"top"
],
"type": "string",
"description": "\"recent\" (default) = posted in the last 24 hours; \"top\" = the best-performing posts, with no time limit. Use \"top\" whenever \"recent\" comes back empty."
},
"limit": {
"type": "number",
"description": "how many posts (1–50, default 10). Instagram bounds a page by how much DATA it holds, not by how many posts, so a big ask is fetched in several smaller pages automatically and may still come back short on a carousel-heavy tag — a short result is NEVER a measure of the tag’s size."
},
"pageId": {
"type": "string",
"description": "Facebook Page id — omit when only one Page is connected. Its linked Instagram account is what Meta charges the hashtag budget against."
}
}
}| countries | string | comma-separated ISO codes the ad was served in, e.g. US,GB |
| payerName | string | who PAID for the ad, which is often a different entity from the advertiser |
| advertiser | string | advertiser name, e.g. Notion |
| excludeFacets | string | only ads whose targeting EXCLUDES these facet categories |
| impressionsTo | number | upper bound; pass both bounds or neither |
| includeFacets | string | only ads whose targeting INCLUDES these facet categories: LANGUAGE, LOCATION, AUDIENCE, AGE, GENDER, COMPANY, EDUCATION, JOB, INTERESTS, TRAITS |
| impressionsFrom | number | lower bound of the estimated impressions range; pass both bounds or neither |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"sort": {
"enum": [
"DESCENDING",
"ASCENDING"
],
"type": "string",
"description": "by creation time; default DESCENDING (newest first)"
},
"count": {
"type": "number",
"description": "max 25"
},
"start": {
"type": "number",
"description": "pagination offset; default 0"
},
"dateTo": {
"type": "string",
"description": "YYYY-MM-DD, EXCLUSIVE — must be given together with dateFrom"
},
"keyword": {
"type": "string",
"description": "searched in the ad’s content; several words are a logical AND, not a phrase"
},
"dateFrom": {
"type": "string",
"description": "YYYY-MM-DD, inclusive — must be given together with dateTo"
},
"countries": {
"type": "string",
"description": "comma-separated ISO codes the ad was served in, e.g. US,GB"
},
"payerName": {
"type": "string",
"description": "who PAID for the ad, which is often a different entity from the advertiser"
},
"advertiser": {
"type": "string",
"description": "advertiser name, e.g. Notion"
},
"excludeFacets": {
"type": "string",
"description": "only ads whose targeting EXCLUDES these facet categories"
},
"impressionsTo": {
"type": "number",
"description": "upper bound; pass both bounds or neither"
},
"includeFacets": {
"type": "string",
"description": "only ads whose targeting INCLUDES these facet categories: LANGUAGE, LOCATION, AUDIENCE, AGE, GENDER, COMPANY, EDUCATION, JOB, INTERESTS, TRAITS"
},
"impressionsFrom": {
"type": "number",
"description": "lower bound of the estimated impressions range; pass both bounds or neither"
}
}
}| string |
| only posts by this Threads account (exact username, no @) — 'what has @competitor said about X' |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"q"
],
"properties": {
"q": {
"type": "string",
"description": "keyword or phrase"
},
"limit": {
"type": "integer",
"maximum": 100,
"minimum": 1,
"description": "how many results (1–100, default 25)"
},
"since": {
"type": "string",
"description": "only posts on/after this date (YYYY-MM-DD, ISO or unix seconds; Threads search reaches back to 2023-07-05)"
},
"until": {
"type": "string",
"description": "only posts on/before this date"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"mediaType": {
"enum": [
"TEXT",
"IMAGE",
"VIDEO"
],
"type": "string",
"description": "TEXT, IMAGE or VIDEO — only that kind of post"
},
"searchMode": {
"enum": [
"KEYWORD",
"TAG"
],
"type": "string",
"description": "KEYWORD (default) or TAG — TAG searches a TOPIC TAG (what is being said under #skincare)"
},
"searchType": {
"enum": [
"TOP",
"RECENT"
],
"type": "string",
"description": "TOP (default) or RECENT"
},
"authorUsername": {
"type": "string",
"description": "only posts by this Threads account (exact username, no @) — 'what has @competitor said about X'"
}
}
}| maxResults |
| number |
| 10–100, default 25 |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"query"
],
"properties": {
"query": {
"type": "string",
"description": "the search, X operators included — e.g. `(protein OR creatine) -is:retweet has:media lang:en`"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"endTime": {
"type": "string",
"description": "ISO 8601 end of the search window; within the last 7 days"
},
"nextToken": {
"type": "string",
"description": "from a previous page"
},
"sortOrder": {
"enum": [
"recency",
"relevancy"
],
"type": "string",
"description": "default relevancy"
},
"startTime": {
"type": "string",
"description": "ISO 8601, no earlier than 7 days ago"
},
"maxResults": {
"type": "number",
"description": "10–100, default 25"
}
}
}| replyToMessageId | string | reply to a specific message in the conversation |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"text"
],
"properties": {
"to": {
"type": "string",
"description": "the recipient’s Bluesky handle, e.g. alice.bsky.social. Ignored when convoId is given."
},
"text": {
"type": "string",
"description": "the message, up to 1000 characters"
},
"dryRun": {
"type": "boolean",
"description": "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."
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"convoId": {
"type": "string",
"description": "reply into an existing conversation (from list_bluesky_convos). Omit to start one with `to`."
},
"replyToMessageId": {
"type": "string",
"description": "reply to a specific message in the conversation"
}
}
}| adAccountId* | string | ad account id (act_ prefix or digits) |
| campaignName | string | the campaign by NAME instead of id (case-insensitive, exactly one match wins; none or several is refused naming the campaigns that exist) |
| customAudienceId | string | MESSENGER_SUBSCRIBER_LIST audience of 100+ people, for a bulk send |
| subscriptionTokens | array | subscriber tokens to send to, up to 200 per call; from list_messenger_subscribers |
| minConversationGapSeconds | number | hold the send if the person chatted with the Page within this many seconds |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"adAccountId",
"campaignId",
"message"
],
"properties": {
"dryRun": {
"type": "boolean",
"description": "true = preview the request body and send nothing"
},
"message": {
"type": "object",
"required": [
"type"
],
"properties": {
"url": {
"type": "string",
"description": "card tap-through URL"
},
"text": {
"type": "string",
"description": "body text for text and button messages"
},
"type": {
"enum": [
"text",
"button",
"generic",
"media"
],
"type": "string",
"description": "text, button (text plus up to 3 web buttons), generic (card), or media (image or video plus buttons)"
},
"title": {
"type": "string",
"description": "card title for a generic message"
},
"buttons": {
"type": "array",
"items": {
"type": "object",
"required": [
"title",
"url"
],
"properties": {
"url": {
"type": "string",
"description": "https link the button opens"
},
"title": {
"type": "string",
"description": "button label shown to the person"
}
}
},
"description": "up to 3 web-link buttons on the message"
},
"videoId": {
"type": "string",
"description": "video id for a media message, instead of imageUrl"
},
"imageUrl": {
"type": "string",
"description": "image URL for a generic card or media message"
},
"subtitle": {
"type": "string",
"description": "card subtitle for a generic message"
}
},
"description": "the message to send: {type, text, title, subtitle, imageUrl, videoId, url, buttons}"
},
"campaignId": {
"type": "string",
"description": "the message campaign id (from create_messenger_marketing_campaign or list_messenger_marketing_campaigns) — OR the campaign NAME as the user said it: a non-numeric value is resolved against the account's own campaigns, so you never need to ask for an id"
},
"adAccountId": {
"type": "string",
"description": "ad account id (act_ prefix or digits)"
},
"campaignName": {
"type": "string",
"description": "the campaign by NAME instead of id (case-insensitive, exactly one match wins; none or several is refused naming the campaigns that exist)"
},
"customAudienceId": {
"type": "string",
"description": "MESSENGER_SUBSCRIBER_LIST audience of 100+ people, for a bulk send"
},
"subscriptionTokens": {
"type": "array",
"items": {
"type": "string"
},
"description": "subscriber tokens to send to, up to 200 per call; from list_messenger_subscribers"
},
"minConversationGapSeconds": {
"type": "number",
"description": "hold the send if the person chatted with the Page within this many seconds"
}
}
}| 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 |
| 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 |
| 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 |
| confirm | boolean | REQUIRED true — this sends real messages to real phones and Meta bills the business for every one |
| language | string | which language of that template, e.g. en_US. Omitted, Hermoso uses the first one it finds and reports which. |
| 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 |
| components | array | 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 |
| recipients | array | the list: either phone numbers in full international form ["+14165550142", …] (or business-scoped user ids, e.g. US.13491208655302741918, for people who reached you by username), or objects { to, components } when each person gets different template values. Duplicates are removed and counted; invalid numbers are refused by name unless skipInvalid is true |
| skipInvalid | boolean | drop unusable numbers instead of refusing the whole list. They are still named in the result — nothing is ever silently dropped |
| 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 |
| 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 |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"jobId": {
"type": "string",
"description": "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"
},
"dryRun": {
"type": "boolean",
"description": "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"
},
"wabaId": {
"type": "string",
"description": "the account the template is read from. Optional — it defaults to the account that owns phoneNumberId, and one naming a DIFFERENT account is refused"
},
"confirm": {
"type": "boolean",
"description": "REQUIRED true — this sends real messages to real phones and Meta bills the business for every one"
},
"language": {
"type": "string",
"description": "which language of that template, e.g. en_US. Omitted, Hermoso uses the first one it finds and reports which."
},
"template": {
"type": "string",
"description": "an APPROVED template name. Required to SEND — a broadcast cannot be free-form text; omit only when reading a broadcast back with jobId"
},
"components": {
"type": "array",
"items": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"description": "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"
},
"recipients": {
"type": "array",
"items": {},
"description": "the list: either phone numbers in full international form [\"+14165550142\", …] (or business-scoped user ids, e.g. US.13491208655302741918, for people who reached you by username), or objects { to, components } when each person gets different template values. Duplicates are removed and counted; invalid numbers are refused by name unless skipInvalid is true"
},
"skipInvalid": {
"type": "boolean",
"description": "drop unusable numbers instead of refusing the whole list. They are still named in the result — nothing is ever silently dropped"
},
"confirmCount": {
"type": "number",
"description": "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"
},
"phoneNumberId": {
"type": "string",
"description": "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"
}
}
}| 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 |
| confirm | boolean | REQUIRED true — this sends a real message to a real phone and Meta bills the business for it |
| language | string | which language of that template, e.g. en_US. Omitted, Hermoso uses the first one it finds and reports which. |
| template | string | an APPROVED template name — required to start a conversation outside the 24-hour window |
| components | array | 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. |
| phoneNumberId* | string | WHICH NUMBER IT SENDS FROM — from list_whatsapp_accounts (a WABA can have several) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"phoneNumberId",
"to"
],
"properties": {
"to": {
"type": "string",
"description": "the recipient in full international form, e.g. +14165550142 — or, for someone who reached the business by WhatsApp username and shared no number, their business-scoped user id exactly as received (e.g. US.13491208655302741918). Authentication templates need the phone number."
},
"text": {
"type": "string",
"description": "a free-form message — only lands INSIDE the 24-hour window"
},
"wabaId": {
"type": "string",
"description": "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"
},
"confirm": {
"type": "boolean",
"description": "REQUIRED true — this sends a real message to a real phone and Meta bills the business for it"
},
"language": {
"type": "string",
"description": "which language of that template, e.g. en_US. Omitted, Hermoso uses the first one it finds and reports which."
},
"template": {
"type": "string",
"description": "an APPROVED template name — required to start a conversation outside the 24-hour window"
},
"components": {
"type": "array",
"items": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"description": "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."
},
"phoneNumberId": {
"type": "string",
"description": "WHICH NUMBER IT SENDS FROM — from list_whatsapp_accounts (a WABA can have several)"
}
}
}| mode |
| string |
| "review" (default): each batch of fresh posts waits as drafts for approval. "auto": fresh posts are scheduled to publish automatically. |
| avoid | array | THE BRIEF: never mention or show these — topics, claims, competitors, faces. |
| chatId | string | TELEGRAM — which chat, group or channel posts go to (@username or numeric id). Without one, telegram is skipped: there is no default chat and posting to the wrong one is a public mistake. |
| pageId | string | FACEBOOK / INSTAGRAM / THREADS — which connected Page to publish from (list_meta_pages). Omit for the brand’s only Page. |
| boardId | string | PINTEREST — which board Pins go on (list_pinterest_boards). Without one, Pinterest is skipped: a Pin on the wrong board is a public mistake, so it is never guessed. |
| enabled | boolean | on/off. true needs a connected channel and a goal (refused otherwise). false PAUSES it: the recurring job is deleted and nothing new is made. Already-queued posts are untouched. |
| formats | string | THE BRIEF: what to make — mix (images with a short video now and then, the default), video (short videos only), carousel (3 new images per post), image. |
| pillars | array | THE BRIEF: content pillars / topics, up to 8 short lines. Every post belongs to one. get_post_refill suggests some from the brand profile. |
| accounts | object | accounts to post to on a channel holding several: {"tiktok":["@a","@b"]} or "all"; null clears. A new setup must pick; an older one posts to the primary until it does. |
| channels | any | REPLACES the list; a channel connected later stays out until added. "all" = every channel, later ones too, only if the user asks |
| goalNote | string | THE BRIEF: a line about the goal (e.g. the launch date and what is launching). |
| timezone | string | IANA timezone the posting times are in, e.g. "America/New_York" (default: the brand's, else UTC) |
| daysAhead | number | how far ahead to keep the queue full, 1–30 (default 7) |
| learnings | array | notes learned from reviews; REPLACES the list |
| addChannels | array | add these |
| postsPerDay | number | posts a day, up to the number of posting times ("2 times a day" = 2). 0 (default) = one per posting time. To post MORE per day than there are posting times, also pass postingTimes. |
| postingTimes | array | the brand's daily posting times, 24-hour "HH:MM" (e.g. ["09:00","13:00","18:00"]); REPLACES the list. Autopilot makes one post per posting time, and scheduled posts use the same times. get_post_refill shows the current ones. |
| removeChannels | array | stop posting here |
| excludeChannels | array | never these channels, even ones connected later; [] clears |
| whopExperienceId | string | WHOP — which forum or chat posts go to (exp_…, from list_whop_destinations). Without one, the whop’s only forum or chat is used; with several, whop is skipped rather than guessed. |
| channelPostsPerDay | object | per channel, how many posts a day (the first N of the posting times), e.g. {"x":1,"instagram":3}. 0 turns a channel off; null resets it to every posting time. Merged into what is saved. |
| forgetCaptionEdits | boolean | true = forget the caption edits learned from past reviews |
| linkedinOrganizationId | string | LINKEDIN — which company Page to post as (list_linkedin_pages). A single shared Page is used automatically; a Page that is not shared with this profile is ignored rather than failing the whole post. |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"goal": {
"enum": [
"followers",
"sales",
"launch",
"other"
],
"type": "string",
"description": "THE BRIEF: what the posts are for — grow followers, drive sales, promote a launch, or other (say what in goalNote). Required to switch it on."
},
"mode": {
"enum": [
"review",
"auto"
],
"type": "string",
"description": "\"review\" (default): each batch of fresh posts waits as drafts for approval. \"auto\": fresh posts are scheduled to publish automatically."
},
"avoid": {
"type": "array",
"items": {
"type": "string"
},
"description": "THE BRIEF: never mention or show these — topics, claims, competitors, faces."
},
"chatId": {
"type": "string",
"description": "TELEGRAM — which chat, group or channel posts go to (@username or numeric id). Without one, telegram is skipped: there is no default chat and posting to the wrong one is a public mistake."
},
"pageId": {
"type": "string",
"description": "FACEBOOK / INSTAGRAM / THREADS — which connected Page to publish from (list_meta_pages). Omit for the brand’s only Page."
},
"boardId": {
"type": "string",
"description": "PINTEREST — which board Pins go on (list_pinterest_boards). Without one, Pinterest is skipped: a Pin on the wrong board is a public mistake, so it is never guessed."
},
"enabled": {
"type": "boolean",
"description": "on/off. true needs a connected channel and a goal (refused otherwise). false PAUSES it: the recurring job is deleted and nothing new is made. Already-queued posts are untouched."
},
"formats": {
"enum": [
"mix",
"video",
"carousel",
"image"
],
"type": "string",
"description": "THE BRIEF: what to make — mix (images with a short video now and then, the default), video (short videos only), carousel (3 new images per post), image."
},
"pillars": {
"type": "array",
"items": {
"type": "string"
},
"description": "THE BRIEF: content pillars / topics, up to 8 short lines. Every post belongs to one. get_post_refill suggests some from the brand profile."
},
"accounts": {
"type": "object",
"description": "accounts to post to on a channel holding several: {\"tiktok\":[\"@a\",\"@b\"]} or \"all\"; null clears. A new setup must pick; an older one posts to the primary until it does.",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"anyOf": [
{
"type": "array",
"items": {
"type": "string"
}
},
{
"type": "string",
"const": "all"
},
{
"type": "null"
}
]
}
},
"channels": {
"anyOf": [
{
"type": "array",
"items": {
"enum": [
"facebook",
"instagram",
"threads",
"tiktok",
"youtube",
"linkedin",
"x",
"pinterest",
"google_business",
"bluesky",
"telegram",
"wordpress",
"discord",
"slack",
"whop"
],
"type": "string"
}
},
{
"type": "string",
"const": "all"
}
],
"description": "REPLACES the list; a channel connected later stays out until added. \"all\" = every channel, later ones too, only if the user asks"
},
"goalNote": {
"type": "string",
"description": "THE BRIEF: a line about the goal (e.g. the launch date and what is launching)."
},
"timezone": {
"type": "string",
"description": "IANA timezone the posting times are in, e.g. \"America/New_York\" (default: the brand's, else UTC)"
},
"daysAhead": {
"type": "number",
"description": "how far ahead to keep the queue full, 1–30 (default 7)"
},
"learnings": {
"type": "array",
"items": {
"type": "string"
},
"description": "notes learned from reviews; REPLACES the list"
},
"addChannels": {
"type": "array",
"items": {
"enum": [
"facebook",
"instagram",
"threads",
"tiktok",
"youtube",
"linkedin",
"x",
"pinterest",
"google_business",
"bluesky",
"telegram",
"wordpress",
"discord",
"slack",
"whop"
],
"type": "string"
},
"description": "add these"
},
"postsPerDay": {
"type": "number",
"description": "posts a day, up to the number of posting times (\"2 times a day\" = 2). 0 (default) = one per posting time. To post MORE per day than there are posting times, also pass postingTimes."
},
"postingTimes": {
"type": "array",
"items": {
"type": "string"
},
"description": "the brand's daily posting times, 24-hour \"HH:MM\" (e.g. [\"09:00\",\"13:00\",\"18:00\"]); REPLACES the list. Autopilot makes one post per posting time, and scheduled posts use the same times. get_post_refill shows the current ones."
},
"removeChannels": {
"type": "array",
"items": {
"enum": [
"facebook",
"instagram",
"threads",
"tiktok",
"youtube",
"linkedin",
"x",
"pinterest",
"google_business",
"bluesky",
"telegram",
"wordpress",
"discord",
"slack",
"whop"
],
"type": "string"
},
"description": "stop posting here"
},
"excludeChannels": {
"type": "array",
"items": {
"enum": [
"facebook",
"instagram",
"threads",
"tiktok",
"youtube",
"linkedin",
"x",
"pinterest",
"google_business",
"bluesky",
"telegram",
"wordpress",
"discord",
"slack",
"whop"
],
"type": "string"
},
"description": "never these channels, even ones connected later; [] clears"
},
"whopExperienceId": {
"type": "string",
"description": "WHOP — which forum or chat posts go to (exp_…, from list_whop_destinations). Without one, the whop’s only forum or chat is used; with several, whop is skipped rather than guessed."
},
"channelPostsPerDay": {
"type": "object",
"description": "per channel, how many posts a day (the first N of the posting times), e.g. {\"x\":1,\"instagram\":3}. 0 turns a channel off; null resets it to every posting time. Merged into what is saved.",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "number"
}
},
"forgetCaptionEdits": {
"type": "boolean",
"description": "true = forget the caption edits learned from past reviews"
},
"linkedinOrganizationId": {
"type": "string",
"description": "LINKEDIN — which company Page to post as (list_linkedin_pages). A single shared Page is used automatically; a Page that is not shared with this profile is ignored rather than failing the whole post."
}
}
}{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"itemId",
"enabled"
],
"properties": {
"itemId": {
"type": "string",
"description": "the TikTok post id"
},
"confirm": {
"type": "boolean",
"description": "REQUIRED true when enabling — it makes the post publicly promotable and accepts TikTok’s advertising terms on the owner’s behalf"
},
"enabled": {
"type": "boolean",
"description": "true turns Ad authorization ON (and mints the Spark Ads code); false turns it off"
},
"authorizationDays": {
"type": "number",
"description": "7 | 30 | 60 | 180 | 365 — TikTok publishes exactly those five; default 30"
}
}
}{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"videoId",
"imageUrl"
],
"properties": {
"brand": {
"type": "string",
"description": "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."
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"videoId": {
"type": "string",
"description": "the YouTube video id (what post_to_youtube returned)"
},
"imageUrl": {
"type": "string",
"description": "A Hermoso-hosted image URL. An arbitrary external host is refused."
}
}
}| leave off for a watermark that shows for the whole video |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"action": {
"enum": [
"set",
"unset"
],
"type": "string",
"description": "defaults to 'set' when an imageUrl is given"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"imageUrl": {
"type": "string",
"description": "any public https image URL — a Hermoso render, or ANY file of your own brought in with upload_file. Square, at least 150x150"
},
"offsetMs": {
"type": "number",
"description": "when the watermark appears, relative to timingType"
},
"durationMs": {
"type": "number",
"description": "how long it stays on screen"
},
"timingType": {
"enum": [
"offsetFromStart",
"offsetFromEnd"
],
"type": "string",
"description": "leave off for a watermark that shows for the whole video"
}
}
}{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"fileId"
],
"properties": {
"type": {
"enum": [
"view",
"edit",
"embed"
],
"type": "string",
"description": "view (default), edit, or embed (personal OneDrive only)"
},
"scope": {
"enum": [
"anonymous",
"organization",
"users"
],
"type": "string",
"description": "anonymous (default, anyone with the link), organization (work or school), or users (invited people only)"
},
"fileId": {
"type": "string",
"description": "OneDrive file or folder id from list_onedrive_files"
},
"confirm": {
"type": "boolean",
"description": "required for an anonymous edit link"
},
"password": {
"type": "string",
"description": "personal OneDrive with Microsoft 365"
},
"expiresAt": {
"type": "string",
"description": "e.g. 2026-11-30 or 2026-11-30T17:00:00Z"
}
}
}| trigger | object | what starts the rule: {type, keywords, match, scope, mediaId, ...}; keywords are case-insensitive |
| followGate | object | Instagram only: followers get the content after tapping a button |
| cooldownHours | number | DM/ref rules: answer one person at most once per this many hours (default 24) |
| publicReplies | array | Instagram/Facebook comment rules: public replies under the comment, rotated |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"text"
],
"properties": {
"id": {
"type": "string",
"description": "saved rule id to test; omit to test a rule that is not saved yet"
},
"name": {
"type": "string",
"description": "name of the automation, shown in the list"
},
"text": {
"type": "string",
"description": "the sample comment, mention or DM"
},
"dryRun": {
"type": "boolean",
"description": "log what would be sent without sending"
},
"account": {
"type": "string",
"description": "the account on that channel: @handle (Instagram, X) or Page name/id (Facebook); omit when only one is connected"
},
"channel": {
"enum": [
"instagram",
"facebook",
"x"
],
"type": "string",
"description": "instagram (default), facebook (a Facebook Page: comments on its posts and ads, plus Messenger) or x"
},
"enabled": {
"type": "boolean",
"description": "false pauses the automation without deleting it"
},
"message": {
"type": "object",
"required": [
"text"
],
"properties": {
"text": {
"type": "string",
"description": "what they receive; on X the public reply (280) or, for a dm rule, the DM"
},
"buttons": {
"type": "array",
"items": {
"type": "object",
"required": [
"title",
"url"
],
"properties": {
"url": {
"type": "string",
"description": "https link"
},
"title": {
"type": "string",
"description": "up to 20 characters"
}
}
},
"description": "up to 3 link buttons (Instagram, Facebook; X has none)"
}
},
"description": "what the person receives: {text, buttons}; on X the text is the public reply, up to 280 characters"
},
"trigger": {
"type": "object",
"required": [
"type"
],
"properties": {
"ref": {
"type": "string",
"description": "ig.me/m/<handle>?ref=<ref> or m.me/<page>?ref=<ref>"
},
"type": {
"enum": [
"comment",
"dm",
"story_reply",
"ref",
"mention"
],
"type": "string",
"description": "comment (all; on X a reply under your post), dm (all), story_reply (Instagram), ref (ig.me/m.me link: Instagram, Facebook), mention (X)"
},
"match": {
"enum": [
"contains",
"exact"
],
"type": "string",
"description": "contains (keyword appears in the text) or exact (whole text must equal the keyword)"
},
"scope": {
"enum": [
"post",
"next",
"any"
],
"type": "string",
"description": "comment rules: one post (mediaId), the NEXT post you publish, or every post"
},
"mediaId": {
"type": "string",
"description": "Instagram media id, Facebook post id (an ad: its effective_object_story_id) or X post id"
},
"keywords": {
"type": "array",
"items": {
"type": "string"
},
"description": "case-insensitive words/phrases; empty = any comment (DM and mention rules need one)"
},
"mediaType": {
"enum": [
"REELS",
"FEED"
],
"type": "string",
"description": "Instagram scope \"next\" only"
}
},
"description": "what starts the rule: {type, keywords, match, scope, mediaId, ...}; keywords are case-insensitive"
},
"followGate": {
"type": "object",
"required": [
"enabled"
],
"properties": {
"ask": {
"type": "string",
"description": "message asking the person to follow the account"
},
"button": {
"type": "string",
"description": "label of the button the person taps once they follow"
},
"enabled": {
"type": "boolean",
"description": "true = non-followers see the follow prompt before the content (Instagram only)"
},
"notFollowing": {
"type": "string",
"description": "message sent to someone who does not follow yet"
}
},
"description": "Instagram only: followers get the content after tapping a button"
},
"cooldownHours": {
"type": "number",
"description": "DM/ref rules: answer one person at most once per this many hours (default 24)"
},
"publicReplies": {
"type": "array",
"items": {
"type": "string"
},
"description": "Instagram/Facebook comment rules: public replies under the comment, rotated"
}
}
}| country / city / age / gender — required by follower_demographics, exactly one |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"since": {
"type": "string",
"description": "YYYY-MM-DD window start (account scope)"
},
"until": {
"type": "string",
"description": "YYYY-MM-DD window end (account scope)"
},
"postId": {
"type": "string",
"description": "post id from list_threads_posts — omit for account-level insights"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"metrics": {
"type": "array",
"items": {
"type": "string"
},
"description": "account metrics: views, likes, replies, reposts, quotes, clicks, followers_count, follower_demographics"
},
"breakdown": {
"type": "array",
"items": {
"type": "string"
},
"description": "country / city / age / gender — required by follower_demographics, exactly one"
}
}
}{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"fields": {
"type": "array",
"items": {
"type": "string"
},
"description": "defaults to everything this authorization can read. Demographics are audience_ages, audience_genders, audience_countries, audience_cities"
},
"endDate": {
"type": "string",
"description": "YYYY-MM-DD (UTC). Default is yesterday"
},
"startDate": {
"type": "string",
"description": "YYYY-MM-DD (UTC). Default is 7 days ago; TikTok keeps at most 60 days"
}
}
}| website domain |
| summary | string | one-line description |
| audience | string | who the brand sells to, one line |
| category | string | product or business category, one line |
| logoDark | string | for light pictures |
| logoLight | string | for dark pictures; "none" removes |
| pronounce | string | how the brand NAME is said aloud, as a simple respelling with the stressed syllable in capitals (e.g. "KOH-dee-ak"). Videos use it as a delivery note beside the spoken line; set it when a render mispronounced the name. |
| positioning | string | how the brand is positioned against alternatives, one line |
| profileType | string | website, social, describe or explore; explore keeps the profile but stops using it |
| pronunciations | object | how PRODUCT names are said aloud, e.g. {"Power Cakes": "POW-er cakes"}. Merged into the saved ones; an empty string removes one. |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"goal": {
"type": "string",
"description": "current marketing goal"
},
"logo": {
"type": "string",
"description": "logo image: URL or local file path to a PNG, JPEG or WebP (no SVG)"
},
"name": {
"type": "string",
"description": "brand name"
},
"brand": {
"type": "string",
"description": "profile id/name from list_brands, this call only"
},
"sells": {
"type": "string",
"description": "what the brand sells"
},
"style": {
"type": "string",
"description": "visual style — palette, typography, aesthetic"
},
"voice": {
"type": "string",
"description": "brand voice/tone"
},
"domain": {
"type": "string",
"description": "website domain"
},
"summary": {
"type": "string",
"description": "one-line description"
},
"audience": {
"type": "string",
"description": "who the brand sells to, one line"
},
"category": {
"type": "string",
"description": "product or business category, one line"
},
"logoDark": {
"type": "string",
"description": "for light pictures"
},
"logoLight": {
"type": "string",
"description": "for dark pictures; \"none\" removes"
},
"pronounce": {
"type": "string",
"description": "how the brand NAME is said aloud, as a simple respelling with the stressed syllable in capitals (e.g. \"KOH-dee-ak\"). Videos use it as a delivery note beside the spoken line; set it when a render mispronounced the name."
},
"positioning": {
"type": "string",
"description": "how the brand is positioned against alternatives, one line"
},
"profileType": {
"enum": [
"website",
"social",
"describe",
"explore"
],
"type": "string",
"description": "website, social, describe or explore; explore keeps the profile but stops using it"
},
"pronunciations": {
"type": "object",
"description": "how PRODUCT names are said aloud, e.g. {\"Power Cakes\": \"POW-er cakes\"}. Merged into the saved ones; an empty string removes one.",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
}
}
}| string |
| which listing, from list_business_locations — only needed when more than one is shared with this profile |
| confirmName | string | the listing’s CURRENT name, echoed back — required when changing title or storefrontAddress |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"fields"
],
"properties": {
"dryRun": {
"type": "boolean",
"description": "validate with Google and write nothing (needs no confirm)"
},
"fields": {
"type": "object",
"description": "the changes, keyed by Google’s Location field names, e.g. {\"websiteUri\":\"https://example.com\"} or {\"regularHours\":{\"periods\":[…]}}",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"confirm": {
"type": "boolean",
"description": "true ONLY after the user has seen the exact before-and-after and approved it"
},
"locationId": {
"type": "string",
"description": "which listing, from list_business_locations — only needed when more than one is shared with this profile"
},
"confirmName": {
"type": "string",
"description": "the listing’s CURRENT name, echoed back — required when changing title or storefrontAddress"
}
}
}| documentId |
| string |
| the document id (from create_doc, or list_drive_files for one the user picked) |
| confirmCells | number | echo back the character count the unconfirmed call reported (rewrite only) |
| replacements | array | find/replace pairs, applied in order |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"docUrl": {
"type": "string",
"description": "a Google Docs URL — the id is extracted from it"
},
"confirm": {
"type": "boolean",
"description": "true runs a whole-body rewrite after the character count is shown"
},
"rewrite": {
"type": "string",
"description": "replace the WHOLE body with this text (\"\" empties the doc)"
},
"dropdowns": {
"type": "array",
"items": {
"type": "object",
"properties": {
"tabId": {
"type": "string",
"description": "tab id holding the dropdown, when the document has several tabs"
},
"title": {
"type": "string",
"description": "the dropdown title (from read_doc)"
},
"value": {
"type": "string",
"description": "the option to select, by its display text"
},
"options": {
"type": "array",
"items": {
"type": "string"
},
"description": "change the dropdown itself: the COMPLETE new option list (2–50) by display text; existing options keep their colour; shared by every chip built from it"
},
"replace": {
"type": "object",
"description": "for an option you remove that a chip shows: {\"Old\": \"New\"}",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
},
"newTitle": {
"type": "string",
"description": "rename the dropdown"
},
"dropdownId": {
"type": "string",
"description": "when two dropdowns share a title"
}
}
},
"description": "dropdown chips to set, or dropdowns to change"
},
"documentId": {
"type": "string",
"description": "the document id (from create_doc, or list_drive_files for one the user picked)"
},
"confirmCells": {
"type": "number",
"description": "echo back the character count the unconfirmed call reported (rewrite only)"
},
"replacements": {
"type": "array",
"items": {
"type": "object",
"required": [
"find"
],
"properties": {
"find": {
"type": "string",
"description": "exact text to find, as read_doc shows it; zero matches is reported as a failure"
},
"replace": {
"type": "string",
"description": "text to put in place of each match"
},
"matchCase": {
"type": "boolean",
"description": "false ignores letter case when matching; default matches case"
}
}
},
"description": "find/replace pairs, applied in order"
}
}
}| previewText | string |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"campaignId"
],
"properties": {
"name": {
"type": "string"
},
"listIds": {
"type": "array",
"items": {
"type": "string"
}
},
"replyTo": {
"type": "string"
},
"subject": {
"type": "string"
},
"fromEmail": {
"type": "string"
},
"fromLabel": {
"type": "string"
},
"campaignId": {
"type": "string"
},
"excludeIds": {
"type": "array",
"items": {
"type": "string"
}
},
"segmentIds": {
"type": "array",
"items": {
"type": "string"
}
},
"templateId": {
"type": "string"
},
"previewText": {
"type": "string"
}
}
}| discoveryMode |
| string |
| LISTED or URL_ONLY (see create_linkedin_event) |
| organizationId | string | Page id that organizes the event; omit when only one is shared |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"eventId"
],
"properties": {
"url": {
"type": "string",
"description": "join or info link for an online or external event"
},
"name": {
"type": "string",
"description": "new event title"
},
"endsAt": {
"type": "string",
"description": "ISO 8601"
},
"address": {
"type": "object",
"required": [
"city",
"country"
],
"properties": {
"city": {
"type": "string",
"description": "venue city"
},
"line1": {
"type": "string",
"description": "venue street address, first line"
},
"region": {
"type": "string",
"description": "venue state or region"
},
"country": {
"type": "string",
"description": "2-letter code, e.g. US"
},
"postalCode": {
"type": "string",
"description": "venue postal or ZIP code"
}
},
"description": "in-person venue address: {line1, city, region, postalCode, country}"
},
"eventId": {
"type": "string",
"description": "from list_linkedin_events or create_linkedin_event"
},
"startsAt": {
"type": "string",
"description": "ISO 8601"
},
"description": {
"type": "string",
"description": "new event description"
},
"venueDetails": {
"type": "string",
"description": "room, floor or parking notes for an in-person event"
},
"discoveryMode": {
"enum": [
"LISTED",
"URL_ONLY"
],
"type": "string",
"description": "LISTED or URL_ONLY (see create_linkedin_event)"
},
"organizationId": {
"type": "string",
"description": "Page id that organizes the event; omit when only one is shared"
}
}
}| storeCatalogSettings | object | store-catalog settings object (local inventory) |
| flightCatalogSettings | object | flight-catalog settings object |
| additionalVerticalOption | string | additional vertical option |
| destinationCatalogSettings | object | destination-catalog settings object (travel verticals) |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"catalogId"
],
"properties": {
"name": {
"type": "string",
"description": "new name for the catalog"
},
"catalogId": {
"type": "string",
"description": "numeric catalog id from list_meta_catalogs"
},
"defaultImageUrl": {
"type": "string",
"description": "public http(s) image URL Meta falls back to for a product with no image"
},
"fallbackImageUrl": {
"type": "string",
"description": "public http(s) image URL used when the default also fails"
},
"daDisplaySettings": {
"type": "object",
"description": "dynamic-ad display settings object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"partnerIntegration": {
"type": "object",
"description": "partner-integration settings object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"storeCatalogSettings": {
"type": "object",
"description": "store-catalog settings object (local inventory)",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"flightCatalogSettings": {
"type": "object",
"description": "flight-catalog settings object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"additionalVerticalOption": {
"enum": [
"LOCAL_DA_CATALOG",
"LOCAL_PRODUCTS"
],
"type": "string",
"description": "additional vertical option"
},
"destinationCatalogSettings": {
"type": "object",
"description": "destination-catalog settings object (travel verticals)",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
}
}| altText | string | accessibility alt text, max 500 |
| boardId | string | move the Pin to this board |
| description | string | max 800 characters — the text Pinterest search reads |
| boardSectionId | string | section within the board |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"pinId"
],
"properties": {
"link": {
"type": "string",
"description": "destination URL, max 2048"
},
"brand": {
"type": "string",
"description": "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."
},
"pinId": {
"type": "string",
"description": "numeric Pin id from list_pinterest_pins"
},
"title": {
"type": "string",
"description": "max 100 characters"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"altText": {
"type": "string",
"description": "accessibility alt text, max 500"
},
"boardId": {
"type": "string",
"description": "move the Pin to this board"
},
"description": {
"type": "string",
"description": "max 800 characters — the text Pinterest search reads"
},
"boardSectionId": {
"type": "string",
"description": "section within the board"
}
}
}| Google spreadsheet id; use this or sheetUrl |
| valueInputOption | string | USER_ENTERED (default) parses formulas, dates and numbers the way typing them would; RAW stores every value as literal text |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"range": {
"type": "string",
"description": "A1 range or anchor cell, e.g. \"B2:C5\", \"B2\", or \"Q3 Report!B2\" (default A1)"
},
"values": {
"type": "array",
"items": {
"type": "array",
"items": {
"type": [
"string",
"number",
"boolean"
]
}
},
"description": "array of row arrays to write"
},
"confirm": {
"type": "boolean",
"description": "required only when the target range already holds values"
},
"updates": {
"type": "array",
"items": {
"type": "object",
"properties": {
"range": {
"type": "string",
"description": "A1 range or anchor cell for this block, e.g. B2:C5 or Q3!B2"
},
"values": {
"type": "array",
"items": {
"type": "array",
"items": {
"type": [
"string",
"number",
"boolean"
]
}
},
"description": "rows for this block, as an array of row arrays"
}
}
},
"description": "write SEVERAL disjoint ranges in one call, instead of range+values"
},
"sheetUrl": {
"type": "string",
"description": "Google Sheets link, instead of spreadsheetId"
},
"spreadsheetId": {
"type": "string",
"description": "Google spreadsheet id; use this or sheetUrl"
},
"valueInputOption": {
"enum": [
"USER_ENTERED",
"RAW"
],
"type": "string",
"description": "USER_ENTERED (default) parses formulas, dates and numbers the way typing them would; RAW stores every value as literal text"
}
}
}| string |
| categories | array |
| featuredImageUrl | string | a new featured image |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"postId"
],
"properties": {
"date": {
"type": "string"
},
"slug": {
"type": "string"
},
"tags": {
"type": "array",
"items": {
"type": "string"
}
},
"text": {
"type": "string",
"description": "the new body — replaces the old one"
},
"brand": {
"type": "string",
"description": "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."
},
"title": {
"type": "string"
},
"postId": {
"type": "number",
"description": "the post id post_to_wordpress or list_wordpress_posts returned"
},
"status": {
"enum": [
"publish",
"draft",
"pending",
"private",
"future"
],
"type": "string"
},
"excerpt": {
"type": "string"
},
"categories": {
"type": "array",
"items": {
"type": "string"
}
},
"featuredImageUrl": {
"type": "string",
"description": "a new featured image"
}
}
}| enhancedCallPrivacy | boolean | true = enhanced privacy for calls |
| alwaysAllowSubscribers | boolean | subscribers can always message the brand, whatever allowDmsFrom says |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"allowDmsFrom": {
"enum": [
"all",
"verified",
"following"
],
"type": "string",
"description": "who may message the brand: all, verified or following"
},
"callsEnabled": {
"type": "boolean",
"description": "audio and video calls on or off"
},
"readReceipts": {
"type": "boolean",
"description": "send and receive read receipts"
},
"qualityFilter": {
"type": "boolean",
"description": "true = low-quality message requests go to a separate folder"
},
"acceptCallsFrom": {
"type": "object",
"properties": {
"everyone": {
"type": "boolean",
"description": "true = anyone may call"
},
"verified": {
"type": "boolean",
"description": "true = verified accounts may call"
},
"following": {
"type": "boolean",
"description": "true = accounts the brand follows may call"
},
"addressBook": {
"type": "boolean",
"description": "true = contacts in the brand's address book may call"
}
},
"description": "who may call the brand: flags for each group below"
},
"enhancedCallPrivacy": {
"type": "boolean",
"description": "true = enhanced privacy for calls"
},
"alwaysAllowSubscribers": {
"type": "boolean",
"description": "subscribers can always message the brand, whatever allowDmsFrom says"
}
}
}| bannerImageUrl | string | any public https image URL to upload as the channel banner — a Hermoso render, or ANY file of your own brought in with upload_file |
| defaultLanguage | string | the channel's default language |
| unsubscribedTrailer | string | video id of the trailer shown to people who are not subscribed |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"title": {
"type": "string",
"description": "the channel title — YouTube often accepts and ignores this; the result says whether it stuck"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"country": {
"type": "string",
"description": "two-letter country code for the channel"
},
"keywords": {
"type": "string",
"description": "channel keywords, COMMA-SEPARATED (Google's wire format is one string, not a list)"
},
"description": {
"type": "string",
"description": "the channel description — the About text"
},
"bannerImageUrl": {
"type": "string",
"description": "any public https image URL to upload as the channel banner — a Hermoso render, or ANY file of your own brought in with upload_file"
},
"defaultLanguage": {
"type": "string",
"description": "the channel's default language"
},
"unsubscribedTrailer": {
"type": "string",
"description": "video id of the trailer shown to people who are not subscribed"
}
}
}| public = live on the channel; confirm with the user first |
| videoId* | string | the YouTube video id |
| publishAt | string | ISO date-time to go public; keeps it private until then; "" clears a schedule |
| categoryId | string | numeric YouTube category id |
| embeddable | boolean | true = allow embedding on other sites |
| aiGenerated | boolean | YouTube’s “altered or synthetic content” label. OMIT IT and the value already on the post stays; a post that never had one is decided from provenance (a Hermoso render is declared, real footage from upload_file or an external URL is not). true/false overrides. |
| description | string | ≤5000 chars |
| madeForKids | boolean | true = made for kids; false = not made for kids |
| brandPartner | string | sponsoring brand's channel id (UC…) or @handle — gives that brand access to run the video as a partnership ad; "" unlinks (update only) |
| localizations | object | translated title/description per language, e.g. {"es":{"title":"…"}}; needs defaultLanguage |
| recordingDate | string | YYYY-MM-DD |
| defaultLanguage | string | language of title/description, e.g. "en" |
| publicStatsViewable | boolean | true = show public view and like counts |
| defaultAudioLanguage | string | spoken language, e.g. "es" |
| paidProductPlacement | boolean | true = contains PAID product placement (sponsored) — YouTube shows the disclosure; legally required for sponsored content |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"videoId"
],
"properties": {
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "video tags, up to 30; omit to leave the current tags"
},
"brand": {
"type": "string",
"description": "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."
},
"title": {
"type": "string",
"description": "≤100 chars"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"license": {
"enum": [
"youtube",
"creativeCommon"
],
"type": "string",
"description": "YouTube licence: youtube (standard) or creativeCommon"
},
"privacy": {
"enum": [
"unlisted",
"public",
"private"
],
"type": "string",
"description": "public = live on the channel; confirm with the user first"
},
"videoId": {
"type": "string",
"description": "the YouTube video id"
},
"publishAt": {
"type": "string",
"description": "ISO date-time to go public; keeps it private until then; \"\" clears a schedule"
},
"categoryId": {
"type": "string",
"description": "numeric YouTube category id"
},
"embeddable": {
"type": "boolean",
"description": "true = allow embedding on other sites"
},
"aiGenerated": {
"type": "boolean",
"description": "YouTube’s “altered or synthetic content” label. OMIT IT and the value already on the post stays; a post that never had one is decided from provenance (a Hermoso render is declared, real footage from upload_file or an external URL is not). true/false overrides."
},
"description": {
"type": "string",
"description": "≤5000 chars"
},
"madeForKids": {
"type": "boolean",
"description": "true = made for kids; false = not made for kids"
},
"brandPartner": {
"type": "string",
"description": "sponsoring brand's channel id (UC…) or @handle — gives that brand access to run the video as a partnership ad; \"\" unlinks (update only)"
},
"localizations": {
"type": "object",
"description": "translated title/description per language, e.g. {\"es\":{\"title\":\"…\"}}; needs defaultLanguage",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"description": {
"type": "string"
}
}
}
},
"recordingDate": {
"type": "string",
"description": "YYYY-MM-DD"
},
"defaultLanguage": {
"type": "string",
"description": "language of title/description, e.g. \"en\""
},
"publicStatsViewable": {
"type": "boolean",
"description": "true = show public view and like counts"
},
"defaultAudioLanguage": {
"type": "string",
"description": "spoken language, e.g. \"es\""
},
"paidProductPlacement": {
"type": "boolean",
"description": "true = contains PAID product placement (sponsored) — YouTube shows the disclosure; legally required for sponsored content"
}
}
}| boolean |
| ASK FOR A ONE-TIME UPLOAD URL instead of uploading now — use this whenever the file is on the user’s machine and you can run a shell or an HTTP request. Returns a uploadUrl you PUT the raw bytes to (any HTTP client), which answers with the durable Hermoso url. It beats `dataUri` for anything but a small image: a data: URI spends the whole file as tokens in this conversation. One file per url, and it expires. |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"url": {
"type": "string",
"description": "a PUBLIC http(s) URL Hermoso fetches server-side (private/internal addresses are refused, and every redirect hop is re-checked). Works on every surface including the hosted connector, and the bytes never cross this connection — prefer this whenever the file is reachable on the web."
},
"name": {
"type": "string",
"description": "original file name — helps pick the right extension"
},
"dataUri": {
"type": "string",
"description": "base64 data: URI of the file bytes (data:<mime>;base64,<…>) — bytes travel over this connection, so keep it small"
},
"getUploadUrl": {
"type": "boolean",
"description": "ASK FOR A ONE-TIME UPLOAD URL instead of uploading now — use this whenever the file is on the user’s machine and you can run a shell or an HTTP request. Returns a uploadUrl you PUT the raw bytes to (any HTTP client), which answers with the durable Hermoso url. It beats `dataUri` for anything but a small image: a data: URI spends the whole file as tokens in this conversation. One file per url, and it expires."
}
}
}| YYYY-MM-DD or a UTC timestamp; defaults to 28 days before the end |
| granularity | string | default Total |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"ids"
],
"properties": {
"ids": {
"type": "array",
"items": {
"type": "string"
},
"description": "numeric X post ids (max 25) — the last part of each post URL"
},
"brand": {
"type": "string",
"description": "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."
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"endDate": {
"type": "string",
"description": "YYYY-MM-DD or a UTC timestamp; defaults to now"
},
"startDate": {
"type": "string",
"description": "YYYY-MM-DD or a UTC timestamp; defaults to 28 days before the end"
},
"granularity": {
"enum": [
"Total",
"Daily",
"Hourly",
"Weekly"
],
"type": "string",
"description": "default Total"
}
}
}{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"id"
],
"properties": {
"id": {
"type": "string",
"description": "the numeric X post id — the last part of the post URL"
},
"brand": {
"type": "string",
"description": "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."
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"publishedAt": {
"type": "number",
"description": "epoch ms the post went out, if known — lets the private owned-post metrics be requested only inside X's 30-day window instead of costing a refused call"
}
}
}| schedule | boolean | false = do not create the job if it is missing; just report that none exists |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"days": {
"type": "number",
"description": "how many recent daily files to read (1–14, default 7)"
},
"since": {
"type": "string",
"description": "YYYY-MM-DD — only files whose data starts on or after this"
},
"until": {
"type": "string",
"description": "YYYY-MM-DD — only files whose data starts before this"
},
"report": {
"enum": [
"thumbnails",
"thumbnails_by_source",
"cards",
"end_screens",
"traffic_source",
"basic"
],
"type": "string",
"description": "default thumbnails"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"schedule": {
"type": "boolean",
"description": "false = do not create the job if it is missing; just report that none exists"
}
}
}| report |
| string |
| which report (default day); contentType = Shorts vs long-form vs live |
| account | string | which connected account: its @handle or id from list_connector_accounts; needed only when several are connected |
| endDate | string | YYYY-MM-DD, default today |
| videoIds | array | narrow to these videos — audienceRetention requires exactly ONE, because the curve is per video |
| startDate | string | YYYY-MM-DD, default 28 days ago |
| contentType | string | keep only this type (videoOnDemand = long-form) — any report except topVideos, the *Detail reports and audienceRetention |
| splitByContentType | boolean | add a content-type column instead of filtering |
{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"limit": {
"type": "number",
"description": "rows, within YouTube’s own cap for that report"
},
"parent": {
"type": "string",
"description": "required by the *Detail reports: the ONE parent to drill into, e.g. \"YT_SEARCH\" / \"SUBSCRIBER\" / \"RELATED_VIDEO\" for trafficSourceDetail, \"EMBEDDED\" for playbackLocationDetail"
},
"report": {
"enum": [
"day",
"month",
"country",
"province",
"city",
"dma",
"trafficSource",
"trafficSourceDetail",
"playbackLocation",
"playbackLocationDetail",
"device",
"operatingSystem",
"demographics",
"sharingService",
"subscribedStatus",
"audienceRetention",
"topVideos",
"contentType"
],
"type": "string",
"description": "which report (default day); contentType = Shorts vs long-form vs live"
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"endDate": {
"type": "string",
"description": "YYYY-MM-DD, default today"
},
"videoIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "narrow to these videos — audienceRetention requires exactly ONE, because the curve is per video"
},
"startDate": {
"type": "string",
"description": "YYYY-MM-DD, default 28 days ago"
},
"contentType": {
"enum": [
"shorts",
"videoOnDemand",
"liveStream",
"story"
],
"type": "string",
"description": "keep only this type (videoOnDemand = long-form) — any report except topVideos, the *Detail reports and audienceRetention"
},
"splitByContentType": {
"type": "boolean",
"description": "add a content-type column instead of filtering"
}
}
}{
"type": "object",
"$schema": "http://json-schema.org/draft-07/schema#",
"required": [
"videoId"
],
"properties": {
"brand": {
"type": "string",
"description": "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."
},
"account": {
"type": "string",
"description": "which connected account: its @handle or id from list_connector_accounts; needed only when several are connected"
},
"endDate": {
"type": "string",
"description": "YYYY-MM-DD, default today"
},
"videoId": {
"type": "string",
"description": "the YouTube video id (the v= part of the watch URL, or the videoId returned by post_to_youtube)"
},
"startDate": {
"type": "string",
"description": "YYYY-MM-DD, default 28 days ago"
}
}
}