hermoso 0.1.264 → 0.1.265
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/mcp/tools.mjs +162 -146
- package/package.json +1 -1
package/mcp/tools.mjs
CHANGED
|
@@ -489,7 +489,10 @@ const publishWrap = (fn) => {
|
|
|
489
489
|
// client kills long tool calls before a 1-3 min render finishes — so cap the in-call wait there and return a
|
|
490
490
|
// RESUMABLE handle instead of dying (the agent polls get_job, which now attaches the poster on done).
|
|
491
491
|
async function renderJob(type, input, label) {
|
|
492
|
-
|
|
492
|
+
return awaitRenderJob(await submitJob(type, input, { label }));
|
|
493
|
+
}
|
|
494
|
+
// The wait half of renderJob, for a job some other route queued (generate_image's queue mode). One waiting rule.
|
|
495
|
+
async function awaitRenderJob(job) {
|
|
493
496
|
const ctx = mcpCtx.getStore(); // AsyncLocalStorage ctx only exists on the remote transport
|
|
494
497
|
const remote = !!ctx;
|
|
495
498
|
// THE ONE PLACE A RENDER TOOL WAITS, SO THE ONE PLACE A CALLER'S WAIT IS HONOURED (2026-09-20). `/v1/tools` puts a
|
|
@@ -3156,7 +3159,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3156
3159
|
server.group('channels');
|
|
3157
3160
|
server.registerTool('post_to_bluesky', {
|
|
3158
3161
|
title: 'Post to Bluesky',
|
|
3159
|
-
description: "Publish a post to Bluesky as the connected account. Text up to 300 characters — Bluesky ALSO caps a post at 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; Hermoso checks both before spending the round trip and says which limit and by how much. MEDIA: either up to 4 images (imageUrls + altText) OR one MP4 video (videoUrl + videoAlt), never both — a Bluesky post record carries a single embed and images and video are two different embed types. Video is MP4 only, up to 300MB at Bluesky's end (Hermoso can fetch up to 150MB from a URL), with optional WebVTT caption tracks; the aspect ratio is measured from the file. Bluesky requires a CONFIRMED EMAIL on the account before it will process any video — if it is unconfirmed you get a refusal saying so, and reconnecting will not help. Links in the text are made clickable automatically. LINK CARDS: Bluesky does NOT scrape links, so a URL posted bare renders as plain blue text — the client composing the post has to build the card. Hermoso builds one AUTOMATICALLY when the post has a URL and NO media: it fetches the page, uses its title/description and uploads its image as the card thumbnail. Pass linkCard:false to suppress it, or linkCard:{uri,title,description,thumbUrl} to control it (give both title and description and the page is not fetched at all). A POST CARRIES ONE EMBED, so a card and images/video cannot both ride: if you pass linkCard explicitly ALONGSIDE media the call is REFUSED by name rather than silently dropping one, and if the URL was merely in the text the MEDIA WINS and the reply says the card was skipped (the link stays clickable either way). Returns the post's public bsky.app URL. Connect at Settings
|
|
3162
|
+
description: "Publish a post to Bluesky as the connected account. Text up to 300 characters — Bluesky ALSO caps a post at 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; Hermoso checks both before spending the round trip and says which limit and by how much. MEDIA: either up to 4 images (imageUrls + altText) OR one MP4 video (videoUrl + videoAlt), never both — a Bluesky post record carries a single embed and images and video are two different embed types. Video is MP4 only, up to 300MB at Bluesky's end (Hermoso can fetch up to 150MB from a URL), with optional WebVTT caption tracks; the aspect ratio is measured from the file. Bluesky requires a CONFIRMED EMAIL on the account before it will process any video — if it is unconfirmed you get a refusal saying so, and reconnecting will not help. Links in the text are made clickable automatically. LINK CARDS: Bluesky does NOT scrape links, so a URL posted bare renders as plain blue text — the client composing the post has to build the card. Hermoso builds one AUTOMATICALLY when the post has a URL and NO media: it fetches the page, uses its title/description and uploads its image as the card thumbnail. Pass linkCard:false to suppress it, or linkCard:{uri,title,description,thumbUrl} to control it (give both title and description and the page is not fetched at all). A POST CARRIES ONE EMBED, so a card and images/video cannot both ride: if you pass linkCard explicitly ALONGSIDE media the call is REFUSED by name rather than silently dropping one, and if the URL was merely in the text the MEDIA WINS and the reply says the card was skipped (the link stays clickable either way). Returns the post's public bsky.app URL. Connect at Settings > Connectors > Bluesky, or here with connect_connector, with a handle and an APP PASSWORD.",
|
|
3160
3163
|
inputSchema: {
|
|
3161
3164
|
account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the brand has more than one bluesky account connected (several and none named is refused by name, never guessed); omit when there is one."),
|
|
3162
3165
|
text: z.string().describe('The post, up to 300 characters / 3000 UTF-8 bytes.'),
|
|
@@ -3183,7 +3186,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3183
3186
|
server.group('channel_admin');
|
|
3184
3187
|
server.registerTool('delete_bluesky_post', {
|
|
3185
3188
|
title: 'Delete a post from the connected Bluesky account',
|
|
3186
|
-
description: "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
|
|
3189
|
+
description: "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).",
|
|
3187
3190
|
inputSchema: {
|
|
3188
3191
|
uri: z.string().describe("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)"),
|
|
3189
3192
|
confirm: z.boolean().optional().describe('REQUIRED true — deletion is permanent and cannot be undone'),
|
|
@@ -3214,7 +3217,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3214
3217
|
server.group('channels');
|
|
3215
3218
|
server.registerTool('post_to_telegram', {
|
|
3216
3219
|
title: 'Post to Telegram',
|
|
3217
|
-
description: "Publish to a Telegram channel, group or chat as the brand's own bot. WHICH CHAT IS ALWAYS REQUIRED AND IS NEVER GUESSED: pass chatId as the public channel's @username (e.g. @hermosoai) or its numeric id (a group is negative; a supergroup or channel starts with -100). There is no default and there cannot be one — the Telegram Bot API publishes no method that lists the chats a bot belongs to, so Hermoso genuinely cannot know them. list_telegram_chats reports the chats that have MESSAGED the bot in the last 24 hours, which is a shortcut for finding an id and is NOT a roster: a chat missing from it can still be posted to. TEXT: up to 4096 characters on a text-only message, but only 1024 the moment ANY photo or video is attached — a caption is not a message, and Hermoso refuses the over-long one before spending the round trip and says which budget applied. MEDIA: one image (imageUrl), one video (videoUrl), or an ALBUM of 2–10 (imageUrls, in order) in which photos and videos may be MIXED — pass a videoUrl alongside imageUrls and it joins the album as one more item. Hermoso uploads the bytes rather than handing Telegram a link, which is what buys the larger ceilings: 10MB per photo and 50MB per video, where a link would be 5MB and 20MB. THE BOT MUST BE IN THE CHAT — an administrator with Post Messages for a channel, an unrestricted member for a group; if it is not, Telegram refuses and the error says to add it rather than to reconnect. Returns the message id and, for a PUBLIC chat, its t.me link — a private group has no public web link, so `url` comes back null rather than as a link that would 404 for whoever you hand it to. 0 credits. Connect at Settings
|
|
3220
|
+
description: "Publish to a Telegram channel, group or chat as the brand's own bot. WHICH CHAT IS ALWAYS REQUIRED AND IS NEVER GUESSED: pass chatId as the public channel's @username (e.g. @hermosoai) or its numeric id (a group is negative; a supergroup or channel starts with -100). There is no default and there cannot be one — the Telegram Bot API publishes no method that lists the chats a bot belongs to, so Hermoso genuinely cannot know them. list_telegram_chats reports the chats that have MESSAGED the bot in the last 24 hours, which is a shortcut for finding an id and is NOT a roster: a chat missing from it can still be posted to. TEXT: up to 4096 characters on a text-only message, but only 1024 the moment ANY photo or video is attached — a caption is not a message, and Hermoso refuses the over-long one before spending the round trip and says which budget applied. MEDIA: one image (imageUrl), one video (videoUrl), or an ALBUM of 2–10 (imageUrls, in order) in which photos and videos may be MIXED — pass a videoUrl alongside imageUrls and it joins the album as one more item. Hermoso uploads the bytes rather than handing Telegram a link, which is what buys the larger ceilings: 10MB per photo and 50MB per video, where a link would be 5MB and 20MB. THE BOT MUST BE IN THE CHAT — an administrator with Post Messages for a channel, an unrestricted member for a group; if it is not, Telegram refuses and the error says to add it rather than to reconnect. Returns the message id and, for a PUBLIC chat, its t.me link — a private group has no public web link, so `url` comes back null rather than as a link that would 404 for whoever you hand it to. 0 credits. Connect at Settings > Connectors > Telegram, or here with connect_connector, by pasting a bot token from @BotFather.",
|
|
3218
3221
|
inputSchema: {
|
|
3219
3222
|
account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the brand has more than one telegram account connected (several and none named is refused by name, never guessed); omit when there is one."),
|
|
3220
3223
|
chatId: z.string().describe("REQUIRED — the destination: a public channel's @username, or the numeric chat id. Never guessed; ask the user, or use list_telegram_chats."),
|
|
@@ -3257,9 +3260,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3257
3260
|
server.registerTool('list_telegram_dms', {
|
|
3258
3261
|
title: 'Read messages sent to your Telegram bot',
|
|
3259
3262
|
description: "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. "
|
|
3260
|
-
+ "
|
|
3263
|
+
+ "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'. "
|
|
3261
3264
|
+ "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. "
|
|
3262
|
-
+ "Free — no vendor charge and no credits. Needs Telegram connected (Settings
|
|
3265
|
+
+ "Free — no vendor charge and no credits. Needs Telegram connected (Settings > Connectors > Telegram, or connect_connector).",
|
|
3263
3266
|
inputSchema: { limit: z.number().optional().describe('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.') },
|
|
3264
3267
|
outputSchema: { messages: z.array(z.any()).optional(), count: z.number().optional(), webhookSet: z.boolean().optional(), windowHours: z.number().optional(), note: z.string().optional() },
|
|
3265
3268
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
@@ -3279,7 +3282,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3279
3282
|
// and nothing to echo back. A confirmText gate would be asking the caller to echo something we invented.
|
|
3280
3283
|
server.registerTool('delete_telegram_message', {
|
|
3281
3284
|
title: 'Delete a Telegram message',
|
|
3282
|
-
description: "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
|
|
3285
|
+
description: "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).",
|
|
3283
3286
|
inputSchema: {
|
|
3284
3287
|
chatId: z.string().describe("the chat the message is in — the same @username or numeric id it was posted with"),
|
|
3285
3288
|
messageId: z.number().describe('the message id post_to_telegram returned (also the number at the end of a t.me link)'),
|
|
@@ -3510,7 +3513,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3510
3513
|
// the human pays once — that card then saves for one-click forever. Packs only — subscriptions are in-app.
|
|
3511
3514
|
server.registerTool('buy_credits', {
|
|
3512
3515
|
title: 'Buy credits',
|
|
3513
|
-
description: "Out of credits? Top up with a credit PACK. Call with no argument to list the available packs (id · credits · price). If the account has a saved card and you have billing-admin rights, calling with `pack` quotes the exact charge and calling again with confirm:true AND the quote's quote_token charges the saved card instantly (same one-click top-up as the app — no redirect). If there's no saved card yet, you get a Stripe checkout URL to hand your human for the FIRST purchase; their card saves for one-click after that. Packs only; subscriptions are managed by a person in Settings
|
|
3516
|
+
description: "Out of credits? Top up with a credit PACK. Call with no argument to list the available packs (id · credits · price). If the account has a saved card and you have billing-admin rights, calling with `pack` quotes the exact charge and calling again with confirm:true AND the quote's quote_token charges the saved card instantly (same one-click top-up as the app — no redirect). If there's no saved card yet, you get a Stripe checkout URL to hand your human for the FIRST purchase; their card saves for one-click after that. Packs only; subscriptions are managed by a person in Settings -> Billing. 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.",
|
|
3514
3517
|
inputSchema: {
|
|
3515
3518
|
pack: z.string().optional().describe('the pack id to buy (e.g. pack-2k) — omit to list the available packs first'),
|
|
3516
3519
|
confirm: z.boolean().optional().describe('set true to actually charge the saved card for `pack` (required for the one-click charge; ignored on the checkout-link path)'),
|
|
@@ -3643,7 +3646,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3643
3646
|
// changes + downgrades are made in-app (the tool returns exactly what to do). Admin-only; a human always pays.
|
|
3644
3647
|
server.registerTool('upgrade_plan', {
|
|
3645
3648
|
title: 'Upgrade plan',
|
|
3646
|
-
description: "Change this account's SUBSCRIPTION plan (admin only). Call with no argument to list the plans (id · monthly price · monthly credits); call again with `plan` set to a plan id. A NEW subscriber gets a ready-to-pay Stripe Checkout URL to hand your human — THEY pay on Stripe (agents never spend money directly). If the account already has a paid plan, or you're DOWNGRADING, the change is made by a person in the app (Settings
|
|
3649
|
+
description: "Change this account's SUBSCRIPTION plan (admin only). Call with no argument to list the plans (id · monthly price · monthly credits); call again with `plan` set to a plan id. A NEW subscriber gets a ready-to-pay Stripe Checkout URL to hand your human — THEY pay on Stripe (agents never spend money directly). If the account already has a paid plan, or you're DOWNGRADING, the change is made by a person in the app (Settings -> Billing) and the tool returns exactly what to do. Members (read-only billing) get an honest 'ask an admin' message. Nothing is charged until your human pays.",
|
|
3647
3650
|
inputSchema: {
|
|
3648
3651
|
plan: z.string().optional().describe('the plan id to move to (e.g. pro) — omit to list the available plans first'),
|
|
3649
3652
|
period: z.enum(['mo', 'yr']).optional().describe('billing cadence — monthly (default) or yearly (2 months free)'),
|
|
@@ -4296,7 +4299,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4296
4299
|
server.group('channels');
|
|
4297
4300
|
server.registerTool('list_meta_pages', {
|
|
4298
4301
|
title: 'List Meta pages & ad accounts',
|
|
4299
|
-
description: 'List the Facebook Pages (with any linked Instagram business account) and ad accounts on the connected Meta account — use before post_to_meta / create_meta_campaign to pick the target. Requires the user to have connected Meta (Settings
|
|
4302
|
+
description: 'List the Facebook Pages (with any linked Instagram business account) and ad accounts on the connected Meta account — use before post_to_meta / create_meta_campaign to pick the target. Requires the user to have connected Meta (Settings > Connectors > Meta); returns a connect hint if not.',
|
|
4300
4303
|
inputSchema: {},
|
|
4301
4304
|
outputSchema: { pages: z.array(z.any()).optional(), adAccounts: z.array(z.any()).optional() },
|
|
4302
4305
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -4319,7 +4322,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4319
4322
|
// user's OWN files, not just generated ones.
|
|
4320
4323
|
const EXT_MIME = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm', m4v: 'video/mp4' };
|
|
4321
4324
|
server.registerTool('upload_file', {
|
|
4322
|
-
title: 'Upload a local file
|
|
4325
|
+
title: 'Upload a local file -> durable public URL',
|
|
4323
4326
|
description: 'Persist an ARBITRARY user file (image, video, audio, PDF or document, up to 150MB) into Hermoso and get back a durable public URL that EVERY publish, schedule and ad-build tool accepts — post_to_meta / post_to_linkedin / post_to_linkedin_page / post_to_youtube / post_to_tiktok / post_to_pinterest / post_to_x / post_to_google_business / schedule_post / upload_meta_asset / upload_google_ads_asset / create_meta_ad / create_linkedin_ads_creative / set_youtube_thumbnail / save_to_drive / save_to_onedrive. THIS IS THE BRING-YOUR-OWN-CREATIVE PATH: it is for files that have NOTHING to do with a Hermoso render (media on the user\'s desktop, an agency\'s finished ad, a photo they shot), and it means you can publish, schedule and run ads through Hermoso without generating anything here. Provide exactly ONE source — passing two is an error, never a silent preference: `url` (ANY public http(s) link — Hermoso fetches it server-side, so nothing crosses this connection and there is no practical size limit; THIS IS THE ONE THAT ALWAYS WORKS, including on the hosted connector), ' + (opts.hosted ? '' : '`path` (a local file, ONLY when Hermoso runs on the user\'s own machine over stdio/CLI; the hosted connector cannot see their disk), or ') + '`dataUri` (a base64 data: URI — keep it under ~15MB, since the bytes travel over this connection). If the file is already at a public https URL, the Meta, Reddit and ChatGPT-Ads tools take it directly and re-host it safely — but LinkedIn (posts and ad creatives), Pinterest, the YouTube thumbnail and upload_google_ads_asset upload the BYTES themselves and therefore refuse an external host, so run it through here first and pass the URL this returns. When in doubt, use this: its URL works everywhere. (Calling the underlying HTTP route directly? POST /api/upload takes the file\'s RAW BYTES as the request body with its own content-type — NOT multipart/form-data — or ?url=<link> with no body.) Returns {url, kind, bytes}.',
|
|
4324
4327
|
inputSchema: {
|
|
4325
4328
|
// THE THIRD SOURCE, AND THE ONLY ONE THAT CARRIES BYTES WITHOUT SPENDING THEM AS TOKENS (2026-09-16). Asked
|
|
@@ -4472,7 +4475,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4472
4475
|
server.group('channels');
|
|
4473
4476
|
server.registerTool('list_whatsapp_accounts', {
|
|
4474
4477
|
title: 'WhatsApp Business accounts and numbers',
|
|
4475
|
-
description: 'The WhatsApp Business Accounts SHARED WITH THIS BRAND and the phone numbers registered on each — the ids every other WhatsApp tool needs, plus each number’s QUALITY RATING, which is what decides how many messages Meta will let it send. Start here. A WABA with no number cannot send anything and the reply says so: Hermoso does not register or verify numbers, that is WhatsApp Manager. A business portfolio that could not be read is REPORTED rather than dropped — an account missing from this list would read as "the brand has no WhatsApp", which is a claim about their business and not about our read. TWO ACCOUNTS CAN CARRY THE SAME DISPLAY NAME — always quote the `display` field or the id when naming one to a user, never the bare name. ONLY THE ACCOUNTS SHARED WITH THIS BRAND ARE REACHABLE. One Meta login often administers WhatsApp for several businesses, so a human ticks which belong to this brand under Settings
|
|
4478
|
+
description: 'The WhatsApp Business Accounts SHARED WITH THIS BRAND and the phone numbers registered on each — the ids every other WhatsApp tool needs, plus each number’s QUALITY RATING, which is what decides how many messages Meta will let it send. Start here. A WABA with no number cannot send anything and the reply says so: Hermoso does not register or verify numbers, that is WhatsApp Manager. A business portfolio that could not be read is REPORTED rather than dropped — an account missing from this list would read as "the brand has no WhatsApp", which is a claim about their business and not about our read. TWO ACCOUNTS CAN CARRY THE SAME DISPLAY NAME — always quote the `display` field or the id when naming one to a user, never the bare name. ONLY THE ACCOUNTS SHARED WITH THIS BRAND ARE REACHABLE. One Meta login often administers WhatsApp for several businesses, so a human ticks which belong to this brand under Settings > Connectors > Meta > Manage accounts (or set_connector_accounts(provider:"meta")); nothing else here can see or touch the rest. With none ticked every WhatsApp tool refuses and names that as the way out. With exactly ONE ticked, wabaId is optional — pass it only to disambiguate. Read-only, 0 credits (WhatsApp conversations are billed by META to the business directly, never in Hermoso credits). Needs Meta connected.',
|
|
4476
4479
|
inputSchema: {},
|
|
4477
4480
|
outputSchema: { count: z.number().optional(), accounts: z.array(z.any()).optional(), unreadable: z.array(z.any()).optional(), note: z.string().optional(), optIn: z.string().optional(), pricing: z.string().optional() },
|
|
4478
4481
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -4565,7 +4568,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4565
4568
|
|
|
4566
4569
|
server.registerTool('send_whatsapp_broadcast', {
|
|
4567
4570
|
title: 'Broadcast a WhatsApp template to many people',
|
|
4568
|
-
description: '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.
|
|
4571
|
+
description: '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.',
|
|
4569
4572
|
inputSchema: {
|
|
4570
4573
|
jobId: z.string().optional().describe('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'),
|
|
4571
4574
|
// OPTIONAL IN THE SCHEMA, REQUIRED BY THE SERVER — and that split is the fix for a live defect, not laxity.
|
|
@@ -4596,7 +4599,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4596
4599
|
server.group('channels');
|
|
4597
4600
|
server.registerTool('post_to_meta', {
|
|
4598
4601
|
title: 'Post to Facebook, Instagram or Threads',
|
|
4599
|
-
description: 'Publish to a connected Facebook Page, its linked Instagram, OR the brand’s Threads account — text/link/image/VIDEO/CAROUSEL. A MULTI-SLIDE creative is a CAROUSEL, not several posts: pass the slides in order as imageUrls[] and they publish as ONE swipeable post (Instagram album, Threads carousel, Facebook multi-photo post). Never publish slide 1 of a deck on its own — the creative tells the viewer to swipe. target:"facebook" (default) posts to the Page; target:"instagram" publishes a photo or Reel to the linked IG business account (needs an image or video); target:"threads" posts to the connected Threads account (text, image, or video). Works with ANY media — a finished Hermoso ad OR an arbitrary user file: imageUrl/videoUrl accept a public https URL, a data: URI, or a Hermoso /generated path; for a LOCAL file (e.g. on the user’s desktop) call upload_file first and pass the url it returns. INSTAGRAM COLLAB: pass `collaborators` (up to 3 usernames) to invite other accounts to CO-AUTHOR the post — it then shows on their profile too once they accept, which is the reach play behind every creator partnership. This PUBLISHES immediately — confirm the copy + media with the user first. Needs a connected Meta account (Settings
|
|
4602
|
+
description: 'Publish to a connected Facebook Page, its linked Instagram, OR the brand’s Threads account — text/link/image/VIDEO/CAROUSEL. A MULTI-SLIDE creative is a CAROUSEL, not several posts: pass the slides in order as imageUrls[] and they publish as ONE swipeable post (Instagram album, Threads carousel, Facebook multi-photo post). Never publish slide 1 of a deck on its own — the creative tells the viewer to swipe. target:"facebook" (default) posts to the Page; target:"instagram" publishes a photo or Reel to the linked IG business account (needs an image or video); target:"threads" posts to the connected Threads account (text, image, or video). Works with ANY media — a finished Hermoso ad OR an arbitrary user file: imageUrl/videoUrl accept a public https URL, a data: URI, or a Hermoso /generated path; for a LOCAL file (e.g. on the user’s desktop) call upload_file first and pass the url it returns. INSTAGRAM COLLAB: pass `collaborators` (up to 3 usernames) to invite other accounts to CO-AUTHOR the post — it then shows on their profile too once they accept, which is the reach play behind every creator partnership. This PUBLISHES immediately — confirm the copy + media with the user first. Needs a connected Meta account (Settings > Connectors > Meta) with posting permission; Threads needs its own connection.',
|
|
4600
4603
|
inputSchema: {
|
|
4601
4604
|
...HOOK_ATTR,
|
|
4602
4605
|
message: z.string().optional().describe('post text / caption'),
|
|
@@ -4609,7 +4612,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4609
4612
|
async: z.boolean().optional().describe('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.'),
|
|
4610
4613
|
link: z.string().optional().describe('a URL to attach (FB text post only)'),
|
|
4611
4614
|
collaborators: z.array(z.string()).optional().describe('INSTAGRAM COLLAB \u2014 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 \u2014 it is how a brand reaches a creator\u2019s audience without paying for placement, and it is the single most-asked thing a scheduler normally cannot do. Pass handles only ("hermosoai"), not profile links; a leading @ is fine. INSTAGRAM ONLY \u2014 Facebook and Threads have no collab post at all and are refused BY NAME rather than silently dropping the co-authors \u2014 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\u2019s profile ALONE; they may also decline, and Instagram sends no notification either way. So never report the post as live on both accounts \u2014 read the invite status back out of the reply, and use instagram_collaborators later to find out whether they accepted.'),
|
|
4612
|
-
target: z.enum(['facebook', 'instagram', 'threads']).optional().describe('default facebook; instagram
|
|
4615
|
+
target: z.enum(['facebook', 'instagram', 'threads']).optional().describe('default facebook; instagram -> the Page’s linked IG; threads -> the brand’s connected Threads account'),
|
|
4613
4616
|
account: z.string().optional().describe('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.'),
|
|
4614
4617
|
scheduleAt: z.string().optional().describe('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.'),
|
|
4615
4618
|
locationId: z.string().optional().describe('TAG A PLACE. THREADS: a place id from search_threads_locations. INSTAGRAM: the NUMERIC ID OF A FACEBOOK PAGE associated with that location, not a place name and not coordinates; a non-numeric value is refused rather than sent.'),
|
|
@@ -4621,9 +4624,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4621
4624
|
place: z.string().optional().describe('FACEBOOK — tag a location on the Page post. The NUMERIC ID OF THE FACEBOOK PAGE for that place, not a place name and not coordinates.'),
|
|
4622
4625
|
callToAction: z.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']).optional().describe('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.'),
|
|
4623
4626
|
callToActionLink: z.string().optional().describe('FACEBOOK — where the button goes, when that is not the post’s own `link`.'),
|
|
4624
|
-
linkName: z.string().optional().describe('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
|
|
4625
|
-
linkDescription: z.string().optional().describe('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
|
|
4626
|
-
linkPicture: z.string().optional().describe('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
|
|
4627
|
+
linkName: z.string().optional().describe('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.'),
|
|
4628
|
+
linkDescription: z.string().optional().describe('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.'),
|
|
4629
|
+
linkPicture: z.string().optional().describe('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.'),
|
|
4627
4630
|
// ── THE SEVEN FROM THE 2026-09-16 PARAMETER AUDIT ──────────────────────────────────────────────────────────
|
|
4628
4631
|
// Meta's table for POST /{ig-user-id}/media lists 21 parameters; we sent 13 and had never looked at these,
|
|
4629
4632
|
// because every sweep we ran diffed PATHS or TOOLS and not one of them is a missing endpoint. Each is refused
|
|
@@ -4645,7 +4648,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4645
4648
|
replyControl: z.enum(['everyone', 'accounts_you_follow', 'mentioned_only', 'parent_post_author_only', 'followers_only']).optional().describe('THREADS ONLY — who may reply. Default is everyone.'),
|
|
4646
4649
|
quotePostId: z.string().optional().describe('THREADS ONLY — the id of a post to QUOTE. A quote post is a distinct post type, not a formatting option.'),
|
|
4647
4650
|
topicTag: z.string().optional().describe('THREADS ONLY — one topic tag for discovery, 1–50 characters. A leading # is stripped for you; Threads refuses "." and "&".'),
|
|
4648
|
-
countryCodes: z.array(z.string()).optional().describe('THREADS ONLY — restrict the post to these ISO 3166-1 alpha-2 countries.
|
|
4651
|
+
countryCodes: z.array(z.string()).optional().describe('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.'),
|
|
4649
4652
|
// ── CROSS-SHARE TO INSTAGRAM (2026-08-24). One idea, two surfaces, one call — and the honesty rule ships
|
|
4650
4653
|
// WITH it: Threads publishes no field on the media object reporting whether the Story was created, so a 200
|
|
4651
4654
|
// proves Meta accepted the request and never that the Story exists. Nothing here may say "also shared to
|
|
@@ -4728,7 +4731,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4728
4731
|
// 2026-08-03: TikTok's video/photo Post Info tables, Google's v4 LocalPost reference, docs.x.com create-post.
|
|
4729
4732
|
brandedContent: z.boolean().optional().describe('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.'),
|
|
4730
4733
|
yourBrand: z.boolean().optional().describe('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.'),
|
|
4731
|
-
privacyLevel: z.enum(['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'FOLLOWER_OF_CREATOR', 'SELF_ONLY']).optional().describe('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
|
|
4734
|
+
privacyLevel: z.enum(['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'FOLLOWER_OF_CREATOR', 'SELF_ONLY']).optional().describe('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.'),
|
|
4732
4735
|
commercialContent: z.boolean().optional().describe('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.'),
|
|
4733
4736
|
disableComment: z.boolean().optional().describe('TIKTOK — turn comments off on this post.'),
|
|
4734
4737
|
disableDuet: z.boolean().optional().describe('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.'),
|
|
@@ -4761,9 +4764,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4761
4764
|
place: z.string().optional().describe('FACEBOOK — tag a location on the Page post. The NUMERIC ID OF THE FACEBOOK PAGE for that place, not a place name and not coordinates.'),
|
|
4762
4765
|
callToAction: z.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']).optional().describe('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.'),
|
|
4763
4766
|
callToActionLink: z.string().optional().describe('FACEBOOK — where the button goes, when that is not the post’s own `link`.'),
|
|
4764
|
-
linkName: z.string().optional().describe('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
|
|
4765
|
-
linkDescription: z.string().optional().describe('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
|
|
4766
|
-
linkPicture: z.string().optional().describe('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
|
|
4767
|
+
linkName: z.string().optional().describe('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.'),
|
|
4768
|
+
linkDescription: z.string().optional().describe('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.'),
|
|
4769
|
+
linkPicture: z.string().optional().describe('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.'),
|
|
4767
4770
|
// ── THE SEVEN FROM THE 2026-09-16 PARAMETER AUDIT ──────────────────────────────────────────────────────────
|
|
4768
4771
|
// Meta's table for POST /{ig-user-id}/media lists 21 parameters; we sent 13 and had never looked at these,
|
|
4769
4772
|
// because every sweep we ran diffed PATHS or TOOLS and not one of them is a missing endpoint. Each is refused
|
|
@@ -4897,7 +4900,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4897
4900
|
// TikTok's privacy level is settable at CREATE and must be settable at EDIT. Without it here an agent
|
|
4898
4901
|
// that queued the wrong level had to cancel and re-create — and since schedValidate refuses a TikTok
|
|
4899
4902
|
// item naming none, a PATCH that dropped it would fail with a rule the caller could not satisfy.
|
|
4900
|
-
privacyLevel: z.enum(['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'FOLLOWER_OF_CREATOR', 'SELF_ONLY']).optional().describe('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
|
|
4903
|
+
privacyLevel: z.enum(['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'FOLLOWER_OF_CREATOR', 'SELF_ONLY']).optional().describe('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.'),
|
|
4901
4904
|
altText: z.union([z.string(), z.array(z.string())]).optional().describe('ACCESSIBILITY \u2014 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.'),
|
|
4902
4905
|
link: z.string().optional(),
|
|
4903
4906
|
// The per-channel publish settings (SCHED_META_FIELDS), on the same omitted-means-keep rule as everything else
|
|
@@ -5100,7 +5103,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5100
5103
|
}));
|
|
5101
5104
|
server.registerTool('post_to_linkedin', {
|
|
5102
5105
|
title: 'Publish to LinkedIn',
|
|
5103
|
-
description: 'Publish a post to the user’s connected LinkedIn profile — text, and optionally an image (pass its served URL as imageUrl). The image does NOT have to be something Hermoso generated: LinkedIn is served the bytes from us, so the URL must be Hermoso-hosted, and upload_file turns ANY file the user already has into exactly that. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Needs a connected LinkedIn account (Settings
|
|
5106
|
+
description: 'Publish a post to the user’s connected LinkedIn profile — text, and optionally an image (pass its served URL as imageUrl). The image does NOT have to be something Hermoso generated: LinkedIn is served the bytes from us, so the URL must be Hermoso-hosted, and upload_file turns ANY file the user already has into exactly that. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Needs a connected LinkedIn account (Settings > Connectors > LinkedIn).',
|
|
5104
5107
|
inputSchema: {
|
|
5105
5108
|
...HOOK_ATTR,
|
|
5106
5109
|
text: z.string().describe('the post text'),
|
|
@@ -5121,7 +5124,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5121
5124
|
// below say so explicitly, because an agent that fires ten posts to "see what sticks" is spending the user's money.
|
|
5122
5125
|
server.registerTool('post_to_x', {
|
|
5123
5126
|
title: 'Publish a post to X (Twitter)',
|
|
5124
|
-
description: '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 \u201cShow more\u201d. Write altText whenever you attach a render. COSTS CREDITS: X charges per API request, so every post in a thread is billed, and a post containing a LINK costs roughly 13× one without — mention the cost before publishing a long thread. Each brand also has a rolling 24-hour ceiling on what it can spend at X, and a request that would cross it is refused WHOLE before anything publishes, so a batch of posts is bounded rather than open-ended. THIS TOOL POSTS ORGANICALLY — it does not create an ad campaign. X ads are built with create_x_ads_campaign
|
|
5127
|
+
description: '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 \u201cShow more\u201d. Write altText whenever you attach a render. COSTS CREDITS: X charges per API request, so every post in a thread is billed, and a post containing a LINK costs roughly 13× one without — mention the cost before publishing a long thread. Each brand also has a rolling 24-hour ceiling on what it can spend at X, and a request that would cross it is refused WHOLE before anything publishes, so a batch of posts is bounded rather than open-ended. THIS TOOL POSTS ORGANICALLY — it does not create an ad campaign. X ads are built with create_x_ads_campaign -> create_x_ads_line_item -> create_x_ads_promoted_tweet, and a promoted post needs a post id, so publish here first and promote that post. Needs X connected (Settings > Connectors > X).',
|
|
5125
5128
|
inputSchema: {
|
|
5126
5129
|
account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the brand has more than one x account connected (several and none named is refused by name, never guessed); omit when there is one."),
|
|
5127
5130
|
...HOOK_ATTR,
|
|
@@ -5155,7 +5158,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5155
5158
|
}));
|
|
5156
5159
|
server.registerTool('post_x_article', {
|
|
5157
5160
|
title: 'Publish a long-form Article to X',
|
|
5158
|
-
description: '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
|
|
5161
|
+
description: '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).',
|
|
5159
5162
|
inputSchema: {
|
|
5160
5163
|
...HOOK_ATTR,
|
|
5161
5164
|
title: z.string().describe('the Article title — X requires one and refuses a draft without it. This is what shows on the timeline card.'),
|
|
@@ -5299,7 +5302,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5299
5302
|
+ "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. "
|
|
5300
5303
|
+ "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. "
|
|
5301
5304
|
+ "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. "
|
|
5302
|
-
+ "Needs X connected, AND an X account connected before direct messages shipped must be reconnected once (Settings
|
|
5305
|
+
+ "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.",
|
|
5303
5306
|
inputSchema: {
|
|
5304
5307
|
maxResults: z.number().optional().describe('how many messages to pull, 1-100 (default 10) — EVERY ONE IS BILLED, and omitting it would make X return 100'),
|
|
5305
5308
|
conversationId: z.string().optional().describe('read just this conversation (from a previous call). One-to-one ids look like "12-345"; group ids are one long number.'),
|
|
@@ -5328,7 +5331,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5328
5331
|
description: "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). "
|
|
5329
5332
|
+ "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. "
|
|
5330
5333
|
+ "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. "
|
|
5331
|
-
+ "Needs X connected with direct-message access — an X account connected before DMs shipped must be reconnected once (Settings
|
|
5334
|
+
+ "Needs X connected with direct-message access — an X account connected before DMs shipped must be reconnected once (Settings > Connectors > X).",
|
|
5332
5335
|
inputSchema: {
|
|
5333
5336
|
text: z.string().describe('the message, exactly as it should be sent'),
|
|
5334
5337
|
conversationId: z.string().optional().describe('reply into an existing conversation (from list_x_dms)'),
|
|
@@ -5365,7 +5368,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5365
5368
|
server.group('channels');
|
|
5366
5369
|
server.registerTool('list_pinterest_boards', {
|
|
5367
5370
|
title: 'List Pinterest boards',
|
|
5368
|
-
description: '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
|
|
5371
|
+
description: '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).',
|
|
5369
5372
|
inputSchema: { privacy: z.enum(['ALL', 'PUBLIC', 'PROTECTED', 'SECRET']).optional().describe('filter by board privacy; default is everything the connection can see') },
|
|
5370
5373
|
outputSchema: { count: z.number().optional(), boards: z.array(z.object({ id: z.string().optional(), name: z.string().optional(), privacy: z.string().optional(), description: z.string().optional(), pins: z.number().nullable().optional(), followers: z.number().nullable().optional() })).optional() },
|
|
5371
5374
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -5415,7 +5418,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5415
5418
|
server.group('channels');
|
|
5416
5419
|
server.registerTool('post_to_pinterest', {
|
|
5417
5420
|
title: 'Create a Pin',
|
|
5418
|
-
description: '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
|
|
5421
|
+
description: '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).',
|
|
5419
5422
|
inputSchema: {
|
|
5420
5423
|
account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the brand has more than one pinterest account connected (several and none named is refused by name, never guessed); omit when there is one."),
|
|
5421
5424
|
...HOOK_ATTR,
|
|
@@ -5516,7 +5519,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5516
5519
|
}));
|
|
5517
5520
|
server.registerTool('update_pinterest_board', {
|
|
5518
5521
|
title: 'Rename or re-privacy a Pinterest board',
|
|
5519
|
-
description: 'Rename a board, rewrite its description, or change its privacy.
|
|
5522
|
+
description: '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.',
|
|
5520
5523
|
inputSchema: {
|
|
5521
5524
|
boardId: z.string().describe('numeric board id from list_pinterest_boards'),
|
|
5522
5525
|
name: z.string().optional(),
|
|
@@ -5623,7 +5626,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5623
5626
|
server.group('channels');
|
|
5624
5627
|
server.registerTool('list_business_locations', {
|
|
5625
5628
|
title: 'List Google business listings',
|
|
5626
|
-
description: 'List the Google Business Profile listings SHARED WITH THIS BRAND — id, title, address, website and Maps link. These are the only listings anything here can post to or read: one Google login often manages several businesses (an agency manages its clients’), and the user ticks which of them belong to this brand. Call this before posting whenever more than one is shared and let the USER pick: a Post on the wrong storefront is a public mistake Hermoso will not make for them. If nothing is shared, ask the user to choose — list_connector_accounts("google_business") then set_connector_accounts — and never name or guess a listing. Read-only, 0 credits. Needs Google Business Profile connected (Settings
|
|
5629
|
+
description: 'List the Google Business Profile listings SHARED WITH THIS BRAND — id, title, address, website and Maps link. These are the only listings anything here can post to or read: one Google login often manages several businesses (an agency manages its clients’), and the user ticks which of them belong to this brand. Call this before posting whenever more than one is shared and let the USER pick: a Post on the wrong storefront is a public mistake Hermoso will not make for them. If nothing is shared, ask the user to choose — list_connector_accounts("google_business") then set_connector_accounts — and never name or guess a listing. Read-only, 0 credits. Needs Google Business Profile connected (Settings > Connectors > Google Business Profile).',
|
|
5627
5630
|
inputSchema: {},
|
|
5628
5631
|
outputSchema: { count: z.number().optional(), shared: z.number().optional(), missing: z.array(z.string()).optional(), locations: z.array(z.object({ id: z.string().optional(), account: z.string().optional(), accountName: z.string().optional(), title: z.string().optional(), address: z.string().optional(), website: z.string().optional(), phone: z.string().optional(), mapsUrl: z.string().optional(), canPost: z.boolean().optional() })).optional() },
|
|
5629
5632
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -5637,7 +5640,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5637
5640
|
}));
|
|
5638
5641
|
server.registerTool('post_to_google_business', {
|
|
5639
5642
|
title: 'Post to Google Business Profile',
|
|
5640
|
-
description: '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
|
|
5643
|
+
description: '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).',
|
|
5641
5644
|
inputSchema: {
|
|
5642
5645
|
...HOOK_ATTR,
|
|
5643
5646
|
summary: z.string().optional().describe('the body text of the Post'),
|
|
@@ -5755,7 +5758,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5755
5758
|
// gbpAccountInfo, so these stay thin and no surface can carry a weaker gate than another.
|
|
5756
5759
|
server.registerTool('get_business_location', {
|
|
5757
5760
|
title: 'Read a Google Business Profile listing',
|
|
5758
|
-
description: '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
|
|
5761
|
+
description: '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).',
|
|
5759
5762
|
inputSchema: { locationId: z.string().optional().describe('which listing, e.g. \'locations/123\' from list_business_locations — only needed when more than one is shared with this brand') },
|
|
5760
5763
|
outputSchema: { locationId: z.string().optional(), account: z.string().optional(), title: z.string().optional(), readMask: z.string().optional(), location: z.record(z.any()).optional() },
|
|
5761
5764
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -5804,7 +5807,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5804
5807
|
server.group('channels');
|
|
5805
5808
|
server.registerTool('post_to_youtube', {
|
|
5806
5809
|
title: 'Post a video to YouTube',
|
|
5807
|
-
description: 'Publish a finished video to the brand’s connected YouTube channel. Pass a Hermoso render URL (or an upload_file url for a local/external file). DEFAULTS TO UNLISTED (link-only — not on the channel, not searchable, but shareable by link AND usable as a YouTube/Google ad). Pass privacy:"public" to put it ON the channel (a public publish — confirm with the user first) or privacy:"private" for eyes-only. Do NOT use "private" for anything meant to run as an ad — private videos CANNOT be used as ads; unlisted is the ad-ready setting. SCHEDULE it with publishAt, FILE it under the right categoryId (the default 22 "People & Blogs" is wrong for most ads), SUBSCRIBER NOTIFICATIONS FOLLOW PRIVACY — a public publish announces the video to the channel’s subscribers (YouTube’s own default), while unlisted/private uploads stay quiet; pass notifySubscribers explicitly to override either way. 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. Needs a connected YouTube channel (Settings
|
|
5810
|
+
description: 'Publish a finished video to the brand’s connected YouTube channel. Pass a Hermoso render URL (or an upload_file url for a local/external file). DEFAULTS TO UNLISTED (link-only — not on the channel, not searchable, but shareable by link AND usable as a YouTube/Google ad). Pass privacy:"public" to put it ON the channel (a public publish — confirm with the user first) or privacy:"private" for eyes-only. Do NOT use "private" for anything meant to run as an ad — private videos CANNOT be used as ads; unlisted is the ad-ready setting. SCHEDULE it with publishAt, FILE it under the right categoryId (the default 22 "People & Blogs" is wrong for most ads), SUBSCRIBER NOTIFICATIONS FOLLOW PRIVACY — a public publish announces the video to the channel’s subscribers (YouTube’s own default), while unlisted/private uploads stay quiet; pass notifySubscribers explicitly to override either way. 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. Needs a connected YouTube channel (Settings > Connectors > YouTube).',
|
|
5808
5811
|
inputSchema: {
|
|
5809
5812
|
account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the brand has more than one youtube account connected (several and none named is refused by name, never guessed); omit when there is one."),
|
|
5810
5813
|
...HOOK_ATTR,
|
|
@@ -6170,7 +6173,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6170
6173
|
}));
|
|
6171
6174
|
server.registerTool('google_business_search_keywords', {
|
|
6172
6175
|
title: 'What people searched to find this business',
|
|
6173
|
-
description: '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.
|
|
6176
|
+
description: '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.',
|
|
6174
6177
|
inputSchema: { locationId: z.string().optional().describe('which listing, when the brand has more than one (list_business_locations)'), months: z.number().optional().describe('how many whole months back, default 3, cap 12. The current month is excluded because it is always partial.'), limit: z.number().optional().describe('max terms, default 100, which is also Google’s maximum'), pageToken: z.string().optional().describe('nextPageToken from a previous call') },
|
|
6175
6178
|
outputSchema: { location: z.string().optional(), locationId: z.string().optional(), from: z.string().optional(), to: z.string().optional(), months: z.number().optional(), count: z.number().optional(), keywords: z.array(z.object({ keyword: z.string().optional(), impressions: z.number().nullable().optional(), suppressed: z.boolean().optional(), below: z.number().nullable().optional(), display: z.string().optional() })).optional(), nextPageToken: z.string().nullable().optional(), note: z.string().optional() },
|
|
6176
6179
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -6230,7 +6233,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6230
6233
|
}));
|
|
6231
6234
|
server.registerTool('x_follows', {
|
|
6232
6235
|
title: 'Who follows an X account (or who it follows)',
|
|
6233
|
-
description: 'The follower or following LIST for an X account \u2014 handles, names, bios, locations and each one\u2019s 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 (\u201cwho follows us\u201d); pass a handle to read anyone public (\u201cwho follows our competitor\u201d).
|
|
6236
|
+
description: 'The follower or following LIST for an X account \u2014 handles, names, bios, locations and each one\u2019s 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 (\u201cwho follows us\u201d); pass a handle to read anyone public (\u201cwho follows our competitor\u201d). 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 \u2014 the tool says so and asks for a reconnect rather than surfacing X\u2019s raw refusal. Capped at 100 per call because X bills per user returned; page with nextToken. Needs X connected.',
|
|
6234
6237
|
inputSchema: {
|
|
6235
6238
|
username: z.string().optional().describe('handle with or without the @ \u2014 omit to read the connected account'),
|
|
6236
6239
|
direction: z.enum(['followers', 'following']).optional().describe('default followers'),
|
|
@@ -6245,7 +6248,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6245
6248
|
}));
|
|
6246
6249
|
server.registerTool('list_x_blocks', {
|
|
6247
6250
|
title: 'Who the connected X account has blocked',
|
|
6248
|
-
description: '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
|
|
6251
|
+
description: '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).',
|
|
6249
6252
|
inputSchema: { maxResults: z.number().optional().describe('1–1000, default 100'), paginationToken: z.string().optional() },
|
|
6250
6253
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
6251
6254
|
}, wrap(async (a = {}) => {
|
|
@@ -6264,7 +6267,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6264
6267
|
}));
|
|
6265
6268
|
server.registerTool('x_account', {
|
|
6266
6269
|
title: 'Read the connected X account',
|
|
6267
|
-
description: 'ACCOUNT-LEVEL numbers for the connected X account \u2014 FOLLOWERS, following, total posts, listed count, media count \u2014 plus display name, bio, location, verified status and when the account was created. This is the tool for \u201chow many followers do we have on X\u201d, \u201cis our X growing\u201d, 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 \u2014 X bills per request and not per field \u2014 so it costs at most one User: Read and is free on a cached lookup. Needs X connected (Settings
|
|
6270
|
+
description: 'ACCOUNT-LEVEL numbers for the connected X account \u2014 FOLLOWERS, following, total posts, listed count, media count \u2014 plus display name, bio, location, verified status and when the account was created. This is the tool for \u201chow many followers do we have on X\u201d, \u201cis our X growing\u201d, 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 \u2014 X bills per request and not per field \u2014 so it costs at most one User: Read and is free on a cached lookup. Needs X connected (Settings > Connectors > X).',
|
|
6268
6271
|
inputSchema: {},
|
|
6269
6272
|
outputSchema: { id: z.string().optional(), username: z.string().optional(), name: z.string().nullable().optional(), verified: z.boolean().optional(), verifiedType: z.string().nullable().optional(), protected: z.boolean().optional(), bio: z.string().nullable().optional(), location: z.string().nullable().optional(), avatar: z.string().nullable().optional(), createdAt: z.string().nullable().optional(), subscriptionType: z.string().nullable().optional(), metrics: z.record(z.number()).optional(), absent: z.record(z.string()).optional(), costCredits: z.number().optional() },
|
|
6270
6273
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -6318,7 +6321,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6318
6321
|
}));
|
|
6319
6322
|
server.registerTool('create_linkedin_event', {
|
|
6320
6323
|
title: 'Create a LinkedIn event',
|
|
6321
|
-
description: 'Create a LinkedIn LIVE event on a company Page the brand administers.
|
|
6324
|
+
description: 'Create a LinkedIn LIVE event on a company Page the brand administers. Warning: THIS REQUIRES LINKEDIN LIVE ACCESS on the member\u2019s account: `liveVideo` is the only online format LinkedIn documents for event creation, and a member without Live access is refused with \u201cThe member does not have LINKEDIN_LIVE creation access\u201d (measured live 2026-08-31). Check the account has it before offering this. Warning: CREATED BUT NOT ANNOUNCED: LinkedIn requires a SEPARATE post to make an event publicly visible, so creating one puts nothing in anyone\u2019s feed and the brand decides when to tell people. That separation is LinkedIn\u2019s own and it is why an agent cannot accidentally broadcast to a company\u2019s followers. ONLINE events only for now \u2014 an in-person event needs a full address object and a custom background image needs a three-step upload, and neither is offered rather than failing at LinkedIn. Needs the Events permission (administrator flag) and a reconnect, the same two conditions as list_linkedin_events. 0 credits.',
|
|
6322
6325
|
inputSchema: {
|
|
6323
6326
|
name: z.string().describe('the event title'),
|
|
6324
6327
|
startsAt: z.string().describe('ISO 8601, must be in the future \u2014 LinkedIn rejects an event that has already begun'),
|
|
@@ -6333,7 +6336,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6333
6336
|
}));
|
|
6334
6337
|
server.registerTool('list_linkedin_events', {
|
|
6335
6338
|
title: 'List a LinkedIn Page\u2019s events',
|
|
6336
|
-
description: 'The events a LinkedIn company Page is organizing \u2014 name, link, start and end times, online or in-person, visibility and lifecycle state. LinkedIn is the one channel where an event is a first-class object rather than a post about one, so this is how you see what a B2B brand actually has scheduled (webinars, launches, live sessions) and pair it with the posts promoting it. Only Pages the user has SHARED with this brand are readable.
|
|
6339
|
+
description: 'The events a LinkedIn company Page is organizing \u2014 name, link, start and end times, online or in-person, visibility and lifecycle state. LinkedIn is the one channel where an event is a first-class object rather than a post about one, so this is how you see what a B2B brand actually has scheduled (webinars, launches, live sessions) and pair it with the posts promoting it. Only Pages the user has SHARED with this brand are readable. 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.',
|
|
6337
6340
|
inputSchema: {
|
|
6338
6341
|
organizationId: z.string().optional().describe('the Page to read \u2014 omit if only one Page is shared with this brand; list_linkedin_pages shows them'),
|
|
6339
6342
|
start: z.number().optional(), count: z.number().optional().describe('max 50, default 10'),
|
|
@@ -6425,7 +6428,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6425
6428
|
}));
|
|
6426
6429
|
server.registerTool('telegram_account', {
|
|
6427
6430
|
title: 'Read a Telegram channel or group',
|
|
6428
|
-
description: 'ACCOUNT-LEVEL numbers for a Telegram channel, group or chat \u2014 its SUBSCRIBER / MEMBER COUNT \u2014 plus title, @username, description, invite link and linked discussion group. This is the answer to \u201chow many subscribers does our Telegram channel have\u201d. WHICH CHAT IS ALWAYS ASKED FOR: pass chatId (a public channel\u2019s @username, or the numeric id) \u2014 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\u2019s numbers as this one\u2019s; list_telegram_chats reports any chat that messaged the bot in the last 24 hours.
|
|
6431
|
+
description: 'ACCOUNT-LEVEL numbers for a Telegram channel, group or chat \u2014 its SUBSCRIBER / MEMBER COUNT \u2014 plus title, @username, description, invite link and linked discussion group. This is the answer to \u201chow many subscribers does our Telegram channel have\u201d. WHICH CHAT IS ALWAYS ASKED FOR: pass chatId (a public channel\u2019s @username, or the numeric id) \u2014 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\u2019s numbers as this one\u2019s; 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 \u2014 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.',
|
|
6429
6432
|
inputSchema: { chatId: z.string().describe('the channel\u2019s @username or numeric chat id \u2014 required; there is no default') },
|
|
6430
6433
|
outputSchema: { chatId: z.string().nullable().optional(), type: z.string().nullable().optional(), title: z.string().nullable().optional(), username: z.string().nullable().optional(), bio: z.string().nullable().optional(), inviteLink: z.string().nullable().optional(), linkedChatId: z.string().nullable().optional(), pinnedMessageId: z.number().nullable().optional(), pinnedText: z.string().nullable().optional(), metrics: z.record(z.number()).optional(), absent: z.record(z.string()).optional(), costCredits: z.number().optional() },
|
|
6431
6434
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -6439,7 +6442,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6439
6442
|
}));
|
|
6440
6443
|
server.registerTool('bluesky_account', {
|
|
6441
6444
|
title: 'Read a Bluesky account',
|
|
6442
|
-
description: 'ACCOUNT-LEVEL numbers for a Bluesky account \u2014 FOLLOWERS, following and total posts \u2014 plus display name, bio, avatar and when it was created. With no argument it reads the CONNECTED account, which is the answer to \u201chow many followers do we have on Bluesky\u201d; 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 \u2014 all three are optional in the lexicon. Read-only, 0 credits, no extra permission. Needs Bluesky connected (Settings
|
|
6445
|
+
description: 'ACCOUNT-LEVEL numbers for a Bluesky account \u2014 FOLLOWERS, following and total posts \u2014 plus display name, bio, avatar and when it was created. With no argument it reads the CONNECTED account, which is the answer to \u201chow many followers do we have on Bluesky\u201d; 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 \u2014 all three are optional in the lexicon. Read-only, 0 credits, no extra permission. Needs Bluesky connected (Settings > Connectors > Bluesky, or connect_connector).',
|
|
6443
6446
|
inputSchema: { actor: z.string().optional().describe('a Bluesky handle (hermoso.ai or @hermoso.ai) or a did:plc:\u2026 \u2014 omit it to read the connected account') },
|
|
6444
6447
|
outputSchema: { did: z.string().nullable().optional(), handle: z.string().nullable().optional(), displayName: z.string().nullable().optional(), bio: z.string().nullable().optional(), avatar: z.string().nullable().optional(), banner: z.string().nullable().optional(), createdAt: z.string().nullable().optional(), indexedAt: z.string().nullable().optional(), self: z.boolean().optional(), metrics: z.record(z.number()).optional(), absent: z.record(z.string()).optional(), costCredits: z.number().optional() },
|
|
6445
6448
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -6550,7 +6553,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6550
6553
|
inputSchema: {
|
|
6551
6554
|
convoId: z.string().describe('from list_bluesky_convos'),
|
|
6552
6555
|
messageId: z.string().describe('from read_bluesky_dm'),
|
|
6553
|
-
value: z.string().describe('exactly one emoji, e.g.
|
|
6556
|
+
value: z.string().describe('exactly one emoji character, e.g. the thumbs-up emoji'),
|
|
6554
6557
|
remove: z.boolean().optional().describe('true to take the reaction off instead of putting it on'),
|
|
6555
6558
|
},
|
|
6556
6559
|
outputSchema: { convoId: z.string().optional(), messageId: z.string().optional(), value: z.string().optional(), removed: z.boolean().optional(), reactions: z.array(z.string()).optional(), text: z.string().optional(), note: z.string().optional() },
|
|
@@ -6561,7 +6564,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6561
6564
|
}));
|
|
6562
6565
|
server.registerTool('tiktok_creator_info', {
|
|
6563
6566
|
title: 'Read the connected TikTok creator’s posting options',
|
|
6564
|
-
description: '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
|
|
6567
|
+
description: '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).',
|
|
6565
6568
|
inputSchema: {},
|
|
6566
6569
|
outputSchema: { nickname: z.string().nullable().optional(), username: z.string().nullable().optional(), avatar: z.string().nullable().optional(), privacyOptions: z.array(z.string()).optional(), commentDisabled: z.boolean().optional(), duetDisabled: z.boolean().optional(), stitchDisabled: z.boolean().optional(), maxDurationSeconds: z.number().nullable().optional() },
|
|
6567
6570
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -6572,7 +6575,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6572
6575
|
server.group('channels');
|
|
6573
6576
|
server.registerTool('post_to_tiktok', {
|
|
6574
6577
|
title: 'Post a video or photo post to TikTok',
|
|
6575
|
-
description: 'Publish to the user’s connected TikTok account — a finished VIDEO, or a PHOTO POST (TikTok’s photo/slideshow format). A photo post carries 1 to 35 images and ONE image is simply a one-slide photo post, so there is nothing special to do for a single picture: pass imageUrls, in the order the slides should appear, and optionally coverIndex. Pass videoUrl for a video. Never pass both — TikTok has no mixed post. TWO destinations either way: destination:"post" puts it LIVE on their profile now — that requires `privacy`, and you must call tiktok_creator_info first, show the creator’s real privacy options and get an explicit yes before calling. destination:"draft" (the default, and the safer one) sends it to TikTok for the user to finish and post themselves from the app. Pass Hermoso render URLs (or upload_file urls for local/external files). Needs TikTok connected (Settings
|
|
6578
|
+
description: 'Publish to the user’s connected TikTok account — a finished VIDEO, or a PHOTO POST (TikTok’s photo/slideshow format). A photo post carries 1 to 35 images and ONE image is simply a one-slide photo post, so there is nothing special to do for a single picture: pass imageUrls, in the order the slides should appear, and optionally coverIndex. Pass videoUrl for a video. Never pass both — TikTok has no mixed post. TWO destinations either way: destination:"post" puts it LIVE on their profile now — that requires `privacy`, and you must call tiktok_creator_info first, show the creator’s real privacy options and get an explicit yes before calling. destination:"draft" (the default, and the safer one) sends it to TikTok for the user to finish and post themselves from the app. Pass Hermoso render URLs (or upload_file urls for local/external files). Needs TikTok connected (Settings > Connectors > TikTok).',
|
|
6576
6579
|
inputSchema: {
|
|
6577
6580
|
account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the brand has more than one tiktok account connected (several and none named is refused by name, never guessed); omit when there is one."),
|
|
6578
6581
|
...HOOK_ATTR,
|
|
@@ -6604,7 +6607,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6604
6607
|
server.group('channel_admin');
|
|
6605
6608
|
server.registerTool('tiktok_account', {
|
|
6606
6609
|
title: 'Read the connected TikTok account',
|
|
6607
|
-
description: '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
|
|
6610
|
+
description: '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).',
|
|
6608
6611
|
inputSchema: {},
|
|
6609
6612
|
outputSchema: { openId: z.string().nullable().optional(), displayName: z.string().nullable().optional(), username: z.string().nullable().optional(), avatar: z.string().nullable().optional(), bio: z.string().nullable().optional(), profileLink: z.string().nullable().optional(), verified: z.boolean().optional(), followers: z.number().nullable().optional(), following: z.number().nullable().optional(), likes: z.number().nullable().optional(), videos: z.number().nullable().optional(), partial: z.boolean().optional() },
|
|
6610
6613
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -6623,7 +6626,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6623
6626
|
// update endpoint for a published post ANYWHERE in its API (the whole Content Posting surface is 8 pages and
|
|
6624
6627
|
// the Display API 4; there is no delete scope in developers.tiktok.com/doc/tiktok-api-scopes at all, read
|
|
6625
6628
|
// 2026-08-05). An agent asked to take a TikTok down must be able to say so without a failed round trip.
|
|
6626
|
-
description: '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.
|
|
6629
|
+
description: '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.',
|
|
6627
6630
|
inputSchema: {
|
|
6628
6631
|
limit: z.number().optional().describe('1-20, default 10 (ignored when videoIds is given)'),
|
|
6629
6632
|
videoIds: z.array(z.string()).optional().describe('read these specific TikTok video ids instead of listing recent ones — up to 20 per call'),
|
|
@@ -6642,7 +6645,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6642
6645
|
server.group('ads');
|
|
6643
6646
|
server.registerTool('upload_meta_asset', {
|
|
6644
6647
|
title: 'Upload an asset to a Meta ad account',
|
|
6645
|
-
description: 'Upload creative(s) — a finished Hermoso ad OR arbitrary user files (e.g. a folder of media from the user’s desktop) — into a connected ad account’s ASSET LIBRARY so the user or a later ad-build step can use them in their OWN campaigns. Pass `url` for one file, or `urls` (up to 20) to BULK-upload in a single call. Each accepts a public https URL, a data: URI, or a Hermoso /generated path; for LOCAL files call upload_file first and pass the url(s) it returns. Image
|
|
6648
|
+
description: 'Upload creative(s) — a finished Hermoso ad OR arbitrary user files (e.g. a folder of media from the user’s desktop) — into a connected ad account’s ASSET LIBRARY so the user or a later ad-build step can use them in their OWN campaigns. Pass `url` for one file, or `urls` (up to 20) to BULK-upload in a single call. Each accepts a public https URL, a data: URI, or a Hermoso /generated path; for LOCAL files call upload_file first and pass the url(s) it returns. Image -> image hash; video -> video id. Pass adAccountId from list_meta_pages.',
|
|
6646
6649
|
inputSchema: {
|
|
6647
6650
|
adAccountId: z.string().describe('ad account id (digits or act_… — from list_meta_pages)'),
|
|
6648
6651
|
url: z.string().optional().describe('a single public https URL / data: URI / /generated path'),
|
|
@@ -6749,15 +6752,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6749
6752
|
attributionSpec: z.array(z.any()).optional().describe('e.g. [{event_type:"CLICK_THROUGH",window_days:7}]'),
|
|
6750
6753
|
};
|
|
6751
6754
|
server.registerTool('create_meta_ad', {
|
|
6752
|
-
title: 'Build a full Meta ad (campaign
|
|
6753
|
-
description: 'Build a complete, ready-to-run Meta ad: campaign
|
|
6755
|
+
title: 'Build a full Meta ad (campaign -> ad set -> ad, paused)',
|
|
6756
|
+
description: 'Build a complete, ready-to-run Meta ad: campaign -> ad set (FULL targeting + budget + schedule + bidding) -> creative -> ad(s), ALL created PAUSED — it spends NOTHING until you activate the campaign with set_meta_campaign_status(confirm:true). This is the "create a campaign and put the ads on it" path. IMAGE, VIDEO (uploaded, transcoded and thumbnailed for you) and CAROUSEL (format:"carousel", 2–10 cards each with its own headline/description/link) all work. Targeting is the `targeting` object: geo down to cities with a radius, age, gender, interests, behaviours, custom audiences and lookalikes, languages, placements, devices and OS. For a conversion objective pass pixelId + conversionEvent and the ad set optimizes for that conversion. Schedule with startTime/endTime + dayparting; bid with bidStrategy + bidAmountUsd/minRoas; use lifetimeBudgetUsd (with endTime) for a fixed flight. Attach to an existing campaign with campaignId or an existing ad set with adSetId. Everything is READ BACK from Meta before you are told it exists — print the returned summary verbatim (it now carries Meta-rendered PREVIEW LINKS for the first ad, valid 24 hours — hand them to the user so they can see the ad; preview_meta_ad renders any ad in any placement). Needs ads-management on the connected account. BOOST AN EXISTING POST: pass boostPostId — a post you have ALREADY published (numeric id, the <pageId>_<postId> form, or a permalink) — INSTEAD of any creative, and the ad promotes that post exactly as published, comments and all. Meta ignores creative overrides on an existing post, so message/headline/cta/link do NOT apply; targeting, budget, schedule, bidding and PAUSED-by-default all work identically. Find ids with list_meta_posts. AN INSTAGRAM POST NEEDS boostTarget:"instagram" — an IG media id and a Facebook post id are both bare digits, so Hermoso will NOT guess which one you meant, and a Facebook boost given an IG media id is refused rather than built against a fabricated id. Instagram eligibility is checked for free before anything is created (Meta refuses to boost a post carrying licensed music or an interactive element).',
|
|
6754
6757
|
inputSchema: {
|
|
6755
6758
|
boostPostId: z.string().optional().describe('Promote a post that ALREADY EXISTS instead of building a new ad from media. Accepts the numeric post id, <pageId>_<postId>, or a permalink (an Instagram post is its NUMERIC media id — an instagram.com link carries only a shortcode, which Meta cannot resolve). Cannot be combined with image/video inputs, and creative fields do not apply — a boost shows the post as published.'), boostTarget: z.enum(['facebook','instagram']).optional().describe("Which surface the boosted post lives on. Default facebook. REQUIRED for an Instagram post: an IG media id and a Facebook post id are both bare digits, so this is never inferred — Meta takes a different creative for each (object_story_id for a Page post; object_id + instagram_user_id + source_instagram_media_id for an IG post). list_meta_posts(target:'instagram') returns the ids."),
|
|
6756
6759
|
adAccountId: z.string().describe('ad account id (act_… or digits — from list_meta_pages)'),
|
|
6757
6760
|
format: z.enum(['auto', 'carousel']).optional().describe('auto = one ad per asset (image or video); carousel = ONE multi-card ad'),
|
|
6758
6761
|
imageUrl: z.string().optional().describe('public https image URL for the ad creative'),
|
|
6759
|
-
imageUrls: z.array(z.string()).optional().describe('several image URLs
|
|
6760
|
-
videoUrl: z.string().optional().describe('a video URL
|
|
6762
|
+
imageUrls: z.array(z.string()).optional().describe('several image URLs -> one ad each, or the carousel cards in order'),
|
|
6763
|
+
videoUrl: z.string().optional().describe('a video URL -> a real Meta VIDEO ad (uploaded + transcoded + thumbnailed for you)'),
|
|
6761
6764
|
thumbnailUrl: z.string().optional().describe('custom video thumbnail (otherwise Meta picks a frame)'),
|
|
6762
6765
|
message: z.string().optional().describe('primary ad text'),
|
|
6763
6766
|
headline: z.string().optional().describe('headline'),
|
|
@@ -6773,7 +6776,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6773
6776
|
instagramUserId: z.string().optional().describe('run it on Instagram under the brand’s own handle'),
|
|
6774
6777
|
name: z.string().optional().describe('base name for the campaign/ad set/ads'),
|
|
6775
6778
|
campaignId: z.string().optional().describe('attach to an existing campaign instead of creating one'),
|
|
6776
|
-
destinationType: z.string().optional().describe('WHERE A CLICK LANDS, and worth deciding for a merchant with a shop. "WEBSITE" sends it to their own site — their pixel fires, their email capture runs, their upsell flow works. "WEBSITE_AND_SHOP" lets Meta route it into the in-app Facebook/Instagram Shop instead: often fewer taps to a purchase, but the visit never reaches their site. FROM META API v26 AN ADVERTISER WITH A SHOP DEFAULTS TO WEBSITE_AND_SHOP, so pass "WEBSITE_AND_SHOP_OPT_OUT" to keep every click on their own site. Also accepts APP, MESSENGER, INSTAGRAM_DIRECT, WHATSAPP, ON_AD, ON_POST, ON_PAGE, ON_EVENT, ON_VIDEO, SHOP_AUTOMATIC. An unknown value is refused by name, and the read-back says in words where clicks will go. CLICK-TO-WHATSAPP ADS (certified against Meta 2026-09-03): pass destinationType "WHATSAPP", cta "WHATSAPP_MESSAGE" and optimizationGoal "CONVERSATIONS" (objective OUTCOME_ENGAGEMENT). Meta refuses the ad set unless the Page already has a WhatsApp number linked (Facebook Page settings
|
|
6779
|
+
destinationType: z.string().optional().describe('WHERE A CLICK LANDS, and worth deciding for a merchant with a shop. "WEBSITE" sends it to their own site — their pixel fires, their email capture runs, their upsell flow works. "WEBSITE_AND_SHOP" lets Meta route it into the in-app Facebook/Instagram Shop instead: often fewer taps to a purchase, but the visit never reaches their site. FROM META API v26 AN ADVERTISER WITH A SHOP DEFAULTS TO WEBSITE_AND_SHOP, so pass "WEBSITE_AND_SHOP_OPT_OUT" to keep every click on their own site. Also accepts APP, MESSENGER, INSTAGRAM_DIRECT, WHATSAPP, ON_AD, ON_POST, ON_PAGE, ON_EVENT, ON_VIDEO, SHOP_AUTOMATIC. An unknown value is refused by name, and the read-back says in words where clicks will go. CLICK-TO-WHATSAPP ADS (certified against Meta 2026-09-03): pass destinationType "WHATSAPP", cta "WHATSAPP_MESSAGE" and optimizationGoal "CONVERSATIONS" (objective OUTCOME_ENGAGEMENT). Meta refuses the ad set unless the Page already has a WhatsApp number linked (Facebook Page settings > WhatsApp) — that link is made on Facebook, not here, and needs no extra Hermoso permission.'),
|
|
6777
6780
|
whatsappPhoneNumber: z.string().optional().describe('CLICK-TO-WHATSAPP: the WhatsApp Business number the chat opens with, in international format (e.g. +15551234567; list_whatsapp_accounts shows the numbers on the connected WhatsApp Business Account). Sent as the ad set’s promoted whatsapp_phone_number when destinationType is WHATSAPP; Meta still requires that number to be linked to the Page.'),
|
|
6778
6781
|
adSetId: z.string().optional().describe('attach the ad(s) to an EXISTING ad set (skips ad-set creation)'),
|
|
6779
6782
|
pageId: z.string().optional().describe('Page id from list_meta_pages; omit = first Page'),
|
|
@@ -7173,7 +7176,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7173
7176
|
server.group('ads');
|
|
7174
7177
|
server.registerTool('list_meta_ads', {
|
|
7175
7178
|
title: 'List Meta campaigns / ad sets / ads',
|
|
7176
|
-
description: 'Read the EXISTING campaigns, ad sets, or ads on a connected Meta ad account — id, name, status, budget, objective. Pass adAccountId (from list_meta_pages) and level (campaign|adset|ad). Scope to a parent with campaignId (
|
|
7179
|
+
description: 'Read the EXISTING campaigns, ad sets, or ads on a connected Meta ad account — id, name, status, budget, objective. Pass adAccountId (from list_meta_pages) and level (campaign|adset|ad). Scope to a parent with campaignId (-> its ad sets/ads) or adsetId (-> its ads), and filter by status (ACTIVE/PAUSED/…). Read-only — use it to inspect an account before editing/deleting, or to answer "what’s running?".',
|
|
7177
7180
|
inputSchema: {
|
|
7178
7181
|
adAccountId: z.string().describe('ad account id (act_… or digits — from list_meta_pages)'),
|
|
7179
7182
|
level: z.enum(['campaign', 'adset', 'ad']).optional().describe('what to list (default campaign)'),
|
|
@@ -7366,7 +7369,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7366
7369
|
}));
|
|
7367
7370
|
server.registerTool('list_meta_lead_forms', {
|
|
7368
7371
|
title: 'List Meta instant (lead) forms',
|
|
7369
|
-
description: 'List the INSTANT LEAD FORMS on a connected Facebook Page — id, name, status, how many leads each has collected and what each one asks. This is where the formId every other lead tool needs comes from, and calling it before create_meta_lead_form is how you avoid building a duplicate. Read-only, free. NEEDS the pages_manage_ads permission, which Hermoso’s Meta consent screen asks for. If Meta answers "Requires pages_manage_ads", the user must RECONNECT Meta under Settings
|
|
7372
|
+
description: 'List the INSTANT LEAD FORMS on a connected Facebook Page — id, name, status, how many leads each has collected and what each one asks. This is where the formId every other lead tool needs comes from, and calling it before create_meta_lead_form is how you avoid building a duplicate. Read-only, free. NEEDS the pages_manage_ads permission, which Hermoso’s Meta consent screen asks for. If Meta answers "Requires pages_manage_ads", the user must RECONNECT Meta under Settings > Connectors > Meta — a connection made before that permission was added cannot gain it by retrying. Call the tool rather than pre-refusing: any refusal comes from Meta and names the one thing that fixes it.',
|
|
7370
7373
|
inputSchema: {
|
|
7371
7374
|
pageId: z.string().optional().describe('which connected Page (from list_meta_pages) — required only if the brand has more than one'),
|
|
7372
7375
|
limit: z.number().optional().describe('max rows (1–100, default 25)'),
|
|
@@ -7989,7 +7992,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7989
7992
|
}));
|
|
7990
7993
|
server.registerTool('list_google_ads_campaigns', {
|
|
7991
7994
|
title: 'List Google Ads accounts / campaigns',
|
|
7992
|
-
description: 'Read the connected Google Ads account(s). Call with NO customerId to list the accessible accounts (customerId + name + currency) — do this first to pick a target. Call WITH customerId to list that account’s campaigns (id, name, status, daily budget, channel) plus performance metrics (impressions, clicks, CTR, avg CPC, cost, conversions). Date window: datePreset (LAST_7_DAYS | LAST_30_DAYS | TODAY | THIS_MONTH | LAST_90_DAYS …) or since+until (YYYY-MM-DD). Read-only, free. Needs Google Ads connected (Settings
|
|
7995
|
+
description: 'Read the connected Google Ads account(s). Call with NO customerId to list the accessible accounts (customerId + name + currency) — do this first to pick a target. Call WITH customerId to list that account’s campaigns (id, name, status, daily budget, channel) plus performance metrics (impressions, clicks, CTR, avg CPC, cost, conversions). Date window: datePreset (LAST_7_DAYS | LAST_30_DAYS | TODAY | THIS_MONTH | LAST_90_DAYS …) or since+until (YYYY-MM-DD). Read-only, free. Needs Google Ads connected (Settings > Connectors > Google Ads).',
|
|
7993
7996
|
inputSchema: {
|
|
7994
7997
|
customerId: z.string().optional().describe('10-digit account id (dashes ok) — omit to list accessible accounts'),
|
|
7995
7998
|
status: z.enum(['ENABLED', 'PAUSED', 'REMOVED']).optional().describe('filter campaigns by status'),
|
|
@@ -8084,7 +8087,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8084
8087
|
}));
|
|
8085
8088
|
server.registerTool('create_google_ads_campaign', {
|
|
8086
8089
|
title: 'Build a Google Ads campaign (paused)',
|
|
8087
|
-
description: 'Build a campaign on a connected Google Ads account. ALWAYS created PAUSED — it spends NOTHING until you enable it with set_google_ads_status(confirm:true). Google\'s object graph is campaign
|
|
8090
|
+
description: 'Build a campaign on a connected Google Ads account. ALWAYS created PAUSED — it spends NOTHING until you enable it with set_google_ads_status(confirm:true). Google\'s object graph is campaign -> ad group -> ad, so a campaign ON ITS OWN CANNOT SERVE AN IMPRESSION: pass adGroup{name, ad{headlines,descriptions,finalUrls}, keywords[]} and this builds budget + campaign + location/language targeting + ad group + ad + keywords in ONE ATOMIC operation (if any part is rejected, nothing at all is created — no half-built campaign to clean up). Also here: bidding strategy, locations by NAME ("United States", "Toronto" — resolved for you), languages, and start/end dates. Google requires 3–15 headlines (≤30 chars) and 2–4 descriptions (≤90 chars) on a search ad. Everything is READ BACK from Google before you are told it exists; print the returned note verbatim, and if it says the campaign cannot serve yet, say that rather than calling it a finished ad.',
|
|
8088
8091
|
inputSchema: {
|
|
8089
8092
|
customerId: z.string().optional().describe('10-digit account id (from list_google_ads_campaigns) — omit to use the brand’s selected default account'),
|
|
8090
8093
|
name: z.string().describe('campaign name'),
|
|
@@ -8169,7 +8172,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8169
8172
|
}));
|
|
8170
8173
|
server.registerTool('set_google_ads_targeting', {
|
|
8171
8174
|
title: 'Add Google Ads location & language targeting',
|
|
8172
|
-
description: 'ADD locations and languages to an existing Google Ads campaign. THIS ADDS; IT DOES NOT REPLACE — Google campaign criteria are a list, this call only ever creates entries, and there is no remove operation here. So a campaign already targeting the United States that you "change to Canada" ends up targeting BOTH and still spending in the US; the read-back names every pre-existing location and language it kept, and you MUST relay that rather than reporting the new total as the answer. Removing targeting is done in Google Ads (Campaign
|
|
8175
|
+
description: 'ADD locations and languages to an existing Google Ads campaign. THIS ADDS; IT DOES NOT REPLACE — Google campaign criteria are a list, this call only ever creates entries, and there is no remove operation here. So a campaign already targeting the United States that you "change to Canada" ends up targeting BOTH and still spending in the US; the read-back names every pre-existing location and language it kept, and you MUST relay that rather than reporting the new total as the answer. Removing targeting is done in Google Ads (Campaign > Settings > Locations). Pass locations by NAME ("United States", "California", "Toronto") — they are resolved to Google\'s geo target ids for you; excludedLocations adds a NEGATIVE criterion (the reliable way to stop serving somewhere from here); languages takes ISO codes ("en","fr"). A campaign with NO location targeting runs WORLDWIDE, which is the most expensive default in Google Ads. Changing a LIVE campaign\'s targeting moves real spend immediately, so that needs confirm:true.',
|
|
8173
8176
|
inputSchema: {
|
|
8174
8177
|
customerId: z.string().optional().describe('omit to use the brand’s selected default account'),
|
|
8175
8178
|
campaignId: z.string().describe('the campaign to target'),
|
|
@@ -8266,7 +8269,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8266
8269
|
}));
|
|
8267
8270
|
server.registerTool('set_google_ads_networks', {
|
|
8268
8271
|
title: 'Change where a Google Ads campaign serves (Search partners / Display)',
|
|
8269
|
-
description: 'Change WHERE an existing Google Ads campaign serves: Google Search, Search partners (target_search_network) and the Display Network (target_content_network), each true/false. The classic use is turning Search partners OFF on a Search campaign, or Display off. Current settings are read first and a no-op says so. On a LIVE (ENABLED) campaign this moves real spend on the next auction — show the user the before
|
|
8272
|
+
description: 'Change WHERE an existing Google Ads campaign serves: Google Search, Search partners (target_search_network) and the Display Network (target_content_network), each true/false. The classic use is turning Search partners OFF on a Search campaign, or Display off. Current settings are read first and a no-op says so. On a LIVE (ENABLED) campaign this moves real spend on the next auction — show the user the before -> after, get an explicit yes, then call again with confirm:true. dryRun:true validates with Google and writes nothing. The result is READ BACK from Google before you are told it took; Google’s own rules (a Display campaign cannot take Google Search on, a Search campaign keeps Google Search on) are relayed by name.',
|
|
8270
8273
|
inputSchema: {
|
|
8271
8274
|
customerId: z.string().optional().describe('10-digit account id (dashes ok) — omit to use the brand’s selected default account'),
|
|
8272
8275
|
campaignId: z.string().describe('the campaign to change'),
|
|
@@ -8489,7 +8492,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8489
8492
|
// of these says so, and list_analytics_properties exists to RESOLVE it rather than making the user go find it.
|
|
8490
8493
|
server.registerTool('list_analytics_properties', {
|
|
8491
8494
|
title: 'List the GA4 properties shared with this brand',
|
|
8492
|
-
description: 'The GA4 properties SHARED WITH THIS BRAND, with their numeric property ids, display names and the Analytics account each sits under. CALL THIS FIRST — every other Analytics tool needs a PROPERTY ID, which is NUMERIC (e.g. 123456789) and is NOT the "G-XXXXXXX" Measurement ID people usually know from their tracking snippet; the API accepts the numeric id and nothing else. Resolve the property yourself from this list instead of asking the user to go and find one, and only ask when two names are genuinely ambiguous. THIS IS NOT EVERY PROPERTY THE GOOGLE ACCOUNT CAN SEE, deliberately: Analytics access is handed out freely, so one login often has Viewer on many different clients\' properties, and the user ticks which ones belong to THIS brand. Only ticked properties can be reported on, and any other one is refused by name. An empty list means nothing is ticked yet — say so and point the user at Settings
|
|
8495
|
+
description: 'The GA4 properties SHARED WITH THIS BRAND, with their numeric property ids, display names and the Analytics account each sits under. CALL THIS FIRST — every other Analytics tool needs a PROPERTY ID, which is NUMERIC (e.g. 123456789) and is NOT the "G-XXXXXXX" Measurement ID people usually know from their tracking snippet; the API accepts the numeric id and nothing else. Resolve the property yourself from this list instead of asking the user to go and find one, and only ask when two names are genuinely ambiguous. THIS IS NOT EVERY PROPERTY THE GOOGLE ACCOUNT CAN SEE, deliberately: Analytics access is handed out freely, so one login often has Viewer on many different clients\' properties, and the user ticks which ones belong to THIS brand. Only ticked properties can be reported on, and any other one is refused by name. An empty list means nothing is ticked yet — say so and point the user at Settings > Connectors > Google Analytics > Manage accounts (or call list_connector_accounts / set_connector_accounts with provider "google_analytics"); never name or guess a property. Read-only, 0 credits. Needs Google Analytics connected (Settings > Connectors > Google Analytics).',
|
|
8493
8496
|
inputSchema: {},
|
|
8494
8497
|
outputSchema: { properties: z.array(z.object({ property: z.string().optional(), displayName: z.string().optional(), account: z.string().optional(), propertyType: z.string().optional() })).optional(), count: z.number().optional(), shared: z.number().optional(), missing: z.array(z.string()).optional() },
|
|
8495
8498
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -9064,7 +9067,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9064
9067
|
|
|
9065
9068
|
server.registerTool('list_search_console_sites', {
|
|
9066
9069
|
title: 'List the Search Console properties shared with this brand',
|
|
9067
|
-
description: 'The Google Search Console properties SHARED WITH THIS BRAND, each with the exact property string every other Search Console tool takes and the connected account\'s permission level on it. CALL THIS FIRST. A property is EITHER "sc-domain:example.com" (a Domain property, covering every scheme and subdomain) OR the full URL-prefix form "https://example.com/" including scheme and trailing slash — Google treats those as different properties and one of them will 403, so resolve it here rather than guessing, and never pass a bare domain. THIS IS NOT EVERY PROPERTY THE GOOGLE ACCOUNT CAN SEE, deliberately: one login commonly holds a dozen clients\' properties, so the user ticks which belong to THIS brand and any other one is refused by name. An empty list means nothing is ticked yet — say so and point the user at Settings
|
|
9070
|
+
description: 'The Google Search Console properties SHARED WITH THIS BRAND, each with the exact property string every other Search Console tool takes and the connected account\'s permission level on it. CALL THIS FIRST. A property is EITHER "sc-domain:example.com" (a Domain property, covering every scheme and subdomain) OR the full URL-prefix form "https://example.com/" including scheme and trailing slash — Google treats those as different properties and one of them will 403, so resolve it here rather than guessing, and never pass a bare domain. THIS IS NOT EVERY PROPERTY THE GOOGLE ACCOUNT CAN SEE, deliberately: one login commonly holds a dozen clients\' properties, so the user ticks which belong to THIS brand and any other one is refused by name. An empty list means nothing is ticked yet — say so and point the user at Settings > Connectors > Google Search Console > Manage accounts (or call list_connector_accounts / set_connector_accounts with provider "google_search_console"); never name or guess a property. A row whose permissionLevel is siteUnverifiedUser will refuse every later call. Read-only, 0 credits. Needs Google Search Console connected.',
|
|
9068
9071
|
inputSchema: {},
|
|
9069
9072
|
outputSchema: { sites: z.array(z.object({ siteUrl: z.string().optional(), permissionLevel: z.string().optional() })).optional(), count: z.number().optional(), shared: z.number().optional(), missing: z.array(z.string()).optional() },
|
|
9070
9073
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -9105,7 +9108,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9105
9108
|
}));
|
|
9106
9109
|
server.registerTool('search_console_url_inspection', {
|
|
9107
9110
|
title: 'Is this page actually in Google?',
|
|
9108
|
-
description: 'THE LIVE INDEX STATUS OF ONE PAGE, straight from Google: the index verdict, coverage state, whether robots.txt allows it, which URL Google picked as canonical versus the one the page itself declares, when it was last crawled and as which crawler, plus rich-result and mobile-usability verdicts. This is the tool for "why is this page getting no traffic" — a page Google cannot crawl, or whose canonical Google has reassigned to a different URL, will never rank however good the copy is. THE URL MUST SIT INSIDE THE PROPERTY: a URL-prefix property covers only its own prefix, while an "sc-domain:" property covers every scheme and subdomain.
|
|
9111
|
+
description: 'THE LIVE INDEX STATUS OF ONE PAGE, straight from Google: the index verdict, coverage state, whether robots.txt allows it, which URL Google picked as canonical versus the one the page itself declares, when it was last crawled and as which crawler, plus rich-result and mobile-usability verdicts. This is the tool for "why is this page getting no traffic" — a page Google cannot crawl, or whose canonical Google has reassigned to a different URL, will never rank however good the copy is. THE URL MUST SIT INSIDE THE PROPERTY: a URL-prefix property covers only its own prefix, while an "sc-domain:" property covers every scheme and subdomain. Important: QUOTA — Google allows 2,000 inspections per DAY and 600 per minute PER SITE, and that is the CUSTOMER\'S property quota, not ours: inspect the handful of URLs the question is actually about and NEVER loop this over a sitemap, a page list or a crawl. Read-only, 0 credits.',
|
|
9109
9112
|
inputSchema: {
|
|
9110
9113
|
siteUrl: z.string().describe('the exact property string from list_search_console_sites, and it must be one SHARED with this brand'),
|
|
9111
9114
|
inspectionUrl: z.string().describe('the full URL to inspect, e.g. "https://example.com/pricing" — it must be under the property'),
|
|
@@ -9170,7 +9173,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9170
9173
|
}));
|
|
9171
9174
|
server.registerTool('add_search_console_site', {
|
|
9172
9175
|
title: 'Add a property to Search Console',
|
|
9173
|
-
description: 'Add a property to the connected Google account. TWO THINGS THAT MUST REACH THE USER, and the answer states both: (1) ADDING IS NOT VERIFYING — the property arrives with the account as an unverified user and EVERY read on it is refused until ownership is proven with a DNS record, an HTML file or a tag, which no API can do and which the user completes in Search Console itself; (2) the new property is NOT yet shared with this brand, so someone has to tick it under Settings
|
|
9176
|
+
description: 'Add a property to the connected Google account. TWO THINGS THAT MUST REACH THE USER, and the answer states both: (1) ADDING IS NOT VERIFYING — the property arrives with the account as an unverified user and EVERY read on it is refused until ownership is proven with a DNS record, an HTML file or a tag, which no API can do and which the user completes in Search Console itself; (2) the new property is NOT yet shared with this brand, so someone has to tick it under Settings > Connectors > Google Search Console > Manage accounts (or call list_connector_accounts with provider google_search_console, then set_connector_accounts) before any tool here can use it. The permission level is READ BACK from Google, so the answer says which of those two states it is actually in. 0 credits.',
|
|
9174
9177
|
inputSchema: {
|
|
9175
9178
|
siteUrl: z.string().describe('"sc-domain:example.com" for a Domain property (covers every scheme and subdomain), or the full URL-prefix form "https://example.com/". These are different properties — pick deliberately.'),
|
|
9176
9179
|
},
|
|
@@ -9205,7 +9208,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9205
9208
|
// which is the one thing here that has no free Google equivalent.
|
|
9206
9209
|
server.registerTool('list_bing_webmaster_sites', {
|
|
9207
9210
|
title: 'List the Bing Webmaster sites shared with this brand',
|
|
9208
|
-
description: 'The Bing Webmaster Tools sites SHARED WITH THIS BRAND, each exactly as Bing holds it (e.g. "https://example.com"). CALL THIS FIRST — every other Bing tool takes that exact string. THIS IS NOT EVERY SITE THE CONNECTION CAN REACH: Microsoft issues one key per USER covering every site that Bing account verified, so the user ticks which belong to THIS brand and any other one is refused by name. An empty list means nothing is ticked yet — point the user at Settings
|
|
9211
|
+
description: 'The Bing Webmaster Tools sites SHARED WITH THIS BRAND, each exactly as Bing holds it (e.g. "https://example.com"). CALL THIS FIRST — every other Bing tool takes that exact string. THIS IS NOT EVERY SITE THE CONNECTION CAN REACH: Microsoft issues one key per USER covering every site that Bing account verified, so the user ticks which belong to THIS brand and any other one is refused by name. An empty list means nothing is ticked yet — point the user at Settings > Connectors > Bing Webmaster Tools > Manage accounts (or call set_connector_accounts with provider "bing_webmaster") and never name or guess a site. A site flagged not verified will refuse every later call. Read-only, 0 credits.',
|
|
9209
9212
|
inputSchema: {},
|
|
9210
9213
|
outputSchema: { sites: z.array(z.object({ siteUrl: z.string().optional(), isVerified: z.boolean().optional() })).optional(), count: z.number().optional(), shared: z.number().optional(), missing: z.array(z.string()).optional() },
|
|
9211
9214
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -9412,7 +9415,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9412
9415
|
}));
|
|
9413
9416
|
server.registerTool('add_bing_webmaster_site', {
|
|
9414
9417
|
title: 'Add a site to the Bing Webmaster account',
|
|
9415
|
-
description: 'Add a site to the connected Bing Webmaster account.
|
|
9418
|
+
description: 'Add a site to the connected Bing Webmaster account. Important: ADDING IS NOT VERIFYING, and saying so is most of this tool\'s value: the site arrives UNVERIFIED and every read on it is refused until an ownership proof is placed on the site itself — an XML file at the root, a meta tag in the home page <head>, or a CNAME DNS record — which no API can do. Place one, then call verify_bing_webmaster_site. Microsoft documents that adding a site which is already there does NOT error, so a success here is not even evidence anything changed, which is why the answer is read back from Bing\'s site list. It is also NOT shared with this brand until the user ticks it under Settings > Connectors > Bing Webmaster Tools > Manage accounts (or call list_connector_accounts with provider bing_webmaster, then set_connector_accounts). 0 credits.',
|
|
9416
9419
|
inputSchema: { siteUrl: z.string().describe('the site with its scheme, e.g. "https://example.com"') },
|
|
9417
9420
|
outputSchema: { siteUrl: z.string().optional(), added: z.boolean().optional(), confirmed: z.boolean().nullable().optional(), isVerified: z.boolean().nullable().optional(), shared: z.boolean().optional(), note: z.string().optional() },
|
|
9418
9421
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
@@ -9451,7 +9454,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9451
9454
|
// nothing for Hermoso to store, revoke or disconnect.
|
|
9452
9455
|
server.registerTool('submit_indexnow', {
|
|
9453
9456
|
title: 'Tell the IndexNow search engines a page changed',
|
|
9454
|
-
description: 'Ping the IndexNow search engines that pages were added, updated or deleted — Bing, Yandex, Naver, Seznam.cz and Yep, all reached by ONE submission.
|
|
9457
|
+
description: 'Ping the IndexNow search engines that pages were added, updated or deleted — Bing, Yandex, Naver, Seznam.cz and Yep, all reached by ONE submission. Important: GOOGLE DOES NOT PARTICIPATE IN INDEXNOW and never has, so say that plainly rather than letting anyone expect a Google effect; for Google use submit_search_console_sitemap and search_console_url_inspection. NO CONNECTION IS NEEDED, because the credential belongs to the USER: they generate a key, host it as a plain text file on their own web server, and any engine fetching that file is the whole ownership check. HERMOSO VERIFIES THAT FILE BEFORE SUBMITTING — that it is reachable and that its contents are exactly the key — and refuses with an explanation if not, because we have no write access to a customer\'s web root and cannot place it for them. If the key file is moved off the site root with `keyLocation`, it authorises ONLY URLs under that directory, and URLs outside it are refused before anything is sent (IndexNow rejects the whole batch for one out-of-scope URL). Up to 10,000 URLs per submission, all on the one host. HTTP 202 is a SUCCESS ("received, validation pending"), not a reason to retry. 0 credits.',
|
|
9455
9458
|
inputSchema: {
|
|
9456
9459
|
host: z.string().describe('the bare hostname that owns these URLs, e.g. "example.com"'),
|
|
9457
9460
|
key: z.string().describe('the IndexNow key — 8-128 letters, numbers and dashes — ALREADY hosted as <key>.txt on that site'),
|
|
@@ -9478,7 +9481,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9478
9481
|
// tool copy SAYS SO — an agent that has not been told the rule will cheerfully write the loop.
|
|
9479
9482
|
server.registerTool('list_posthog_projects', {
|
|
9480
9483
|
title: 'List the PostHog projects this key can see',
|
|
9481
|
-
description: 'The PostHog projects the connected personal API key can see, and which ONE this brand is pointed at. Only the ACTIVE project is readable. That is deliberate: PostHog\'s API otherwise falls back to "the last project you visited in the UI", which would make every answer depend on the user\'s browsing history, so Hermoso pins a project at connect time instead of relying on their implicit default. To move this brand to a different project, the user reconnects PostHog under Settings
|
|
9484
|
+
description: 'The PostHog projects the connected personal API key can see, and which ONE this brand is pointed at. Only the ACTIVE project is readable. That is deliberate: PostHog\'s API otherwise falls back to "the last project you visited in the UI", which would make every answer depend on the user\'s browsing history, so Hermoso pins a project at connect time instead of relying on their implicit default. To move this brand to a different project, the user reconnects PostHog under Settings > Connectors > PostHog, or with connect_connector, with that project id. Read-only, 0 credits.',
|
|
9482
9485
|
inputSchema: {},
|
|
9483
9486
|
outputSchema: { projects: z.array(z.any()).optional(), count: z.number().optional(), active: z.string().optional(), note: z.string().optional() },
|
|
9484
9487
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -9556,7 +9559,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9556
9559
|
}));
|
|
9557
9560
|
server.registerTool('mixpanel_retention', {
|
|
9558
9561
|
title: 'Mixpanel retention cohorts',
|
|
9559
|
-
description: 'MIXPANEL RETENTION — how many of the people who did a first thing came back and did another, cohorted by day / week / month. This endpoint is FULLY SUPPORTED, unlike segmentation and funnels, which Mixpanel has put in maintenance mode — so it is the one typed Mixpanel report to reach for first. retentionType "birth" cohorts people by their FIRST occurrence of bornEvent (new-user retention, and Mixpanel\'s DEFAULT); "compounded" counts anyone active.
|
|
9562
|
+
description: 'MIXPANEL RETENTION — how many of the people who did a first thing came back and did another, cohorted by day / week / month. This endpoint is FULLY SUPPORTED, unlike segmentation and funnels, which Mixpanel has put in maintenance mode — so it is the one typed Mixpanel report to reach for first. retentionType "birth" cohorts people by their FIRST occurrence of bornEvent (new-user retention, and Mixpanel\'s DEFAULT); "compounded" counts anyone active. Warning: MIXPANEL REQUIRES bornEvent WHENEVER retentionType IS "birth", AND BIRTH IS THE DEFAULT — so a call with neither is refused HERE, for free, rather than spending one of the sixty hourly queries on their 400; use list_mixpanel_events first to name a real event. TWO FILTERS, NOT ONE: bornWhere filters who ENTERS the cohort, where filters the RETURNING event. And interval is the WIDTH of each bucket while intervalCount is HOW MANY of them — different knobs. Dates are YYYY-MM-DD and BOTH ENDS ARE INCLUSIVE, resolved in the PROJECT\'s timezone (UTC unless its owner changed it) rather than in yours. 60 queries/hour across the whole Query API (5 concurrent) — the tightest budget of any connector here, so widen a range rather than looping over days. Read-only, 0 credits.',
|
|
9560
9563
|
inputSchema: {
|
|
9561
9564
|
fromDate: z.string().optional().describe('YYYY-MM-DD (default 30 days ago)'),
|
|
9562
9565
|
toDate: z.string().optional().describe('YYYY-MM-DD (default today)'),
|
|
@@ -9580,7 +9583,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9580
9583
|
}));
|
|
9581
9584
|
server.registerTool('mixpanel_segmentation', {
|
|
9582
9585
|
title: 'Mixpanel event segmentation (maintenance mode)',
|
|
9583
|
-
description: 'One Mixpanel event over time, optionally broken down by a property (`on`) and filtered (`where`).
|
|
9586
|
+
description: 'One Mixpanel event over time, optionally broken down by a property (`on`) and filtered (`where`). Warning: MIXPANEL HAS PUT THIS ENDPOINT IN MAINTENANCE MODE — their words: "We recommend discontinuing new use of this endpoint. To break down and filter event data, build an Insights report in-app and query it programmatically with the Insights Query API." It still answers today, which is why it is offered rather than withheld, but SAY SO when you use it and prefer mixpanel_insights whenever the user can build the report. It takes ONE event name and not an array (the /events endpoints take an array; this one does not — an easy and silent mistake). 60 queries/hour across the whole Query API. Read-only, 0 credits.',
|
|
9584
9587
|
inputSchema: {
|
|
9585
9588
|
event: z.string().describe('a SINGLE event name — not an array'),
|
|
9586
9589
|
fromDate: z.string().optional().describe('YYYY-MM-DD (default 30 days ago)'),
|
|
@@ -9600,7 +9603,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9600
9603
|
}));
|
|
9601
9604
|
server.registerTool('mixpanel_funnel', {
|
|
9602
9605
|
title: 'Read a saved Mixpanel funnel (maintenance mode)',
|
|
9603
|
-
description: 'A saved Mixpanel funnel. Call with NO id to LIST the saved funnels and their ids; call with funnelId to read its conversion data.
|
|
9606
|
+
description: 'A saved Mixpanel funnel. Call with NO id to LIST the saved funnels and their ids; call with funnelId to read its conversion data. Warning: MIXPANEL HAS PUT THE FUNNELS QUERY API IN MAINTENANCE MODE — their words: "We recommend discontinuing new use of this endpoint. To get funnel data, build a Funnels report in-app and query it programmatically with the Insights Query API." So prefer mixpanel_insights with that report\'s bookmark id; this is offered because it still answers and because it is the only way to LIST a project\'s funnels. `length` IS BOUNDED AT 90 DAYS, WHICH IS NOT THE NUMBER 90: it counts lengthUnits, so 90 days is 2160 hours or 129600 minutes, and an over-long window is REFUSED BY NAME rather than trimmed — silently shortening it would answer a different question with nothing to show that it had happened. Omit both and Mixpanel uses whatever the funnel was saved with in its own UI, which is usually what you want. Dates are YYYY-MM-DD and BOTH ENDS ARE INCLUSIVE, resolved in the PROJECT\'s timezone (UTC unless its owner changed it) rather than in yours. 60 queries/hour across the whole Query API (5 concurrent) — the tightest budget of any connector here, so widen a range rather than looping over days. Read-only, 0 credits.',
|
|
9604
9607
|
inputSchema: {
|
|
9605
9608
|
funnelId: z.string().optional().describe('omit to list the saved funnels and their ids'),
|
|
9606
9609
|
fromDate: z.string().optional().describe('YYYY-MM-DD (default 30 days ago)'),
|
|
@@ -9786,7 +9789,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9786
9789
|
}));
|
|
9787
9790
|
server.registerTool('list_posthog_annotations', {
|
|
9788
9791
|
title: 'List PostHog annotations',
|
|
9789
|
-
description: "The markers already on this PostHog project's charts — releases, experiments, incidents — with their timestamps, scopes and who wrote them. READ THESE BEFORE EXPLAINING A CHANGE IN A CHART: an annotation is an explanation somebody already wrote down. NEEDS THE `annotation:read` SCOPE on the personal API key, and a key created before Hermoso supported annotations will not have it — PostHog's own refusal NAMES the scope, and that is a scope to add in PostHog
|
|
9792
|
+
description: "The markers already on this PostHog project's charts — releases, experiments, incidents — with their timestamps, scopes and who wrote them. READ THESE BEFORE EXPLAINING A CHANGE IN A CHART: an annotation is an explanation somebody already wrote down. NEEDS THE `annotation:read` SCOPE on the personal API key, and a key created before Hermoso supported annotations will not have it — PostHog's own refusal NAMES the scope, and that is a scope to add in PostHog > Settings > Personal API keys, NOT a broken connection and NOT a reason to reconnect. Returns ONE page and hands back the next offset rather than paging for you, because PostHog's policy is that a third-party connector is not an exporter. Read-only, 0 credits.",
|
|
9790
9793
|
inputSchema: {
|
|
9791
9794
|
limit: z.number().optional().describe('1–100, default 25'),
|
|
9792
9795
|
offset: z.number().optional().describe('for the next page — this tool does not paginate for you'),
|
|
@@ -9800,7 +9803,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9800
9803
|
}));
|
|
9801
9804
|
server.registerTool('create_posthog_annotation', {
|
|
9802
9805
|
title: 'Mark the PostHog timeline',
|
|
9803
|
-
description: "MARK THE POSTHOG TIMELINE — write what just happened into PostHog at the moment it happened, so every chart afterwards shows it in place. The same closed loop as create_amplitude_annotation, and worth reaching for unprompted after a campaign goes live, a creative is swapped or a landing page changes. `dateMarker` is what POSITIONS the marker on the x-axis (ISO 8601); omit it and NOW is used, which is right for \"this just went live\" — and the timestamp PostHog actually STORED is reported back rather than the one sent. `scope` is project (the default — shows on everything) / organization / dashboard / dashboard_item, and `dashboard` and `dashboard_item` each need their id or the annotation attaches to nothing and appears nowhere. PostHog also publishes a `recording` scope which THEY THEMSELVES REJECT, so it is refused by name here rather than sent. `creationType` USR means a person wrote it, GIT means a bot or a deployment did. NEEDS THE `annotation:write` SCOPE on the personal API key — a key made before Hermoso supported annotations will not have it; PostHog names it in their refusal, and it is added in PostHog
|
|
9806
|
+
description: "MARK THE POSTHOG TIMELINE — write what just happened into PostHog at the moment it happened, so every chart afterwards shows it in place. The same closed loop as create_amplitude_annotation, and worth reaching for unprompted after a campaign goes live, a creative is swapped or a landing page changes. `dateMarker` is what POSITIONS the marker on the x-axis (ISO 8601); omit it and NOW is used, which is right for \"this just went live\" — and the timestamp PostHog actually STORED is reported back rather than the one sent. `scope` is project (the default — shows on everything) / organization / dashboard / dashboard_item, and `dashboard` and `dashboard_item` each need their id or the annotation attaches to nothing and appears nowhere. PostHog also publishes a `recording` scope which THEY THEMSELVES REJECT, so it is refused by name here rather than sent. `creationType` USR means a person wrote it, GIT means a bot or a deployment did. NEEDS THE `annotation:write` SCOPE on the personal API key — a key made before Hermoso supported annotations will not have it; PostHog names it in their refusal, and it is added in PostHog > Settings > Personal API keys without reconnecting. THIS WRITES TO THE USER'S OWN ANALYTICS TOOL. 0 credits.",
|
|
9804
9807
|
inputSchema: {
|
|
9805
9808
|
content: z.string().describe('the text shown on the chart, ≤8192 characters'),
|
|
9806
9809
|
dateMarker: z.string().optional().describe('ISO 8601 — defaults to now, which is right for "this just went live"'),
|
|
@@ -9851,7 +9854,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9851
9854
|
// ---------- Stripe (2026-09-09): the brand's OWN revenue, read-only via a restricted key ----------
|
|
9852
9855
|
server.registerTool('stripe_report', {
|
|
9853
9856
|
title: 'Stripe revenue report',
|
|
9854
|
-
description: "Revenue from the brand's OWN Stripe account: gross, refunds, net and succeeded-charge count per day/week/month, new customers in the window, and active subscriptions + MRR where the key can read them. This is the money side of the loop — read an ad, a hook or a launch against real revenue instead of clicks. Default window is the last 30 days; a window with no charges answers a real zero, not a failed read. Read-only, never moves money, free. Needs Stripe connected (Settings
|
|
9857
|
+
description: "Revenue from the brand's OWN Stripe account: gross, refunds, net and succeeded-charge count per day/week/month, new customers in the window, and active subscriptions + MRR where the key can read them. This is the money side of the loop — read an ad, a hook or a launch against real revenue instead of clicks. Default window is the last 30 days; a window with no charges answers a real zero, not a failed read. Read-only, never moves money, free. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector: paste a restricted key from Developers > Restricted keys).",
|
|
9855
9858
|
inputSchema: {
|
|
9856
9859
|
since: z.string().optional().describe('YYYY-MM-DD (default 30 days ago)'),
|
|
9857
9860
|
until: z.string().optional().describe('YYYY-MM-DD (default today)'),
|
|
@@ -9887,98 +9890,98 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9887
9890
|
// ---------- Stripe, the full surface (2026-09-09): reads, and writes that confirm on a live key ----------
|
|
9888
9891
|
server.registerTool('list_stripe_subscriptions', {
|
|
9889
9892
|
title: 'list stripe subscriptions',
|
|
9890
|
-
description: "Subscriptions on the brand's OWN Stripe account with each item's price in MAJOR units (19.00 means $19.00), interval and quantity, and the MRR each contributes. With NO status it returns everything that counts toward MRR — active + trialing + past_due — and says so; pass status all, canceled, unpaid, incomplete, incomplete_expired or paused for the rest, or filter by customer or price. Read-only, free. Needs Stripe connected (Settings
|
|
9893
|
+
description: "Subscriptions on the brand's OWN Stripe account with each item's price in MAJOR units (19.00 means $19.00), interval and quantity, and the MRR each contributes. With NO status it returns everything that counts toward MRR — active + trialing + past_due — and says so; pass status all, canceled, unpaid, incomplete, incomplete_expired or paused for the rest, or filter by customer or price. Read-only, free. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
|
|
9891
9894
|
inputSchema: { status: z.string().optional(), customerId: z.string().optional(), priceId: z.string().optional(), limit: z.number().optional(), after: z.string().optional() },
|
|
9892
9895
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
9893
9896
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
9894
9897
|
}, wrap(async (a) => { const d = await apiGet('/api/stripe/subscriptions', a); return ok(d.count ? `${d.note}\n${d.subscriptions.map(x => `• ${x.id} ${x.status} — ${x.customerEmail || x.customer}: ${x.items.map(i => `${i.quantity}×${i.amount == null ? '?' : i.amount.toFixed(2)} ${i.currency}/${i.interval || 'once'}${i.nickname ? ` (${i.nickname})` : ''}`).join(', ')} · MRR ${x.mrr}${x.cancelAtPeriodEnd ? ' · cancels at period end' : ''}`).join('\n')}` : d.note, d); }));
|
|
9895
9898
|
server.registerTool('list_stripe_invoices', {
|
|
9896
9899
|
title: 'list stripe invoices',
|
|
9897
|
-
description: "Invoices on the brand's Stripe account, newest first: number, status (draft, open, paid, uncollectible, void), amount due and paid, customer email, dates and the hosted invoice link; filter by status or customer. Read-only, free. Needs Stripe connected (Settings
|
|
9900
|
+
description: "Invoices on the brand's Stripe account, newest first: number, status (draft, open, paid, uncollectible, void), amount due and paid, customer email, dates and the hosted invoice link; filter by status or customer. Read-only, free. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
|
|
9898
9901
|
inputSchema: { status: z.string().optional(), customerId: z.string().optional(), limit: z.number().optional(), after: z.string().optional() },
|
|
9899
9902
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
9900
9903
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
9901
9904
|
}, wrap(async (a) => { const d = await apiGet('/api/stripe/invoices', a); return ok(d.count ? `${d.count} invoice(s):\n${d.invoices.map(i => `• ${i.number || i.id} ${i.status} — ${i.amountPaid}/${i.amountDue} ${i.currency} — ${i.customerEmail || i.customer} — ${i.created.slice(0, 10)}${i.hostedInvoiceUrl ? ` ${i.hostedInvoiceUrl}` : ''}`).join('\n')}${d.hasMore ? '\n(more: pass after=' + d.invoices[d.invoices.length - 1].id + ')' : ''}` : d.note, d); }));
|
|
9902
9905
|
server.registerTool('list_stripe_products', {
|
|
9903
9906
|
title: 'list stripe products',
|
|
9904
|
-
description: "Products on the brand's Stripe account with their active prices (amount, currency, one-time or recurring interval), which is what create_stripe_payment_link and create_stripe_subscription take. active:false lists archived products too. Read-only, free. Needs Stripe connected (Settings
|
|
9907
|
+
description: "Products on the brand's Stripe account with their active prices (amount, currency, one-time or recurring interval), which is what create_stripe_payment_link and create_stripe_subscription take. active:false lists archived products too. Read-only, free. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
|
|
9905
9908
|
inputSchema: { active: z.boolean().optional(), limit: z.number().optional(), after: z.string().optional() },
|
|
9906
9909
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
9907
9910
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
9908
9911
|
}, wrap(async (a) => { const d = await apiGet('/api/stripe/products', a); return ok(d.count ? `${d.count} product(s):\n${d.products.map(p => `• ${p.name} (${p.id})${p.active ? '' : ' · archived'}: ${p.prices.map(x => `${x.id} ${x.amount == null ? '?' : x.amount.toFixed(2)} ${x.currency}${x.interval ? '/' + x.interval : ''}${x.nickname ? ` (${x.nickname})` : ''}`).join(', ') || 'no active price'}`).join('\n')}${d.hasMore ? '\n(more: pass after=' + d.products[d.products.length - 1].id + ')' : ''}` : d.note, d); }));
|
|
9909
9912
|
server.registerTool('stripe_balance', {
|
|
9910
9913
|
title: 'stripe balance',
|
|
9911
|
-
description: "The brand's Stripe balance (available and pending, per currency) and the most recent payouts with status and arrival date. Read-only, free. Needs Stripe connected (Settings
|
|
9914
|
+
description: "The brand's Stripe balance (available and pending, per currency) and the most recent payouts with status and arrival date. Read-only, free. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
|
|
9912
9915
|
inputSchema: { limit: z.number().optional() },
|
|
9913
9916
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
9914
9917
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
9915
9918
|
}, wrap(async (a) => { const d = await apiGet('/api/stripe/balance', a); return ok(`${d.note}\n${(d.payouts || []).map(p => `• ${p.arrivalDate} ${p.amount} ${p.currency} ${p.status} (${p.id})`).join('\n')}`, d); }));
|
|
9916
9919
|
server.registerTool('list_stripe_refunds', {
|
|
9917
9920
|
title: 'list stripe refunds',
|
|
9918
|
-
description: "Refunds on the brand's Stripe account, newest first: amount, status, reason and the charge refunded; filter by charge. Read-only, free. Needs Stripe connected (Settings
|
|
9921
|
+
description: "Refunds on the brand's Stripe account, newest first: amount, status, reason and the charge refunded; filter by charge. Read-only, free. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
|
|
9919
9922
|
inputSchema: { chargeId: z.string().optional(), limit: z.number().optional(), after: z.string().optional() },
|
|
9920
9923
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
9921
9924
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
9922
9925
|
}, wrap(async (a) => { const d = await apiGet('/api/stripe/refunds', a); return ok(d.count ? `${d.count} refund(s):\n${d.refunds.map(r => `• ${r.created.slice(0, 10)} ${r.amount} ${r.currency} ${r.status}${r.reason ? ` (${r.reason})` : ''} on ${r.charge} (${r.id})`).join('\n')}` : d.note, d); }));
|
|
9923
9926
|
server.registerTool('list_stripe_coupons', {
|
|
9924
9927
|
title: 'list stripe coupons',
|
|
9925
|
-
description: "Coupons on the brand's Stripe account: percent or amount off, duration, validity and redemptions. Read-only, free. Needs Stripe connected (Settings
|
|
9928
|
+
description: "Coupons on the brand's Stripe account: percent or amount off, duration, validity and redemptions. Read-only, free. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
|
|
9926
9929
|
inputSchema: { limit: z.number().optional(), after: z.string().optional() },
|
|
9927
9930
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
9928
9931
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
9929
9932
|
}, wrap(async (a) => { const d = await apiGet('/api/stripe/coupons', a); return ok(d.count ? `${d.count} coupon(s):\n${d.coupons.map(c => `• ${c.id}${c.name ? ` ${c.name}` : ''}: ${c.percentOff != null ? c.percentOff + '% off' : c.amountOff + ' ' + c.currency + ' off'}, ${c.duration}${c.durationInMonths ? ' ' + c.durationInMonths + ' months' : ''}${c.valid ? '' : ' · no longer valid'} · redeemed ${c.timesRedeemed}${c.maxRedemptions ? '/' + c.maxRedemptions : ''}`).join('\n')}` : d.note, d); }));
|
|
9930
9933
|
server.registerTool('create_stripe_product', {
|
|
9931
9934
|
title: 'create stripe product',
|
|
9932
|
-
description: "Create a product on the brand's Stripe account together with its default price: name, optional description, `amount` in MAJOR units — 19 or 19.00 both mean $19.00, and a zero-decimal currency like JPY is whole — a 3-letter currency, and an optional recurring interval (day, week, month, year) with intervalCount. Returns both ids, read back. On a LIVE key it shows what it is about to create and needs confirm:true; on a test key it just does it. Idempotent on its arguments. Needs Stripe connected (Settings
|
|
9935
|
+
description: "Create a product on the brand's Stripe account together with its default price: name, optional description, `amount` in MAJOR units — 19 or 19.00 both mean $19.00, and a zero-decimal currency like JPY is whole — a 3-letter currency, and an optional recurring interval (day, week, month, year) with intervalCount. Returns both ids, read back. On a LIVE key it shows what it is about to create and needs confirm:true; on a test key it just does it. Idempotent on its arguments. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
|
|
9933
9936
|
inputSchema: { name: z.string(), description: z.string().optional(), amount: z.number().describe('MAJOR units — 19 or 19.00 is $19.00, never 1900'), currency: z.string(), interval: z.string().optional(), intervalCount: z.number().optional(), nickname: z.string().optional(), metadata: z.record(z.any()).optional(), confirm: z.boolean().optional() },
|
|
9934
9937
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
9935
9938
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
9936
9939
|
}, wrap(async (a) => { const d = await apiPost('/api/stripe/product', a); return ok(d.note, d); }));
|
|
9937
9940
|
server.registerTool('create_stripe_price', {
|
|
9938
9941
|
title: 'create stripe price',
|
|
9939
|
-
description: "Add a price to an existing Stripe product: `amount` in MAJOR units (19 or 19.00 is $19.00, never 1900), currency, optional recurring interval and intervalCount, optional nickname. Read back. LIVE key: confirm:true after showing the preview. Idempotent on its arguments. Needs Stripe connected (Settings
|
|
9942
|
+
description: "Add a price to an existing Stripe product: `amount` in MAJOR units (19 or 19.00 is $19.00, never 1900), currency, optional recurring interval and intervalCount, optional nickname. Read back. LIVE key: confirm:true after showing the preview. Idempotent on its arguments. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
|
|
9940
9943
|
inputSchema: { productId: z.string(), amount: z.number().describe('MAJOR units — 19 or 19.00 is $19.00, never 1900'), currency: z.string(), interval: z.string().optional(), intervalCount: z.number().optional(), nickname: z.string().optional(), confirm: z.boolean().optional() },
|
|
9941
9944
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
9942
9945
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
9943
9946
|
}, wrap(async (a) => { const d = await apiPost('/api/stripe/price', a); return ok(d.note, d); }));
|
|
9944
9947
|
server.registerTool('create_stripe_payment_link', {
|
|
9945
9948
|
title: 'create stripe payment link',
|
|
9946
|
-
description: "Create a shareable Stripe Payment Link: for ONE price pass priceId (price_…) with an optional quantity; for several pass lineItems: [{priceId, quantity}]. Optionally redirect to afterCompletionUrl when paid. Returns the URL. LIVE key: confirm:true after showing the preview. Idempotent on its arguments. Needs Stripe connected (Settings
|
|
9949
|
+
description: "Create a shareable Stripe Payment Link: for ONE price pass priceId (price_…) with an optional quantity; for several pass lineItems: [{priceId, quantity}]. Optionally redirect to afterCompletionUrl when paid. Returns the URL. LIVE key: confirm:true after showing the preview. Idempotent on its arguments. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
|
|
9947
9950
|
inputSchema: { priceId: z.string().optional().describe('one price — the shorthand for a single line item'), quantity: z.number().optional(), lineItems: z.array(z.object({ priceId: z.string(), quantity: z.number().optional() })).optional().describe('several prices at once; use instead of priceId'), afterCompletionUrl: z.string().optional(), metadata: z.record(z.any()).optional(), confirm: z.boolean().optional() },
|
|
9948
9951
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
9949
9952
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
9950
9953
|
}, wrap(async (a) => { const d = await apiPost('/api/stripe/payment-link', a); return ok(d.note, d); }));
|
|
9951
9954
|
server.registerTool('create_stripe_coupon', {
|
|
9952
9955
|
title: 'create stripe coupon',
|
|
9953
|
-
description: "Create a Stripe coupon: exactly one of percentOff (1–100) or amountOff (major units) + currency; duration once (default), repeating (with durationInMonths) or forever; optional name, id and maxRedemptions. Read back. LIVE key: confirm:true after showing the preview. Needs Stripe connected (Settings
|
|
9956
|
+
description: "Create a Stripe coupon: exactly one of percentOff (1–100) or amountOff (major units) + currency; duration once (default), repeating (with durationInMonths) or forever; optional name, id and maxRedemptions. Read back. LIVE key: confirm:true after showing the preview. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
|
|
9954
9957
|
inputSchema: { name: z.string().optional(), id: z.string().optional(), percentOff: z.number().optional(), amountOff: z.number().optional(), currency: z.string().optional(), duration: z.string().optional(), durationInMonths: z.number().optional(), maxRedemptions: z.number().optional(), confirm: z.boolean().optional() },
|
|
9955
9958
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
9956
9959
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
9957
9960
|
}, wrap(async (a) => { const d = await apiPost('/api/stripe/coupon', a); return ok(d.note, d); }));
|
|
9958
9961
|
server.registerTool('create_stripe_customer', {
|
|
9959
9962
|
title: 'create stripe customer',
|
|
9960
|
-
description: "Create a Stripe customer by email (optional name, phone, description, metadata). If a customer with that email already exists it is returned instead and nothing is created — pass allowDuplicate:true to create a second one on purpose. LIVE key: confirm:true after showing the preview. Needs Stripe connected (Settings
|
|
9963
|
+
description: "Create a Stripe customer by email (optional name, phone, description, metadata). If a customer with that email already exists it is returned instead and nothing is created — pass allowDuplicate:true to create a second one on purpose. LIVE key: confirm:true after showing the preview. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
|
|
9961
9964
|
inputSchema: { email: z.string(), name: z.string().optional(), phone: z.string().optional(), description: z.string().optional(), metadata: z.record(z.any()).optional(), allowDuplicate: z.boolean().optional(), confirm: z.boolean().optional() },
|
|
9962
9965
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
9963
9966
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
9964
9967
|
}, wrap(async (a) => { const d = await apiPost('/api/stripe/customer', a); return ok(d.note, d); }));
|
|
9965
9968
|
server.registerTool('create_stripe_subscription', {
|
|
9966
9969
|
title: 'create stripe subscription',
|
|
9967
|
-
description: "Subscribe a Stripe customer (cus_…) to a price (price_…), optional quantity and trialDays. THIS CHARGES THE CUSTOMER'S SAVED PAYMENT METHOD when there is no trial, so it ALWAYS needs confirm:true (test keys included) after you show the user exactly what will be created. Read back; an INCOMPLETE status means no chargeable card is on file. Needs Stripe connected (Settings
|
|
9970
|
+
description: "Subscribe a Stripe customer (cus_…) to a price (price_…), optional quantity and trialDays. THIS CHARGES THE CUSTOMER'S SAVED PAYMENT METHOD when there is no trial, so it ALWAYS needs confirm:true (test keys included) after you show the user exactly what will be created. Read back; an INCOMPLETE status means no chargeable card is on file. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
|
|
9968
9971
|
inputSchema: { customerId: z.string(), priceId: z.string(), quantity: z.number().optional(), trialDays: z.number().optional(), metadata: z.record(z.any()).optional(), confirm: z.boolean().optional() },
|
|
9969
9972
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
9970
9973
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
9971
9974
|
}, wrap(async (a) => { const d = await apiPost('/api/stripe/subscription', a); return ok(d.note, d); }));
|
|
9972
9975
|
server.registerTool('cancel_stripe_subscription', {
|
|
9973
9976
|
title: 'cancel stripe subscription',
|
|
9974
|
-
description: "Cancel a Stripe subscription: at the end of the current period by default (the customer keeps access until then), or immediately:true to end it now. ALWAYS needs confirm:true, test keys included. The read-back distinguishes the two — a period-end cancel reports the date it will end and the status it keeps until then, an immediate one reports status canceled. Needs Stripe connected (Settings
|
|
9977
|
+
description: "Cancel a Stripe subscription: at the end of the current period by default (the customer keeps access until then), or immediately:true to end it now. ALWAYS needs confirm:true, test keys included. The read-back distinguishes the two — a period-end cancel reports the date it will end and the status it keeps until then, an immediate one reports status canceled. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
|
|
9975
9978
|
inputSchema: { subscriptionId: z.string(), immediately: z.boolean().optional(), confirm: z.boolean().optional() },
|
|
9976
9979
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
9977
9980
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
9978
9981
|
}, wrap(async (a) => { const d = await apiPost('/api/stripe/subscription/cancel', a); return ok(d.note, d); }));
|
|
9979
9982
|
server.registerTool('refund_stripe_charge', {
|
|
9980
9983
|
title: 'refund stripe charge',
|
|
9981
|
-
description: "Refund a Stripe charge (ch_… or a pi_… payment intent): the full refundable amount, or a partial `amount` in major units; optional reason duplicate / fraudulent / requested_by_customer. ALWAYS needs confirm:true, test keys included. Read back. Needs Stripe connected (Settings
|
|
9984
|
+
description: "Refund a Stripe charge (ch_… or a pi_… payment intent): the full refundable amount, or a partial `amount` in major units; optional reason duplicate / fraudulent / requested_by_customer. ALWAYS needs confirm:true, test keys included. Read back. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
|
|
9982
9985
|
inputSchema: { chargeId: z.string(), amount: z.number().optional(), reason: z.string().optional(), confirm: z.boolean().optional() },
|
|
9983
9986
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
9984
9987
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
@@ -9986,7 +9989,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9986
9989
|
// ---------- HubSpot (2026-09-09): the brand's OWN CRM — contacts, an upsert by email, lead forms → contacts, deals ----------
|
|
9987
9990
|
server.registerTool('list_hubspot_contacts', {
|
|
9988
9991
|
title: 'List or search HubSpot contacts',
|
|
9989
|
-
description: "Contacts from the brand's OWN HubSpot CRM, newest first — email, name, lifecycle stage, lead status, created date, original source — or a search by email / first name / last name (whole-token match, wildcards allowed). Read-only, free. Needs HubSpot connected (Settings
|
|
9992
|
+
description: "Contacts from the brand's OWN HubSpot CRM, newest first — email, name, lifecycle stage, lead status, created date, original source — or a search by email / first name / last name (whole-token match, wildcards allowed). Read-only, free. Needs HubSpot connected (Settings > Connectors > HubSpot).",
|
|
9990
9993
|
inputSchema: { search: z.string().optional().describe('email or name to search for'), limit: z.number().optional().describe('1–100, default 25'), after: z.string().optional().describe('pagination cursor from the previous page') },
|
|
9991
9994
|
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), contacts: z.array(z.any()).optional(), after: z.string().nullable().optional(), note: z.string().optional() },
|
|
9992
9995
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -10041,7 +10044,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10041
10044
|
}));
|
|
10042
10045
|
server.registerTool('list_microsoft_ads_campaigns', {
|
|
10043
10046
|
title: 'List Microsoft Advertising accounts / campaigns',
|
|
10044
|
-
description: 'Read the brand’s connected Microsoft Advertising (Bing Ads) account(s). Call with NO accountId to list the accounts shared with this brand — do this first to pick a target. Call WITH accountId to list that account’s campaigns (id, name, status, daily budget, campaign type, and whether the budget is SHARED). Microsoft statuses are Active / Paused — never Google’s ENABLED — and Microsoft also sets BudgetPaused, BudgetAndManualPaused and Suspended on its own, so report the status you read rather than assuming a paused campaign was paused by a person. Read-only, free. Needs Microsoft Advertising connected (Settings
|
|
10047
|
+
description: 'Read the brand’s connected Microsoft Advertising (Bing Ads) account(s). Call with NO accountId to list the accounts shared with this brand — do this first to pick a target. Call WITH accountId to list that account’s campaigns (id, name, status, daily budget, campaign type, and whether the budget is SHARED). Microsoft statuses are Active / Paused — never Google’s ENABLED — and Microsoft also sets BudgetPaused, BudgetAndManualPaused and Suspended on its own, so report the status you read rather than assuming a paused campaign was paused by a person. Read-only, free. Needs Microsoft Advertising connected (Settings > Connectors > Microsoft Advertising).',
|
|
10045
10048
|
inputSchema: {
|
|
10046
10049
|
accountId: z.string().optional().describe('Microsoft ad account id — omit to list the accounts shared with this brand'),
|
|
10047
10050
|
},
|
|
@@ -10150,7 +10153,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10150
10153
|
}));
|
|
10151
10154
|
server.registerTool('create_microsoft_ads_campaign', {
|
|
10152
10155
|
title: 'Build a Microsoft Advertising campaign (paused)',
|
|
10153
|
-
description: 'Build a campaign on a connected Microsoft Advertising (Bing Ads) account. ALWAYS created Paused — it spends NOTHING until you activate it with set_microsoft_ads_status(confirm:true). Microsoft’s object graph is campaign
|
|
10156
|
+
description: 'Build a campaign on a connected Microsoft Advertising (Bing Ads) account. ALWAYS created Paused — it spends NOTHING until you activate it with set_microsoft_ads_status(confirm:true). Microsoft’s object graph is campaign -> ad group -> responsive search ad -> keywords, so a campaign ON ITS OWN CANNOT SERVE AN IMPRESSION: pass adGroup{name, ad{headlines,descriptions,finalUrls}, keywords[]} and this builds the whole tree. Microsoft has NO atomic multi-object write (unlike Google), so the levels are created in sequence and the campaign is DELETED again if anything below it is rejected — you never inherit a half-built campaign. Microsoft requires 3–15 headlines (≤30 chars) and 2–4 descriptions (≤90 chars); expanded text ads can no longer be created at all. dailyBudget is in the ACCOUNT’S currency, not necessarily USD. LOCATION TARGETING: pass locations[] (country / region / city names, ISO country codes, or numeric Microsoft location ids). A Microsoft campaign has NO geo targeting unless it is set, and Microsoft does not require any — so if you pass none, the campaign IS CREATED and serves WORLDWIDE (Microsoft’s own default), and the returned note says so loudly. That is safe at this stage because the campaign is Paused and spends nothing; it is NOT safe to activate without telling the user, so relay the warning. Nothing is created when a location you DID name cannot be resolved (call microsoft_ads_geo_search to disambiguate, then pass the id). Pass worldwide:true to record that everywhere was deliberate and suppress the nudge. The locations are written and READ BACK inside the same rollback as the rest of the tree, so a campaign is either targeted as asked or does not exist. Everything is READ BACK from Microsoft before you are told it exists; print the returned note verbatim, and if it says the campaign cannot serve yet, say that rather than calling it a finished ad.',
|
|
10154
10157
|
inputSchema: {
|
|
10155
10158
|
accountId: z.string().optional().describe('Microsoft ad account id — omit to use the brand’s single shared account'),
|
|
10156
10159
|
name: z.string().describe('campaign name, ≤128 characters'),
|
|
@@ -10491,7 +10494,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10491
10494
|
});
|
|
10492
10495
|
server.registerTool('list_openai_ads_campaigns', {
|
|
10493
10496
|
title: 'List ChatGPT Ads account / campaigns / ad groups / ads',
|
|
10494
|
-
description: 'Read the brand’s connected ChatGPT Ads account — the ads that appear below ChatGPT answers. Every campaign, ad group and ad row carries servingIssues, OpenAI’s own list of what is blocking delivery (payment method, brand review, budget spent, ad in review, landing page not crawlable, country policy…) with a plain meaning each: null means OpenAI was not asked, [] means it reports no blocker, which is still not a promise of impressions. Call with NO ids to get the ad account itself (name, currency, status, review state) plus its campaigns; with campaignId to list that campaign’s ad groups; with adGroupId to list that ad group’s ads, including each ad’s REVIEW status, which is what decides whether it can ever show. Statuses here are active / paused / archived. Read-only, free, zero spend risk. Needs ChatGPT Ads connected (Settings
|
|
10497
|
+
description: 'Read the brand’s connected ChatGPT Ads account — the ads that appear below ChatGPT answers. Every campaign, ad group and ad row carries servingIssues, OpenAI’s own list of what is blocking delivery (payment method, brand review, budget spent, ad in review, landing page not crawlable, country policy…) with a plain meaning each: null means OpenAI was not asked, [] means it reports no blocker, which is still not a promise of impressions. Call with NO ids to get the ad account itself (name, currency, status, review state) plus its campaigns; with campaignId to list that campaign’s ad groups; with adGroupId to list that ad group’s ads, including each ad’s REVIEW status, which is what decides whether it can ever show. Statuses here are active / paused / archived. Read-only, free, zero spend risk. Needs ChatGPT Ads connected (Settings > Connectors > ChatGPT Ads, or connect_connector): the user pastes an Advertiser API key from ChatGPT Ads Manager > Settings — there is no OAuth and no manager account, and one key is scoped to one ad account.',
|
|
10495
10498
|
inputSchema: {
|
|
10496
10499
|
campaignId: z.string().optional().describe('list this campaign’s ad groups'),
|
|
10497
10500
|
adGroupId: z.string().optional().describe('list this ad group’s ads'),
|
|
@@ -10544,7 +10547,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10544
10547
|
server.group('ads');
|
|
10545
10548
|
server.registerTool('list_x_ads_accounts', {
|
|
10546
10549
|
title: 'List X ad accounts',
|
|
10547
|
-
description: 'List the X (Twitter) ad accounts this brand can act on, with the PERMISSION LEVEL held on each so you can tell an admin grant from a read-only one before attempting a write. X grants API access PER AD ACCOUNT, not per app: the customer adds Hermoso’s X user to their ad account at business.x.com
|
|
10550
|
+
description: 'List the X (Twitter) ad accounts this brand can act on, with the PERMISSION LEVEL held on each so you can tell an admin grant from a read-only one before attempting a write. X grants API access PER AD ACCOUNT, not per app: the customer adds Hermoso’s X user to their ad account at business.x.com -> Account access, and it appears here. Read-only, free.',
|
|
10548
10551
|
inputSchema: {},
|
|
10549
10552
|
outputSchema: { count: z.number().optional(), accounts: z.array(z.any()).optional(), note: z.string().optional() },
|
|
10550
10553
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -10728,7 +10731,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10728
10731
|
}));
|
|
10729
10732
|
server.registerTool('apply_microsoft_ads_recommendations', {
|
|
10730
10733
|
title: 'Apply Microsoft recommendations (spend-gated)',
|
|
10731
|
-
description: 'Apply Microsoft Advertising recommendations by id. THIS SPENDS REAL MONEY AND HAS NO UNDO. It raises daily budgets, adds keywords, widens keywords to broad match, DELETES negative keywords so the account buys more searches, and publishes Microsoft-written ads under the brand’s name, all live at the next auction. THE GATE IS UNLIKE THE OTHER MICROSOFT SPEND SWITCHES, AND confirm:true ALONE IS REFUSED: it proves you meant to apply something, not that you know what THESE ones do, and it reads identically for one keyword suggestion and forty budget raises. So call it ONCE with no confirmation. Nothing is applied, and you get every recommendation named with its campaign, exactly what it changes, the current
|
|
10734
|
+
description: 'Apply Microsoft Advertising recommendations by id. THIS SPENDS REAL MONEY AND HAS NO UNDO. It raises daily budgets, adds keywords, widens keywords to broad match, DELETES negative keywords so the account buys more searches, and publishes Microsoft-written ads under the brand’s name, all live at the next auction. THE GATE IS UNLIKE THE OTHER MICROSOFT SPEND SWITCHES, AND confirm:true ALONE IS REFUSED: it proves you meant to apply something, not that you know what THESE ones do, and it reads identically for one keyword suggestion and forty budget raises. So call it ONCE with no confirmation. Nothing is applied, and you get every recommendation named with its campaign, exactly what it changes, the current -> recommended budget where there is one, and Microsoft’s own cost estimate. SHOW THAT LIST TO THE USER, get an explicit yes, then call again with confirm:true, confirmCount:<n> and confirmCostIncrease:<n>. BOTH NUMBERS ARE RECOMPUTED FROM A FRESH READ at that moment, so a recommendation Microsoft has withdrawn, already applied or re-priced since you looked fails the check instead of being applied unseen. If ANY id you name is no longer on offer, NOTHING is applied, not even the ones that still are, because applying part of a set you inspected whole is not what you asked for. Microsoft can reject individual items and apply the rest, so the result reports per item: never call a partial apply a failure, and never call it a success. 0 credits to us; the spend lands on the ad account.',
|
|
10732
10735
|
inputSchema: {
|
|
10733
10736
|
accountId: z.string().optional().describe('Microsoft ad account id, omit to use the brand’s single shared account'),
|
|
10734
10737
|
recommendationIds: z.array(z.string()).describe('ids from microsoft_ads_recommendations, max 100. There is deliberately no "apply everything"'),
|
|
@@ -10778,7 +10781,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10778
10781
|
// sentences move with them.
|
|
10779
10782
|
server.registerTool('list_apple_ads_campaigns', {
|
|
10780
10783
|
title: 'List Apple Ads campaigns',
|
|
10781
|
-
description: 'Read the brand’s connected Apple Ads (Apple Search Ads) campaigns — App Store search ads, on Apple’s Platform API. Each row carries status, the system-computed displayStatus, the daily budget, the bid strategy, the countries and placements it runs in and, when a campaign cannot run, systemStatusReasons stating exactly why. Read-only, free, zero spend risk. Needs Apple Ads connected (Settings
|
|
10784
|
+
description: 'Read the brand’s connected Apple Ads (Apple Search Ads) campaigns — App Store search ads, on Apple’s Platform API. Each row carries status, the system-computed displayStatus, the daily budget, the bid strategy, the countries and placements it runs in and, when a campaign cannot run, systemStatusReasons stating exactly why. Read-only, free, zero spend risk. Needs Apple Ads connected (Settings > Connectors > Apple Ads, or connect_connector): there is no OAuth — Hermoso generates an EC signing key, the user pastes the public half into Apple Ads > Account Settings > API and pastes back clientId / teamId / keyId. To BUILD on this account, use create_apple_ads_campaign / create_apple_ads_ad_group / add_apple_ads_keywords: everything is created PAUSED and only set_apple_ads_status(confirm:true) can arm spend.',
|
|
10782
10785
|
inputSchema: {
|
|
10783
10786
|
limit: z.number().optional().describe('page size, default 100, Apple’s max is 1000'),
|
|
10784
10787
|
offset: z.number().optional().describe('offset pagination'),
|
|
@@ -10833,7 +10836,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10833
10836
|
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/report', a); return ok(`${d.note}\n${JSON.stringify((d.rows || []).slice(0, 40))}`, d); }));
|
|
10834
10837
|
server.registerTool('list_apple_ads_orgs', {
|
|
10835
10838
|
title: 'List Apple Ads organizations',
|
|
10836
|
-
description: 'The Apple Ads ad accounts these credentials can act as, with the roles held on each, plus the organization’s currency, time zone and payment model. NAMING: Apple’s older API called these “organizations” (campaign groups) and its Platform API calls them AD ACCOUNTS — the id is the same number, and `orgId` and `adAccountId` on each row are equal. One login can cover several: an agency managing multiple clients has one per client. Reading this does NOT switch account: Apple Ads is pinned to the ONE chosen when the connection was made, so an agent can never act as another client’s. To use a different one, pick it in Settings
|
|
10839
|
+
description: 'The Apple Ads ad accounts these credentials can act as, with the roles held on each, plus the organization’s currency, time zone and payment model. NAMING: Apple’s older API called these “organizations” (campaign groups) and its Platform API calls them AD ACCOUNTS — the id is the same number, and `orgId` and `adAccountId` on each row are equal. One login can cover several: an agency managing multiple clients has one per client. Reading this does NOT switch account: Apple Ads is pinned to the ONE chosen when the connection was made, so an agent can never act as another client’s. To use a different one, pick it in Settings > Connectors > Apple Ads > Manage accounts, with set_connector_accounts, or by reconnecting. A payment model of null is worth reporting: Apple states that without one, campaigns cannot run. Read-only, free.',
|
|
10837
10840
|
inputSchema: {},
|
|
10838
10841
|
outputSchema: { ok: z.boolean().optional(), active: z.string().nullable().optional(), count: z.number().optional(), orgs: z.array(z.any()).optional(), org: z.any().optional(), note: z.string().optional() },
|
|
10839
10842
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -11220,7 +11223,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11220
11223
|
|
|
11221
11224
|
server.registerTool('apply_apple_ads_recommendation', {
|
|
11222
11225
|
title: 'Apply an Apple Ads recommendation',
|
|
11223
|
-
description: 'THIS CHANGES WHAT A LIVE CAMPAIGN MAY SPEND, and it is the only tool in the Apple Ads suggestion/recommendation family that touches money. Applying a daily-budget recommendation raises that campaign’s daily budget to Apple’s suggested amount; applying a target-CPA recommendation moves the CPA its auto-bidder chases. REQUIRES confirm:true, and the refusal you get without it is built from the recommendation READ BACK FROM APPLE — it names the campaign, states current
|
|
11226
|
+
description: 'THIS CHANGES WHAT A LIVE CAMPAIGN MAY SPEND, and it is the only tool in the Apple Ads suggestion/recommendation family that touches money. Applying a daily-budget recommendation raises that campaign’s daily budget to Apple’s suggested amount; applying a target-CPA recommendation moves the CPA its auto-bidder chases. REQUIRES confirm:true, and the refusal you get without it is built from the recommendation READ BACK FROM APPLE — it names the campaign, states current -> recommended, and quotes Apple’s own projected spend at the new level — so you are confirming a specific change to a specific campaign rather than confirming that you pressed a button. Pass amount to apply a DIFFERENT number than Apple suggested; omit it to take Apple’s. The campaign’s STATUS IS NOT TOUCHED: this moves a ceiling, it never enables or pauses anything, and it does not make a paused campaign start spending. Only an AVAILABLE recommendation can be applied and applying is TERMINAL. The reply is confirmed by re-reading the CAMPAIGN, not by Apple’s acknowledgement. Free to call; the spending it enables is real.',
|
|
11224
11227
|
inputSchema: {
|
|
11225
11228
|
type: z.enum(['daily_budget', 'target_cpa']).optional().describe('Default daily_budget.'),
|
|
11226
11229
|
id: z.string().describe('REQUIRED — the recommendation id from list_apple_ads_recommendations.'),
|
|
@@ -11532,7 +11535,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11532
11535
|
}));
|
|
11533
11536
|
server.registerTool('create_x_ads_line_item', {
|
|
11534
11537
|
title: 'Build an X ads line item (paused)',
|
|
11535
|
-
description: "Create a line item — X's ad group — under a campaign. ALWAYS CREATED PAUSED with no override. THE TREE ON X IS campaign
|
|
11538
|
+
description: "Create a line item — X's ad group — under a campaign. ALWAYS CREATED PAUSED with no override. THE TREE ON X IS campaign -> line item -> promoted post, and a campaign ALONE CANNOT SERVE: this is the middle level, and it still cannot serve until you attach a post with create_x_ads_promoted_tweet. Targeting attaches HERE (add_x_ads_targeting), never to the campaign. `objective` is validated before dispatch because X answers an invalid one with a 500 that reads like an outage; note that WEBSITE_CONVERSIONS and SITE_VISITS are `goal` values and are NOT objectives. `bidStrategy` MAX/TARGET require a bidAmount; AUTO lets X set it. Omitting startTime records now, and the read-back says so. Everything is READ BACK from X before you are told it exists; print the returned note verbatim.",
|
|
11536
11539
|
inputSchema: {
|
|
11537
11540
|
accountId: z.string().describe('from list_x_ads_accounts'),
|
|
11538
11541
|
campaignId: z.string().describe('from create_x_ads_campaign or list_x_ads_campaigns'),
|
|
@@ -11724,7 +11727,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11724
11727
|
}, wrap(async (a) => { const d = await apiPost('/api/openai-ads/audience/archive', a); return ok(d.note, d); }));
|
|
11725
11728
|
server.registerTool('create_openai_ads_campaign', {
|
|
11726
11729
|
title: 'Build a ChatGPT Ads campaign (paused)',
|
|
11727
|
-
description: 'Build a campaign on the connected ChatGPT Ads account — the ads that appear below ChatGPT answers. ALWAYS created PAUSED at every level, with no override: it spends NOTHING until you activate it with set_openai_ads_status(confirm:true). The object graph is campaign
|
|
11730
|
+
description: 'Build a campaign on the connected ChatGPT Ads account — the ads that appear below ChatGPT answers. ALWAYS created PAUSED at every level, with no override: it spends NOTHING until you activate it with set_openai_ads_status(confirm:true). The object graph is campaign -> ad group -> ad, and a campaign ON ITS OWN CANNOT SERVE AN IMPRESSION, so pass adGroup{name, maxBid, contextHints, ad{creative}} and this builds the whole tree. THE CREATIVE IS A TEXT + IMAGE CARD AND NOTHING ELSE — title 3–50 characters, body 100 maximum, one landing page, one still image. THERE IS NO VIDEO ON THIS CHANNEL: never offer a video ad here, and if the brand only has video, pull a frame from it first. TARGETING IS SEMANTIC: context hints are natural-language descriptions of the conversations where this ad belongs (up to 2,000 per ad group). Geo (countries / locationIds) and PLATFORMS (which of the iOS app, Android app and web the ad runs on) are the only other dimensions — leave platforms out to run on all three. They guide matching, they are NOT exact-match keywords, and they do not guarantee delivery. OpenAI’s own guidance is BREADTH — many genuinely distinct hints and many distinct title/body angles beat one message repeated — which is exactly what plan_variations and mine_angles produce. OpenAI has no atomic multi-object write available here, so the whole tree is VALIDATED before the first write; if a level below the campaign is still rejected, the campaign is left PAUSED (spending nothing) and the note says exactly what exists — nothing is archived behind your back, because archiving is irreversible. Everything is READ BACK from OpenAI before you are told it exists: print the returned note verbatim, and if it says the campaign cannot serve yet, say that rather than calling it a finished ad.',
|
|
11728
11731
|
inputSchema: {
|
|
11729
11732
|
name: z.string().describe('campaign name, at least 3 characters'),
|
|
11730
11733
|
description: z.string().optional(),
|
|
@@ -11977,7 +11980,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11977
11980
|
};
|
|
11978
11981
|
const pinAdGroupShape = {
|
|
11979
11982
|
name: z.string().describe('ad group name'),
|
|
11980
|
-
billableEvent: z.enum(['CLICKTHROUGH', 'IMPRESSION', 'VIDEO_V_50_MRC']).optional().describe('LEAVE THIS OUT unless you know better — Pinterest ties it to the campaign objective and refuses a mismatch: CONSIDERATION takes CLICKTHROUGH; AWARENESS, SALES, LEADS, WEB_CONVERSION, VIDEO_COMPLETION and APP_INSTALL take IMPRESSION; CATALOG_SALES takes either. Omitted
|
|
11983
|
+
billableEvent: z.enum(['CLICKTHROUGH', 'IMPRESSION', 'VIDEO_V_50_MRC']).optional().describe('LEAVE THIS OUT unless you know better — Pinterest ties it to the campaign objective and refuses a mismatch: CONSIDERATION takes CLICKTHROUGH; AWARENESS, SALES, LEADS, WEB_CONVERSION, VIDEO_COMPLETION and APP_INSTALL take IMPRESSION; CATALOG_SALES takes either. Omitted -> the right one for the objective is used.'),
|
|
11981
11984
|
bid: z.number().optional().describe('REQUIRED — what you pay per billable event, in the ad account’s currency. Pinterest rejects an ad group without one and Hermoso will not invent a bid. It must also be BELOW the campaign budget and above Pinterest’s own bid floor for the placement, both of which Pinterest states in its refusal.'),
|
|
11982
11985
|
budget: z.number().optional().describe('ad-group budget — only valid when the campaign is NOT budget-optimized (Pinterest optimizes at campaign level by default)'),
|
|
11983
11986
|
placementGroup: z.enum(['ALL', 'SEARCH', 'BROWSE', 'OTHER']).optional(),
|
|
@@ -12124,10 +12127,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12124
12127
|
|
|
12125
12128
|
server.registerTool('list_pinterest_ads_campaigns', {
|
|
12126
12129
|
title: 'List Pinterest ad accounts / campaigns',
|
|
12127
|
-
description: 'Read the brand’s connected Pinterest AD account(s). Call with NO adAccountId to list the ad accounts shared with this brand — do this first to pick a target. Call WITH adAccountId to list that account’s campaigns (id, name, status, objective, budget, and Pinterest’s own summary status). Add campaignId to get that ONE campaign’s whole tree — its AD GROUPS and ADS with their ids, statuses and review status. That is the only way to enumerate them, and it matters: Pinterest has no delete, so an ad group you cannot see is one you cannot even archive. All money is in the AD ACCOUNT’S currency, which is not necessarily dollars. Pinterest’s own list default hides DRAFT and ARCHIVED objects; this asks for all four statuses so nothing is silently missing. Read-only, free. Needs Pinterest connected (Settings
|
|
12130
|
+
description: 'Read the brand’s connected Pinterest AD account(s). Call with NO adAccountId to list the ad accounts shared with this brand — do this first to pick a target. Call WITH adAccountId to list that account’s campaigns (id, name, status, objective, budget, and Pinterest’s own summary status). Add campaignId to get that ONE campaign’s whole tree — its AD GROUPS and ADS with their ids, statuses and review status. That is the only way to enumerate them, and it matters: Pinterest has no delete, so an ad group you cannot see is one you cannot even archive. All money is in the AD ACCOUNT’S currency, which is not necessarily dollars. Pinterest’s own list default hides DRAFT and ARCHIVED objects; this asks for all four statuses so nothing is silently missing. Read-only, free. Needs Pinterest connected (Settings > Connectors > Pinterest) and the ad account ticked under Manage accounts (or call list_connector_accounts with provider pinterest_ads, then set_connector_accounts).',
|
|
12128
12131
|
inputSchema: {
|
|
12129
12132
|
adAccountId: z.string().optional().describe('Pinterest ad account id — omit to list the ad accounts shared with this brand'),
|
|
12130
|
-
campaignId: z.string().optional().describe('one campaign
|
|
12133
|
+
campaignId: z.string().optional().describe('one campaign -> its ad groups and ads too (the only way to enumerate them)'),
|
|
12131
12134
|
statuses: z.array(z.enum(['ACTIVE', 'PAUSED', 'ARCHIVED', 'DRAFT'])).optional(),
|
|
12132
12135
|
},
|
|
12133
12136
|
outputSchema: { accounts: z.array(z.any()).optional(), adAccountId: z.string().optional(), currency: z.string().optional(), count: z.number().optional(), campaigns: z.array(z.any()).optional(), adGroups: z.array(z.any()).optional(), ads: z.array(z.any()).optional(), note: z.string().optional() },
|
|
@@ -12153,7 +12156,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12153
12156
|
description: 'Performance for a Pinterest ad account — spend, impressions, clicks, CTR, effective CPC and conversions — at ANY of Pinterest’s four levels: the whole ACCOUNT, by CAMPAIGN, by AD GROUP, or by individual AD. Ad level is how you answer "WHICH AD IS WINNING". EVERY SUB-ACCOUNT LEVEL NEEDS ITS IDS — this was measured, not read: Pinterest refuses campaign level without campaignIds, ad-group level without adGroupIds, AND ad level without adIds ("Either ads id filter or both pin id and campaign id filters must be specified"), so there is NO account-wide per-ad call. Get the ids from list_pinterest_ads_campaigns (name a campaignId and it returns that campaign’s ad groups and ads), or use level:"account" for a whole-account total with no ids at all. At AD level Pinterest publishes one alternative its own refusal names: pinIds AND campaignIds TOGETHER, which reports every ad promoting those Pins — half of that pair is refused here naming the missing half. The level is inferred from whichever ids you pass, so naming campaignIds still reports by campaign. Default columns lead with that level’s OWN id and name, because a report whose rows cannot be told apart answers nothing. Window via since/until (YYYY-MM-DD) and granularity. Pinterest keeps only 90 days and refuses ranges longer than 90 days (at HOUR granularity: 8 days back, 3-day windows) — this refuses those up front with the reason rather than letting Pinterest return an opaque error. A report with ZERO rows genuinely means nothing delivered in that window; say exactly that and never present zeros as measured performance. Read-only, free.',
|
|
12154
12157
|
inputSchema: {
|
|
12155
12158
|
adAccountId: z.string().optional(),
|
|
12156
|
-
level: z.enum(['account', 'campaign', 'adGroup', 'ad']).optional().describe('which level to report at — omit and it is inferred from the ids you pass (none
|
|
12159
|
+
level: z.enum(['account', 'campaign', 'adGroup', 'ad']).optional().describe('which level to report at — omit and it is inferred from the ids you pass (none -> the whole account)'),
|
|
12157
12160
|
campaignIds: z.array(z.string()).optional().describe('REQUIRED for campaign level; also the second half of the ad-level pinIds pair'),
|
|
12158
12161
|
adGroupIds: z.array(z.string()).optional().describe('REQUIRED for ad-group level — Pinterest has no all-of-them form there'),
|
|
12159
12162
|
adIds: z.array(z.string()).optional().describe('REQUIRED for ad level — Pinterest refuses /ads/analytics without it, unless you pass pinIds AND campaignIds instead'),
|
|
@@ -12171,7 +12174,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12171
12174
|
}));
|
|
12172
12175
|
server.registerTool('create_pinterest_ads_campaign', {
|
|
12173
12176
|
title: 'Build a Pinterest ad campaign (paused)',
|
|
12174
|
-
description: 'Build a campaign on a connected Pinterest ad account. ALWAYS created PAUSED — worth knowing that Pinterest’s own API defaults new campaigns to ACTIVE, so this deliberately overrides that; it spends NOTHING until you activate it with set_pinterest_ads_status(confirm:true). Pinterest’s object graph is campaign
|
|
12177
|
+
description: 'Build a campaign on a connected Pinterest ad account. ALWAYS created PAUSED — worth knowing that Pinterest’s own API defaults new campaigns to ACTIVE, so this deliberately overrides that; it spends NOTHING until you activate it with set_pinterest_ads_status(confirm:true). Pinterest’s object graph is campaign -> ad group -> ad, and an ad PROMOTES AN EXISTING PIN, so a campaign ON ITS OWN CANNOT SERVE AN IMPRESSION: pass adGroup{name, targetingSpec, ad{pinId}} and this builds the whole tree. Pinterest has NO atomic multi-object write, so the levels are created in sequence and the campaign is ARCHIVED again if anything below it is rejected (Pinterest has no delete) — you never inherit a half-built campaign. Budgets are ordinary amounts in the ad account’s currency; the micro-currency conversion Pinterest requires is handled for you. Every ad group must target at least one place. Everything is READ BACK from Pinterest before you are told it exists; print the returned note verbatim, and if it says the campaign cannot serve yet, say that rather than calling it a finished ad.',
|
|
12175
12178
|
inputSchema: {
|
|
12176
12179
|
adAccountId: z.string().optional(),
|
|
12177
12180
|
name: z.string().describe('campaign name, ≤255 characters'),
|
|
@@ -12345,7 +12348,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12345
12348
|
// account the brand was not given, and everything is created PAUSED with no override.
|
|
12346
12349
|
server.registerTool('list_reddit_ads_campaigns', {
|
|
12347
12350
|
title: 'List Reddit ad accounts / campaigns',
|
|
12348
|
-
description: 'Read the brand’s connected Reddit AD account(s). Call with NO adAccountId to list the ad accounts shared with this brand — do this first to pick a target. Call WITH adAccountId to read that account’s whole tree at once: campaigns, ad groups and ads, each with its configured status and Reddit’s own effective status (the effective one is what says whether it could actually serve — PENDING_APPROVAL, CAMPAIGN_PAUSED, REJECTED and so on). Read-only, free. Needs Reddit Ads connected (Settings
|
|
12351
|
+
description: 'Read the brand’s connected Reddit AD account(s). Call with NO adAccountId to list the ad accounts shared with this brand — do this first to pick a target. Call WITH adAccountId to read that account’s whole tree at once: campaigns, ad groups and ads, each with its configured status and Reddit’s own effective status (the effective one is what says whether it could actually serve — PENDING_APPROVAL, CAMPAIGN_PAUSED, REJECTED and so on). Read-only, free. Needs Reddit Ads connected (Settings > Connectors > Reddit Ads) and the ad account ticked under Manage accounts (or call list_connector_accounts with provider reddit_ads, then set_connector_accounts).',
|
|
12349
12352
|
inputSchema: { adAccountId: z.string().optional().describe('Reddit ad account id (a2_…) — omit to list the ad accounts shared with this brand') },
|
|
12350
12353
|
outputSchema: { accounts: z.array(z.any()).optional(), adAccountId: z.string().optional(), name: z.string().optional(), campaigns: z.array(z.any()).optional(), adGroups: z.array(z.any()).optional(), ads: z.array(z.any()).optional() },
|
|
12351
12354
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -12761,7 +12764,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12761
12764
|
// reconnect; and a custom audience is the one Reddit object with a real DELETE, so it is confirm-gated.
|
|
12762
12765
|
server.registerTool('list_reddit_ads_pixels', {
|
|
12763
12766
|
title: 'List Reddit conversion pixels (and whether they are firing)',
|
|
12764
|
-
description: 'List the conversion pixels on a Reddit ad account, each with the LAST TIME IT FIRED — which is the difference between "a pixel exists" and "conversion tracking works". Call this before building anything: since 13 July 2026 Reddit REQUIRES a pixel on every ad group and every CBO campaign, so an account with none cannot run ads at all. IMPORTANT: the Reddit API has no operation that creates a pixel — if the account has none, the only fix is for the user to add it in Reddit’s Events Manager (ads.reddit.com
|
|
12767
|
+
description: 'List the conversion pixels on a Reddit ad account, each with the LAST TIME IT FIRED — which is the difference between "a pixel exists" and "conversion tracking works". Call this before building anything: since 13 July 2026 Reddit REQUIRES a pixel on every ad group and every CBO campaign, so an account with none cannot run ads at all. IMPORTANT: the Reddit API has no operation that creates a pixel — if the account has none, the only fix is for the user to add it in Reddit’s Events Manager (ads.reddit.com > Events Manager); never claim you can create one. Read-only, free.',
|
|
12765
12768
|
inputSchema: { adAccountId: z.string().optional().describe('Reddit ad account id (a2_…) — omit when only one is shared') },
|
|
12766
12769
|
outputSchema: { adAccountId: z.string().optional(), count: z.number().optional(), pixels: z.array(z.any()).optional(), note: z.string().optional() },
|
|
12767
12770
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -12849,7 +12852,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12849
12852
|
inputSchema: {
|
|
12850
12853
|
adAccountId: z.string().optional(),
|
|
12851
12854
|
pixelId: z.string().optional().describe('from list_reddit_ads_pixels — only needed when the account has more than one'),
|
|
12852
|
-
testId: z.string().optional().describe('a test id from Events Manager
|
|
12855
|
+
testId: z.string().optional().describe('a test id from Events Manager > Testing — events sent with it are visible there and NEVER counted in reporting'),
|
|
12853
12856
|
events: z.array(z.object({
|
|
12854
12857
|
trackingType: z.enum(['PAGE_VISIT', 'VIEW_CONTENT', 'SEARCH', 'ADD_TO_CART', 'ADD_TO_WISHLIST', 'PURCHASE', 'LEAD', 'SIGN_UP', 'CUSTOM']).optional().describe('default PAGE_VISIT'),
|
|
12855
12858
|
customEventName: z.string().optional().describe('required when trackingType is CUSTOM — free-form, CASE-SENSITIVE, max 64 chars; only the 20 most recent custom events show on Reddit’s dashboard'),
|
|
@@ -13091,7 +13094,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13091
13094
|
const TIKTOK_WEB_OPT_EVENTS = ['ON_WEB_DETAIL', 'ON_WEB_SEARCH', 'ON_WEB_ADD_TO_WISHLIST', 'ON_WEB_CART', 'INITIATE_ORDER', 'ADD_BILLING', 'SHOPPING', 'CONSULT', 'DOWNLOAD_START', 'FORM', 'ON_WEB_REGISTER', 'ON_WEB_SUBSCRIBE'];
|
|
13092
13095
|
server.registerTool('list_tiktok_ads_accounts', {
|
|
13093
13096
|
title: 'List TikTok advertiser accounts',
|
|
13094
|
-
description: 'List the TikTok ADVERTISER accounts this brand can act on — id, name, currency, timezone and status. Every other TikTok Ads tool needs an advertiserId, and this is where it comes from: call this first and let the USER pick when there is more than one. Read-only, free, spends nothing. Needs TikTok ADS connected (Settings
|
|
13097
|
+
description: 'List the TikTok ADVERTISER accounts this brand can act on — id, name, currency, timezone and status. Every other TikTok Ads tool needs an advertiserId, and this is where it comes from: call this first and let the USER pick when there is more than one. Read-only, free, spends nothing. Needs TikTok ADS connected (Settings > Connectors > TikTok Ads) — that is a DIFFERENT connection from the TikTok posting connector behind post_to_tiktok, so a brand that publishes to TikTok every day may still have nothing here.',
|
|
13095
13098
|
inputSchema: {},
|
|
13096
13099
|
outputSchema: { advertisers: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
|
|
13097
13100
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -13181,7 +13184,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13181
13184
|
}));
|
|
13182
13185
|
server.registerTool('authorize_tiktok_ads_spark_post', {
|
|
13183
13186
|
title: 'Apply a TikTok Spark Ads authorization code',
|
|
13184
|
-
description: 'Apply an authorization code so this ad account may promote somebody else’s TikTok post. THE CODE CANNOT BE MINTED FROM THE API — only the post’s owner can generate one, in the TikTok app: open the post
|
|
13187
|
+
description: 'Apply an authorization code so this ad account may promote somebody else’s TikTok post. THE CODE CANNOT BE MINTED FROM THE API — only the post’s owner can generate one, in the TikTok app: open the post -> three-dot menu -> Ad settings -> toggle Ad authorization -> choose a duration -> copy the code. Ask them for it; there is no way around this and no endpoint that creates one. Applying a code SPENDS NOTHING and creates nothing that can serve — it only grants permission — so it is deliberately not confirm-gated. If the post is a DUET or STITCH of another post, or mentions one, TikTok requires the ORIGINAL post owner’s code too: pass it as originalPostAuthCode or the call is refused. THE ANSWER IS THE READ-BACK: the reply lists the posts now authorised, because TikTok’s own response body is empty and a bare 200 proves nothing.',
|
|
13185
13188
|
inputSchema: {
|
|
13186
13189
|
advertiserId: z.string().optional(),
|
|
13187
13190
|
authCode: z.string().describe('the code the post owner generated in the TikTok app. Paste it verbatim — Hermoso handles the + / %2B escaping TikTok warns about.'),
|
|
@@ -13971,12 +13974,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13971
13974
|
// Google Ads conversion bidding being offerable with no way to configure the tracking it depends on.
|
|
13972
13975
|
server.registerTool('send_tiktok_ads_events', {
|
|
13973
13976
|
title: 'Send server-side conversion events to TikTok (Events API 2.0)',
|
|
13974
|
-
description: 'SERVER-SIDE CONVERSION TRACKING — report a purchase, signup, form submission or lead to TikTok straight from a server, so TikTok can attribute it and optimise delivery toward it. This is what makes a pixel useful: a pixel with no events is inert, and a conversion campaign optimising toward an event nobody sends has nothing to learn from. ALWAYS OFFER testEventCode FIRST: an event carrying it lands in the Test Events tab of Events Manager and is EXCLUDED from reporting, attribution and optimisation, whereas an event sent WITHOUT it is a real, permanent conversion that TikTok will optimise against and that NO endpoint deletes. The code is copied out of Events Manager in a browser (open the pixel or event set
|
|
13977
|
+
description: 'SERVER-SIDE CONVERSION TRACKING — report a purchase, signup, form submission or lead to TikTok straight from a server, so TikTok can attribute it and optimise delivery toward it. This is what makes a pixel useful: a pixel with no events is inert, and a conversion campaign optimising toward an event nobody sends has nothing to learn from. ALWAYS OFFER testEventCode FIRST: an event carrying it lands in the Test Events tab of Events Manager and is EXCLUDED from reporting, attribution and optimisation, whereas an event sent WITHOUT it is a real, permanent conversion that TikTok will optimise against and that NO endpoint deletes. The code is copied out of Events Manager in a browser (open the pixel or event set > Test Events > the code button under "Test Server Events") and no API mints one, exactly like a Spark Ads authorization code. eventTime is a Unix timestamp in SECONDS — milliseconds are refused here for free, because TikTok accepts them and reads them as a date tens of thousands of years away, so the event is silently attributed to nothing. Emails, phones and external ids are trimmed, lower-cased, E.164-normalized and SHA-256 hashed by Hermoso exactly as TikTok specifies before anything leaves the process; a value that is already a 64-character hash is passed through untouched, so nothing is ever double-hashed. A phone with no "+" country code is refused rather than guessed. Free.',
|
|
13975
13978
|
inputSchema: {
|
|
13976
13979
|
advertiserId: z.string().optional(),
|
|
13977
13980
|
eventSource: z.enum(['web', 'app', 'offline', 'crm']).describe('web = a website pixel · app = a mobile app (TikTok gates this behind an allowlist) · offline = a physical store · crm = lead events from a CRM'),
|
|
13978
13981
|
eventSourceId: z.string().describe('web: the pixel CODE from list_tiktok_ads_pixels (NOT the pixelId) · app: the TikTok App ID · offline: the Offline Event Set ID · crm: the CRM Event Set ID'),
|
|
13979
|
-
testEventCode: z.string().optional().describe('from Events Manager
|
|
13982
|
+
testEventCode: z.string().optional().describe('from Events Manager > Test Events. WITH it nothing reaches reporting or optimisation; WITHOUT it these are real permanent conversions. Offer it before sending anything live.'),
|
|
13980
13983
|
events: z.array(z.object({
|
|
13981
13984
|
event: z.string().describe('a TikTok standard event (CompletePayment, Purchase, AddToCart, CompleteRegistration, SubmitForm, ViewContent …) or your own custom name'),
|
|
13982
13985
|
eventTime: z.number().describe('Unix timestamp in SECONDS, UTC'),
|
|
@@ -14310,7 +14313,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14310
14313
|
// comment the advertiser's own identity posted. Both tools say so.
|
|
14311
14314
|
server.registerTool('list_tiktok_ads_comments', {
|
|
14312
14315
|
title: 'Read the comments on a TikTok ad group’s ads',
|
|
14313
|
-
description: 'The public comments people have left on your TikTok ads — the read that finally makes TikTok comment moderation possible through Hermoso, where Meta and YouTube moderation already shipped. Covers both paid-impression ads and Spark Ads. TWO THINGS ARE NOT NEGOTIABLE AND BOTH COME FROM TIKTOK: comments are scoped to an AD GROUP and to nothing else (their search_field accepts the single value ADGROUP_ID, so there is no way to ask for one ad or a whole campaign — get ad group ids from list_tiktok_ads_campaigns), and startTime
|
|
14316
|
+
description: 'The public comments people have left on your TikTok ads — the read that finally makes TikTok comment moderation possible through Hermoso, where Meta and YouTube moderation already shipped. Covers both paid-impression ads and Spark Ads. TWO THINGS ARE NOT NEGOTIABLE AND BOTH COME FROM TIKTOK: comments are scoped to an AD GROUP and to nothing else (their search_field accepts the single value ADGROUP_ID, so there is no way to ask for one ad or a whole campaign — get ad group ids from list_tiktok_ads_campaigns), and startTime->endTime may span AT MOST 30 DAYS. That 30-day cap is not in TikTok’s parameter table; it was measured, and asking for more answers an opaque "The maximum allowed time span is 30 days" — so an empty result means "none in these 30 days", never "none ever", and covering a longer period means one call per slice. Every row carries what the other tools need: commentId, adId, tiktokItemId, identityId and identityType, plus hitBlockedWord (caught by your blocked-word list), isPinned, likes, replies, and canDelete — TikTok’s own per-comment answer to whether YOU may delete it, which is false for anything a member of the public wrote. Read-only, free.',
|
|
14314
14317
|
inputSchema: {
|
|
14315
14318
|
advertiserId: z.string().optional().describe('from list_tiktok_ads_accounts — omit only when exactly one is reachable'),
|
|
14316
14319
|
adgroupId: z.string().describe('REQUIRED — from list_tiktok_ads_campaigns. TikTok offers no other way to scope a comment read.'),
|
|
@@ -14383,7 +14386,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14383
14386
|
}));
|
|
14384
14387
|
server.registerTool('delete_tiktok_ads_comment', {
|
|
14385
14388
|
title: 'Delete your OWN comment on a TikTok ad',
|
|
14386
|
-
description: '
|
|
14389
|
+
description: 'Warning: READ THIS BEFORE REACHING FOR IT: TikTok’s comment delete removes a comment YOUR OWN advertiser identity posted — your own reply. It CANNOT remove a comment left by a member of the public, and TikTok reports that per comment as canDelete on every row from list_tiktok_ads_comments. To take somebody else’s comment out of public view, use moderate_tiktok_ads_comment with operation HIDDEN, which is the moderation tool and is reversible. IRREVERSIBLE: TikTok publishes no undelete, the replies under it go with it, and re-posting means a new comment with a new id at the bottom of the thread rather than the one people answered. WITHOUT confirm NOTHING IS DELETED — you get the comment’s real text, author and date READ BACK FROM TIKTOK, which is what you show the user before asking for a yes. Deleting then additionally needs confirmText set to that exact text, because a confirm flag proves you meant to delete something and cannot prove you aimed at the right comment. If the comment cannot be READ, the delete is refused outright rather than performed blind.',
|
|
14387
14390
|
inputSchema: {
|
|
14388
14391
|
advertiserId: z.string().optional().describe('from list_tiktok_ads_accounts'),
|
|
14389
14392
|
commentId: z.string().describe('from list_tiktok_ads_comments — one whose canDelete is true'),
|
|
@@ -14418,7 +14421,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14418
14421
|
}));
|
|
14419
14422
|
server.registerTool('manage_tiktok_ads_blocked_words', {
|
|
14420
14423
|
title: 'Add, remove or replace TikTok comment blocked words',
|
|
14421
|
-
description: 'Change the ad account’s blocked-word list — the standing filter that auto-hides any comment containing one of these words across every ad on the account. operation "add" (up to TikTok’s 500-word account limit, refused here for free if the list would overflow rather than failing at TikTok), "remove", or "replace" one word with another.
|
|
14424
|
+
description: 'Change the ad account’s blocked-word list — the standing filter that auto-hides any comment containing one of these words across every ad on the account. operation "add" (up to TikTok’s 500-word account limit, refused here for free if the list would overflow rather than failing at TikTok), "remove", or "replace" one word with another. Warning: REMOVING A WORD REPUBLISHES HISTORY: TikTok’s own words are that comments already hidden by it "will become public instead of being visible only to the commentor" — a bulk unhide across every ad on the account, for which TikTok reports no count. So a remove without confirm changes nothing and tells you which of the words are actually on the list first. EVERY WRITE HERE IS READ BACK: TikTok answers all three operations with an empty response and its delete explicitly does not error on a word that was never on the list, so reporting off the response would report a no-op as a change — the reply is the diff of the list before and after.',
|
|
14422
14425
|
inputSchema: {
|
|
14423
14426
|
advertiserId: z.string().optional().describe('from list_tiktok_ads_accounts'),
|
|
14424
14427
|
operation: z.enum(['add', 'remove', 'replace']),
|
|
@@ -14435,7 +14438,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14435
14438
|
}));
|
|
14436
14439
|
server.registerTool('tiktok_ads_diagnosis', {
|
|
14437
14440
|
title: 'TikTok’s own verdict on what is wrong with your ad groups',
|
|
14438
|
-
description: 'TikTok’s issues-and-suggestions diagnosis for the ad groups on an ad account — its own read on why delivery is underperforming, in three categories: CREATIVE (no background music, video too short, resolution too low), BID_AND_BUDGET (a suggested bid or budget, a recommendation to switch to Maximum Delivery, and the full Estimated Delivery Results tables pairing nine bid levels or fifteen budget levels with their estimated cost, conversions, CPA and impressions), and EVENT_TRACK (a pixel that has recorded nothing for seven days, which means the ad group is optimising toward an event nothing is firing). Every issue code comes back with a plain sentence saying what to DO about it.
|
|
14441
|
+
description: 'TikTok’s issues-and-suggestions diagnosis for the ad groups on an ad account — its own read on why delivery is underperforming, in three categories: CREATIVE (no background music, video too short, resolution too low), BID_AND_BUDGET (a suggested bid or budget, a recommendation to switch to Maximum Delivery, and the full Estimated Delivery Results tables pairing nine bid levels or fifteen budget levels with their estimated cost, conversions, CPA and impressions), and EVENT_TRACK (a pixel that has recorded nothing for seven days, which means the ad group is optimising toward an event nothing is firing). Every issue code comes back with a plain sentence saying what to DO about it. Warning: AN EMPTY ANSWER IS NOT A CLEAN BILL OF HEALTH: TikTok diagnoses ACTIVE ad groups only and omits any ad group it has no suggestions for, so a missing ad group is EITHER healthy OR not currently active and TikTok does not distinguish the two. At most 20 ad groups per call. Read-only, free.',
|
|
14439
14442
|
inputSchema: {
|
|
14440
14443
|
advertiserId: z.string().optional().describe('from list_tiktok_ads_accounts'),
|
|
14441
14444
|
adgroupIds: z.array(z.string()).optional().describe('up to 20 — omit for every active ad group on the account'),
|
|
@@ -14452,7 +14455,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14452
14455
|
}));
|
|
14453
14456
|
server.registerTool('get_tiktok_ads_brand_safety', {
|
|
14454
14457
|
title: 'Read a TikTok ad account’s Brand Safety Hub settings',
|
|
14455
|
-
description: 'What content this TikTok ad account’s ads are allowed to appear next to — the inventory filter tier (EXPANDED / STANDARD / LIMITED / NO_BRAND_SAFETY) plus the suitability controls: which content categories are excluded and which vertical’s sensitive content is avoided. It also returns, free and in the same call, THE CATALOGUE of every category id you could set, because those ids are unguessable and there is otherwise no way to act on the setting.
|
|
14458
|
+
description: 'What content this TikTok ad account’s ads are allowed to appear next to — the inventory filter tier (EXPANDED / STANDARD / LIMITED / NO_BRAND_SAFETY) plus the suitability controls: which content categories are excluded and which vertical’s sensitive content is avoided. It also returns, free and in the same call, THE CATALOGUE of every category id you could set, because those ids are unguessable and there is otherwise no way to act on the setting. Warning: THE SCOPE IS THE THING TO KNOW: TikTok applies this to future Smart+ campaigns and, in their own words, "will not apply to future regular campaigns created using /campaign/create/" — which is the endpoint create_tiktok_ads_campaign uses. So this is the ad account default and the Ads Manager setting, NOT a guarantee inherited by a campaign Hermoso builds. A NULL vertical-sensitivity catalogue means this advertiser is not on TikTok’s allowlist for that feature, never that no vertical categories exist. Read-only, free.',
|
|
14456
14459
|
inputSchema: {
|
|
14457
14460
|
advertiserId: z.string().optional().describe('from list_tiktok_ads_accounts'),
|
|
14458
14461
|
categories: z.boolean().optional().describe('default true — set false to skip the category catalogue and read only the current setting'),
|
|
@@ -14468,7 +14471,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14468
14471
|
}));
|
|
14469
14472
|
server.registerTool('set_tiktok_ads_brand_safety', {
|
|
14470
14473
|
title: 'Set a TikTok ad account’s Brand Safety Hub settings',
|
|
14471
|
-
description: 'Set what content this TikTok ad account’s ads may appear next to. THREE RULES TIKTOK IMPOSES AND ONE THING THAT CANNOT BE UNDONE. (1) The suitability controls move as a TRIO: TikTok requires brandSafetyType, categoryExclusionIds and verticalSensitivityId "simultaneously", so read the current values with get_tiktok_ads_brand_safety and send all three — a partial update is refused here rather than silently doing something else. (2) NO_BRAND_SAFETY is a tier TikTok REPORTS but does not ACCEPT: the write enum is EXPANDED_INVENTORY / STANDARD_INVENTORY / LIMITED_INVENTORY only, so an account can be moved off it through the API and not back onto it. (3) Category exclusions and vertical sensitivity are valid only under STANDARD or LIMITED.
|
|
14474
|
+
description: 'Set what content this TikTok ad account’s ads may appear next to. THREE RULES TIKTOK IMPOSES AND ONE THING THAT CANNOT BE UNDONE. (1) The suitability controls move as a TRIO: TikTok requires brandSafetyType, categoryExclusionIds and verticalSensitivityId "simultaneously", so read the current values with get_tiktok_ads_brand_safety and send all three — a partial update is refused here rather than silently doing something else. (2) NO_BRAND_SAFETY is a tier TikTok REPORTS but does not ACCEPT: the write enum is EXPANDED_INVENTORY / STANDARD_INVENTORY / LIMITED_INVENTORY only, so an account can be moved off it through the API and not back onto it. (3) Category exclusions and vertical sensitivity are valid only under STANDARD or LIMITED. Warning: coverAllObjectives IS A ONE-WAY DOOR — TikTok: "Once set to true, this setting cannot be updated back to false." Turning it on widens the settings from Reach / Video Views / Community Interaction to every objective, permanently, so that direction is confirm-gated and nothing else here is. AND MIND THE SCOPE: TikTok applies this to future Smart+ campaigns and explicitly NOT to regular campaigns created through /campaign/create/, which is what create_tiktok_ads_campaign uses. The reply is TikTok’s own stored row read back, so it reports what actually changed rather than what was sent. EXPANDED_INVENTORY is allowlist-only per advertiser; if TikTok refuses it the remedy is your TikTok rep, not a different field.',
|
|
14472
14475
|
inputSchema: {
|
|
14473
14476
|
advertiserId: z.string().optional().describe('from list_tiktok_ads_accounts'),
|
|
14474
14477
|
coverAllObjectives: z.boolean().describe('REQUIRED by TikTok on every update. true is PERMANENT — it cannot be set back to false.'),
|
|
@@ -14507,7 +14510,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14507
14510
|
}));
|
|
14508
14511
|
server.registerTool('create_tiktok_ads_ad', {
|
|
14509
14512
|
title: 'Create a TikTok ad',
|
|
14510
|
-
description: 'Create the ad itself — the creative that runs under an existing TikTok ad group. CREATED PAUSED with no override (TikTok’s own default is ENABLED); it spends nothing until set_tiktok_ads_status(confirm:true), and TikTok additionally reviews it before it can ever show. WHERE THE CREATIVE COMES FROM: run upload_tiktok_ads_creative on a finished Hermoso render (or any public https video) and pass back the videoId AND the coverImageId it returns as imageIds — a TikTok video ad needs BOTH, and TikTok rejects any cover whose dimensions differ from the video, so the video’s own cover is the only one that reliably fits. A videoId with no imageIds is refused here for free rather than failing at TikTok with "Unsupported image size", which blames the image for a problem in the video. AN IDENTITY IS MANDATORY AND HAS NO DEFAULT: identityId AND identityType both come from list_tiktok_ads_identities and the ad appears publicly as that TikTok account, so let the USER pick and never guess — the call is refused outright without either. SPARK ADS — RUN A REAL ORGANIC POST INSTEAD: pass tiktokItemId (from list_tiktok_ads_identity_posts or list_tiktok_ads_spark_posts) INSTEAD OF videoId, and the ad IS that TikTok post, keeping its own comments, likes and shares under the account that made it. Spark needs an identityType of TT_USER, BC_AUTH_TT or AUTH_CODE — never CUSTOMIZED_USER, which is a Custom Identity and cannot carry one. THIS MATTERS BEYOND STYLE: TikTok is phasing Custom Identity out — ad accounts created on or after January 15, 2026 cannot create non-Spark ads at all, and existing accounts can no longer create them either, for any ad group delivering to Automatic or Select Placement with TikTok included (only Pangle / Global App Bundle campaigns are unaffected). Defaults: adFormat SINGLE_VIDEO, callToAction LEARN_MORE. THE STATUS IS READ BACK FROM TIKTOK’S OWN STORED ROW, never assumed: if it comes back anything other than DISABLE the note is a
|
|
14513
|
+
description: 'Create the ad itself — the creative that runs under an existing TikTok ad group. CREATED PAUSED with no override (TikTok’s own default is ENABLED); it spends nothing until set_tiktok_ads_status(confirm:true), and TikTok additionally reviews it before it can ever show. WHERE THE CREATIVE COMES FROM: run upload_tiktok_ads_creative on a finished Hermoso render (or any public https video) and pass back the videoId AND the coverImageId it returns as imageIds — a TikTok video ad needs BOTH, and TikTok rejects any cover whose dimensions differ from the video, so the video’s own cover is the only one that reliably fits. A videoId with no imageIds is refused here for free rather than failing at TikTok with "Unsupported image size", which blames the image for a problem in the video. AN IDENTITY IS MANDATORY AND HAS NO DEFAULT: identityId AND identityType both come from list_tiktok_ads_identities and the ad appears publicly as that TikTok account, so let the USER pick and never guess — the call is refused outright without either. SPARK ADS — RUN A REAL ORGANIC POST INSTEAD: pass tiktokItemId (from list_tiktok_ads_identity_posts or list_tiktok_ads_spark_posts) INSTEAD OF videoId, and the ad IS that TikTok post, keeping its own comments, likes and shares under the account that made it. Spark needs an identityType of TT_USER, BC_AUTH_TT or AUTH_CODE — never CUSTOMIZED_USER, which is a Custom Identity and cannot carry one. THIS MATTERS BEYOND STYLE: TikTok is phasing Custom Identity out — ad accounts created on or after January 15, 2026 cannot create non-Spark ads at all, and existing accounts can no longer create them either, for any ad group delivering to Automatic or Select Placement with TikTok included (only Pangle / Global App Bundle campaigns are unaffected). Defaults: adFormat SINGLE_VIDEO, callToAction LEARN_MORE. THE STATUS IS READ BACK FROM TIKTOK’S OWN STORED ROW, never assumed: if it comes back anything other than DISABLE the note is a Warning: warning that the ad would serve the moment its campaign is enabled — print that verbatim and act on it before touching anything above it.',
|
|
14511
14514
|
inputSchema: {
|
|
14512
14515
|
advertiserId: z.string().optional(),
|
|
14513
14516
|
adgroupId: z.string().describe('the ad group whose targeting, budget and schedule this ad runs under'),
|
|
@@ -14712,7 +14715,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14712
14715
|
}));
|
|
14713
14716
|
server.registerTool('send_tiktok_ads_offline_events', {
|
|
14714
14717
|
title: 'Report in-store and other real-world conversions to TikTok',
|
|
14715
|
-
description: 'REPORT REAL-WORLD CONVERSIONS — an in-store purchase, a phone booking, a signed contract — so TikTok can attribute them to the ads that caused them and optimise delivery toward them. This is what makes a TikTok campaign measurable for a business whose sale does not happen on a website. Emails and phone numbers are normalized and SHA-256 hashed by Hermoso exactly as TikTok specifies before anything leaves the process, using the SAME implementation as send_tiktok_ads_events; a value that is already a 64-character hash is passed through untouched, and a phone with no "+" country code is refused rather than guessed. EITHER emails OR phone_numbers is REQUIRED on every event — an offline conversion identifying nobody is attributed to nothing.
|
|
14718
|
+
description: 'REPORT REAL-WORLD CONVERSIONS — an in-store purchase, a phone booking, a signed contract — so TikTok can attribute them to the ads that caused them and optimise delivery toward them. This is what makes a TikTok campaign measurable for a business whose sale does not happen on a website. Emails and phone numbers are normalized and SHA-256 hashed by Hermoso exactly as TikTok specifies before anything leaves the process, using the SAME implementation as send_tiktok_ads_events; a value that is already a 64-character hash is passed through untouched, and a phone with no "+" country code is refused rather than guessed. EITHER emails OR phone_numbers is REQUIRED on every event — an offline conversion identifying nobody is attributed to nothing. Warning: THE TIMESTAMP IS AN ISO-8601 STRING HERE ("2026-08-19T19:11:01Z"), NOT the Unix-seconds NUMBER that send_tiktok_ads_events takes: TikTok accepts a wrong-shaped one, reads it as some other date and attributes the conversion to nothing, so it is refused here for free. Warning: THERE IS NO TEST MODE: TikTok documents test_event_code on Events API 2.0 and on NEITHER offline endpoint, so everything sent here is a real, permanent conversion that no endpoint deletes. To rehearse the pipeline first, send the same events through send_tiktok_ads_events with eventSource "offline", the same event set id and a testEventCode from Events Manager — those land in the Test Events tab and are excluded from reporting, attribution and optimisation. Warning: REPORTING NEEDS A ROLE, NOT JUST A CONNECTION: TikTok requires the connected user to be an ADMIN or OPERATOR of that advertiser account, measured live — the same token can create and delete Offline Event SETS and still be refused on the events themselves. If that happens Hermoso says so plainly rather than telling anyone to reconnect, because reconnecting cannot grant a role; it has to change in TikTok Business Center. Free.',
|
|
14716
14719
|
inputSchema: {
|
|
14717
14720
|
advertiserId: z.string().optional(),
|
|
14718
14721
|
eventSetId: z.string().describe('the Offline Event Set these belong to — from list_tiktok_ads_offline_event_sets'),
|
|
@@ -14747,7 +14750,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14747
14750
|
}));
|
|
14748
14751
|
server.registerTool('create_tiktok_ads_crm_event_set', {
|
|
14749
14752
|
title: 'Create a TikTok CRM Event set',
|
|
14750
|
-
description: 'Create a CRM Event set — the container for lead-lifecycle events, and the id send_tiktok_ads_events needs as eventSourceId when eventSource is "crm". Making one is how a LEAD_GENERATION campaign stops optimising toward form fills and starts optimising toward leads that qualify and close.
|
|
14753
|
+
description: 'Create a CRM Event set — the container for lead-lifecycle events, and the id send_tiktok_ads_events needs as eventSourceId when eventSource is "crm". Making one is how a LEAD_GENERATION campaign stops optimising toward form fills and starts optimising toward leads that qualify and close. Warning: TIKTOK PUBLISHES CREATE AND LIST FOR THESE AND NOTHING ELSE — no update and no delete anywhere in its API reference — so a set made here is PERMANENT, in the same way a TikTok pixel is, and an advertiser is capped at fifty with no API way to free a slot. Name it something the user will still recognise in a year. Free.',
|
|
14751
14754
|
inputSchema: {
|
|
14752
14755
|
advertiserId: z.string().optional(),
|
|
14753
14756
|
name: z.string().describe('max 40 characters; TikTok trims it and refuses a duplicate. There is no way to rename or delete it afterwards.'),
|
|
@@ -15432,7 +15435,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15432
15435
|
}));
|
|
15433
15436
|
server.registerTool('create_snapchat_ads_ad', {
|
|
15434
15437
|
title: 'Create a Snapchat ad (forced paused)',
|
|
15435
|
-
description: 'Create the Snapchat ad itself, inside an ad squad. CREATED PAUSED with no override. THE CREATIVE COMES FROM upload_snapchat_ads_creative: pass the creativeId it returns. AN AD POINTS AT A CREATIVE, NEVER AT A MEDIA ID — passing a mediaId is refused by name rather than failing at Snapchat with a field-path error. SNAPCHAT REVIEWS EVERY AD before it can show: the returned reviewStatus says whether that has happened, and an ad Snapchat has REJECTED cannot serve even once it is activated, so relay a rejection instead of reporting a successful build. The status is READ BACK from Snapchat’s own row — if the note carries a
|
|
15438
|
+
description: 'Create the Snapchat ad itself, inside an ad squad. CREATED PAUSED with no override. THE CREATIVE COMES FROM upload_snapchat_ads_creative: pass the creativeId it returns. AN AD POINTS AT A CREATIVE, NEVER AT A MEDIA ID — passing a mediaId is refused by name rather than failing at Snapchat with a field-path error. SNAPCHAT REVIEWS EVERY AD before it can show: the returned reviewStatus says whether that has happened, and an ad Snapchat has REJECTED cannot serve even once it is activated, so relay a rejection instead of reporting a successful build. The status is READ BACK from Snapchat’s own row — if the note carries a Warning: saying it was stored as anything other than PAUSED, relay that and pause it before anything above it is activated.',
|
|
15436
15439
|
inputSchema: {
|
|
15437
15440
|
adAccountId: z.string().optional(),
|
|
15438
15441
|
adSquadId: z.string().describe('the ad squad this ad belongs to'),
|
|
@@ -15566,7 +15569,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15566
15569
|
server.group('channel_admin');
|
|
15567
15570
|
server.registerTool('list_linkedin_comments', {
|
|
15568
15571
|
title: 'Read the comments on a LinkedIn Page post',
|
|
15569
|
-
description: "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
|
|
15572
|
+
description: "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.",
|
|
15570
15573
|
inputSchema: {
|
|
15571
15574
|
postUrn: z.string().describe('the post to read — urn:li:share:…, urn:li:ugcPost:… or urn:li:activity:…'),
|
|
15572
15575
|
limit: z.number().optional().describe('how many to fetch (default 50; clamped below LinkedIn’s own 200 timeout bound rather than refused)'),
|
|
@@ -16052,7 +16055,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16052
16055
|
server.group('channel_admin');
|
|
16053
16056
|
server.registerTool('manage_meta_post', {
|
|
16054
16057
|
title: 'Edit or delete a published post',
|
|
16055
|
-
description: 'Edit the text of, or delete, a published post. target:"facebook"
|
|
16058
|
+
description: '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.',
|
|
16056
16059
|
inputSchema: {
|
|
16057
16060
|
postId: z.string().describe('the post id returned by post_to_meta — for Instagram, the media id from list_instagram_media'),
|
|
16058
16061
|
action: z.enum(['edit', 'delete']).describe('edit the text (FB only) or delete the post'),
|
|
@@ -16077,7 +16080,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16077
16080
|
server.group('files');
|
|
16078
16081
|
server.registerTool('save_to_drive', {
|
|
16079
16082
|
title: 'Save file(s) to Google Drive',
|
|
16080
|
-
description: '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
|
|
16083
|
+
description: '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.',
|
|
16081
16084
|
inputSchema: {
|
|
16082
16085
|
url: z.string().optional().describe('a single Hermoso render URL to save'),
|
|
16083
16086
|
urls: z.array(z.string()).optional().describe('several render URLs (up to 20) to save in one call'),
|
|
@@ -16172,7 +16175,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16172
16175
|
name: z.string().optional().describe('new name'),
|
|
16173
16176
|
moveToFolderId: z.string().optional().describe('folder id to move the file into (from create_drive_folder / list_drive_files)'),
|
|
16174
16177
|
removeFromFolderId: z.string().optional().describe('the old parent folder id to remove (when moving)'),
|
|
16175
|
-
trash: z.boolean().optional().describe('true
|
|
16178
|
+
trash: z.boolean().optional().describe('true -> move to Trash; false -> restore from Trash'),
|
|
16176
16179
|
},
|
|
16177
16180
|
outputSchema: { id: z.string().optional(), name: z.string().optional(), trashed: z.boolean().optional() },
|
|
16178
16181
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
@@ -16212,7 +16215,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16212
16215
|
server.group('files');
|
|
16213
16216
|
server.registerTool('create_sheet', {
|
|
16214
16217
|
title: 'Create a Google Sheet',
|
|
16215
|
-
description: '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
|
|
16218
|
+
description: '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).',
|
|
16216
16219
|
inputSchema: {
|
|
16217
16220
|
title: z.string().optional().describe('spreadsheet title'),
|
|
16218
16221
|
rows: z.array(z.array(z.union([z.string(), z.number(), z.boolean()]))).optional().describe('rows to write — array of row arrays; first row = headers'),
|
|
@@ -16258,7 +16261,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16258
16261
|
server.group('files');
|
|
16259
16262
|
server.registerTool('export_swipefile_deck', {
|
|
16260
16263
|
title: 'Swipefile to Google Slides',
|
|
16261
|
-
description: 'Turn a SWIPEFILE COLLECTION into a real Google Slides deck — one slide per saved ad, carrying the creative, the brand, the ad copy, the run dates with the run length, and the platform. This is the thing a marketer actually presents to a client or a team; until now the swipefile’s only export was JSON. Returns the presentation id + URL. Creates a NEW deck every time: under the drive.file scope Hermoso can only touch files it created, so it cannot add slides to a deck the user already has. A creative whose ad-library link has expired cannot be embedded — Meta signs those URLs with a short expiry — so that slide says so in words and keeps its copy and run dates, and the reply reports how many. Needs Google Drive connected (Settings
|
|
16264
|
+
description: 'Turn a SWIPEFILE COLLECTION into a real Google Slides deck — one slide per saved ad, carrying the creative, the brand, the ad copy, the run dates with the run length, and the platform. This is the thing a marketer actually presents to a client or a team; until now the swipefile’s only export was JSON. Returns the presentation id + URL. Creates a NEW deck every time: under the drive.file scope Hermoso can only touch files it created, so it cannot add slides to a deck the user already has. A creative whose ad-library link has expired cannot be embedded — Meta signs those URLs with a short expiry — so that slide says so in words and keeps its copy and run dates, and the reply reports how many. Needs Google Drive connected (Settings > Connectors > Google Drive — one connection covers Drive, Sheets, Docs and Slides).',
|
|
16262
16265
|
inputSchema: {
|
|
16263
16266
|
collection: z.string().optional().describe('the swipefile collection to export, by name or id (default: the first collection)'),
|
|
16264
16267
|
title: z.string().optional().describe('deck title (default: the collection name)'),
|
|
@@ -16275,7 +16278,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16275
16278
|
server.group('files');
|
|
16276
16279
|
server.registerTool('create_doc', {
|
|
16277
16280
|
title: 'Create a Google Doc',
|
|
16278
|
-
description: '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
|
|
16281
|
+
description: '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).',
|
|
16279
16282
|
inputSchema: {
|
|
16280
16283
|
title: z.string().optional().describe('document title'),
|
|
16281
16284
|
text: z.string().optional().describe('body text to insert'),
|
|
@@ -16420,7 +16423,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16420
16423
|
server.group('files');
|
|
16421
16424
|
server.registerTool('save_to_onedrive', {
|
|
16422
16425
|
title: 'Save file(s) to OneDrive',
|
|
16423
|
-
description: '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
|
|
16426
|
+
description: '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).',
|
|
16424
16427
|
inputSchema: {
|
|
16425
16428
|
url: z.string().optional().describe('a single Hermoso render URL to save'),
|
|
16426
16429
|
urls: z.array(z.string()).optional().describe('several render URLs (up to 20) to save in one call'),
|
|
@@ -16527,7 +16530,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16527
16530
|
brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name, or a brand profile object {name,domain,category,palette,products,…}. OMIT to use the workspace’s SAVED brand + memory automatically (see get_brand); use draft_brand to onboard a new one'),
|
|
16528
16531
|
product: z.string().describe('what to advertise + any angle/offer the user specified'),
|
|
16529
16532
|
format: z.enum(['auto', 'image', 'video']).optional().describe("'image', 'video', or 'auto' when unspecified"),
|
|
16530
|
-
durationSeconds: z.number().optional().describe('VIDEO ONLY — the total spot length the user explicitly asked for, in seconds, copied verbatim (30 for "a 30 second ad"). The planner authors the storyboard TO it: the scenes’ seconds sum to it and the script is word-budgeted for it. Supported range 4–180; anything outside is CLAMPED to it (the reply says so). A length that fits ONE clip of the render model renders as a single continuous pass; anything longer is STITCHED from acts filled to that model’s clip maximum with the remainder last (on a 15s-clip model, 40
|
|
16533
|
+
durationSeconds: z.number().optional().describe('VIDEO ONLY — the total spot length the user explicitly asked for, in seconds, copied verbatim (30 for "a 30 second ad"). The planner authors the storyboard TO it: the scenes’ seconds sum to it and the script is word-budgeted for it. Supported range 4–180; anything outside is CLAMPED to it (the reply says so). A length that fits ONE clip of the render model renders as a single continuous pass; anything longer is STITCHED from acts filled to that model’s clip maximum with the remainder last (on a 15s-clip model, 40 -> 15+15+10 and 17 -> 13+4) — never time-compressed. The maximum is the model’s own: 15s on most, 30s on the longest-clip model, so a 30s ad can be one unbroken take rather than two acts. Omit when the user named no length; do NOT pass a guess, an omitted value keeps the recipe-aware default.'),
|
|
16531
16534
|
hook: z.string().optional().describe('force the VISUAL scroll-stop mechanic the opening beat is built on — a hook id from list_hooks (e.g. "direct_callout", "mid_problem", "macro_asmr"). Omit to let the planner pick. A hook that cannot be delivered in this brief is DROPPED with the reason rather than rendered wrongly — an on-screen-text hook on an authentic/UGC ad is the one that bites, because that register carries zero on-screen text.'),
|
|
16532
16535
|
setting: z.string().optional().describe('force the WHERE — a setting id from list_hooks (e.g. "kitchen", "gym", or a surreal one like "volcano_rim" / "airplane_wing", which are played 100% straight and never acknowledged). Omit for a neutral setting.'),
|
|
16533
16536
|
recipe: z.string().optional().describe('a recipe id from hermoso_capabilities to force an archetype'),
|
|
@@ -16673,7 +16676,20 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16673
16676
|
// silently turns off the brand pipeline on an on-brand ad (the same rule lib/raw-passthrough.mjs's predicate uses).
|
|
16674
16677
|
// A MASKED EDIT IS AN EDIT OF THE CALLER'S OWN IMAGE: the saved brand's product photos must not be hydrated in front
|
|
16675
16678
|
// of it, so a mask implies useBrand:false (the server also refuses a mask with no refImages, free).
|
|
16676
|
-
const
|
|
16679
|
+
const _imgBody = { prompt, refImages: refs, useBrand: maskRef ? false : useBrand !== false, aspectRatio, model, imageSize, ...(maskRef ? { mask: maskRef } : {}), ...(raw === true ? { raw: true } : {}) };
|
|
16680
|
+
// A CALLER WITH A WAIT BUDGET GETS A QUEUED JOB (2026-09-21, R321). Only `/v1/tools/generate_image?wait=` sets
|
|
16681
|
+
// `waitMs`; every other caller renders inside the request exactly as before. The server does the same brand-photo
|
|
16682
|
+
// handling either way, then queues the ordinary image job; awaitRenderJob waits what the caller allowed and hands
|
|
16683
|
+
// back the job handle past that, which /v1 turns into its 202.
|
|
16684
|
+
const _wctx = mcpCtx.getStore();
|
|
16685
|
+
if (_wctx && _wctx.waitMs !== undefined && _wctx.waitMs !== null) {
|
|
16686
|
+
const q = await apiPost('/api/generate/image', { ..._imgBody, queue: true });
|
|
16687
|
+
const r = await awaitRenderJob({ id: q.jobId });
|
|
16688
|
+
if (r.stillRendering) return { content: [{ type: 'text', text: `Image queued as job ${r.jobId}. It is rendering; read it with get_job.${q.productNote ? `\n${q.productNote}` : ''}` }], structuredContent: { jobId: r.jobId, stillRendering: true, ...(q.productNote ? { productNote: q.productNote } : {}) } };
|
|
16689
|
+
const img = await imageBlock(r.url);
|
|
16690
|
+
return { content: [{ type: 'text', text: `Image ready: ${r.url}${r.model ? ` (${r.model})` : ''}${q.productNote ? `\n${q.productNote}` : ''}` }, ...(img ? [img] : [])], structuredContent: { image: r.url, model: r.model, jobId: r.jobId, creditsUsed: r.creditsUsed, ...(q.productNote ? { productNote: q.productNote } : {}) } };
|
|
16691
|
+
}
|
|
16692
|
+
const d = await apiPost('/api/generate/image', _imgBody); // explicit boolean so the server's saved-brand hydration default is unambiguous
|
|
16677
16693
|
const img = await imageBlock(abs(d.image)); // show the actual creative inline in Claude, not just a URL
|
|
16678
16694
|
return { content: [{ type: 'text', text: `Image ready: ${abs(d.image)}${d.model ? ` (${d.model})` : ''}${switchNote({ raw: d })}${d.productNote ? `\n${d.productNote}` : ''}` }, ...(img ? [img] : [])], structuredContent: { ...d, image: abs(d.image) } };
|
|
16679
16695
|
}));
|
|
@@ -16692,7 +16708,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16692
16708
|
const staticItemsOut = z.array(z.any()).optional().describe('one entry per output: the image URL (or an error), what changed, and a textCheck flag when the rendered text may not match');
|
|
16693
16709
|
server.registerTool('edit_image', {
|
|
16694
16710
|
title: 'Edit an image',
|
|
16695
|
-
description: "EDIT an existing image in place with a plain-language instruction and keep everything else: 'make the headline bigger', 'add our logo bottom right', 'swap the background for a kitchen', 'remove the person on the left', 'erase all the text'. Pass `image` (URL, Library item, upload_file URL or local path) and `instruction`. The same edit the web Studio's
|
|
16711
|
+
description: "EDIT an existing image in place with a plain-language instruction and keep everything else: 'make the headline bigger', 'add our logo bottom right', 'swap the background for a kitchen', 'remove the person on the left', 'erase all the text'. Pass `image` (URL, Library item, upload_file URL or local path) and `instruction`. The same edit the web Studio's Edit runs: composition, aspect ratio, people and every untouched line of text stay as they are; the saved brand's real name and website are pinned so an added line never invents one, and the brand's real logo is attached when the instruction asks for the logo. Set removal:true when the edit STRIPS text, branding or an object, so nothing branded is put back. For a precise region, pass `mask` (see generate_image). One image edit's credits; returns the new image URL. For a new image from a prompt use generate_image; to rebuild a competitor's ad for your brand use clone_static.",
|
|
16696
16712
|
inputSchema: {
|
|
16697
16713
|
image: z.string().describe('the image to edit: URL, Library item URL, upload_file URL or local path'),
|
|
16698
16714
|
instruction: z.string().describe('the change to make, in plain words (pass the user’s own words for a removal or plain photo edit)'),
|
|
@@ -16760,7 +16776,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16760
16776
|
server.group('create');
|
|
16761
16777
|
server.registerTool('make_thumbnail', {
|
|
16762
16778
|
title: 'Make video thumbnail',
|
|
16763
|
-
description: "Render a click-driving YOUTUBE / Shorts / Instagram THUMBNAIL or video cover — the full production pipeline (concept framework
|
|
16779
|
+
description: "Render a click-driving YOUTUBE / Shorts / Instagram THUMBNAIL or video cover — the full production pipeline (concept framework -> casting -> scene -> render -> surgical tweaks -> text), not a bare image prompt. Use this for any \"thumbnail\", \"video cover\", \"video preview\" or MrBeast-style packaging ask INSTEAD of generate_image. About 9 credits per variant; the headline overlay is free.\n\nCONCEPT — every thumbnail must open an INFORMATION GAP (the image raises a question the title answers) while staying truthful to the video. Brainstorm ≥5 concepts across the 16 frameworks before you pick, and feel free to combine two. Frameworks (pass as `framework`): before_after · social_ui · three_step · screenshot · posed_portrait (the default) · posed_action · specific_day · graphical · landscape · map_aerial · product · adding_text · repetition · size_difference · news_clip · amplified_reality. Call hermoso_capabilities for each one's full 'realize it with' note plus the emotion, overlay-style, font and rim-colour catalogs.\n\nTHREE GATES, all BEFORE you render:\n1. WHO IS IN FRAME — never assume and never silently substitute a stranger. If the framework puts a person in frame and no face photo is attached, the tool refuses (nothing rendered, nothing charged) and tells you to ask the user once: themselves (send a face photo -> the identity gets locked), a generated person (`castGenericPerson:true`), or a people-free framework.\n2. TEXT — the default is a CLEAN render with the headline TYPESET OVER THE TOP afterwards (free, always legible, correctly spelled). Just pass `headline`. Only set `bakeText:true` if the user explicitly asks for the words painted INTO the image — verified live, that renders the asked-for words correctly but leaks garbled invented text across the rest of the frame. Never infer text intent from the topic or the framework.\n3. HOW MANY — ask once whether they want one thumbnail or a SET (offer 4: the same concept at different emotions and/or camera takes). Default is 1; `variants` caps at 16.\n\nIDENTITY LOCK is automatic for every attached face photo. `emotion` is the single biggest CTR lever on a face: shock · hype · fear · confusion · determination · smug · charisma · disgust · awe · rage · laugh (or your own phrase). Finished thumbnail needs a fix? Re-call with `tweak` + `sourceImage` for a surgical, pixel-faithful edit (emotion / background / background_color / rim_light) instead of re-rendering — tweaks chain. ALWAYS check the returned postRenderCheck against the image before you present it.\n\nPROMPT LANGUAGE — write every DESCRIPTIVE field in ENGLISH (`sceneBrief`, `keyElements`, `location`, `composition`, `background`, `topic`, each person's `describe`, and every `reference` field), translating the user's wording where needed: the image models are trained on English and a non-English scene description renders noticeably worse. Text that gets BAKED OR TYPESET stays verbatim in the user's own language — `headline`, `headlineLines` and `bakedUiText` are never translated.",
|
|
16764
16780
|
inputSchema: {
|
|
16765
16781
|
framework: z.string().optional().describe("concept framework id (default 'posed_portrait'); see the list in this description / hermoso_capabilities"),
|
|
16766
16782
|
frameworkRequested: z.boolean().optional().describe('true ONLY when the USER named this framework — it is what authorizes a text-carrying framework (social_ui / news_clip / specific_day / map_aerial) to bake its short UI label'),
|
|
@@ -16826,7 +16842,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16826
16842
|
server.group('create');
|
|
16827
16843
|
server.registerTool('generate_voice', {
|
|
16828
16844
|
title: 'Generate voiceover',
|
|
16829
|
-
description: "RAW text-to-speech from the voice-model catalog: speak a script in a chosen voice and return the served MP3 URL. For a standalone voiceover / narration clip — NOT for adding audio to a video (render_ad and generate_video voice their own spots; change_voice re-voices a finished clip). engine picks the voice model (default 'seed-audio'; also 'eleven-v3', 'minimax-speech', 'kokoro'); voice is a preset name from that engine (see hermoso_capabilities
|
|
16845
|
+
description: "RAW text-to-speech from the voice-model catalog: speak a script in a chosen voice and return the served MP3 URL. For a standalone voiceover / narration clip — NOT for adding audio to a video (render_ad and generate_video voice their own spots; change_voice re-voices a finished clip). engine picks the voice model (default 'seed-audio'; also 'eleven-v3', 'minimax-speech', 'kokoro'); voice is a preset name from that engine (see hermoso_capabilities -> voice engines) — a name that engine does not have is REFUSED for free with its real list, and a few engines generate their own voice and take no preset at all (the reply says which voice actually spoke). Paid (a couple of credits by length; ≤900 characters).",
|
|
16830
16846
|
inputSchema: {
|
|
16831
16847
|
text: z.string().describe('the script to speak (≤900 characters)'),
|
|
16832
16848
|
engine: z.string().optional().describe("voice-engine id: 'seed-audio' (default), 'eleven-v3', 'minimax-speech', or 'kokoro' — listed in hermoso_capabilities"),
|
|
@@ -16850,7 +16866,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16850
16866
|
|
|
16851
16867
|
server.registerTool('generate_text', {
|
|
16852
16868
|
title: 'Generate text',
|
|
16853
|
-
description: "Text generation against the writing-model catalog (Claude, Gemini, GPT, Llama, DeepSeek…) — ad copy, hooks, scripts, rewrites, brainstorms. Prompt-only, no ad assembly (for a finished on-brand creative use plan_ad
|
|
16869
|
+
description: "Text generation against the writing-model catalog (Claude, Gemini, GPT, Llama, DeepSeek…) — ad copy, hooks, scripts, rewrites, brainstorms. Prompt-only, no ad assembly (for a finished on-brand creative use plan_ad -> render_ad). BY DEFAULT the model answers as a marketing copywriter (a short house system prompt is applied, which is what you want for ad copy); pass raw:true for a plain, unstyled answer from the model itself with NO system prompt at all. model = a writing-model id from hermoso_capabilities (omit for the default Claude orchestrator). Paid (a credit or two by length).",
|
|
16854
16870
|
inputSchema: {
|
|
16855
16871
|
prompt: z.string().describe('the writing task / question'),
|
|
16856
16872
|
raw: z.boolean().optional().describe('RAW MODEL ACCESS: send the prompt with NO Hermoso system prompt — the model answers as itself rather than as an ad copywriter. Use it whenever the ask is not marketing copy (analysis, code, extraction, a plain question). Default false: the copywriter framing is applied.'),
|
|
@@ -17042,7 +17058,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17042
17058
|
// leg priced by the same videoCostUsd the Models catalog quotes. Every lane SETTLES to the exact cost afterwards.
|
|
17043
17059
|
server.registerTool('clip_video', {
|
|
17044
17060
|
title: 'Clip a long video',
|
|
17045
|
-
description: "Cut ONE long video into several RANKED, ready-to-post short clips (podcast, webinar, interview, conference talk, long ad cut
|
|
17061
|
+
description: "Cut ONE long video into several RANKED, ready-to-post short clips (podcast, webinar, interview, conference talk, long ad cut -> Reels/Shorts/TikTok). Transcribes the source with timestamps, picks the strongest SELF-CONTAINED moments, then cuts + reframes each with ffmpeg — no video model renders anything, which is why it's fast and cheap. THE VERTICAL REFRAME IS SUBJECT-AWARE: a few stills per clip go to ONE cheap vision call, which decides a SINGLE crop offset that is held for that clip's whole length — so a speaker sitting camera-left is not cropped out of their own clip, while the framing still never drifts INSIDE a clip. It costs one small vision call per clip, billed as its own event. When nothing is being discarded, or no single subject can be located, the crop stays dead centre — read `reframedToSubject` and each clip's `reframeWhy` back off the result rather than assuming either way. ACCEPTS: (a) a YouTube link (or Vimeo / Loom / Dailymotion / Streamable / Rumble / Wistia / Twitch / TED) — the server pulls the video down itself; (b) a direct https .mp4/.mov/.webm; (c) a Hermoso /generated/ URL (upload_file turns a local file into one). NOT supported: TikTok / Instagram / Facebook links, and anything age-restricted, private, members-only, geo-blocked or still LIVE — those fail fast with the real reason and are fully refunded, so ask for a direct file or an upload rather than retrying. Source must be at least ~15s and under ~600MB; only the first ~40 minutes is analysed (the result reports truncated:true when it hits that). Cost: a ~7-credit hold, settled to the exact transcription + encode cost, plus the clip-selection model's tokens billed as their own small event. RETURNS clips[] — each with its OWN served mp4 URL, title, hook, ready-to-post caption, 0-100 score and source timecode — not a single video. SUBTITLES ARE BURNED IN BY DEFAULT — slim white CAPS, thin black outline, bottom safe band, no box and no plate — because short-form is watched on mute; pass captions:false for clean footage. TIMING IS APPROXIMATE, NOT WORD-LEVEL: each cue is anchored to the transcript's own per-sentence timestamp and split inside a sentence by character count, so it tracks the speech closely but is not frame-accurate sync — never promise that. Read captionsBurned back off the result: it counts the clips that actually carry a burned track, and captionNote says why any are bare.",
|
|
17046
17062
|
inputSchema: {
|
|
17047
17063
|
video: z.string().describe('the long video to clip — a YouTube/Vimeo/Loom/Dailymotion/Streamable/Rumble/Wistia/Twitch/TED watch URL, a direct https .mp4/.mov/.webm, or a Hermoso /generated/ URL'),
|
|
17048
17064
|
count: z.number().optional().describe('how many clips to cut, 1-8 (default 4)'),
|
|
@@ -17282,7 +17298,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17282
17298
|
|
|
17283
17299
|
server.registerTool('stitch_video', {
|
|
17284
17300
|
title: 'Stitch multi-scene video',
|
|
17285
|
-
description: 'Render a multi-scene STITCHED video (≥2 scenes) — ONLY for spots LONGER than ONE clip of the chosen model. A multi-beat ad that FITS one clip renders better and cheaper as ONE single-pass generate_video/render_ad (a single generation carries the whole hook
|
|
17301
|
+
description: 'Render a multi-scene STITCHED video (≥2 scenes) — ONLY for spots LONGER than ONE clip of the chosen model. A multi-beat ad that FITS one clip renders better and cheaper as ONE single-pass generate_video/render_ad (a single generation carries the whole hook->demo->payoff arc) — never stitch those. What fits is the model’s own maximum from hermoso_capabilities, not a fixed number: 15s on most models, 30s on the longest-clip one, so a 30s spot need not be stitched at all if you name that model. Blocks until done. Spends credits.',
|
|
17286
17302
|
inputSchema: {
|
|
17287
17303
|
scenes: z.array(z.object({}).passthrough()).min(2).describe('array of scene objects (visual + optional voiceover/seconds)'),
|
|
17288
17304
|
aspectRatio: z.string().optional().describe('output aspect ratio, e.g. 9:16 (default) / 1:1 / 16:9'),
|
|
@@ -17601,7 +17617,7 @@ function memoryNoteVerdict(text) {
|
|
|
17601
17617
|
}));
|
|
17602
17618
|
server.registerTool('save_to_swipefile', {
|
|
17603
17619
|
title: 'Save ads to the swipefile',
|
|
17604
|
-
description: 'Save one or more ads/creatives to a named SWIPEFILE collection, creating the collection if it does not exist — the headless twin of the
|
|
17620
|
+
description: '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. Use it whenever research turns up something worth keeping: a competitor ad from search_meta_ads / pull_competitor_ads, an organic post, or one of your own renders. Saved ads persist to the workspace board the web Swipefile tab shows, and feed the taste signal every future ad is planned against. De-dupes: re-saving the same ad (same key/link/media) MOVES it into the named collection instead of duplicating it. Free.',
|
|
17605
17621
|
inputSchema: {
|
|
17606
17622
|
collection: z.string().describe('the collection name — an existing one, or a new one to create'),
|
|
17607
17623
|
items: z.array(z.object({
|
|
@@ -17648,7 +17664,7 @@ function memoryNoteVerdict(text) {
|
|
|
17648
17664
|
// second store: the swipefile is already synced, tombstoned and union-merged.
|
|
17649
17665
|
server.registerTool('update_saved_creator', {
|
|
17650
17666
|
title: 'Update a saved creator (outreach status, note)',
|
|
17651
|
-
description: 'Set the OUTREACH STATUS and/or a NOTE on a creator already saved in the swipefile (find_creators
|
|
17667
|
+
description: 'Set the OUTREACH STATUS and/or a NOTE on a creator already saved in the swipefile (find_creators -> save_to_swipefile, or the heart on a creator card). 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.',
|
|
17652
17668
|
inputSchema: {
|
|
17653
17669
|
key: z.string().describe("the saved row's key from list_swipefile, e.g. tiktok:handle"),
|
|
17654
17670
|
status: z.enum(['new', 'contacted', 'replied', 'booked', 'passed']).optional(),
|
|
@@ -17770,7 +17786,7 @@ function memoryNoteVerdict(text) {
|
|
|
17770
17786
|
}));
|
|
17771
17787
|
server.registerTool('save_creator', {
|
|
17772
17788
|
title: 'Save a creator',
|
|
17773
|
-
description: '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
|
|
17789
|
+
description: 'Add a portrait to this workspace’s reusable CAST so the SAME person can star in future ads — the headless twin of the app’s + > Pick a creator > save. Pass the portrait’s public url (a generate_image render of a person, a headshot, any public photo) plus a name to call them by; from then on list_creators returns them and their url can be re-passed to generate_avatar / generate_video / recast_motion. Saving is FREE and renders nothing. LIKENESS — `source` says what the portrait IS: leave it "generated" for an AI-made person, and use "upload"/"social" ONLY for a REAL person. Pass consented:true only when the user has told you that person agreed to their likeness being used; never assert that on their behalf.',
|
|
17774
17790
|
inputSchema: {
|
|
17775
17791
|
name: z.string().describe('what to call this creator (e.g. “Sarah”) — list_creators and the app’s picker match on it'),
|
|
17776
17792
|
image: z.string().describe('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'),
|
|
@@ -17894,7 +17910,7 @@ function memoryNoteVerdict(text) {
|
|
|
17894
17910
|
}));
|
|
17895
17911
|
server.registerTool('list_connectors', {
|
|
17896
17912
|
title: 'List connectors',
|
|
17897
|
-
description: '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 \u2014 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
|
|
17913
|
+
description: '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 \u2014 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. A paste-a-key account needs no browser at all: connect_connector connects it from here if the user prefers that to the app. Read-only, free.',
|
|
17898
17914
|
inputSchema: {},
|
|
17899
17915
|
outputSchema: { connectors: z.array(z.any()).optional(), providers: z.array(z.string()).optional() },
|
|
17900
17916
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
@@ -18209,7 +18225,7 @@ function memoryNoteVerdict(text) {
|
|
|
18209
18225
|
const keyConnectRedact = (text, secrets) => { let t = String(text ?? ''); for (const s of secrets) if (s && s.length >= 4) t = t.split(s).join('[redacted]'); return t; };
|
|
18210
18226
|
server.registerTool('connect_connector', {
|
|
18211
18227
|
title: 'Connect a paste-a-key account',
|
|
18212
|
-
description: `Connect an account that links with a KEY rather than a sign-in screen, from here, with no browser: ${Object.values(KEY_CONNECTORS).map(s => s.label).join(', ')}. 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
|
|
18228
|
+
description: `Connect an account that links with a KEY rather than a sign-in screen, from here, with no browser: ${Object.values(KEY_CONNECTORS).map(s => s.label).join(', ')}. 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: ${Object.entries(KEY_CONNECTORS).map(([id, s]) => `${id} {${keyConnectFieldList(s)}}`).join(' · ')}.`,
|
|
18213
18229
|
inputSchema: {
|
|
18214
18230
|
provider: z.string().describe(`the connector id: ${Object.keys(KEY_CONNECTORS).join(', ')}`),
|
|
18215
18231
|
fields: z.record(z.string()).optional().describe('that provider\'s own field names and values, e.g. {"apiKey":"…"}; the names for each provider are in the description'),
|
|
@@ -18544,7 +18560,7 @@ function memoryNoteVerdict(text) {
|
|
|
18544
18560
|
const watchWhen = (ms) => (+ms > 0 ? new Date(+ms).toISOString().slice(0, 16).replace('T', ' ') + ' UTC' : 'never');
|
|
18545
18561
|
server.registerTool('set_competitor_watch', {
|
|
18546
18562
|
title: 'Set the competitor watch',
|
|
18547
|
-
description: 'Set (or STOP) this workspace\'s standing COMPETITOR WATCH — the weekly job that re-checks each named brand\'s ad libraries and reports what is NEW since last time. The same watch the web app\'s Ad Spy
|
|
18563
|
+
description: 'Set (or STOP) this workspace\'s standing COMPETITOR WATCH — the weekly job that re-checks each named brand\'s ad libraries and reports what is NEW since last time. The same watch the web app\'s Ad Spy > Watching tab manages, and the same one the weekly digest email is sent from (turn that email on/off with update_settings({watchEmail})). '
|
|
18548
18564
|
+ 'This REPLACES the whole watched list, it does not add to it — pass every brand you want watched, every time. Max 5 brands (the server trims past that). Pass an EMPTY list to stop the watch entirely, which also clears the findings. '
|
|
18549
18565
|
+ 'Give a `domain` wherever you know one: Google Ads Transparency is looked up BY DOMAIN and is skipped for a brand without one, and the domain is what resolves the right Meta page for a brand with an ambiguous name. '
|
|
18550
18566
|
+ 'The run itself spends credits against the ad libraries (roughly 3 per brand on Meta, 1 each on Google and LinkedIn) and is hard-capped per run server-side, so an oversized watch is trimmed rather than allowed to run away. Setting the list is free; only a run spends. '
|
|
@@ -18584,7 +18600,7 @@ function memoryNoteVerdict(text) {
|
|
|
18584
18600
|
server.registerTool('list_watch_findings', {
|
|
18585
18601
|
_meta: openaiMeta(AD_SPY_URI, 'Reading the competitor watch…', 'Competitor watch read'),
|
|
18586
18602
|
title: 'Read the competitor watch',
|
|
18587
|
-
description: 'Read what the standing COMPETITOR WATCH has found — the new ads each watched brand has launched since the last check, plus the watch\'s own state (who is watched, when it last ran, when it runs next, and whether the last run actually succeeded). The same board the web app\'s Ad Spy
|
|
18603
|
+
description: 'Read what the standing COMPETITOR WATCH has found — the new ads each watched brand has launched since the last check, plus the watch\'s own state (who is watched, when it last ran, when it runs next, and whether the last run actually succeeded). The same board the web app\'s Ad Spy > Watching tab renders. '
|
|
18588
18604
|
+ 'Use it to answer "what are our competitors running that\'s new?", to feed a teardown, or to save something worth keeping with save_to_swipefile. '
|
|
18589
18605
|
+ 'Findings marked `seed:true` are NOT new launches — the first check of a brand has nothing to diff against, so it seeds the board with what that brand is running right now; only later runs surface genuine changes. '
|
|
18590
18606
|
+ 'Read-only and free — it returns the stored results of past runs and never triggers a check (set_competitor_watch({runNow:true}) is what runs one).',
|
|
@@ -19213,7 +19229,7 @@ function memoryNoteVerdict(text) {
|
|
|
19213
19229
|
|
|
19214
19230
|
server.registerTool('reframe_video', {
|
|
19215
19231
|
title: 'Reframe video',
|
|
19216
|
-
description: "Reframe a video to a different aspect ratio (e.g. 16:9 master
|
|
19232
|
+
description: "Reframe a video to a different aspect ratio (e.g. 16:9 master -> 9:16 vertical) with smart subject tracking. Paid render; returns the served URL of the reframed video.",
|
|
19217
19233
|
inputSchema: { video: z.string().describe('the source video URL'), aspectRatio: z.enum(['9:16', '1:1', '16:9', '4:3', '3:4', '21:9', '9:21']).describe('the target aspect ratio') },
|
|
19218
19234
|
outputSchema: { ...JOB_OUT },
|
|
19219
19235
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|