hermoso 0.1.461 → 0.1.463
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/README.md +2 -2
- package/mcp/client.mjs +4 -1
- package/mcp/openai-extensions.mjs +1 -1
- package/mcp/roster-scope.mjs +16 -1
- package/mcp/tools.mjs +685 -94
- package/package.json +2 -2
package/mcp/tools.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { UNKNOWN_ARGS_SYM, UNKNOWN_ARGS_DETAIL_SYM, UNKNOWN_ARGS_REFUSED_SYM, ar
|
|
|
17
17
|
// re-exports every symbol from here). `./roster-scope.mjs` is the only specifier that resolves in a byte-identical
|
|
18
18
|
// twin, for the same reason ./well-formed.mjs is. See applyToolGates() for the seam and roster-scope.mjs for the law.
|
|
19
19
|
import { MOTION_STYLE_IDS, motionStyleMenu } from './motion-styles.mjs';
|
|
20
|
-
import { toolHeldBackByConnectors as heldBackByConnectorsOf, toolProvider as providerOf, toolUnoffered, toolWithheldByServer as withheldByServerOf, metaAlternativeNote as metaAlternativeNoteOf } from './roster-scope.mjs';
|
|
20
|
+
import { toolHeldBackByConnectors as heldBackByConnectorsOf, toolProvider as providerOf, toolUnoffered, toolWithheldByServer as withheldByServerOf, toolHeldForNoShopifyStore as heldForNoShopifyStoreOf, SHOPIFY_STORE_NOTE, metaAlternativeNote as metaAlternativeNoteOf } from './roster-scope.mjs';
|
|
21
21
|
// A tool a widget host lists under ANOTHER name (a WIDGET_HOST_RENAMES alias, or one operation of a WIDGET_HOST_SPLITS
|
|
22
22
|
// tool) is gated, attributed and explained as the tool it comes from: same connector, same Instagram-Login exemption,
|
|
23
23
|
// same server withhold. Function declarations so they are hoisted; the tables they read are only consulted at call time.
|
|
@@ -258,7 +258,7 @@ export const CAPABILITY_MAP = [
|
|
|
258
258
|
'B) CREATE: on-brand image & video ads (real product composited in, copy + CTA baked). draft_brand / get_brand / update_brand (patch single fields without re-onboarding) / use_brand · list_brands / create_brand / delete_brand (one account holds MANY profiles: brands, clients or creators, so an agency runs every client through here; each has its own brand details, memory, swipefile, Library and connectors, and create_brand → draft_brand onboards a new one end to end) · plan_ad (concept + copy) → render_ad (the Studio quality pipeline) or generate_image / generate_video / generate_avatar (UGC creators + lip-sync) · list_creators / save_creator / delete_creator (the workspace’s REUSABLE CAST — saved creators with their portrait urls, so the SAME person stars in every ad; list them before ever generating a new one, then cast one into the ad with render_ad’s `creator`, which also skips the character-portrait render and so costs LESS than casting a stranger) · make_template_ad (native HTML ad formats) · make_motion_film (motion graphics: kinetic typography, logo intros, launch films; no video model) · clone_static / recast_motion / reframe_video / insert_scene / upscale_video / dub_video / change_voice / finish_video / fix_beat / hook_variants / stitch_video · plan_variations + score_ad (fan out + rank).',
|
|
259
259
|
'C) RAW MODEL PLAYGROUND — direct access to the full catalog (30+ image / video / voice / writing models, each with the exact per-render credit cost shown above), no ad framing: generate_image / generate_video (useBrand:false) for plain prompt-only renders, generate_voice for raw text-to-speech against any voice engine, and generate_text for the writing models (Claude / Gemini / GPT / Llama / DeepSeek…) — all against ANY catalog id.',
|
|
260
260
|
'D) ACCOUNT — hermoso_credits (balance) · billing_status (plan + your billing role) · buy_credits (one-click top-up on the saved card, or a first-purchase checkout link) · upgrade_plan / set_auto_reload (admin) · list_jobs / get_job (track async renders) · get_settings / update_settings (the LANGUAGE every ad, script and plan is written in (chat replies follow the language the user writes in) — set it once and every render obeys it, over MCP as well as in the app — plus app appearance and the weekly competitor-watch email) · list_team / invite_member / remove_member / set_role (who else can work in this profile).',
|
|
261
|
-
'E) PUBLISH & MANAGE YOUR CHANNELS — post, run ads, and organize files on the user’s OWN connected accounts (Settings ▸ Connectors), all driven over this MCP. Bring ANY file in with upload_file (desktop/external media, not just Hermoso renders). MANAGING THE CONNECTIONS THEMSELVES: list_connectors (what is linked, and what could be) · list_connector_accounts then set_connector_accounts (WHICH Facebook Pages / Instagram / Meta ad accounts / Google Ads customers / LinkedIn company Pages and ad accounts / Pinterest ad accounts / Microsoft Advertising accounts / Reddit ad accounts / Google Business listings / Google Analytics properties this profile may post to, spend from and read — one person often administers or has access to several belonging to different clients, only the chosen ones are usable anywhere, and an empty choice shares nothing) · connect_connector (connect a PASTE-A-KEY account from here: ' + Object.values(KEY_CONNECTORS).map((s) => s.label).join(', ') + '; offer it beside the Connectors page in the app and let the user choose, because a key pasted into a chat stays in its history) · disconnect_connector (drop a connection from this brand, without revoking access at the platform; confirm-gated because reconnecting a sign-in account needs a browser) · leave_connector (on a connector several teammates can each contribute their OWN account to, remove just YOURS — teammates’ accounts keep working and nothing is revoked at the provider). LINKING an account that connects through a provider sign-in screen (OAuth) is the one step that is not headless: hand the user its connect link, https://app.hermoso.ai/?connect=<provider>, or send them to Workspace ▸ Connectors in the app. META: list_meta_pages · instagram_insights (ACCOUNT-level Instagram performance — views, reach, accounts engaged, interactions, saves, profile link taps — plus the audience DEMOGRAPHICS by age / city / country / gender) · list_instagram_media (the brand’s own recent Instagram posts, and where the media id every other Instagram tool needs comes from) · list_instagram_dm_automations / save_instagram_dm_automation / test_instagram_dm_automation / delete_instagram_dm_automation (INSTAGRAM DM AUTOMATIONS — a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply; test shows what a sample would send without sending) · list_dm_automations / save_dm_automation / test_dm_automation / delete_dm_automation (THE SAME ON EVERY CHANNEL — Instagram, Facebook Page comments and ads + Messenger, and X: a keyword mention or reply gets a public reply, a DM keyword a DM; X is billed in credits per delivered event and per send) · search_instagram_audio (licensed music and original sounds an Instagram Reel may use, by keyword or trending) · list_instagram_collab_invites then respond_instagram_collab_invite (collab-post invitations waiting on the account; accept or decline one, read back from Instagram) · list_instagram_collab_media (posts this account co-authors) · like_instagram (like a post or comment as the connected account) · post_to_meta (Facebook / Instagram / Threads) · list_meta_posts (the Page’s / Instagram account’s OWN existing posts with their ids — THIS is where the postId every other Meta read needs comes from; without it an agent that did not itself just publish has no way to name a post) · list_meta_ads + meta_insights (read existing campaigns/ad sets/ads + spend/CTR/CPC, with breakdowns by age / gender / placement / country) · preview_meta_ad (Meta renders the REAL ad per placement — a link the user can look at, valid 24h) · list_meta_pixels + create_meta_pixel (the pixel a conversion-optimised campaign REQUIRES — Meta will not let a build optimise for conversions without one, and until these existed a caller had no way to discover the id they had to pass) · estimate_meta_reach (how many people a targeting spec reaches, BEFORE a budget is committed) · list_meta_audiences / create_meta_audience (website-pixel retargeting, Page + Instagram engagement audiences, and lookalikes — creating one spends nothing) · list_meta_conversations / read_meta_conversation / reply_to_meta_message (MESSENGER AND INSTAGRAM DMs — the brand’s direct-message threads and a reply to someone who wrote first. Meta only permits a reply within 24 HOURS of the person acting, and read_meta_conversation says whether that window is open BEFORE anything is drafted; Hermoso sends replies only, never a proactive message or a message tag) · subscribe_meta_webhooks / meta_webhook_status / unsubscribe_meta_webhooks / list_meta_webhook_events (REAL-TIME EVENTS — have Meta PUSH new comments, mentions, lead-form submissions and inbound DMs to Hermoso instead of polling for them. Every other inbox read asks an edge “anything new?”; this is the only way to be TOLD, and it is how a lead arrives the moment it is submitted rather than when somebody thinks to look. An empty feed is ambiguous — check meta_webhook_status first, because an unsubscribed Page is silent and looks exactly like a quiet one) · instagram_collaborators (who ACCEPTED a Collab invite on an Instagram post — publishing only SENDS the invite, so this is the only way to know whether the post is actually live on the other account too) · list_instagram_shopping_catalogs / search_instagram_shopping_products / manage_instagram_product_tags (INSTAGRAM SHOPPING — make a post SHOPPABLE. Check eligibility and the account’s taggable catalogs, find the product ids, then pass productTags to post_to_meta so tapping the picture opens the product’s price sheet inside Instagram. Tagging needs an APPROVED Instagram Shop, so check FIRST — otherwise it fails after the media is already uploaded — and note that a tag whose product is not “approved” is stored and shown to nobody. Meta publishes no way to REMOVE a tag) · create_meta_catalog / update_meta_catalog / meta_catalog_blast_radius / delete_meta_catalog (BUILD AND RETIRE A CATALOG — create one on a named business portfolio, rename or re-point it, and, before ever proposing a delete, read meta_catalog_blast_radius: a catalog delete is PERMANENT with no archive and no undo, its product sets go with it, and any ad set still bound to one keeps spending with nothing to show) · list_meta_partnership_creators / manage_meta_partnership_creator (PARTNERSHIP ADS — the creators whose content this profile may run as an advert, and who may tag this brand as a paid partner. Two separate lists, neither implying the other, and neither defaults on; adding is a REQUEST the creator must accept, and an ad naming a creator who is only PENDING fails for a reason nothing in the error says) · list_meta_catalogs / list_meta_product_sets / list_meta_catalog_products (PRODUCT CATALOGS — the merchant’s own Meta catalogs, the product SETS inside each and the products themselves with Meta’s review status. A catalog is the input to Advantage+ catalog ads, the highest-performing ecommerce format on Meta: pass productCatalogId to create_meta_campaign and productSetId to create_meta_adset / create_meta_ad, and Meta builds every impression from the product’s own image, name and price — no render needed. An empty list is a fact about which business portfolio this login administers, NEVER about whether the merchant has a catalog) · create_meta_campaign / create_meta_ad / upload_meta_asset (build) · upload_meta_playable / list_meta_playables (PLAYABLE ads: the advertiser’s own interactive HTML game, run through create_meta_ad’s playableAssetId in an App Installs ad set) · list_meta_lead_forms / create_meta_lead_form (INSTANT LEAD FORMS — the form a lead ad opens INSIDE Facebook/Instagram instead of sending the click to a website; pass the id as create_meta_ad(objective:\"OUTCOME_LEADS\", leadFormId:…) and read the submissions with read_meta_leads) · update_meta_object / delete_meta_object / set_meta_campaign_status (edit, delete, activate — every spend + delete is confirm-gated) · delete_meta_audience (remove a custom audience or lookalike — its blast radius is the PEOPLE in it and the lookalikes built from it, which Meta refuses to delete around) · manage_meta_post (edit or delete a published post). THREADS (a separate connection from Meta, on its own API): post_to_meta(target:"threads") publishes · list_threads_posts · threads_insights · list_threads_replies / reply_to_thread / hide_thread_reply · list_threads_mentions · search_threads_keyword · repost_thread (amplify a customer’s post or one of your own to the brand’s profile — the Threads retweet, and there is NO documented un-repost) · delete_thread (confirm-gated; Threads has no EDIT at all, so delete-and-repost is the only correction) · threads_publishing_limit (how much of the rolling-24h quota is left — 250 posts, 1,000 replies, 100 DELETIONS, 500 location searches; check it before a bulk clean-up, because a quota refusal otherwise reads as a broken connection). SCHEDULING (one content calendar across every channel): schedule_post (queue a post for a future time to one or MORE channels at once — Facebook / Instagram / Threads / TikTok / YouTube / LinkedIn / X / Pinterest / Bluesky / Telegram (ten; Google Business Profile is accepted but held back on Google API access) — with per-channel captions; Hermoso publishes it at that time, nothing has to stay open — it goes LIVE PUBLICLY by default, and only stages as draft/unlisted/private if the user asks, and an impossible channel+visibility pair, an over-length caption or media the channel cannot carry is REFUSED while you are still there rather than failing hours later) · list_scheduled (what is queued and what already fired, with PER-CHANNEL outcomes) · reschedule_post (move a queued post to a new time, or change its caption, media, channels or target Page/board — send only what changes) · cancel_scheduled (pull a queued post before it goes out). POST PERFORMANCE (the loop that closes research → publish → learn — Hermoso records the HOOK and SUBJECT of everything it publishes, because those exist only at the moment of publishing and can never be recovered from a post id afterwards): list_published_posts (everything this brand has published across every channel, with the hook it was written to and its measured engagement) · post_performance (which HOOKS and SUBJECTS are getting traction — engagement rates compared WITHIN a channel and NEVER summed across them, with a verdict suppressed below 5 measured posts and the reason stated) · collect_post_metrics (pull fresh numbers ~24h and ~7d after each publish; a metric a channel cannot report is recorded ABSENT with its reason and never as zero, and X is skipped unless asked because it bills per call) · backfill_posts (import a channel’s past posts so the analysis has history — dry-run and cost-quoted first, and an imported post never votes on a hook unless it matched a Hermoso creation). YOUTUBE (publish, measure AND manage): post_to_youtube (publish a finished video to the brand’s channel — PUBLIC by default; unlisted (link-only, the ad-ready setting) or private only when the user asks) · list_youtube_videos (the channel’s OWN uploads with their video ids — call this to resolve “my latest video” yourself instead of asking the user for a link; it is where the videoId every other YouTube tool needs comes from, and it sees unlisted/private uploads a public search cannot) · update_youtube_video (retitle/re-describe/re-tag, and FLIP AN UNLISTED UPLOAD PUBLIC; confirm before going public) · delete_youtube_video (take one down for good — irreversible, so the unconfirmed call reports the video’s real title, privacy, views and comments first; use update_youtube_video(privacy:"private") when they only want it out of sight) · set_youtube_thumbnail (put a Hermoso thumbnail on an uploaded video — the biggest single lever on click-through, and YouTube otherwise picks a frame at random; needs a phone-verified channel) · update_youtube_channel (brand the CHANNEL ITSELF — banner art, description, keywords, country, the trailer non-subscribers see; everything else here brands the videos, this brands the page they sit on. It MERGES with the current settings, and it reports any field YouTube accepted but silently ignored, channel title above all) · set_youtube_watermark (the subscribe badge overlaid on EVERY video on the channel, including ones uploaded later — one square image brands the whole channel at once; the API publishes no way to read it back, so it reports accepted rather than confirmed) · list_youtube_video_stats (views, likes and comments for up to 50 videos IN ONE CALL, which is how to answer "how are my last twenty uploads doing" without one youtube_video_insights per video. It carries NO titles, because VideoStatsSnippet publishes only publishTime, so join on videoId with list_youtube_videos for names. YouTube calls this endpoint "intentionally not atomic", so a short answer is normal: the missing ids are named, and a missing id is never zero views) · youtube_video_insights (per-VIDEO views, watch time, average view PERCENTAGE/retention, likes, comments, shares, subscribers gained — the numbers that say whether a hook held; youtube_channel only gives channel-wide totals) · youtube_channel_report (the same numbers BROKEN DOWN — traffic source (search vs browse vs suggested vs shorts feed), the actual search terms, country/city, device, age+gender, subscribed vs not, and the audience-RETENTION curve showing exactly where viewers left) · list_youtube_comments + list_youtube_comment_replies + reply_to_youtube_comment (read viewer questions and objections in their own words, and answer as the channel) · moderate_youtube_comment (hide, reject, spam-report or delete an abusive comment — reject is reversible, delete is not) · list_youtube_playlists + manage_youtube_playlist + manage_youtube_playlist_items (organise the channel: create playlists, add/remove/re-order videos in them) · manage_youtube_playlist_image (a custom cover on a playlist — make_thumbnail renders the artwork, this is the call that puts it on. YouTube answers every failure here as an HTTP 500 whose real reason is buried inside it, and the tool unpacks that; if it comes back refused, check channel verification first) · manage_youtube_channel_section (the SHELVES ON THE CHANNEL HOMEPAGE — put a chosen playlist or a featured channel above YouTube’s own default layout, and re-order them. Every write is PUBLIC IMMEDIATELY, a delete has no undo, and YouTube’s own section list LAGS a write by a few seconds in both directions, so never treat a list taken straight afterwards as proof either way) · list_youtube_captions + manage_youtube_caption (real subtitle TRACKS — what YouTube indexes the video by and what a viewer toggles on, which is NOT the same as captions burned into the picture; downloading one is also the quickest way to get an existing video’s script back) · list_youtube_categories (which categoryId post_to_youtube will accept in a given country) · youtube_bulk_report (THE ONLY PLACE YOUTUBE PUBLISHES THUMBNAIL IMPRESSIONS AND THUMBNAIL CTR — a different, SCHEDULED API: the first call starts a job and returns nothing, then YouTube writes one file per day, the first within 48 hours, plus a 30-day backfill. It also carries per-card and per-end-screen metrics and an uncapped list of the search terms people arrived on) · list_youtube_report_jobs (whether that thumbnail history is already accumulating, and since when — check before promising a number) · delete_youtube_report_job (stop one; the job IS the history, so deleting it throws the accumulated files away) · youtube_channel (read title + subscriber/view/video counts for reporting). TIKTOK: post_to_tiktok (post a finished video — or a PHOTO POST, TikTok’s photo/slideshow format of 1 to 35 images where a single image is just a one-slide post —LIVE to the profile, or into TikTok drafts to review in the app) · tiktok_creator_info (the creator’s REAL privacy options — read them and let the user choose before any direct post) · tiktok_post_status (follow a post_to_tiktok that came back pending: done, failed or still processing) · tiktok_account (bio, verified status, follower/following/likes/video counts) · list_tiktok_videos (their own posts with views/likes/comments/shares — either the most recent, or specific videoIds read directly however old they are). ⚠️ TIKTOK HAS NO DELETE AND NO EDIT: its API publishes no way to remove a posted video or change its caption, privacy, cover or comment/duet/stitch settings — every one of those is fixed at publish time and there is no delete scope in TikTok’s scope catalogue at all. If the user wants a TikTok taken down or changed, say plainly that it has to be done in the TikTok app rather than hunting for a tool. TIKTOK ACCOUNT AUTHORIZATION (a SECOND, separate consent on the SAME TikTok app the TikTok Ads connection uses — holding one does NOT give you the other, so a brand fully connected for ads can still be unauthorized here, and that is a real third state rather than a broken session): tiktok_account_status (which state this profile is in, the TikTok business id, the scopes the grant carries and any MISSING from it — TikTok binds scopes at authorize time and never retroactively, so only a re-authorization picks up a new one — plus the exact URL to send the user to, because authorizing is the one step that needs a browser) · list_tiktok_comments + list_tiktok_comment_replies (the comments on the brand’s OWN posts, hidden ones included — TikTok’s answer to list_meta_comments and list_youtube_comments) · comment_on_tiktok_video · reply_to_tiktok_comment · moderate_tiktok_comment (LIKE / UNLIKE / HIDE / UNHIDE / DELETE — you can only DELETE a comment this account wrote, so HIDE is the tool for a stranger’s, and TikTok warns UNHIDE may not take effect when its own moderation is what hid it) · upload_tiktok_comment_image (a new comment will not take a raw image URL; a reply will) · set_tiktok_post_ad_authorization (THIS IS WHERE A SPARK ADS AUTHORIZATION CODE COMES FROM for the brand’s OWN post — previously a human had to copy one out of the TikTok app; hand the code to authorize_tiktok_ads_spark_post) · get_tiktok_post_ad_authorization · extend_tiktok_post_ad_authorization (the days are ADDED to what is left, not set as an absolute) · delete_tiktok_post_ad_authorization. BRAND MONITORING AND AUDIENCE, on that same account authorization (these need permissions added on 2026-08-20, so a brand that authorized before then holds a grant that predates them and has to authorize once more; tiktok_account_status names exactly which are missing, and the remedy is always to authorize the TikTok ACCOUNT again rather than to touch the advertiser connection, which is a separate grant and is unaffected): list_tiktok_mentions (public posts whose caption @-mentions the brand, TikTok’s answer to x_mentions and list_threads_mentions) · list_tiktok_mention_comments (comments whose text mentions it) · get_tiktok_mention (one mention in full, for the mentions webhook, and TikTok only keeps that data 48 hours) · tiktok_mention_top_terms (the top 20 keywords and top 20 hashtags inside those mentions) · list_tiktok_brand_hashtags + manage_tiktok_brand_hashtags + list_tiktok_brand_hashtag_posts (the hashtags TikTok counts as this brand’s, up to 50, and the posts carrying them; a new one is not counted for 24 hours and cannot be removed for 7 days) · tiktok_account_insights (follower demographics by age, gender, country and city plus the daily performance series, needing a BUSINESS account with 100+ followers, and capped at 60 days rather than the 90 the mention tools cover) · tiktok_category_benchmark (the same numbers averaged across an industry, so ‘are we ahead of our category’ is answerable). ALL OF THIS IS ORGANIC LISTENING ON THE BRAND’S OWN ACCOUNT, not ad research: for competitors’ ads use the ad-library research tools instead. TIKTOK ADS (a SEPARATE connection from the TikTok posting connector above — Settings ▸ Connectors ▸ TikTok Ads; a brand that posts to TikTok every day may still have no ad account here, so never read one as the other): list_tiktok_ads_accounts (the ADVERTISER accounts this profile can act on — every other TikTok Ads tool needs an advertiserId and this is where it comes from) · list_tiktok_ads_pixels + create_tiktok_ads_pixel + list_tiktok_ads_custom_conversions + tiktok_ads_pixel_stats (CONVERSION TRACKING — a conversion-optimised ad group dies at creation with "Please select a pixel" without one, so discover the pixel and its events BEFORE building the tree; note TikTok publishes no way to DELETE a pixel, so one you create is permanent) · list_tiktok_ads_campaigns (the whole tree — campaigns, ad groups and ads with their statuses) · tiktok_ads_report (impressions, clicks, spend, CTR, CPC, conversions and video views at any level) · list_tiktok_ads_identities (the TikTok accounts an ad may post AS — MANDATORY, with NO default: call it and let the USER pick, because the ad runs publicly under whichever account is named) · search_tiktok_ads_targeting (resolve location / interest / hashtag / language ids — an ad group cannot be created without location ids, and a guessed id targets the wrong people) · list_tiktok_ads_identity_posts (the ORGANIC posts an identity has already published — where a Spark Ad’s post id comes from) · list_tiktok_ads_spark_posts (the posts authorised for Spark Ads, i.e. promoting an organic post instead of uploading a new video) · authorize_tiktok_ads_spark_post + unbind_tiktok_ads_spark_post (add a creator’s post to that authorised set with the code they generated in the TikTok app, or release it again) · upload_tiktok_ads_playable / list_tiktok_ads_playables / delete_tiktok_ads_playable (PLAYABLE ads: the advertiser’s own interactive HTML zip, run through create_tiktok_smart_ad’s playableUrls) · upload_tiktok_ads_creative (THE STEP THAT TURNS A RENDER INTO AN AD — put a finished Hermoso video on the ad account and it hands back the videoId AND the coverImageId create_tiktok_ads_ad needs; there is no other source for either) · create_tiktok_ads_campaign → create_tiktok_ads_ad_group → create_tiktok_ads_ad (the tree) · set_tiktok_ads_budget · set_tiktok_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_tiktok_ads_object (removal on TikTok is a STATUS, not a verb — the same route as set_tiktok_ads_status) · create_tiktok_smart_campaign → create_tiktok_smart_ad_group → create_tiktok_smart_ad (Smart+, TikTok’s Performance Max — born PAUSED) · list_tiktok_smart_campaigns · set_tiktok_smart_status (the Smart+ money switch, confirm-gated) · tiktok_bid_protection (the ad-credit compensation TikTok pays when a Smart+ object misses its bid) · tiktok_lead_ads_terms (read and accept TikTok’s Lead Ads terms, which a lead-generation ad needs first) · list_tiktok_ads_lead_forms + list_tiktok_ads_lead_fields + download_tiktok_ads_leads + manage_tiktok_ads_test_lead (LEAD ADS — an Instant Form is built in TikTok Ads Manager and NO API creates one, so list them to find the id a LEAD_GENERATION ad group needs. The lead REGION is required with no default: it selects which of three separate lead stores you read, and leaving it out is a THIRD value rather than “all”, so an advertiser who omits it downloads an empty file and wrongly concludes there are no leads) · list_tiktok_ads_audiences + create_tiktok_ads_audience + create_tiktok_ads_lookalike_audience + apply_tiktok_ads_audience + update_tiktok_ads_audience + delete_tiktok_ads_audience + tiktok_ads_audience_overlap (CUSTOM AUDIENCES and lookalikes — TikTok targeting is otherwise interests-and-geo only. A freshly created audience reports itself invalid for up to 48 hours BY DESIGN, so that is not a failure to retry) · list_tiktok_ads_business_centers + list_tiktok_ads_catalogs + create_tiktok_ads_catalog + list_tiktok_ads_catalog_products + list_tiktok_ads_catalog_sets + manage_tiktok_ads_catalog_feed + tiktok_ads_catalog_diagnostics (DPA / PRODUCT CATALOGS, the Shopify lane — a catalog is keyed on a BUSINESS CENTER id, NOT an advertiser id, so list the Business Centers first or every call refuses) · list_tiktok_ads_apps + list_tiktok_ads_app_events (the registered apps an APP_INSTALL campaign needs — nothing else can produce an app id) · tiktok_ads_rf_inventory_estimate + create_tiktok_ads_rf_ad_group (REACH & FREQUENCY — a RESERVATION, so it is confirm-gated like a status change rather than born paused, and it needs a per-ad-account allowlist plus a signed branding contract that no endpoint reports. Always price it with the estimate first: TikTok silently books its own maximum rather than refusing an out-of-range value) · send_tiktok_ads_events (SERVER-SIDE conversion events — there is a vendor-sanctioned test code for exercising it without entering the advertiser’s real reporting), and its offline/crm sources take the event-set ids the two tools below mint) · list_tiktok_ads_offline_event_sets + manage_tiktok_ads_offline_event_set + send_tiktok_ads_offline_events (REAL-WORLD CONVERSIONS — an in-store purchase, a phone booking, a signed contract, reported so TikTok can attribute them to the ads that caused them. The timestamp is an ISO-8601 STRING here and a Unix NUMBER on send_tiktok_ads_events; a wrong-shaped one is accepted by TikTok and attributed to nothing. There is NO test code on this pair, so everything sent is a real permanent conversion — rehearse through send_tiktok_ads_events with eventSource “offline” and a testEventCode instead. Reporting also needs the connected user to be an ADMIN or OPERATOR of the advertiser, which managing the event SETS does not) · list_tiktok_ads_crm_event_sets + create_tiktok_ads_crm_event_set (LEAD-LIFECYCLE events — sending “this lead qualified / closed” back is what makes a LEAD_GENERATION campaign optimise toward leads that convert rather than form fills. TikTok publishes create and list and nothing else, so one of these is PERMANENT) · list_tiktok_tto_accounts + list_tiktok_creator_labels + discover_tiktok_creators + tiktok_creator_leaderboard + check_tiktok_creator_status + list_tiktok_tto_brand_profiles + create_tiktok_tto_brand_profile + list_tiktok_tto_campaigns + create_tiktok_tto_campaign + update_tiktok_tto_campaign + link_tiktok_tto_video + list_tiktok_tto_link_requests + tiktok_tto_campaign_report + request_tiktok_tto_spark_authorization + get_tiktok_tto_spark_authorization + manage_tiktok_tto_anchor (TIKTOK ONE / CREATOR MARKETPLACE: INFLUENCER MARKETING, and the only place in Hermoso that does it: find creators by audience size, engagement, price and who their followers actually are, check whether they have joined TikTok One, invite them to a campaign with an invite link, ask them to tag a video to it, and read every metric SPLIT ORGANIC VERSUS PAID. Its account id is a THIRD id space; not an advertiser id and not a Business Center id; so start at list_tiktok_tto_accounts. It rides this same connection with nothing extra to apply for. IT ALSO CLOSES THE SPARK ADS LOOP: request_tiktok_tto_spark_authorization asks a creator directly and get_tiktok_tto_spark_authorization returns the code authorize_tiktok_ads_spark_post takes, which is otherwise obtainable only by the creator pasting one out of the TikTok app. Two things put a notification in a real person’s inbox; a campaign invitation and a video-linking request; and a repeated linking request is a REMINDER that TikTok caps at two, so read list_tiktok_tto_link_requests before re-sending anything) · list_tiktok_ads_stores + list_tiktok_ads_store_products (TIKTOK SHOPS: what a Shopping Ads or GMV Max campaign sells from; the store list is keyed on an ad account and the product list on a BUSINESS CENTER, which each store row names) · tiktok_ads_verification_status + list_tiktok_ads_verification_documents + submit_tiktok_ads_verification (BUSINESS VERIFICATION: an unverified account hits limits that get diagnosed as something else, so it is worth reading during onboarding. Hermoso never handles a verification DOCUMENT: submitting sends account details plus the ids of images the user uploaded in TikTok Ads Manager, and the legal name and document number can never be changed afterwards, so it is confirm-gated) · list_tiktok_ads_payment_portfolios + list_tiktok_ads_payment_portfolio_links (HOW THE AD ACCOUNTS ARE FUNDED: read-only, because "why did delivery stop" is often a funding answer, and because deciding where a customer’s money sits is not ours to do) · create_tiktok_ads_rule + list_tiktok_ads_rules + update_tiktok_ads_rule + bind_tiktok_ads_rule + set_tiktok_ads_rule_status + tiktok_ads_rule_results (AUTOMATED RULES — standing instructions TikTok runs on the account unattended. THE SECOND SPEND SWITCH ON THIS PLATFORM and gated in TWO CLASSES: a rule that can only pause, decrease or email needs confirm:true, while one that can TURN_ON an object or RAISE a budget or bid needs confirm:true AND confirmScope echoing the token list_tiktok_ads_rules prints, computed from the rule as TikTok STORES it. Every rule is created TURNED OFF and read back to prove it, because TikTok publishes no way to create one in the off position. TikTok emails rule notifications to the DEVELOPER address on the app rather than to the advertiser, so tiktok_ads_rule_results is the only place a customer sees what a rule did — and TikTok itself says this endpoint is for direct advertisers and may refuse a platform-managed account entirely). · list_tiktok_ads_comments + tiktok_ads_comment_thread + moderate_tiktok_ads_comment + reply_to_tiktok_ads_comment + delete_tiktok_ads_comment (COMMENT MODERATION on your own TikTok ads — the platform where the comment section IS the ad. HIDE is the moderation verb and works on anyone’s comment and is reversible; DELETE only ever removes a comment your OWN identity posted, which TikTok reports per comment as canDelete. Comments are scoped to an AD GROUP and to nothing else, and the time window may span at most 30 DAYS, so an empty answer means “none in these 30 days” rather than “none ever”) · list_tiktok_ads_blocked_words + manage_tiktok_ads_blocked_words (a standing 500-word filter that auto-hides any comment containing one of these across EVERY ad on the account — nothing else in Hermoso does this, and removing a word republishes every comment it had hidden) · tiktok_ads_diagnosis (TikTok’s own issues-and-suggestions verdict on your ad groups — creative, bid/budget with its full estimated-delivery tables, and a pixel that has gone quiet. It covers ACTIVE ad groups only and omits any it has nothing to say about, so an empty answer is not a clean bill of health) · get_tiktok_ads_brand_safety + set_tiktok_ads_brand_safety (what content the ads may appear next to. Two things to say out loud: TikTok applies this to Smart+ campaigns and explicitly NOT to the regular campaigns create_tiktok_ads_campaign builds, and coverAllObjectives is a ONE-WAY DOOR TikTok cannot set back). TWO THINGS HERE ARE UNLIKE EVERY OTHER AD PLATFORM: TikTok creates objects ENABLED by default, so Hermoso forces every campaign, ad group and ad PAUSED with no override and nothing serves until set_tiktok_ads_status(confirm:true); and TikTok’s QPS is 1, so every call is serialized and a tree build or a bulk read is SLOW BY DESIGN — a throttle is not a broken connection. SNAPCHAT ADS (the tenth ad platform — Settings ▸ Connectors ▸ Snapchat Ads; a SEPARATE connection from Snapchat posting): list_snapchat_ads_accounts (the organizations and AD ACCOUNTS this profile can act on — every other Snapchat tool needs an adAccountId and this is where it comes from) · list_snapchat_ads_campaigns (the whole tree — campaigns, ad squads and ads) · snapchat_ads_report (impressions, spend, swipes and video quartiles at any level) · search_snapchat_ads_targeting (resolve country / region / interest / language ids — an ad squad cannot be created without at least one country) · upload_snapchat_ads_creative (put a finished render on the ad account as MEDIA and then as the CREATIVE an ad points at — Snapchat has no upload-from-URL, so Hermoso streams the bytes) · create_snapchat_ads_campaign → create_snapchat_ads_ad_squad → create_snapchat_ads_ad (the tree, every tier born PAUSED) · set_snapchat_ads_budget · set_snapchat_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_snapchat_ads_object (a REAL delete verb here, unlike TikTok — irreversible, so offer PAUSED first). THREE THINGS TO SAY OUT LOUD ON THIS PLATFORM: money is MICRO-CURRENCY (1,000,000 = one unit), so quote plain amounts and let Hermoso convert, and never pass both units — under-converting fails loudly while double-converting asks for a budget a million times too large; the creative HEADLINE is capped at 34 characters and brandName at 32, far shorter than Meta or Google, and over-long copy is refused rather than truncated; and a Snapchat ad points at a CREATIVE, never at a media id. SNAPCHAT POSTING (Stories / Spotlights on a Public Profile) IS BUILT BUT NOT YET REACHABLE — Snap’s Public Profile API is allowlist-only and Hermoso has not been allowlisted, so the connector is deliberately not offered; say that plainly rather than looking for a tool. LINKEDIN: post_to_linkedin (publish a finished post to the connected LinkedIn PROFILE) · list_linkedin_pages (the company Pages this connection administers — call this first and let the USER pick, never guess a Page) · post_to_linkedin_page (publish as a company PAGE rather than a person — this is the one most brands actually want) · manage_linkedin_post (edit the copy of a published post, or delete it) \u00b7 list_linkedin_comments / reply_to_linkedin_comment / delete_linkedin_comment (moderate the comments on your Page\u2019s posts \u2014 a SEPARATE LinkedIn authorization grants these, see Connectors) · linkedin_page_analytics (ORGANIC Page performance — followers, follower gains, Page views, and post impressions/clicks/engagement, for the Page total or per post; this is the free organic read, NOT linkedin_ads_report). LINKEDIN ADS (full three-tier management): list_linkedin_ads_campaigns (ad accounts, then a chosen account’s campaign groups, campaigns and — with campaignId — the CREATIVES under them) · linkedin_ads_report (impressions, clicks, cost, conversions, leads) · search_linkedin_ads_targeting (resolve locations / titles / industries / seniorities / company sizes to the URNs LinkedIn demands — never invent one) · linkedin_audience_count (HOW MANY members that targeting actually reaches, before a budget is committed — and a returned 0 means fewer than 300 people, LinkedIn’s privacy floor and also its campaign minimum, never an empty audience) · linkedin_bid_pricing (LinkedIn’s own suggested bid and daily-budget range for that audience — quote it instead of guessing what LinkedIn costs) · create_linkedin_ads_campaign_group → create_linkedin_ads_campaign → create_linkedin_ads_creative (the tree, every tier born DRAFT) · set_linkedin_ads_budget / set_linkedin_ads_status / delete_linkedin_ads_object (budgets, activate/pause at any tier, delete — every spend change confirm-gated). LINKEDIN LEAD SYNC: list_linkedin_lead_forms · list_linkedin_leads / get_linkedin_lead (the LEADS its forms collected, answers named by field — PERSONAL DATA: show, never republish) · subscribe_linkedin_leads / list_linkedin_lead_events / list_linkedin_lead_subscriptions / delete_linkedin_lead_subscription (real-time push to Hermoso, optional forwardTo relay to a CRM; LinkedIn validates ONLY Hermoso’s own webhook). LinkedIn is a THREE-tier platform and the third tier is the one people forget: a campaign with no creative shows nothing, and all three tiers must be ACTIVE before a single impression is served. REDDIT ADS: list_reddit_ads_campaigns / reddit_ads_report (read the account tree + performance) · list_reddit_ads_profiles + list_reddit_ads_posts / create_reddit_ads_post / update_reddit_ads_post (the CREATIVE — a Reddit ad promotes a post) · create_reddit_ads_campaign / update_reddit_ads_campaign · create_reddit_ads_ad_group / update_reddit_ads_ad_group · create_reddit_ads_ad / update_reddit_ads_ad · create_reddit_ads_max_campaign / get_reddit_ads_max_template / update_reddit_ads_max_template (a Reddit MAX campaign: automated campaign, ad group and a template ad Reddit generates ads from, built from creative-library assets, all PAUSED) · set_reddit_ads_status (the ONLY switch that arms real spend, confirm-gated) · delete_reddit_ads_object (remove a campaign, ad group or ad — Reddit has no delete verb, removal is a status, and it refuses to delete anything touched in the last 3 hours) · delete_reddit_ads_saved_audience · search_reddit_ads_targeting / reddit_ads_forecast / reddit_ads_bid_suggestion (free planning) · list_reddit_ads_pixels + send_reddit_ads_conversions (conversion tracking — Reddit now requires a pixel on every ad group) · list_reddit_ads_audiences / create_reddit_ads_audience / update_reddit_ads_audience_users / delete_reddit_ads_audience (retargeting lists) · list_reddit_ads_saved_audiences / create_reddit_ads_saved_audience / update_reddit_ads_saved_audience · list_reddit_ads_lead_forms / create_reddit_ads_lead_form · reddit_ads_history (who changed what, when). TELEGRAM: post_to_telegram (publish to a channel, group or chat as the brand’s own bot — text up to 4096 characters, but only 1024 once any photo or video is attached; one image, one video, or an album of 2–10 in which photos and videos may be mixed. chatId IS ALWAYS REQUIRED and is never guessed: the Bot API publishes NO method that lists the chats a bot belongs to, so pass the public channel’s @username or the numeric id) · list_telegram_chats (chats that MESSAGED the bot in the last 24 hours — a shortcut for finding an id, NOT a roster, and a chat missing from it can still be posted to) · list_telegram_dms (what those chats actually SAID, newest per chat — free, and a rolling 24-hour window rather than an inbox: the Bot API has no history endpoint at all) · delete_telegram_message (confirm-gated; Telegram refuses once a message is more than 48 hours old). BLUESKY: post_to_bluesky (publish as the connected account — text up to 300 characters AND, separately, 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; either up to 10 images OR one MP4 video, never both, because a Bluesky post record carries exactly one embed; links are made clickable automatically) · delete_bluesky_post (PERMANENTLY remove one of the account’s own posts — no trash and no undelete. Call it WITHOUT confirm first: it deletes nothing and reports the post’s real text and live like/repost/reply/quote counts, and once the post has any engagement it also wants confirmText echoing its text. Takes the AT-URI or just the record key from the bsky.app link) · list_bluesky_convos / read_bluesky_dm / send_bluesky_dm / mark_bluesky_convo_read (the account’s DIRECT MESSAGES — free, 1000 characters each, text only, and they need a PRIVILEGED app password: an ordinary one posts fine and cannot chat). Replies, mentions AND direct messages all arrive in list_inbox and are answered with reply_to_inbox_item. X / TWITTER: post_to_x (publish a post — text, an image or a video render WITH alt text, a POLL, a reply, or a whole thread, and optionally restrict who may reply; X is the ONE channel that bills per API request, a post carrying a LINK costs roughly 13× one without, and each profile has a rolling 24-hour ceiling on X spend that refuses a request whole rather than publishing half of it) · delete_x_post (remove one) · x_post_metrics (the PUBLIC counts — impressions, likes, reposts, replies, quotes, bookmarks) · x_post_insights (the ADVERTISER numbers for your own posts — link clicks, profile visits, video views and completion quartiles, up to 25 posts at once; this is what says whether a creative worked, and x_post_metrics cannot tell you, but it only sees the LAST 28 HOURS) · x_post_insights_historical (the same advertiser numbers over ANY date range — the one to use for anything older than yesterday) · x_mentions (who is talking to the brand, in their own words — the read half of the reply loop, and a source of real customer language for ad copy) · list_x_dms (the brand’s X DIRECT MESSAGES, grouped into conversations, saying which are waiting on a reply — billed per message returned, and X keeps only 30 days) · send_x_dm (reply privately to one named person; never a broadcast) · x_chat_settings / update_x_chat_settings (the X account’s DM privacy: who may message the brand, the request filter, read receipts and calls). X IS THE ONE CONNECTOR THAT COSTS CREDITS PER CALL — X charges us per API request, so posting, deleting, reading metrics, reading insights and pulling mentions each bill the user, a post CONTAINING A LINK costs 13× one without, and insights and mentions are billed PER POST RETURNED. Say so before posting a thread or pulling a big page of mentions, and prefer one post over five when the content allows. X ADS (the PAID half — a SEPARATE connection from the organic tools above: its own product on its own host with OAuth 1.0a signing, and X grants API access PER AD ACCOUNT rather than per app, so the customer adds Hermoso’s X user at business.x.com → Account access before anything here resolves): list_x_ads_accounts (the ad accounts this profile can act on, WITH the permission level held on each — read it before attempting a write) · list_x_ads_funding_instruments (a campaign cannot be created without one) · list_x_ads_campaigns / list_x_ads_line_items / list_x_ads_promoted_tweets / list_x_ads_targeting (the whole tree as it stands) · x_ads_report (impressions, clicks, spend and engagements at any level) · x_ads_geo_search / x_ads_targeting_search (resolve places and targeting values to the ids X demands — never invent one) · create_x_ads_campaign → create_x_ads_line_item → create_x_ads_promoted_tweet (the tree, every tier born PAUSED with no override; A CAMPAIGN ALONE CANNOT SERVE ON X — it needs a line item and a promoted post underneath it, and the read-back says so rather than letting you call it a finished ad) · add_x_ads_targeting · update_x_ads_campaign / update_x_ads_line_item (throttle or raise spend on a running campaign without rebuilding it) · set_x_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_x_ads_object. PINTEREST — POSTING AND ADS ARE TWO SEPARATE CONNECTIONS on the same Pinterest login (Pinterest keeps ads access behind different permissions), so a brand can hold either without the other and connecting one does not connect the other; if an ads call says Pinterest Ads is not connected, that is the card to send them to, NOT the Pinterest posting one. ADS: pinterest_ads_async_report (the DEEP paid report — 914 days back where the quick one stops at 90, and three times the metric columns; generated asynchronously, so pass the returned token back rather than re-submitting) · pinterest_targeting_analytics (WHICH audience segment delivered — by keyword, interest, age, gender, location, placement) · pinterest_audience_insights (WHO the audience is: interest affinities plus demographics, the input to a creative brief rather than a performance report) · pinterest_analytics (ORGANIC performance — impressions, saves, Pin clicks, outbound clicks, for the account, the TOP PINS, the top video Pins, or one Pin; Pinterest keeps 90 days and publishes no board-level analytics at all) · create_pinterest_board (make a board — a NEW Pinterest account has none and a Pin needs one) · list_pinterest_boards (the user must pick a board — never choose one for them) · post_to_pinterest (create an image or video Pin on a chosen board, with a title, description and destination link) · list_pinterest_pins (the Pins on a board with their ids — where the pinId every Pin tool needs comes from, and it flags any Pin an ad is promoting) · update_pinterest_pin (retitle, re-describe, fix a dead link, move it — Pinterest keeps this endpoint in a limited BETA, so it may be refused outright and save_pinterest_pin is the generally-available way onto another board; a Pin’s picture can never be swapped by anyone) · save_pinterest_pin (copy a Pin onto another board) · delete_pinterest_pin (confirm-gated, and it says whether an ad is promoting the Pin first) · update_pinterest_board (rename, re-describe, or hide it — SECRET hides every Pin on the board, reversibly) · delete_pinterest_board (the heaviest one here: the board AND every Pin on it, confirm-gated with the Pin count echoed back — offer hiding it instead). GOOGLE ADS (full management): list_google_ads_campaigns (list accounts, then a customer’s campaigns + spend/CTR/CPC/conversions) · google_ads_report (any GAQL breakdown — ad groups, keywords, search terms, geo) · create_google_ads_campaign (paused) · set_google_ads_budget / set_google_ads_status (change budget, enable/pause — every spend change confirm-gated) · delete_google_ads_object (remove a campaign, ad group, ad, KEYWORD, asset LINK or conversion action — Google has no delete verb, `remove` is the terminal state and it cannot be undone; call it unconfirmed first to see the spend and the tree that go with it) · upload_google_ads_asset (add an image render or a YouTube video to the ad account’s asset library) · create_google_ads_performance_max_campaign (Google’s cross-surface campaign type, RETAIL INCLUDED — pass merchantCenterId to make it a Shopping-feed Performance Max advertising the WHOLE Merchant Center feed under one root listing group, and feedLabel to narrow it to a single feed; only PARTITIONING that feed by brand/category/custom label is refused by name) · add_google_ads_assets (sitelinks, callouts, structured snippets, call, promotion, price, lead-form, business name and logo assets, CREATED AND ATTACHED — an asset that is not attached shows nothing) · manage_google_ads_assets (list, edit, pause or detach them) · set_google_ads_tracking (UTMs / tracking template on a campaign, ad group or ad; new campaigns get standard UTMs by default) · manage_google_ads_audience + set_google_ads_audiences (remarketing lists, custom segments, affinity / in-market interests, demographics, Performance Max signals and search themes) · upload_google_ads_video (a render straight into Google Ads, no YouTube channel needed) · create_google_ads_demand_gen_campaign (YouTube Shorts / in-feed / in-stream video ads, born PAUSED) · list_google_ads_conversion_actions + create_google_ads_conversion_action (what Google counts as a result — MAXIMIZE_CONVERSIONS, TARGET_CPA, TARGET_ROAS and every Performance Max campaign are undeliverable without one, and Hermoso refuses to build them on an account that has none) · google_ads_keyword_ideas (Keyword Planner — real monthly search volume, competition and top-of-page bids; use it before choosing keywords) · google_ads_recommendations + manage_google_ads_recommendations (Google’s own advice and optimization score; apply / dismiss / auto-apply, spend-type ones only after the user’s yes) · preview_google_ads_ad (a shareable no-login preview link for a Performance Max asset group or YouTube video ad, for client approval) · google_ads_policy_issues (WHY Google disapproved or limited an ad, asset or keyword: the policy topic and the evidence) · google_ads_change_history (WHAT CHANGED ON THE ACCOUNT AND WHEN — the answer to “performance fell off a cliff on Tuesday, what happened?”. Its default source is field-level and reaches 30 days; the other source reaches 90 and is the ONLY one that sees Google Ads Editor and criterion edits, so check both before telling anyone nothing changed). GOOGLE MERCHANT CENTER (the product feed behind every Shopping ad and every free listing, on the SAME connection as Google Ads): register_merchant_developer (the ONE-TIME link between Hermoso’s Google Cloud project and the merchant’s account. Google refuses every other Merchant call until it is done, so run this first when calls are being refused) · list_merchant_accounts (which Merchant Centers this login can reach, and where the merchantCenterId every other tool needs comes from) · list_merchant_products (the feed itself, with each product’s disapprovals) · list_merchant_issues (account-level problems, the answer to "why is nothing showing at all") · merchant_issue_help + trigger_merchant_issue_action (Google’s OWN remediation steps for a problem, and the button that fires one. Several of those actions are one-shot in Google’s own words, so firing one is confirm-gated) · list_merchant_data_sources + create_merchant_data_source + delete_merchant_data_source (feeds. A product write only lands in an API-input feed, and most accounts have none until one is made, so check before writing) · upsert_merchant_product + update_merchant_product + delete_merchant_product (write the feed) · list_merchant_inventory + set_merchant_inventory (the per-STORE and per-REGION price, stock level and availability override on one product, which is what stops a Shopping ad advertising something the nearest store has sold out of. The write MERGES, because Google’s insert replaces the whole entry, and Google takes up to 30 minutes to reflect it on the product) · list_merchant_promotions + create_merchant_promotion (sale and discount badges on a listing. Google validates them asynchronously, so created is never the same as approved) · manage_merchant_notifications (Google POSTs to a URL THE MERCHANT RUNS the moment a product is disapproved, instead of someone having to poll) · merchant_account_status (WHY THE ACCOUNT IS OR IS NOT SERVING — the first thing to run when Shopping ads or free listings show nothing, and the one read that does not believe the program state: an account can report both programs ENABLED and serve in ZERO countries, because a region counts as active only where every requirement is met. It names Google’s own unmet requirements, then the settings that explain them: homepage claimed or not, business address, phone and support contact, active shipping services, return policies, terms accepted) · manage_merchant_settings (FIXES what merchant_account_status finds: shipping services merged by name so unmentioned ones are kept, return policies, business address and support contact, homepage claim, programs on or off; overwrites and removals ask first) · manage_merchant_conversion_source (WHERE MERCHANT CENTER GETS ITS CONVERSION DATA FROM, which is what free-listing and Shopping performance reporting is built on — a merchant with no conversion source sees clicks and no outcomes. Either a Google tag destination, whose MC-… id comes back only on the create and is the id the Google tag has to send conversions to, or a link to a GA4 property, which is IMMUTABLE and needs the connected Google account to be an admin there. A delete is an ARCHIVE and undelete restores it until the expiry Google reports) · merchant_quota (whether the account is simply out of daily API quota or out of product slots, which looks identical to a broken integration and is not. Google resets it at MIDDAY UTC) · merchant_report (the reports Google computes for free, including competitive visibility, best sellers and price competitiveness). MICROSOFT MERCHANT CENTER (the same job on Microsoft’s side, on the Microsoft Advertising connection): list_microsoft_merchant_stores · list_microsoft_merchant_products · upsert_microsoft_merchant_product · delete_microsoft_merchant_product · list_microsoft_merchant_issues · list_microsoft_merchant_catalogs + manage_microsoft_merchant_catalog. GOOGLE ANALYTICS (GA4 — the brand’s OWN site data, and a SEPARATE connection from Google Ads: a brand that spends on Ads every day may have no Analytics access at all, so never read one as the other): list_analytics_properties (call this FIRST — every other Analytics tool needs a NUMERIC property id, and what users actually know is the “G-XXXXXXX” Measurement ID from their tracking snippet, which no endpoint accepts; resolve it from this list rather than sending them hunting. It lists the properties SHARED WITH THIS PROFILE, not everything the Google account can see — Analytics access is handed out freely and one login often has Viewer on many clients’ properties, so the user ticks which belong to this profile and any other one is refused by name; an empty list means nothing is ticked yet, which set_connector_accounts or Settings ▸ Connectors ▸ Google Analytics ▸ Manage accounts fixes) · analytics_report (what happened — sessions, users, revenue, conversions and engagement broken down by channel, source/medium, campaign, landing page, country, device or date, i.e. the read that says whether the traffic an ad bought actually did anything) · analytics_realtime (who is on the site right now, ~30 minutes — a DIFFERENT metric set that rejects `sessions` outright, never a shortcut for analytics_report) · list_analytics_definitions (what the property already measures: its key events and its own custom dimensions, and the check to run before creating either) · create_analytics_key_event (mark an event GA4 already collects as a KEY EVENT — the 2024 rename of a conversion, and what makes it importable into Google Ads; marking an event the site never fires creates one that can never fire) · create_analytics_custom_dimension (register an event parameter the site already sends so reports can break down by it — say out loud first that a GA4 custom dimension CANNOT be deleted, only archived, and a property is capped at 50 event-scoped ones, so a typo permanently burns a slot) · list_analytics_data_streams (the streams on a property and the measurement ID (G-...) each one carries, which is what a gtag or GTM install needs and what nobody can find in the GA4 UI when asked) · get_analytics_stream_setup (the finished gtag <script> block to paste into the site — the last mile list_analytics_data_streams stops short of — plus whether enhanced measurement is really collecting scrolls, outbound clicks, site search, video, downloads and form interactions, and whether redaction is stripping campaign parameters out of recorded URLs. Web streams only. Read the master switch before believing a toggle: with enhanced measurement off for the stream, every toggle is inert whatever it says) · list_analytics_metadata (every dimension and metric this property can be asked for, including its own custom ones, which is what stops analytics_report guessing a field name) · check_analytics_compatibility (whether a dimension and metric can appear in the same report before spending a call finding out they cannot) · create_analytics_custom_metric + archive_analytics_custom_metric · archive_analytics_custom_dimension · delete_analytics_key_event (all one-way in the same sense as their create twins: archiving is not deleting and there is no un-archive) · list_analytics_google_ads_links + link_google_ads_to_analytics + unlink_google_ads_from_analytics (the join that makes a GA4 audience usable in Google Ads and a GA4 key event importable as a conversion — without it a perfectly good audience simply never appears in the ads account, with no error anywhere) · list_analytics_audiences + create_analytics_audience + archive_analytics_audience (GA4 remarketing audiences, the input to Google Ads remarketing. Archiving is one-way) · manage_analytics_measurement_protocol_secret (mint the API secret that lets the customer’s OWN SERVER send events straight into GA4, the Google twin of the conversions APIs already here for Reddit, Snapchat and OpenAI Ads. Say out loud that there is NO rotation anywhere in the API, so replacing a secret means create the new one, move every sender across, then delete the old one) · manage_analytics_channel_group (HOW GA4 BUCKETS TRAFFIC — the answer to “why is my campaign showing as Unassigned”, and the one number an ad studio is judged on. Read the Default channel group’s rules before diagnosing anything, then author your own group whose channels catch the campaigns Hermoso publishes. The rule fields are the eachScope… names, NOT the sessionSource / medium dimensions reports use, and GA4 stops at the first rule that matches so order decides everything) · manage_analytics_calculated_metric (the derived number a marketer actually reports — cost per purchase, revenue per session — built from metrics GA4 already collects and then available to analytics_report under its own permanent API name. The id is permanent, and a formula naming a metric the property does not collect is created happily and flagged invalid, so read that flag back) · manage_analytics_annotations (mark a launch or a post on the customer’s own GA4 charts, so the traffic it moved shows where they look) · analytics_data_retention (how long GA4 keeps event data — the 2-month default caps every exploration; lowering it deletes data, so it is confirm-gated). MICROSOFT ADVERTISING / BING ADS (full management, mirroring Google): list_microsoft_ads_campaigns (list the shared ad accounts, then a chosen account’s campaigns + budgets) · microsoft_ads_geo_search (resolve country / region / city names to the Microsoft location ids a campaign needs — call it when an ask is ambiguous and let the USER pick) · microsoft_ads_report (impressions, clicks, CTR, average CPC, spend, conversions — generated asynchronously, so it may come back pending and must be called again) · create_microsoft_ads_campaign (campaign → ad group → responsive search ad → keywords, always Paused; with no locations[] it is created serving WORLDWIDE, Microsoft’s own default, and the read-back warns loudly — relay that before anyone activates it) · create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (fill in an existing account) · set_microsoft_ads_budget / set_microsoft_ads_status (change budget, activate/pause — every spend change confirm-gated; Microsoft statuses are Active/Paused, never Deleted) · search_microsoft_ads_profiles / list_microsoft_ads_profile_targeting / set_microsoft_ads_profile_targeting (LinkedIn profile targeting: company, industry, job function, seniority and job title bid adjustments on a Search, Shopping or DSA campaign; confirm-gated on an ACTIVE campaign) · delete_microsoft_ads_object (a REAL delete — campaign, ad group, ad or keyword — permanent, with no undelete; call it unconfirmed first to see what goes with it) · microsoft_ads_keyword_ideas (Microsoft’s Keyword Planner — real search volume, competition and suggested bids, with NO planning-tier gate, unlike Google’s) · microsoft_ads_traffic_estimates (what those keywords would deliver at a named bid — a range, never one number) · microsoft_ads_budget_opportunities (where Microsoft says a budget is capping delivery, and what raising it is forecast to buy) · microsoft_ads_auction_insights (who ELSE is bidding on the same auctions — rival domains with their impression share, overlap and outranking share; shares of YOUR auctions, never a measure of a competitor’s whole account) · microsoft_ads_bulk_download (export the account as ONE bulk file — the only way to read ~185 Microsoft record types Hermoso cannot otherwise touch: sitelinks, callouts, structured snippets, labels, shared negative keyword lists, bid strategies, audiences, experiments, seasonality adjustments, conversion goals, asset groups, feeds) · microsoft_ads_bulk_upload (apply an edited bulk file — hundreds of objects in one request. IT IS GATED HARDER THAN ANYTHING ELSE ON THIS CONNECTOR, because a bulk file carries a Status column and can turn campaigns ON without ever touching set_microsoft_ads_status: confirm:true alone is refused, and you must first call it unconfirmed to get the row-by-row list of what it would ACTIVATE and DELETE, show that to the user, then echo both counts back as confirmActivations/confirmDeletions — or pass pauseInstead:true to land the file with every activation written as Paused) · list_microsoft_ads_conversion_goals (what the account counts as a conversion, and which goals are OFFLINE ones) · send_microsoft_ads_offline_conversions (close the loop: phone sales, in-store purchases and late-closing leads fed back so smart bidding stops optimising against website conversions alone — pass PLAIN emails and E.164 phones, hashing happens server-side to Microsoft’s own published spec) · microsoft_ads_editorial_reasons (WHY an ad or keyword was disapproved: the code, the word Microsoft objected to, where, and the appeal status) · list_microsoft_ads_audiences (the account’s Customer Match lists with their current sizes; a fresh list reads 0 for up to 48 hours and Microsoft will not use one under 300 people, so never call that a failed upload) · create_microsoft_ads_customer_list then apply_microsoft_ads_customer_list (build a Customer Match audience from PLAIN email addresses, normalized and SHA-256 hashed server-side to Microsoft’s own published spec so no plaintext ever leaves us; the user must be shown Microsoft’s Customer Match terms and agree first) · microsoft_ads_recommendations (what Microsoft ITSELF suggests changing, each one priced by Microsoft: budget raises carrying the current and recommended daily amount, new and broadened keywords, negative keywords it wants removed, and ads it has written. Every one INCREASES what the account buys, which is what they are for, so none is a free win and an empty list means Microsoft has no advice rather than that the account is optimal) · apply_microsoft_ads_recommendations (act on them, gated exactly like the bulk upload: confirm:true alone is REFUSED, so call it unconfirmed first to get every recommendation named with what it changes and Microsoft’s own cost estimate, show that to the user, then echo confirmCount and confirmCostIncrease back. Both are recomputed from a fresh read, and there is no undo) · dismiss_microsoft_ads_recommendations (take advice off the list. It cannot spend, so it needs no confirmation at all, and it is the right answer to “make it stop suggesting that” rather than applying something to clear it) · microsoft_ads_auto_apply (THE READ THAT ANSWERS “is Microsoft changing this account while nobody is looking?”, per type. An inherited account can already be opted in with nobody at the brand having done it) · set_microsoft_ads_auto_apply (turn that standing permission on or off. Switching any type ON is the strongest consent anywhere in Hermoso: Microsoft then writes and publishes its own ads under the brand’s name, deletes negative keywords so the account buys more searches, and changes conversion goals, unattended and indefinitely, with NOTHING to preview beforehand. So confirm:true is not enough and every type must be named in confirmTypes. Switching it OFF is never gated). GOOGLE BUSINESS PROFILE (the local-SEO channel — the listing panel on Google Search and Maps, which for a local business is where the demand actually is, and there is no delete): list_business_locations (the listings the connected Google account manages — call this first and let the USER pick when there is more than one; a Post on the wrong storefront is a public mistake) · post_to_google_business (publish a Post to the listing — text, ONE PHOTO and a call-to-action button; Google’s Posts API takes no video, so pass a still. EVENT and OFFER posts both require a title and a start date, and on an OFFER Google ignores the button link) · list_google_business_posts (what is showing right now, with each Post’s state) · delete_google_business_post (take one down — immediate and public, so confirm first) · list_google_business_reviews (the reviews on the listing, and which ones have NO reply yet — for a local business the highest-leverage surface there is) · reply_to_google_business_review (answer one publicly as the business; it is an UPSERT, so it replaces any existing reply) · list_google_business_questions + answer_google_business_question (the public Q&A on the listing) · google_business_search_keywords (the actual search terms people typed to find the listing — free local keyword data; low-volume terms are SUPPRESSED and come back as "fewer than N", never as zero) · google_business_insights (Search + Maps impressions, calls, website clicks, direction requests, messages, bookings — listing-level; Google discontinued per-Post insights in 2023 with no replacement, so never promise per-Post numbers) · get_business_location (everything the listing actually says — name, address, phone, website, categories, description, hours, service area — as the merchant set it; the answer to “what does our Google listing say?”) · update_business_location (change any of that — hours, phone, website, description, categories, even the name or address. It edits the live panel on Search and Maps with no draft and no undo, so call it WITHOUT confirm first: nothing is written, Google validates the payload, and you get the current value of every field you are about to change to show the user) · google_business_account (whose Business Profile account the listing is on, and whether the connected Google account’s role can edit it at all). Google gates this API behind a per-project access request and the default quota is zero, so the connection can be live and calls still refused — the error says so. CHATGPT ADS (ads under ChatGPT answers, via OpenAI’s Advertiser API — full management): list_openai_ads_campaigns (the ad account, then its campaigns, ad groups and ads with each ad’s review state) · openai_ads_report (impressions, clicks, spend, CTR, CPC, CPM at account / campaign / ad group / ad scope — run this first, it validates the key with zero spend risk) · openai_ads_geo_search (location ids) · list_openai_ads_audiences + create_openai_ads_audience (custom audiences — geo and these are the only list-based targeting this platform has; target them with customAudienceIds / excludedCustomAudienceIds on a campaign) · create_openai_ads_campaign (campaign → ad group → ad in one call, always PAUSED) · create_openai_ads_ad_group / create_openai_ads_ad (fill in an existing campaign) · update_openai_ads_object (rename, re-budget, rewrite context hints or the ad copy) · set_openai_ads_budget / set_openai_ads_status (change budget, activate, pause) · delete_openai_ads_object (ARCHIVE — this API has no delete and OpenAI say archiving is not reversible, so offer pausing first). TWO RULES THIS CHANNEL DOES NOT SHARE WITH THE OTHERS: it is connected by PASTING an Advertiser API key (no OAuth, no manager account, one key = one ad account), and it has exactly ONE creative format — a text plus image card, title 50 characters, body 100. There is NO VIDEO on ChatGPT Ads, so never offer a video ad here. GOOGLE DRIVE — ONE connection covering Drive, Sheets and Docs (full CRUD over the files Hermoso created there, plus any file the user hands over with the Google file picker in the app): save_to_drive · list_drive_files / get_drive_file · update_drive_file (rename/move/trash) · delete_drive_file · create_drive_folder. GOOGLE SHEETS (part of the Google Drive connection — export data to a spreadsheet the app creates, or read one the user picked; drive.file, no verification): create_sheet · append_to_sheet · read_sheet. GOOGLE DOCS (part of the Google Drive connection — export copy/brief/report as a doc, or read one the user picked; drive.file, no verification): create_doc · append_to_doc. GOOGLE SLIDES (part of the Google Drive connection — turn a swipefile collection into a real presentation, one slide per saved ad with the creative, brand, copy, run dates and platform; drive.file, no verification, no new scope): export_swipefile_deck — it CREATES a deck each time and cannot append to one the user already has, and a creative whose ad-library link has expired is reported rather than silently dropped. ONEDRIVE (full CRUD over the user’s Microsoft OneDrive): convert_onedrive_file (Microsoft converts a file server-side to PDF or JPG — ~130 formats including PowerPoint and Word decks, PSD, Illustrator, Sketch, 3D, video, iPhone HEIC and raw camera files; JPG needs both width and height) · save_to_onedrive · list_onedrive_files / get_onedrive_file · update_onedrive_file (rename/move) · delete_onedrive_file · create_onedrive_folder. INTERCOM (the brand’s own support inbox, nothing billed): list_intercom_conversations (filter by state, assignee, team, tag, channel, customer, dates, priority, unread, or app = the product the customer wrote from) · read_intercom_conversation · reply_to_intercom_conversation / add_intercom_note / manage_intercom_conversation (each a preview until confirm:true) · tag_intercom_conversation · list_intercom_tags · search_intercom_contacts · list_intercom_teammates · intercom_customer_language (customers’ own words for ad copy). VERCEL (the brand’s own sites, nothing billed): list_vercel_projects · list_vercel_deployments · list_vercel_domains · vercel_web_analytics (page views, visitors, top pages, referrers, countries) · deploy_vercel_page (a static landing page, confirm-gated; preview by default) · delete_vercel_deployment. Use these standalone — Hermoso is a full posting/ads/file-storage control surface, not only an ad generator.',
|
|
261
|
+
'E) PUBLISH & MANAGE YOUR CHANNELS — post, run ads, and organize files on the user’s OWN connected accounts (Settings ▸ Connectors), all driven over this MCP. Bring ANY file in with upload_file (desktop/external media, not just Hermoso renders). MANAGING THE CONNECTIONS THEMSELVES: list_connectors (what is linked, and what could be) · list_connector_accounts then set_connector_accounts (WHICH Facebook Pages / Instagram / Meta ad accounts / Google Ads customers / LinkedIn company Pages and ad accounts / Pinterest ad accounts / Microsoft Advertising accounts / Reddit ad accounts / Google Business listings / Google Analytics properties this profile may post to, spend from and read — one person often administers or has access to several belonging to different clients, only the chosen ones are usable anywhere, and an empty choice shares nothing) · connect_connector (connect a PASTE-A-KEY account from here: ' + Object.values(KEY_CONNECTORS).map((s) => s.label).join(', ') + '; offer it beside the Connectors page in the app and let the user choose, because a key pasted into a chat stays in its history) · disconnect_connector (drop a connection from this brand, without revoking access at the platform; confirm-gated because reconnecting a sign-in account needs a browser) · leave_connector (on a connector several teammates can each contribute their OWN account to, remove just YOURS — teammates’ accounts keep working and nothing is revoked at the provider). LINKING an account that connects through a provider sign-in screen (OAuth) is the one step that is not headless: hand the user its connect link, https://app.hermoso.ai/?connect=<provider>, or send them to Workspace ▸ Connectors in the app. META: list_meta_pages · instagram_insights (ACCOUNT-level Instagram performance — views, reach, accounts engaged, interactions, saves, profile link taps — plus the audience DEMOGRAPHICS by age / city / country / gender) · list_instagram_media (the brand’s own recent Instagram posts, and where the media id every other Instagram tool needs comes from) · list_instagram_dm_automations / save_instagram_dm_automation / test_instagram_dm_automation / delete_instagram_dm_automation (INSTAGRAM DM AUTOMATIONS — a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply; test shows what a sample would send without sending) · list_dm_automations / save_dm_automation / test_dm_automation / delete_dm_automation (THE SAME ON EVERY CHANNEL — Instagram, Facebook Page comments and ads + Messenger, and X: a keyword mention or reply gets a public reply, a DM keyword a DM; X is billed in credits per delivered event and per send) · search_instagram_audio (licensed music and original sounds an Instagram Reel may use, by keyword or trending) · list_instagram_collab_invites then respond_instagram_collab_invite (collab-post invitations waiting on the account; accept or decline one, read back from Instagram) · list_instagram_collab_media (posts this account co-authors) · like_instagram (like a post or comment as the connected account) · post_to_meta (Facebook / Instagram / Threads) · list_meta_posts (the Page’s / Instagram account’s OWN existing posts with their ids — THIS is where the postId every other Meta read needs comes from; without it an agent that did not itself just publish has no way to name a post) · list_meta_ads + meta_insights (read existing campaigns/ad sets/ads + spend/CTR/CPC, with breakdowns by age / gender / placement / country) · preview_meta_ad (Meta renders the REAL ad per placement — a link the user can look at, valid 24h) · list_meta_pixels + create_meta_pixel (the pixel a conversion-optimised campaign REQUIRES — Meta will not let a build optimise for conversions without one, and until these existed a caller had no way to discover the id they had to pass) · estimate_meta_reach (how many people a targeting spec reaches, BEFORE a budget is committed) · list_meta_audiences / create_meta_audience (website-pixel retargeting, Page + Instagram engagement audiences, and lookalikes — creating one spends nothing) · list_meta_conversations / read_meta_conversation / reply_to_meta_message (MESSENGER AND INSTAGRAM DMs — the brand’s direct-message threads and a reply to someone who wrote first. Meta only permits a reply within 24 HOURS of the person acting, and read_meta_conversation says whether that window is open BEFORE anything is drafted; Hermoso sends replies only, never a proactive message or a message tag) · subscribe_meta_webhooks / meta_webhook_status / unsubscribe_meta_webhooks / list_meta_webhook_events (REAL-TIME EVENTS — have Meta PUSH new comments, mentions, lead-form submissions and inbound DMs to Hermoso instead of polling for them. Every other inbox read asks an edge “anything new?”; this is the only way to be TOLD, and it is how a lead arrives the moment it is submitted rather than when somebody thinks to look. An empty feed is ambiguous — check meta_webhook_status first, because an unsubscribed Page is silent and looks exactly like a quiet one) · instagram_collaborators (who ACCEPTED a Collab invite on an Instagram post — publishing only SENDS the invite, so this is the only way to know whether the post is actually live on the other account too) · list_instagram_shopping_catalogs / search_instagram_shopping_products / manage_instagram_product_tags (INSTAGRAM SHOPPING — make a post SHOPPABLE. Check eligibility and the account’s taggable catalogs, find the product ids, then pass productTags to post_to_meta so tapping the picture opens the product’s price sheet inside Instagram. Tagging needs an APPROVED Instagram Shop, so check FIRST — otherwise it fails after the media is already uploaded — and note that a tag whose product is not “approved” is stored and shown to nobody. Meta publishes no way to REMOVE a tag) · create_meta_catalog / update_meta_catalog / meta_catalog_blast_radius / delete_meta_catalog (BUILD AND RETIRE A CATALOG — create one on a named business portfolio, rename or re-point it, and, before ever proposing a delete, read meta_catalog_blast_radius: a catalog delete is PERMANENT with no archive and no undo, its product sets go with it, and any ad set still bound to one keeps spending with nothing to show) · list_meta_partnership_creators / manage_meta_partnership_creator (PARTNERSHIP ADS — the creators whose content this profile may run as an advert, and who may tag this brand as a paid partner. Two separate lists, neither implying the other, and neither defaults on; adding is a REQUEST the creator must accept, and an ad naming a creator who is only PENDING fails for a reason nothing in the error says) · list_meta_catalogs / list_meta_product_sets / list_meta_catalog_products (PRODUCT CATALOGS — the merchant’s own Meta catalogs, the product SETS inside each and the products themselves with Meta’s review status. A catalog is the input to Advantage+ catalog ads, the highest-performing ecommerce format on Meta: pass productCatalogId to create_meta_campaign and productSetId to create_meta_adset / create_meta_ad, and Meta builds every impression from the product’s own image, name and price — no render needed. An empty list is a fact about which business portfolio this login administers, NEVER about whether the merchant has a catalog) · create_meta_campaign / create_meta_ad / upload_meta_asset (build) · upload_meta_playable / list_meta_playables (PLAYABLE ads: the advertiser’s own interactive HTML game, run through create_meta_ad’s playableAssetId in an App Installs ad set) · list_meta_lead_forms / create_meta_lead_form (INSTANT LEAD FORMS — the form a lead ad opens INSIDE Facebook/Instagram instead of sending the click to a website; pass the id as create_meta_ad(objective:\"OUTCOME_LEADS\", leadFormId:…) and read the submissions with read_meta_leads) · update_meta_object / delete_meta_object / set_meta_campaign_status (edit, delete, activate — every spend + delete is confirm-gated) · delete_meta_audience (remove a custom audience or lookalike — its blast radius is the PEOPLE in it and the lookalikes built from it, which Meta refuses to delete around) · manage_meta_post (edit or delete a published post). THREADS (a separate connection from Meta, on its own API): post_to_meta(target:"threads") publishes · list_threads_posts · threads_insights · list_threads_replies / reply_to_thread / hide_thread_reply · list_threads_mentions · search_threads_keyword · repost_thread (amplify a customer’s post or one of your own to the brand’s profile — the Threads retweet, and there is NO documented un-repost) · delete_thread (confirm-gated; Threads has no EDIT at all, so delete-and-repost is the only correction) · threads_publishing_limit (how much of the rolling-24h quota is left — 250 posts, 1,000 replies, 100 DELETIONS, 500 location searches; check it before a bulk clean-up, because a quota refusal otherwise reads as a broken connection). SCHEDULING (one content calendar across every channel): schedule_post (queue a post for a future time to one or MORE channels at once — Facebook / Instagram / Threads / TikTok / YouTube / LinkedIn / X / Pinterest / Bluesky / Telegram (ten; Google Business Profile is accepted but held back on Google API access) — with per-channel captions; Hermoso publishes it at that time, nothing has to stay open — it goes LIVE PUBLICLY by default, and only stages as draft/unlisted/private if the user asks, and an impossible channel+visibility pair, an over-length caption or media the channel cannot carry is REFUSED while you are still there rather than failing hours later) · list_scheduled (what is queued and what already fired, with PER-CHANNEL outcomes) · reschedule_post (move a queued post to a new time, or change its caption, media, channels or target Page/board — send only what changes) · cancel_scheduled (pull a queued post before it goes out). POST PERFORMANCE (the loop that closes research → publish → learn — Hermoso records the HOOK and SUBJECT of everything it publishes, because those exist only at the moment of publishing and can never be recovered from a post id afterwards): list_published_posts (everything this brand has published across every channel, with the hook it was written to and its measured engagement) · post_performance (which HOOKS and SUBJECTS are getting traction — engagement rates compared WITHIN a channel and NEVER summed across them, with a verdict suppressed below 5 measured posts and the reason stated) · collect_post_metrics (pull fresh numbers ~24h and ~7d after each publish; a metric a channel cannot report is recorded ABSENT with its reason and never as zero, and X is skipped unless asked because it bills per call) · backfill_posts (import a channel’s past posts so the analysis has history — dry-run and cost-quoted first, and an imported post never votes on a hook unless it matched a Hermoso creation). YOUTUBE (publish, measure AND manage): post_to_youtube (publish a finished video to the brand’s channel — PUBLIC by default; unlisted (link-only, the ad-ready setting) or private only when the user asks) · list_youtube_videos (the channel’s OWN uploads with their video ids — call this to resolve “my latest video” yourself instead of asking the user for a link; it is where the videoId every other YouTube tool needs comes from, and it sees unlisted/private uploads a public search cannot) · update_youtube_video (retitle/re-describe/re-tag, and FLIP AN UNLISTED UPLOAD PUBLIC; confirm before going public) · delete_youtube_video (take one down for good — irreversible, so the unconfirmed call reports the video’s real title, privacy, views and comments first; use update_youtube_video(privacy:"private") when they only want it out of sight) · set_youtube_thumbnail (put a Hermoso thumbnail on an uploaded video — the biggest single lever on click-through, and YouTube otherwise picks a frame at random; needs a phone-verified channel) · update_youtube_channel (brand the CHANNEL ITSELF — banner art, description, keywords, country, the trailer non-subscribers see; everything else here brands the videos, this brands the page they sit on. It MERGES with the current settings, and it reports any field YouTube accepted but silently ignored, channel title above all) · set_youtube_watermark (the subscribe badge overlaid on EVERY video on the channel, including ones uploaded later — one square image brands the whole channel at once; the API publishes no way to read it back, so it reports accepted rather than confirmed) · list_youtube_video_stats (views, likes and comments for up to 50 videos IN ONE CALL, which is how to answer "how are my last twenty uploads doing" without one youtube_video_insights per video. It carries NO titles, because VideoStatsSnippet publishes only publishTime, so join on videoId with list_youtube_videos for names. YouTube calls this endpoint "intentionally not atomic", so a short answer is normal: the missing ids are named, and a missing id is never zero views) · youtube_video_insights (per-VIDEO views, watch time, average view PERCENTAGE/retention, likes, comments, shares, subscribers gained — the numbers that say whether a hook held; youtube_channel only gives channel-wide totals) · youtube_channel_report (the same numbers BROKEN DOWN — traffic source (search vs browse vs suggested vs shorts feed), the actual search terms, country/city, device, age+gender, subscribed vs not, and the audience-RETENTION curve showing exactly where viewers left) · list_youtube_comments + list_youtube_comment_replies + reply_to_youtube_comment (read viewer questions and objections in their own words, and answer as the channel) · moderate_youtube_comment (hide, reject, spam-report or delete an abusive comment — reject is reversible, delete is not) · list_youtube_playlists + manage_youtube_playlist + manage_youtube_playlist_items (organise the channel: create playlists, add/remove/re-order videos in them) · manage_youtube_playlist_image (a custom cover on a playlist — make_thumbnail renders the artwork, this is the call that puts it on. YouTube answers every failure here as an HTTP 500 whose real reason is buried inside it, and the tool unpacks that; if it comes back refused, check channel verification first) · manage_youtube_channel_section (the SHELVES ON THE CHANNEL HOMEPAGE — put a chosen playlist or a featured channel above YouTube’s own default layout, and re-order them. Every write is PUBLIC IMMEDIATELY, a delete has no undo, and YouTube’s own section list LAGS a write by a few seconds in both directions, so never treat a list taken straight afterwards as proof either way) · list_youtube_captions + manage_youtube_caption (real subtitle TRACKS — what YouTube indexes the video by and what a viewer toggles on, which is NOT the same as captions burned into the picture; downloading one is also the quickest way to get an existing video’s script back) · list_youtube_categories (which categoryId post_to_youtube will accept in a given country) · youtube_bulk_report (THE ONLY PLACE YOUTUBE PUBLISHES THUMBNAIL IMPRESSIONS AND THUMBNAIL CTR — a different, SCHEDULED API: the first call starts a job and returns nothing, then YouTube writes one file per day, the first within 48 hours, plus a 30-day backfill. It also carries per-card and per-end-screen metrics and an uncapped list of the search terms people arrived on) · list_youtube_report_jobs (whether that thumbnail history is already accumulating, and since when — check before promising a number) · delete_youtube_report_job (stop one; the job IS the history, so deleting it throws the accumulated files away) · youtube_channel (read title + subscriber/view/video counts for reporting). TIKTOK: post_to_tiktok (post a finished video — or a PHOTO POST, TikTok’s photo/slideshow format of 1 to 35 images where a single image is just a one-slide post —LIVE to the profile, or into TikTok drafts to review in the app) · tiktok_creator_info (the creator’s REAL privacy options — read them and let the user choose before any direct post) · tiktok_post_status (follow a post_to_tiktok that came back pending: done, failed or still processing) · tiktok_account (bio, verified status, follower/following/likes/video counts) · list_tiktok_videos (their own posts with views/likes/comments/shares — either the most recent, or specific videoIds read directly however old they are). ⚠️ TIKTOK HAS NO DELETE AND NO EDIT: its API publishes no way to remove a posted video or change its caption, privacy, cover or comment/duet/stitch settings — every one of those is fixed at publish time and there is no delete scope in TikTok’s scope catalogue at all. If the user wants a TikTok taken down or changed, say plainly that it has to be done in the TikTok app rather than hunting for a tool. TIKTOK ACCOUNT AUTHORIZATION (a SECOND, separate consent on the SAME TikTok app the TikTok Ads connection uses — holding one does NOT give you the other, so a brand fully connected for ads can still be unauthorized here, and that is a real third state rather than a broken session): tiktok_account_status (which state this profile is in, the TikTok business id, the scopes the grant carries and any MISSING from it — TikTok binds scopes at authorize time and never retroactively, so only a re-authorization picks up a new one — plus the exact URL to send the user to, because authorizing is the one step that needs a browser) · list_tiktok_comments + list_tiktok_comment_replies (the comments on the brand’s OWN posts, hidden ones included — TikTok’s answer to list_meta_comments and list_youtube_comments) · comment_on_tiktok_video · reply_to_tiktok_comment · moderate_tiktok_comment (LIKE / UNLIKE / HIDE / UNHIDE / DELETE — you can only DELETE a comment this account wrote, so HIDE is the tool for a stranger’s, and TikTok warns UNHIDE may not take effect when its own moderation is what hid it) · upload_tiktok_comment_image (a new comment will not take a raw image URL; a reply will) · set_tiktok_post_ad_authorization (THIS IS WHERE A SPARK ADS AUTHORIZATION CODE COMES FROM for the brand’s OWN post — previously a human had to copy one out of the TikTok app; hand the code to authorize_tiktok_ads_spark_post) · get_tiktok_post_ad_authorization · extend_tiktok_post_ad_authorization (the days are ADDED to what is left, not set as an absolute) · delete_tiktok_post_ad_authorization. BRAND MONITORING AND AUDIENCE, on that same account authorization (these need permissions added on 2026-08-20, so a brand that authorized before then holds a grant that predates them and has to authorize once more; tiktok_account_status names exactly which are missing, and the remedy is always to authorize the TikTok ACCOUNT again rather than to touch the advertiser connection, which is a separate grant and is unaffected): list_tiktok_mentions (public posts whose caption @-mentions the brand, TikTok’s answer to x_mentions and list_threads_mentions) · list_tiktok_mention_comments (comments whose text mentions it) · get_tiktok_mention (one mention in full, for the mentions webhook, and TikTok only keeps that data 48 hours) · tiktok_mention_top_terms (the top 20 keywords and top 20 hashtags inside those mentions) · list_tiktok_brand_hashtags + manage_tiktok_brand_hashtags + list_tiktok_brand_hashtag_posts (the hashtags TikTok counts as this brand’s, up to 50, and the posts carrying them; a new one is not counted for 24 hours and cannot be removed for 7 days) · tiktok_account_insights (follower demographics by age, gender, country and city plus the daily performance series, needing a BUSINESS account with 100+ followers, and capped at 60 days rather than the 90 the mention tools cover) · tiktok_category_benchmark (the same numbers averaged across an industry, so ‘are we ahead of our category’ is answerable). ALL OF THIS IS ORGANIC LISTENING ON THE BRAND’S OWN ACCOUNT, not ad research: for competitors’ ads use the ad-library research tools instead. TIKTOK ADS (a SEPARATE connection from the TikTok posting connector above — Settings ▸ Connectors ▸ TikTok Ads; a brand that posts to TikTok every day may still have no ad account here, so never read one as the other): list_tiktok_ads_accounts (the ADVERTISER accounts this profile can act on — every other TikTok Ads tool needs an advertiserId and this is where it comes from) · list_tiktok_ads_pixels + create_tiktok_ads_pixel + list_tiktok_ads_custom_conversions + tiktok_ads_pixel_stats (CONVERSION TRACKING — a conversion-optimised ad group dies at creation with "Please select a pixel" without one, so discover the pixel and its events BEFORE building the tree; note TikTok publishes no way to DELETE a pixel, so one you create is permanent) · list_tiktok_ads_campaigns (the whole tree — campaigns, ad groups and ads with their statuses) · tiktok_ads_report (impressions, clicks, spend, CTR, CPC, conversions and video views at any level) · list_tiktok_ads_identities (the TikTok accounts an ad may post AS — MANDATORY, with NO default: call it and let the USER pick, because the ad runs publicly under whichever account is named) · search_tiktok_ads_targeting (resolve location / interest / hashtag / language ids — an ad group cannot be created without location ids, and a guessed id targets the wrong people) · list_tiktok_ads_identity_posts (the ORGANIC posts an identity has already published — where a Spark Ad’s post id comes from) · list_tiktok_ads_spark_posts (the posts authorised for Spark Ads, i.e. promoting an organic post instead of uploading a new video) · authorize_tiktok_ads_spark_post + unbind_tiktok_ads_spark_post (add a creator’s post to that authorised set with the code they generated in the TikTok app, or release it again) · upload_tiktok_ads_playable / list_tiktok_ads_playables / delete_tiktok_ads_playable (PLAYABLE ads: the advertiser’s own interactive HTML zip, run through create_tiktok_smart_ad’s playableUrls) · upload_tiktok_ads_creative (THE STEP THAT TURNS A RENDER INTO AN AD — put a finished Hermoso video on the ad account and it hands back the videoId AND the coverImageId create_tiktok_ads_ad needs; there is no other source for either) · create_tiktok_ads_campaign → create_tiktok_ads_ad_group → create_tiktok_ads_ad (the tree) · set_tiktok_ads_budget · set_tiktok_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_tiktok_ads_object (removal on TikTok is a STATUS, not a verb — the same route as set_tiktok_ads_status) · create_tiktok_smart_campaign → create_tiktok_smart_ad_group → create_tiktok_smart_ad (Smart+, TikTok’s Performance Max — born PAUSED) · list_tiktok_smart_campaigns · set_tiktok_smart_status (the Smart+ money switch, confirm-gated) · tiktok_bid_protection (the ad-credit compensation TikTok pays when a Smart+ object misses its bid) · tiktok_lead_ads_terms (read and accept TikTok’s Lead Ads terms, which a lead-generation ad needs first) · list_tiktok_ads_lead_forms + list_tiktok_ads_lead_fields + download_tiktok_ads_leads + manage_tiktok_ads_test_lead (LEAD ADS — an Instant Form is built in TikTok Ads Manager and NO API creates one, so list them to find the id a LEAD_GENERATION ad group needs. The lead REGION is required with no default: it selects which of three separate lead stores you read, and leaving it out is a THIRD value rather than “all”, so an advertiser who omits it downloads an empty file and wrongly concludes there are no leads) · list_tiktok_ads_audiences + create_tiktok_ads_audience + create_tiktok_ads_lookalike_audience + apply_tiktok_ads_audience + update_tiktok_ads_audience + delete_tiktok_ads_audience + tiktok_ads_audience_overlap (CUSTOM AUDIENCES and lookalikes — TikTok targeting is otherwise interests-and-geo only. A freshly created audience reports itself invalid for up to 48 hours BY DESIGN, so that is not a failure to retry) · list_tiktok_ads_business_centers + list_tiktok_ads_catalogs + create_tiktok_ads_catalog + list_tiktok_ads_catalog_products + list_tiktok_ads_catalog_sets + manage_tiktok_ads_catalog_feed + tiktok_ads_catalog_diagnostics (DPA / PRODUCT CATALOGS, the Shopify lane — a catalog is keyed on a BUSINESS CENTER id, NOT an advertiser id, so list the Business Centers first or every call refuses) · list_tiktok_ads_apps + list_tiktok_ads_app_events (the registered apps an APP_INSTALL campaign needs — nothing else can produce an app id) · tiktok_ads_rf_inventory_estimate + create_tiktok_ads_rf_ad_group (REACH & FREQUENCY — a RESERVATION, so it is confirm-gated like a status change rather than born paused, and it needs a per-ad-account allowlist plus a signed branding contract that no endpoint reports. Always price it with the estimate first: TikTok silently books its own maximum rather than refusing an out-of-range value) · send_tiktok_ads_events (SERVER-SIDE conversion events — there is a vendor-sanctioned test code for exercising it without entering the advertiser’s real reporting), and its offline/crm sources take the event-set ids the two tools below mint) · list_tiktok_ads_offline_event_sets + manage_tiktok_ads_offline_event_set + send_tiktok_ads_offline_events (REAL-WORLD CONVERSIONS — an in-store purchase, a phone booking, a signed contract, reported so TikTok can attribute them to the ads that caused them. The timestamp is an ISO-8601 STRING here and a Unix NUMBER on send_tiktok_ads_events; a wrong-shaped one is accepted by TikTok and attributed to nothing. There is NO test code on this pair, so everything sent is a real permanent conversion — rehearse through send_tiktok_ads_events with eventSource “offline” and a testEventCode instead. Reporting also needs the connected user to be an ADMIN or OPERATOR of the advertiser, which managing the event SETS does not) · list_tiktok_ads_crm_event_sets + create_tiktok_ads_crm_event_set (LEAD-LIFECYCLE events — sending “this lead qualified / closed” back is what makes a LEAD_GENERATION campaign optimise toward leads that convert rather than form fills. TikTok publishes create and list and nothing else, so one of these is PERMANENT) · list_tiktok_tto_accounts + list_tiktok_creator_labels + discover_tiktok_creators + tiktok_creator_leaderboard + check_tiktok_creator_status + list_tiktok_tto_brand_profiles + create_tiktok_tto_brand_profile + list_tiktok_tto_campaigns + create_tiktok_tto_campaign + update_tiktok_tto_campaign + link_tiktok_tto_video + list_tiktok_tto_link_requests + tiktok_tto_campaign_report + request_tiktok_tto_spark_authorization + get_tiktok_tto_spark_authorization + manage_tiktok_tto_anchor (TIKTOK ONE / CREATOR MARKETPLACE: INFLUENCER MARKETING, and the only place in Hermoso that does it: find creators by audience size, engagement, price and who their followers actually are, check whether they have joined TikTok One, invite them to a campaign with an invite link, ask them to tag a video to it, and read every metric SPLIT ORGANIC VERSUS PAID. Its account id is a THIRD id space; not an advertiser id and not a Business Center id; so start at list_tiktok_tto_accounts. It rides this same connection with nothing extra to apply for. IT ALSO CLOSES THE SPARK ADS LOOP: request_tiktok_tto_spark_authorization asks a creator directly and get_tiktok_tto_spark_authorization returns the code authorize_tiktok_ads_spark_post takes, which is otherwise obtainable only by the creator pasting one out of the TikTok app. Two things put a notification in a real person’s inbox; a campaign invitation and a video-linking request; and a repeated linking request is a REMINDER that TikTok caps at two, so read list_tiktok_tto_link_requests before re-sending anything) · list_tiktok_ads_stores + list_tiktok_ads_store_products (TIKTOK SHOPS: what a Shopping Ads or GMV Max campaign sells from; the store list is keyed on an ad account and the product list on a BUSINESS CENTER, which each store row names) · tiktok_ads_verification_status + list_tiktok_ads_verification_documents + submit_tiktok_ads_verification (BUSINESS VERIFICATION: an unverified account hits limits that get diagnosed as something else, so it is worth reading during onboarding. Hermoso never handles a verification DOCUMENT: submitting sends account details plus the ids of images the user uploaded in TikTok Ads Manager, and the legal name and document number can never be changed afterwards, so it is confirm-gated) · list_tiktok_ads_payment_portfolios + list_tiktok_ads_payment_portfolio_links (HOW THE AD ACCOUNTS ARE FUNDED: read-only, because "why did delivery stop" is often a funding answer, and because deciding where a customer’s money sits is not ours to do) · create_tiktok_ads_rule + list_tiktok_ads_rules + update_tiktok_ads_rule + bind_tiktok_ads_rule + set_tiktok_ads_rule_status + tiktok_ads_rule_results (AUTOMATED RULES — standing instructions TikTok runs on the account unattended. THE SECOND SPEND SWITCH ON THIS PLATFORM and gated in TWO CLASSES: a rule that can only pause, decrease or email needs confirm:true, while one that can TURN_ON an object or RAISE a budget or bid needs confirm:true AND confirmScope echoing the token list_tiktok_ads_rules prints, computed from the rule as TikTok STORES it. Every rule is created TURNED OFF and read back to prove it, because TikTok publishes no way to create one in the off position. TikTok emails rule notifications to the DEVELOPER address on the app rather than to the advertiser, so tiktok_ads_rule_results is the only place a customer sees what a rule did — and TikTok itself says this endpoint is for direct advertisers and may refuse a platform-managed account entirely). · list_tiktok_ads_comments + tiktok_ads_comment_thread + moderate_tiktok_ads_comment + reply_to_tiktok_ads_comment + delete_tiktok_ads_comment (COMMENT MODERATION on your own TikTok ads — the platform where the comment section IS the ad. HIDE is the moderation verb and works on anyone’s comment and is reversible; DELETE only ever removes a comment your OWN identity posted, which TikTok reports per comment as canDelete. Comments are scoped to an AD GROUP and to nothing else, and the time window may span at most 30 DAYS, so an empty answer means “none in these 30 days” rather than “none ever”) · list_tiktok_ads_blocked_words + manage_tiktok_ads_blocked_words (a standing 500-word filter that auto-hides any comment containing one of these across EVERY ad on the account — nothing else in Hermoso does this, and removing a word republishes every comment it had hidden) · tiktok_ads_diagnosis (TikTok’s own issues-and-suggestions verdict on your ad groups — creative, bid/budget with its full estimated-delivery tables, and a pixel that has gone quiet. It covers ACTIVE ad groups only and omits any it has nothing to say about, so an empty answer is not a clean bill of health) · get_tiktok_ads_brand_safety + set_tiktok_ads_brand_safety (what content the ads may appear next to. Two things to say out loud: TikTok applies this to Smart+ campaigns and explicitly NOT to the regular campaigns create_tiktok_ads_campaign builds, and coverAllObjectives is a ONE-WAY DOOR TikTok cannot set back). TWO THINGS HERE ARE UNLIKE EVERY OTHER AD PLATFORM: TikTok creates objects ENABLED by default, so Hermoso forces every campaign, ad group and ad PAUSED with no override and nothing serves until set_tiktok_ads_status(confirm:true); and TikTok’s QPS is 1, so every call is serialized and a tree build or a bulk read is SLOW BY DESIGN — a throttle is not a broken connection. SNAPCHAT ADS (the tenth ad platform — Settings ▸ Connectors ▸ Snapchat Ads; a SEPARATE connection from Snapchat posting): list_snapchat_ads_accounts (the organizations and AD ACCOUNTS this profile can act on — every other Snapchat tool needs an adAccountId and this is where it comes from) · list_snapchat_ads_campaigns (the whole tree — campaigns, ad squads and ads) · snapchat_ads_report (impressions, spend, swipes and video quartiles at any level) · search_snapchat_ads_targeting (resolve country / region / interest / language ids — an ad squad cannot be created without at least one country) · upload_snapchat_ads_creative (put a finished render on the ad account as MEDIA and then as the CREATIVE an ad points at — Snapchat has no upload-from-URL, so Hermoso streams the bytes) · create_snapchat_ads_campaign → create_snapchat_ads_ad_squad → create_snapchat_ads_ad (the tree, every tier born PAUSED) · set_snapchat_ads_budget · set_snapchat_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_snapchat_ads_object (a REAL delete verb here, unlike TikTok — irreversible, so offer PAUSED first). THREE THINGS TO SAY OUT LOUD ON THIS PLATFORM: money is MICRO-CURRENCY (1,000,000 = one unit), so quote plain amounts and let Hermoso convert, and never pass both units — under-converting fails loudly while double-converting asks for a budget a million times too large; the creative HEADLINE is capped at 34 characters and brandName at 32, far shorter than Meta or Google, and over-long copy is refused rather than truncated; and a Snapchat ad points at a CREATIVE, never at a media id. SNAPCHAT POSTING (Stories / Spotlights on a Public Profile) IS BUILT BUT NOT YET REACHABLE — Snap’s Public Profile API is allowlist-only and Hermoso has not been allowlisted, so the connector is deliberately not offered; say that plainly rather than looking for a tool. LINKEDIN: post_to_linkedin (publish a finished post to the connected LinkedIn PROFILE) · list_linkedin_pages (the company Pages this connection administers — call this first and let the USER pick, never guess a Page) · post_to_linkedin_page (publish as a company PAGE rather than a person — this is the one most brands actually want) · manage_linkedin_post (edit the copy of a published post, or delete it) \u00b7 list_linkedin_comments / reply_to_linkedin_comment / delete_linkedin_comment (moderate the comments on your Page\u2019s posts \u2014 a SEPARATE LinkedIn authorization grants these, see Connectors) · linkedin_page_analytics (ORGANIC Page performance — followers, follower gains, Page views, and post impressions/clicks/engagement, for the Page total or per post; this is the free organic read, NOT linkedin_ads_report). LINKEDIN ADS (full three-tier management): list_linkedin_ads_campaigns (ad accounts, then a chosen account’s campaign groups, campaigns and — with campaignId — the CREATIVES under them) · linkedin_ads_report (impressions, clicks, cost, conversions, leads) · search_linkedin_ads_targeting (resolve locations / titles / industries / seniorities / company sizes to the URNs LinkedIn demands — never invent one) · linkedin_audience_count (HOW MANY members that targeting actually reaches, before a budget is committed — and a returned 0 means fewer than 300 people, LinkedIn’s privacy floor and also its campaign minimum, never an empty audience) · linkedin_bid_pricing (LinkedIn’s own suggested bid and daily-budget range for that audience — quote it instead of guessing what LinkedIn costs) · create_linkedin_ads_campaign_group → create_linkedin_ads_campaign → create_linkedin_ads_creative (the tree, every tier born DRAFT) · set_linkedin_ads_budget / set_linkedin_ads_status / delete_linkedin_ads_object (budgets, activate/pause at any tier, delete — every spend change confirm-gated). LINKEDIN LEAD SYNC: list_linkedin_lead_forms · list_linkedin_leads / get_linkedin_lead (the LEADS its forms collected, answers named by field — PERSONAL DATA: show, never republish) · subscribe_linkedin_leads / list_linkedin_lead_events / list_linkedin_lead_subscriptions / delete_linkedin_lead_subscription (real-time push to Hermoso, optional forwardTo relay to a CRM; LinkedIn validates ONLY Hermoso’s own webhook). LinkedIn is a THREE-tier platform and the third tier is the one people forget: a campaign with no creative shows nothing, and all three tiers must be ACTIVE before a single impression is served. REDDIT ADS: list_reddit_ads_campaigns / reddit_ads_report (read the account tree + performance) · list_reddit_ads_profiles + list_reddit_ads_posts / create_reddit_ads_post / update_reddit_ads_post (the CREATIVE — a Reddit ad promotes a post) · create_reddit_ads_campaign / update_reddit_ads_campaign · create_reddit_ads_ad_group / update_reddit_ads_ad_group · create_reddit_ads_ad / update_reddit_ads_ad · create_reddit_ads_max_campaign / get_reddit_ads_max_template / update_reddit_ads_max_template (a Reddit MAX campaign: automated campaign, ad group and a template ad Reddit generates ads from, built from creative-library assets, all PAUSED) · set_reddit_ads_status (the ONLY switch that arms real spend, confirm-gated) · delete_reddit_ads_object (remove a campaign, ad group or ad — Reddit has no delete verb, removal is a status, and it refuses to delete anything touched in the last 3 hours) · delete_reddit_ads_saved_audience · search_reddit_ads_targeting / reddit_ads_forecast / reddit_ads_bid_suggestion (free planning) · list_reddit_ads_pixels + send_reddit_ads_conversions (conversion tracking — Reddit now requires a pixel on every ad group) · list_reddit_ads_audiences / create_reddit_ads_audience / update_reddit_ads_audience_users / delete_reddit_ads_audience (retargeting lists) · list_reddit_ads_saved_audiences / create_reddit_ads_saved_audience / update_reddit_ads_saved_audience · list_reddit_ads_lead_forms / create_reddit_ads_lead_form · reddit_ads_history (who changed what, when). TELEGRAM: post_to_telegram (publish to a channel, group or chat as the brand’s own bot — text up to 4096 characters, but only 1024 once any photo or video is attached; one image, one video, or an album of 2–10 in which photos and videos may be mixed. chatId IS ALWAYS REQUIRED and is never guessed: the Bot API publishes NO method that lists the chats a bot belongs to, so pass the public channel’s @username or the numeric id) · list_telegram_chats (chats that MESSAGED the bot in the last 24 hours — a shortcut for finding an id, NOT a roster, and a chat missing from it can still be posted to) · list_telegram_dms (what those chats actually SAID, newest per chat — free, and a rolling 24-hour window rather than an inbox: the Bot API has no history endpoint at all) · delete_telegram_message (confirm-gated; Telegram refuses once a message is more than 48 hours old). BLUESKY: post_to_bluesky (publish as the connected account — text up to 300 characters AND, separately, 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; either up to 10 images OR one MP4 video, never both, because a Bluesky post record carries exactly one embed; links are made clickable automatically) · delete_bluesky_post (PERMANENTLY remove one of the account’s own posts — no trash and no undelete. Call it WITHOUT confirm first: it deletes nothing and reports the post’s real text and live like/repost/reply/quote counts, and once the post has any engagement it also wants confirmText echoing its text. Takes the AT-URI or just the record key from the bsky.app link) · list_bluesky_convos / read_bluesky_dm / send_bluesky_dm / mark_bluesky_convo_read (the account’s DIRECT MESSAGES — free, 1000 characters each, text only, and they need a PRIVILEGED app password: an ordinary one posts fine and cannot chat). Replies, mentions AND direct messages all arrive in list_inbox and are answered with reply_to_inbox_item. X / TWITTER: post_to_x (publish a post — text, an image or a video render WITH alt text, a POLL, a reply, or a whole thread, and optionally restrict who may reply; X is the ONE channel that bills per API request, a post carrying a LINK costs roughly 13× one without, and each profile has a rolling 24-hour ceiling on X spend that refuses a request whole rather than publishing half of it) · delete_x_post (remove one) · x_post_metrics (the PUBLIC counts — impressions, likes, reposts, replies, quotes, bookmarks) · x_post_insights (the ADVERTISER numbers for your own posts — link clicks, profile visits, video views and completion quartiles, up to 25 posts at once; this is what says whether a creative worked, and x_post_metrics cannot tell you, but it only sees the LAST 28 HOURS) · x_post_insights_historical (the same advertiser numbers over ANY date range — the one to use for anything older than yesterday) · x_mentions (who is talking to the brand, in their own words — the read half of the reply loop, and a source of real customer language for ad copy) · list_x_dms (the brand’s X DIRECT MESSAGES, grouped into conversations, saying which are waiting on a reply — billed per message returned, and X keeps only 30 days) · send_x_dm (reply privately to one named person; never a broadcast) · x_chat_settings / update_x_chat_settings (the X account’s DM privacy: who may message the brand, the request filter, read receipts and calls). X IS THE ONE CONNECTOR THAT COSTS CREDITS PER CALL — X charges us per API request, so posting, deleting, reading metrics, reading insights and pulling mentions each bill the user, a post CONTAINING A LINK costs 13× one without, and insights and mentions are billed PER POST RETURNED. Say so before posting a thread or pulling a big page of mentions, and prefer one post over five when the content allows. X ADS (the PAID half — a SEPARATE connection from the organic tools above: its own product on its own host with OAuth 1.0a signing, and X grants API access PER AD ACCOUNT rather than per app, so the customer adds Hermoso’s X user at business.x.com → Account access before anything here resolves): list_x_ads_accounts (the ad accounts this profile can act on, WITH the permission level held on each — read it before attempting a write) · list_x_ads_funding_instruments (a campaign cannot be created without one) · list_x_ads_campaigns / list_x_ads_line_items / list_x_ads_promoted_tweets / list_x_ads_targeting (the whole tree as it stands) · x_ads_report (impressions, clicks, spend and engagements at any level) · x_ads_geo_search / x_ads_targeting_search (resolve places and targeting values to the ids X demands — never invent one) · create_x_ads_campaign → create_x_ads_line_item → create_x_ads_promoted_tweet (the tree, every tier born PAUSED with no override; A CAMPAIGN ALONE CANNOT SERVE ON X — it needs a line item and a promoted post underneath it, and the read-back says so rather than letting you call it a finished ad) · add_x_ads_targeting · update_x_ads_campaign / update_x_ads_line_item (throttle or raise spend on a running campaign without rebuilding it) · set_x_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_x_ads_object. PINTEREST — POSTING AND ADS ARE TWO SEPARATE CONNECTIONS on the same Pinterest login (Pinterest keeps ads access behind different permissions), so a brand can hold either without the other and connecting one does not connect the other; if an ads call says Pinterest Ads is not connected, that is the card to send them to, NOT the Pinterest posting one. ADS: pinterest_ads_async_report (the DEEP paid report — 914 days back where the quick one stops at 90, and three times the metric columns; generated asynchronously, so pass the returned token back rather than re-submitting) · pinterest_targeting_analytics (WHICH audience segment delivered — by keyword, interest, age, gender, location, placement) · pinterest_audience_insights (WHO the audience is: interest affinities plus demographics, the input to a creative brief rather than a performance report) · pinterest_analytics (ORGANIC performance — impressions, saves, Pin clicks, outbound clicks, for the account, the TOP PINS, the top video Pins, or one Pin; Pinterest keeps 90 days and publishes no board-level analytics at all) · create_pinterest_board (make a board — a NEW Pinterest account has none and a Pin needs one) · list_pinterest_boards (the user must pick a board — never choose one for them) · post_to_pinterest (create an image or video Pin on a chosen board, with a title, description and destination link) · list_pinterest_pins (the Pins on a board with their ids — where the pinId every Pin tool needs comes from, and it flags any Pin an ad is promoting) · update_pinterest_pin (retitle, re-describe, fix a dead link, move it — Pinterest keeps this endpoint in a limited BETA, so it may be refused outright and save_pinterest_pin is the generally-available way onto another board; a Pin’s picture can never be swapped by anyone) · save_pinterest_pin (copy a Pin onto another board) · delete_pinterest_pin (confirm-gated, and it says whether an ad is promoting the Pin first) · update_pinterest_board (rename, re-describe, or hide it — SECRET hides every Pin on the board, reversibly) · delete_pinterest_board (the heaviest one here: the board AND every Pin on it, confirm-gated with the Pin count echoed back — offer hiding it instead). GOOGLE ADS (full management): list_google_ads_campaigns (list accounts, then a customer’s campaigns + spend/CTR/CPC/conversions) · google_ads_report (any GAQL breakdown — ad groups, keywords, search terms, geo) · create_google_ads_campaign (paused) · set_google_ads_budget / set_google_ads_status (change budget, enable/pause — every spend change confirm-gated) · delete_google_ads_object (remove a campaign, ad group, ad, KEYWORD, asset LINK or conversion action — Google has no delete verb, `remove` is the terminal state and it cannot be undone; call it unconfirmed first to see the spend and the tree that go with it) · upload_google_ads_asset (add an image render or a YouTube video to the ad account’s asset library) · create_google_ads_performance_max_campaign (Google’s cross-surface campaign type, RETAIL INCLUDED — pass merchantCenterId to make it a Shopping-feed Performance Max advertising the WHOLE Merchant Center feed under one root listing group, and feedLabel to narrow it to a single feed; only PARTITIONING that feed by brand/category/custom label is refused by name) · add_google_ads_assets (sitelinks, callouts, structured snippets, call, promotion, price, lead-form, business name and logo assets, CREATED AND ATTACHED — an asset that is not attached shows nothing) · manage_google_ads_assets (list, edit, pause or detach them) · set_google_ads_tracking (UTMs / tracking template on a campaign, ad group or ad; new campaigns get standard UTMs by default) · manage_google_ads_audience + set_google_ads_audiences (remarketing lists, custom segments, affinity / in-market interests, demographics, Performance Max signals and search themes) · upload_google_ads_video (a render straight into Google Ads, no YouTube channel needed) · create_google_ads_demand_gen_campaign (YouTube Shorts / in-feed / in-stream video ads, born PAUSED) · list_google_ads_conversion_actions + create_google_ads_conversion_action (what Google counts as a result — MAXIMIZE_CONVERSIONS, TARGET_CPA, TARGET_ROAS and every Performance Max campaign are undeliverable without one, and Hermoso refuses to build them on an account that has none) · google_ads_keyword_ideas (Keyword Planner — real monthly search volume, competition and top-of-page bids; use it before choosing keywords) · google_ads_recommendations + manage_google_ads_recommendations (Google’s own advice and optimization score; apply / dismiss / auto-apply, spend-type ones only after the user’s yes) · preview_google_ads_ad (a shareable no-login preview link for a Performance Max asset group or YouTube video ad, for client approval) · google_ads_policy_issues (WHY Google disapproved or limited an ad, asset or keyword: the policy topic and the evidence) · google_ads_change_history (WHAT CHANGED ON THE ACCOUNT AND WHEN — the answer to “performance fell off a cliff on Tuesday, what happened?”. Its default source is field-level and reaches 30 days; the other source reaches 90 and is the ONLY one that sees Google Ads Editor and criterion edits, so check both before telling anyone nothing changed). GOOGLE MERCHANT CENTER (the product feed behind every Shopping ad and every free listing, on the SAME connection as Google Ads): register_merchant_developer (the ONE-TIME link between Hermoso’s Google Cloud project and the merchant’s account. Google refuses every other Merchant call until it is done, so run this first when calls are being refused) · list_merchant_accounts (which Merchant Centers this login can reach, and where the merchantCenterId every other tool needs comes from) · list_merchant_products (the feed itself, with each product’s disapprovals) · list_merchant_issues (account-level problems, the answer to "why is nothing showing at all") · merchant_issue_help + trigger_merchant_issue_action (Google’s OWN remediation steps for a problem, and the button that fires one. Several of those actions are one-shot in Google’s own words, so firing one is confirm-gated) · list_merchant_data_sources + create_merchant_data_source + delete_merchant_data_source (feeds. A product write only lands in an API-input feed, and most accounts have none until one is made, so check before writing) · upsert_merchant_product + update_merchant_product + delete_merchant_product (write the feed) · list_merchant_inventory + set_merchant_inventory (the per-STORE and per-REGION price, stock level and availability override on one product, which is what stops a Shopping ad advertising something the nearest store has sold out of. The write MERGES, because Google’s insert replaces the whole entry, and Google takes up to 30 minutes to reflect it on the product) · list_merchant_promotions + create_merchant_promotion (sale and discount badges on a listing. Google validates them asynchronously, so created is never the same as approved) · manage_merchant_notifications (Google POSTs to a URL THE MERCHANT RUNS the moment a product is disapproved, instead of someone having to poll) · merchant_account_status (WHY THE ACCOUNT IS OR IS NOT SERVING — the first thing to run when Shopping ads or free listings show nothing, and the one read that does not believe the program state: an account can report both programs ENABLED and serve in ZERO countries, because a region counts as active only where every requirement is met. It names Google’s own unmet requirements, then the settings that explain them: homepage claimed or not, business address, phone and support contact, active shipping services, return policies, terms accepted) · manage_merchant_settings (FIXES what merchant_account_status finds: shipping services merged by name so unmentioned ones are kept, return policies, business address and support contact, homepage claim, programs on or off; overwrites and removals ask first) · manage_merchant_conversion_source (WHERE MERCHANT CENTER GETS ITS CONVERSION DATA FROM, which is what free-listing and Shopping performance reporting is built on — a merchant with no conversion source sees clicks and no outcomes. Either a Google tag destination, whose MC-… id comes back only on the create and is the id the Google tag has to send conversions to, or a link to a GA4 property, which is IMMUTABLE and needs the connected Google account to be an admin there. A delete is an ARCHIVE and undelete restores it until the expiry Google reports) · merchant_quota (whether the account is simply out of daily API quota or out of product slots, which looks identical to a broken integration and is not. Google resets it at MIDDAY UTC) · merchant_report (the reports Google computes for free, including competitive visibility, best sellers and price competitiveness). MICROSOFT MERCHANT CENTER (the same job on Microsoft’s side, on the Microsoft Advertising connection): list_microsoft_merchant_stores · list_microsoft_merchant_products · upsert_microsoft_merchant_product · delete_microsoft_merchant_product · list_microsoft_merchant_issues · list_microsoft_merchant_catalogs + manage_microsoft_merchant_catalog. GOOGLE ANALYTICS (GA4 — the brand’s OWN site data, and a SEPARATE connection from Google Ads: a brand that spends on Ads every day may have no Analytics access at all, so never read one as the other): list_analytics_properties (call this FIRST — every other Analytics tool needs a NUMERIC property id, and what users actually know is the “G-XXXXXXX” Measurement ID from their tracking snippet, which no endpoint accepts; resolve it from this list rather than sending them hunting. It lists the properties SHARED WITH THIS PROFILE, not everything the Google account can see — Analytics access is handed out freely and one login often has Viewer on many clients’ properties, so the user ticks which belong to this profile and any other one is refused by name; an empty list means nothing is ticked yet, which set_connector_accounts or Settings ▸ Connectors ▸ Google Analytics ▸ Manage accounts fixes) · analytics_report (what happened — sessions, users, revenue, conversions and engagement broken down by channel, source/medium, campaign, landing page, country, device or date, i.e. the read that says whether the traffic an ad bought actually did anything) · analytics_realtime (who is on the site right now, ~30 minutes — a DIFFERENT metric set that rejects `sessions` outright, never a shortcut for analytics_report) · list_analytics_definitions (what the property already measures: its key events and its own custom dimensions, and the check to run before creating either) · create_analytics_key_event (mark an event GA4 already collects as a KEY EVENT — the 2024 rename of a conversion, and what makes it importable into Google Ads; marking an event the site never fires creates one that can never fire) · create_analytics_custom_dimension (register an event parameter the site already sends so reports can break down by it — say out loud first that a GA4 custom dimension CANNOT be deleted, only archived, and a property is capped at 50 event-scoped ones, so a typo permanently burns a slot) · list_analytics_data_streams (the streams on a property and the measurement ID (G-...) each one carries, which is what a gtag or GTM install needs and what nobody can find in the GA4 UI when asked) · get_analytics_stream_setup (the finished gtag <script> block to paste into the site — the last mile list_analytics_data_streams stops short of — plus whether enhanced measurement is really collecting scrolls, outbound clicks, site search, video, downloads and form interactions, and whether redaction is stripping campaign parameters out of recorded URLs. Web streams only. Read the master switch before believing a toggle: with enhanced measurement off for the stream, every toggle is inert whatever it says) · list_analytics_metadata (every dimension and metric this property can be asked for, including its own custom ones, which is what stops analytics_report guessing a field name) · check_analytics_compatibility (whether a dimension and metric can appear in the same report before spending a call finding out they cannot) · create_analytics_custom_metric + archive_analytics_custom_metric · archive_analytics_custom_dimension · delete_analytics_key_event (all one-way in the same sense as their create twins: archiving is not deleting and there is no un-archive) · list_analytics_google_ads_links + link_google_ads_to_analytics + unlink_google_ads_from_analytics (the join that makes a GA4 audience usable in Google Ads and a GA4 key event importable as a conversion — without it a perfectly good audience simply never appears in the ads account, with no error anywhere) · list_analytics_audiences + create_analytics_audience + archive_analytics_audience (GA4 remarketing audiences, the input to Google Ads remarketing. Archiving is one-way) · manage_analytics_measurement_protocol_secret (mint the API secret that lets the customer’s OWN SERVER send events straight into GA4, the Google twin of the conversions APIs already here for Reddit, Snapchat and OpenAI Ads. Say out loud that there is NO rotation anywhere in the API, so replacing a secret means create the new one, move every sender across, then delete the old one) · manage_analytics_channel_group (HOW GA4 BUCKETS TRAFFIC — the answer to “why is my campaign showing as Unassigned”, and the one number an ad studio is judged on. Read the Default channel group’s rules before diagnosing anything, then author your own group whose channels catch the campaigns Hermoso publishes. The rule fields are the eachScope… names, NOT the sessionSource / medium dimensions reports use, and GA4 stops at the first rule that matches so order decides everything) · manage_analytics_calculated_metric (the derived number a marketer actually reports — cost per purchase, revenue per session — built from metrics GA4 already collects and then available to analytics_report under its own permanent API name. The id is permanent, and a formula naming a metric the property does not collect is created happily and flagged invalid, so read that flag back) · manage_analytics_annotations (mark a launch or a post on the customer’s own GA4 charts, so the traffic it moved shows where they look) · analytics_data_retention (how long GA4 keeps event data — the 2-month default caps every exploration; lowering it deletes data, so it is confirm-gated). MICROSOFT ADVERTISING / BING ADS (full management, mirroring Google): list_microsoft_ads_campaigns (list the shared ad accounts, then a chosen account’s campaigns + budgets) · microsoft_ads_geo_search (resolve country / region / city names to the Microsoft location ids a campaign needs — call it when an ask is ambiguous and let the USER pick) · microsoft_ads_report (impressions, clicks, CTR, average CPC, spend, conversions — generated asynchronously, so it may come back pending and must be called again) · create_microsoft_ads_campaign (campaign → ad group → responsive search ad → keywords, always Paused; with no locations[] it is created serving WORLDWIDE, Microsoft’s own default, and the read-back warns loudly — relay that before anyone activates it) · create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (fill in an existing account) · set_microsoft_ads_budget / set_microsoft_ads_status (change budget, activate/pause — every spend change confirm-gated; Microsoft statuses are Active/Paused, never Deleted) · search_microsoft_ads_profiles / list_microsoft_ads_profile_targeting / set_microsoft_ads_profile_targeting (LinkedIn profile targeting: company, industry, job function, seniority and job title bid adjustments on a Search, Shopping or DSA campaign; confirm-gated on an ACTIVE campaign) · delete_microsoft_ads_object (a REAL delete — campaign, ad group, ad or keyword — permanent, with no undelete; call it unconfirmed first to see what goes with it) · microsoft_ads_keyword_ideas (Microsoft’s Keyword Planner — real search volume, competition and suggested bids, with NO planning-tier gate, unlike Google’s) · microsoft_ads_traffic_estimates (what those keywords would deliver at a named bid — a range, never one number) · microsoft_ads_budget_opportunities (where Microsoft says a budget is capping delivery, and what raising it is forecast to buy) · microsoft_ads_auction_insights (who ELSE is bidding on the same auctions — rival domains with their impression share, overlap and outranking share; shares of YOUR auctions, never a measure of a competitor’s whole account) · microsoft_ads_bulk_download (export the account as ONE bulk file — the only way to read ~185 Microsoft record types Hermoso cannot otherwise touch: sitelinks, callouts, structured snippets, labels, shared negative keyword lists, bid strategies, audiences, experiments, seasonality adjustments, conversion goals, asset groups, feeds) · microsoft_ads_bulk_upload (apply an edited bulk file — hundreds of objects in one request. IT IS GATED HARDER THAN ANYTHING ELSE ON THIS CONNECTOR, because a bulk file carries a Status column and can turn campaigns ON without ever touching set_microsoft_ads_status: confirm:true alone is refused, and you must first call it unconfirmed to get the row-by-row list of what it would ACTIVATE and DELETE, show that to the user, then echo both counts back as confirmActivations/confirmDeletions — or pass pauseInstead:true to land the file with every activation written as Paused) · list_microsoft_ads_conversion_goals (what the account counts as a conversion, and which goals are OFFLINE ones) · send_microsoft_ads_offline_conversions (close the loop: phone sales, in-store purchases and late-closing leads fed back so smart bidding stops optimising against website conversions alone — pass PLAIN emails and E.164 phones, hashing happens server-side to Microsoft’s own published spec) · microsoft_ads_editorial_reasons (WHY an ad or keyword was disapproved: the code, the word Microsoft objected to, where, and the appeal status) · list_microsoft_ads_audiences (the account’s Customer Match lists with their current sizes; a fresh list reads 0 for up to 48 hours and Microsoft will not use one under 300 people, so never call that a failed upload) · create_microsoft_ads_customer_list then apply_microsoft_ads_customer_list (build a Customer Match audience from PLAIN email addresses, normalized and SHA-256 hashed server-side to Microsoft’s own published spec so no plaintext ever leaves us; the user must be shown Microsoft’s Customer Match terms and agree first) · microsoft_ads_recommendations (what Microsoft ITSELF suggests changing, each one priced by Microsoft: budget raises carrying the current and recommended daily amount, new and broadened keywords, negative keywords it wants removed, and ads it has written. Every one INCREASES what the account buys, which is what they are for, so none is a free win and an empty list means Microsoft has no advice rather than that the account is optimal) · apply_microsoft_ads_recommendations (act on them, gated exactly like the bulk upload: confirm:true alone is REFUSED, so call it unconfirmed first to get every recommendation named with what it changes and Microsoft’s own cost estimate, show that to the user, then echo confirmCount and confirmCostIncrease back. Both are recomputed from a fresh read, and there is no undo) · dismiss_microsoft_ads_recommendations (take advice off the list. It cannot spend, so it needs no confirmation at all, and it is the right answer to “make it stop suggesting that” rather than applying something to clear it) · microsoft_ads_auto_apply (THE READ THAT ANSWERS “is Microsoft changing this account while nobody is looking?”, per type. An inherited account can already be opted in with nobody at the brand having done it) · set_microsoft_ads_auto_apply (turn that standing permission on or off. Switching any type ON is the strongest consent anywhere in Hermoso: Microsoft then writes and publishes its own ads under the brand’s name, deletes negative keywords so the account buys more searches, and changes conversion goals, unattended and indefinitely, with NOTHING to preview beforehand. So confirm:true is not enough and every type must be named in confirmTypes. Switching it OFF is never gated). GOOGLE BUSINESS PROFILE (the local-SEO channel — the listing panel on Google Search and Maps, which for a local business is where the demand actually is, and there is no delete): list_business_locations (the listings the connected Google account manages — call this first and let the USER pick when there is more than one; a Post on the wrong storefront is a public mistake) · post_to_google_business (publish a Post to the listing — text, ONE PHOTO and a call-to-action button; Google’s Posts API takes no video, so pass a still. EVENT and OFFER posts both require a title and a start date, and on an OFFER Google ignores the button link) · list_google_business_posts (what is showing right now, with each Post’s state) · delete_google_business_post (take one down — immediate and public, so confirm first) · list_google_business_reviews (the reviews on the listing, and which ones have NO reply yet — for a local business the highest-leverage surface there is) · reply_to_google_business_review (answer one publicly as the business; it is an UPSERT, so it replaces any existing reply) · list_google_business_questions + answer_google_business_question (the public Q&A on the listing) · google_business_search_keywords (the actual search terms people typed to find the listing — free local keyword data; low-volume terms are SUPPRESSED and come back as "fewer than N", never as zero) · google_business_insights (Search + Maps impressions, calls, website clicks, direction requests, messages, bookings — listing-level; Google discontinued per-Post insights in 2023 with no replacement, so never promise per-Post numbers) · get_business_location (everything the listing actually says — name, address, phone, website, categories, description, hours, service area — as the merchant set it; the answer to “what does our Google listing say?”) · update_business_location (change any of that — hours, phone, website, description, categories, even the name or address. It edits the live panel on Search and Maps with no draft and no undo, so call it WITHOUT confirm first: nothing is written, Google validates the payload, and you get the current value of every field you are about to change to show the user) · google_business_account (whose Business Profile account the listing is on, and whether the connected Google account’s role can edit it at all). Google gates this API behind a per-project access request and the default quota is zero, so the connection can be live and calls still refused — the error says so. CHATGPT ADS (ads under ChatGPT answers, via OpenAI’s Advertiser API — full management): list_openai_ads_campaigns (the ad account, then its campaigns, ad groups and ads with each ad’s review state) · openai_ads_report (impressions, clicks, spend, CTR, CPC, CPM at account / campaign / ad group / ad scope — run this first, it validates the key with zero spend risk) · openai_ads_geo_search (location ids) · list_openai_ads_audiences + create_openai_ads_audience (custom audiences — geo and these are the only list-based targeting this platform has; target them with customAudienceIds / excludedCustomAudienceIds on a campaign) · create_openai_ads_campaign (campaign → ad group → ad in one call, always PAUSED) · create_openai_ads_ad_group / create_openai_ads_ad (fill in an existing campaign) · update_openai_ads_object (rename, re-budget, rewrite context hints or the ad copy) · set_openai_ads_budget / set_openai_ads_status (change budget, activate, pause) · delete_openai_ads_object (ARCHIVE — this API has no delete and OpenAI say archiving is not reversible, so offer pausing first). TWO RULES THIS CHANNEL DOES NOT SHARE WITH THE OTHERS: it is connected by PASTING an Advertiser API key (no OAuth, no manager account, one key = one ad account), and it has exactly ONE creative format — a text plus image card, title 50 characters, body 100. There is NO VIDEO on ChatGPT Ads, so never offer a video ad here. GOOGLE DRIVE — ONE connection covering Drive, Sheets and Docs (full CRUD over the files Hermoso created there, plus any file the user hands over with the Google file picker in the app): save_to_drive · list_drive_files / get_drive_file · update_drive_file (rename/move/trash) · delete_drive_file · create_drive_folder. GOOGLE SHEETS (part of the Google Drive connection — export data to a spreadsheet the app creates, or read one the user picked; drive.file, no verification): create_sheet · append_to_sheet · read_sheet. GOOGLE DOCS (part of the Google Drive connection — export copy/brief/report as a doc, or read one the user picked; drive.file, no verification): create_doc · append_to_doc. GOOGLE SLIDES (part of the Google Drive connection — turn a swipefile collection into a real presentation, one slide per saved ad with the creative, brand, copy, run dates and platform; drive.file, no verification, no new scope): export_swipefile_deck — it CREATES a deck each time and cannot append to one the user already has, and a creative whose ad-library link has expired is reported rather than silently dropped. ONEDRIVE (full CRUD over the user’s Microsoft OneDrive): convert_onedrive_file (Microsoft converts a file server-side to PDF or JPG — ~130 formats including PowerPoint and Word decks, PSD, Illustrator, Sketch, 3D, video, iPhone HEIC and raw camera files; JPG needs both width and height) · save_to_onedrive · list_onedrive_files / get_onedrive_file · update_onedrive_file (rename/move) · delete_onedrive_file · create_onedrive_folder · share_onedrive_file (a view/edit link for a client) · onedrive_file_permissions (who can open it; revoke). INTERCOM (the brand’s own support inbox, nothing billed): list_intercom_conversations (filter by state, assignee, team, tag, channel, customer, dates, priority, unread, or app = the product the customer wrote from) · read_intercom_conversation · reply_to_intercom_conversation / add_intercom_note / manage_intercom_conversation (each a preview until confirm:true) · tag_intercom_conversation · list_intercom_tags · search_intercom_contacts · list_intercom_teammates · intercom_customer_language (customers’ own words for ad copy). VERCEL (the brand’s own sites, nothing billed): list_vercel_projects · list_vercel_deployments · list_vercel_domains · vercel_web_analytics (page views, visitors, top pages, referrers, countries) · vercel_event_analytics (custom-event conversions by campaign) · deploy_vercel_page (a static landing page, confirm-gated; preview by default) · delete_vercel_deployment. Use these standalone — Hermoso is a full posting/ads/file-storage control surface, not only an ad generator.',
|
|
262
262
|
'F) YOUR TOOL LIST IS A STARTING POINT, NOT THE PRODUCT \u2014 and the whole catalogue is two calls away. The listed roster is deliberately small (the core tools, plus whatever this connection asked for or you have switched on); every other tool in this map is held out of the LIST on SIZE alone and is fully built, fully live and fully callable. THE ROUTE THAT WORKS ON EVERY HOST, including the ones that cannot reload their tool list at all (claude.ai, ChatGPT): find_tools({query}) searches every tool by task or name and returns its parameters, its credit cost and its recent health in one line, then call_tool({name, args}) RUNS it \u2014 same account, same permissions, same result as if it had been listed. A direct tools/call to a name you already know works too. enable_tools({groups:[\u2026]}) additionally LISTS a whole group for hosts that re-list: `ads` (paid-campaign management on Meta, Google Ads, LinkedIn, Reddit, Microsoft, Pinterest, X, TikTok, Snapchat, ChatGPT Ads, Apple Search Ads \u2014 by far the heaviest group), `analytics` (GA4, Search Console), `channel_admin` (reading a channel back: insights, comments, DMs, catalogs, templates, webhooks, editing or deleting a published post), plus research, create, channels, files, workspace, or \'all\'. NEVER tell a user Hermoso cannot build a campaign, read a comment, answer a DM or pull a channel\u2019s numbers because you cannot see the tool \u2014 look it up and call it.',
|
|
263
263
|
].join('\n');
|
|
264
264
|
|
|
@@ -341,7 +341,7 @@ export const MCP_INSTRUCTIONS = [
|
|
|
341
341
|
'• CREATE (on-brand ads): render_ad (Studio quality pipeline) or generate_image / generate_video / generate_avatar render on their own; plan_ad authors a board first when the ad wants one and render_ad takes it; get_brand (what we already know) / draft_brand (onboard one) / update_brand (patch a field) manage the saved brand, which the create tools hydrate by themselves; list_creators / save_creator / delete_creator (the reusable saved CAST — re-cast the same face instead of generating a new person every time; render_ad’s `creator` stars one of them in the ad); make_template_ad (native HTML formats); make_thumbnail (YouTube / Shorts / Instagram video thumbnails + covers — use it for any thumbnail or video-cover ask, never generate_image); clone_static / recast_motion / reframe_video / insert_scene / upscale_video / dub_video / change_voice / finish_video / fix_beat / hook_variants / stitch_video; plan_variations + score_ad.',
|
|
342
342
|
'• RAW MODEL PLAYGROUND: generate_image / generate_video (useBrand:false) for prompt-only renders, generate_voice for text-to-speech, generate_text for the writing models — against any of 30+ image / video / voice / writing model ids (credit costs in hermoso_capabilities), no ad framing.',
|
|
343
343
|
'• ACCOUNT & WORKSPACES: hermoso_credits, billing_status, buy_credits (one-click top-up / first-purchase link), upgrade_plan / set_auto_reload (admin), list_jobs / get_job; list_brands / create_brand / use_brand / delete_brand (one account holds MANY profiles: brands, clients or creators, so an agency runs every client through here, each with its own brand details, memory, Library and connectors; create_brand → draft_brand onboards a new one, delete_brand is confirm-gated); get_settings / update_settings (the LANGUAGE every ad, script and plan is written in (chat replies follow the language the user writes in) — set it once and every render obeys it — plus app appearance and the weekly competitor-watch email); list_team / invite_member / remove_member / set_role.',
|
|
344
|
-
'• PUBLISH & MANAGE YOUR CHANNELS (the user’s connected accounts, over this MCP): Meta — post_to_meta (FB/IG/Threads), upload_file (post ANY external/local file), list_meta_ads + meta_insights (read campaigns/ad sets/ads + performance, broken down by age/gender/placement/country), preview_meta_ad (see the real ad per placement, 24h links), estimate_meta_reach (audience size before you spend), list_meta_audiences / create_meta_audience (retargeting + lookalikes), create_meta_campaign / create_meta_ad / upload_meta_asset (build), update_meta_object / delete_meta_object / set_meta_campaign_status (edit/delete/activate — spend + deletes confirm-gated), manage_meta_post (edit/delete a post); DM automations (a keyword comment, DM, story reply or ig.me link gets an automatic private reply) — save_dm_automation / test_dm_automation / list_dm_automations / delete_dm_automation on Instagram, Facebook (Page comments, ads, Messenger) and X (X billed in credits per event and send), and the Instagram-only save_instagram_dm_automation / test_instagram_dm_automation / list_instagram_dm_automations / delete_instagram_dm_automation; X — x_chat_settings / update_x_chat_settings (who may DM the brand on X); Microsoft Advertising (Bing Ads) — list_microsoft_ads_campaigns, microsoft_ads_report, microsoft_ads_geo_search, create_microsoft_ads_campaign / create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (all created Paused), set_microsoft_ads_budget / set_microsoft_ads_status (spend confirm-gated); ChatGPT Ads (OpenAI Advertiser API) — list_openai_ads_campaigns, openai_ads_report, openai_ads_geo_search, create_openai_ads_campaign / create_openai_ads_ad_group / create_openai_ads_ad (all created PAUSED), update_openai_ads_object, set_openai_ads_budget / set_openai_ads_status (spend + archive confirm-gated). Connected by API key; ONE creative format (a text + image card, no video); Pinterest — list_pinterest_boards then post_to_pinterest (the user picks the board); Google Business Profile — list_business_locations, post_to_google_business, list_google_business_posts, delete_google_business_post, google_business_insights, get_business_location / update_business_location (read and CHANGE what the listing says — hours, phone, website, description, categories, name, address; the edit is live on Search and Maps, so the unconfirmed call writes nothing and shows the before-and-after), google_business_account (whose account it is on and whether that role can edit it); Google Drive (ONE connection covering Drive, Sheets and Docs) — save_to_drive, list_drive_files, get_drive_file, update_drive_file, delete_drive_file, create_drive_folder, plus create_sheet / append_to_sheet / read_sheet and create_doc / append_to_doc / read_doc (Hermoso-created files, plus any file the user hands over with the Google file picker in the app); Microsoft OneDrive — save_to_onedrive, list_onedrive_files, get_onedrive_file, update_onedrive_file, delete_onedrive_file, create_onedrive_folder; MANAGING THE CONNECTIONS — list_connectors, list_connector_accounts + set_connector_accounts (which Pages / ad accounts / company Pages this profile may post to and spend from — fails closed, an empty choice shares nothing), leave_connector (remove just YOUR OWN account from a connector several teammates have each joined — theirs keep working) · disconnect_connector (confirm-gated: reconnecting needs a browser). LINKING a NEW account is the one step that is not headless (an OAuth consent screen) — send the user to Workspace ▸ Connectors in the app.',
|
|
344
|
+
'• PUBLISH & MANAGE YOUR CHANNELS (the user’s connected accounts, over this MCP): Meta — post_to_meta (FB/IG/Threads), upload_file (post ANY external/local file), list_meta_ads + meta_insights (read campaigns/ad sets/ads + performance, broken down by age/gender/placement/country), preview_meta_ad (see the real ad per placement, 24h links), estimate_meta_reach (audience size before you spend), list_meta_audiences / create_meta_audience (retargeting + lookalikes), create_meta_campaign / create_meta_ad / upload_meta_asset (build), update_meta_object / delete_meta_object / set_meta_campaign_status (edit/delete/activate — spend + deletes confirm-gated), manage_meta_post (edit/delete a post); DM automations (a keyword comment, DM, story reply or ig.me link gets an automatic private reply) — save_dm_automation / test_dm_automation / list_dm_automations / delete_dm_automation on Instagram, Facebook (Page comments, ads, Messenger) and X (X billed in credits per event and send), and the Instagram-only save_instagram_dm_automation / test_instagram_dm_automation / list_instagram_dm_automations / delete_instagram_dm_automation; X — x_chat_settings / update_x_chat_settings (who may DM the brand on X); Microsoft Advertising (Bing Ads) — list_microsoft_ads_campaigns, microsoft_ads_report, microsoft_ads_geo_search, create_microsoft_ads_campaign / create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (all created Paused), set_microsoft_ads_budget / set_microsoft_ads_status (spend confirm-gated); ChatGPT Ads (OpenAI Advertiser API) — list_openai_ads_campaigns, openai_ads_report, openai_ads_geo_search, create_openai_ads_campaign / create_openai_ads_ad_group / create_openai_ads_ad (all created PAUSED), update_openai_ads_object, set_openai_ads_budget / set_openai_ads_status (spend + archive confirm-gated). Connected by API key; ONE creative format (a text + image card, no video); Pinterest — list_pinterest_boards then post_to_pinterest (the user picks the board); Google Business Profile — list_business_locations, post_to_google_business, list_google_business_posts, delete_google_business_post, google_business_insights, get_business_location / update_business_location (read and CHANGE what the listing says — hours, phone, website, description, categories, name, address; the edit is live on Search and Maps, so the unconfirmed call writes nothing and shows the before-and-after), google_business_account (whose account it is on and whether that role can edit it); Google Drive (ONE connection covering Drive, Sheets and Docs) — save_to_drive, list_drive_files, get_drive_file, update_drive_file, delete_drive_file, create_drive_folder, plus create_sheet / append_to_sheet / read_sheet and create_doc / append_to_doc / read_doc (Hermoso-created files, plus any file the user hands over with the Google file picker in the app); Microsoft OneDrive — save_to_onedrive, list_onedrive_files, get_onedrive_file, update_onedrive_file, delete_onedrive_file, create_onedrive_folder, share_onedrive_file, onedrive_file_permissions; MANAGING THE CONNECTIONS — list_connectors, list_connector_accounts + set_connector_accounts (which Pages / ad accounts / company Pages this profile may post to and spend from — fails closed, an empty choice shares nothing), leave_connector (remove just YOUR OWN account from a connector several teammates have each joined — theirs keep working) · disconnect_connector (confirm-gated: reconnecting needs a browser). LINKING a NEW account is the one step that is not headless (an OAuth consent screen) — send the user to Workspace ▸ Connectors in the app.',
|
|
345
345
|
'YOUR ROSTER IS CORE-FIRST, AND NOTHING IS MISSING OR UNFINISHED. What you are LISTED is the core tools plus whatever this connection asked for or you have switched on; the rest of the product \u2014 hundreds of tools across research, creation, publishing, paid campaigns, analytics and channel administration \u2014 is held out of the LIST on SIZE ALONE. IT IS ALL CALLABLE RIGHT NOW. find_tools({query}) searches EVERY tool, listed or not, and each row carries its parameters, its credit cost and its recent health; call_tool({name, args}) then runs it through the same handler, the same account and the same permissions; a direct tools/call to a name you already know works too. This route needs no reload and works on every host, including claude.ai and ChatGPT, which fix their tool list when the connection is made. enable_tools({groups:[\u2026]}) additionally LISTS a group for hosts that re-list \u2014 free and instant: the MOMENT the user asks to build, budget, target, report on or change a campaign, enable_tools({groups:[\'ads\']}); for sessions, conversions, revenue by channel or how their site ranks, enable_tools({groups:[\'analytics\']}); to read, moderate, reply to, measure or delete something ALREADY on a channel, enable_tools({groups:[\'channel_admin\']}). IF THE TOOLS DO NOT APPEAR after that call \u2014 some clients cache their tool list for the whole conversation \u2014 do NOT tell the user the capability does not exist and do not keep retrying: just use call_tool, which does not depend on the roster changing at all. Reconnecting with `?tools=all` on the server URL lists everything at connect time. NEVER tell a user Hermoso cannot manage their campaigns or read their analytics, cannot read a comment, answer a DM or pull a channel\u2019s numbers, because you cannot see the tool \u2014 look it up with find_tools and run it with call_tool. The full set of group names is: core, research, create, channels, channel_admin, analytics, files, workspace, ads \u2014 or \'all\'.',
|
|
346
346
|
// HINTS (2026-09-17). The advice was always in the prose; this says the keyed copy exists so a client that
|
|
347
347
|
// prefers structure does not have to parse a sentence to find it.
|
|
@@ -1986,6 +1986,12 @@ export const INBOX_SOURCES = [
|
|
|
1986
1986
|
// chat, because a single call spans every chat that wrote in and the reply target is a property of each ROW, not
|
|
1987
1987
|
// of the envelope. Its `reply` is the ordinary publish route: on Telegram a reply IS a message to a chat id.
|
|
1988
1988
|
{ source: 'telegram_dm', provider: 'telegram', read: '/api/telegram/messages', reply: '/api/telegram/post', kind: 'dm', label: 'Telegram message' },
|
|
1989
|
+
// INTERCOM, added 2026-10-27 — the brand's own SUPPORT inbox, which is where its customers talk most and which a list
|
|
1990
|
+
// titled "everything people said to this brand" left out. Reads the OPEN conversations (the existing
|
|
1991
|
+
// list_intercom_conversations route, one row per conversation, so nothing is folded). The reply route carries the
|
|
1992
|
+
// conversation id in its PATH, so `:id` is filled in by reply_to_inbox_item; that route sends to the customer only
|
|
1993
|
+
// with confirm:true, which reply_to_inbox_item passes because its own contract is "the user already said yes".
|
|
1994
|
+
{ source: 'intercom', provider: 'intercom', read: '/api/intercom/conversations', reply: '/api/intercom/conversations/:id/reply', kind: 'support', label: 'Intercom conversation', query: { view: 'open' } },
|
|
1989
1995
|
];
|
|
1990
1996
|
|
|
1991
1997
|
export const INBOX_SOURCE_NAMES = INBOX_SOURCES.map((s) => s.source);
|
|
@@ -2110,8 +2116,11 @@ export function normalizeInboxItem(source, row = {}) {
|
|
|
2110
2116
|
// withholds has no text, and this line used to drop every one of them — an Instagram inbox full of story
|
|
2111
2117
|
// mentions read as "Nothing waiting." The read route's one-line `summary` stands in for the missing text.
|
|
2112
2118
|
const text = pick('text', 'message', 'comment', 'body', 'content', 'reviewReply.comment', 'snippet.textDisplay') || pick('summary');
|
|
2119
|
+
// An Intercom conversation's words are its `preview` (the opening message) under its `subject`. Read only for that
|
|
2120
|
+
// source, so no other vendor's `preview` field is ever mistaken for what someone said.
|
|
2121
|
+
const icText = source === 'intercom' ? [pick('subject'), pick('preview')].filter(Boolean).join(' — ') : '';
|
|
2113
2122
|
const rating = row.starRating ?? row.rating ?? null;
|
|
2114
|
-
if (!nativeId || (!text && rating == null)) return null;
|
|
2123
|
+
if (!nativeId || (!text && !icText && rating == null)) return null;
|
|
2115
2124
|
const mk = src.kind === 'dm' && typeof row.kind === 'string' && typeof row.summary === 'string' ? row.kind : ''; // the Meta read shape only
|
|
2116
2125
|
// A META DM FROM ONE OF SEVERAL PAGES CARRIES ITS PAGE IN THE ID (2026-10-26): the reply has to leave through the
|
|
2117
2126
|
// Page the person wrote to, and reply_to_inbox_item only ever sees the id. `~p` cannot occur in a Meta scoped id.
|
|
@@ -2125,16 +2134,18 @@ export function normalizeInboxItem(source, row = {}) {
|
|
|
2125
2134
|
// `from` and `sender*` are the DM spellings: Meta's conversation route hands back `from` as a display string
|
|
2126
2135
|
// (its `from.name` sibling is the COMMENT shape) and Bluesky's hands back a handle, falling back to the DID
|
|
2127
2136
|
// when the profile is not in the payload. A DID is ugly and it is still the truth about who wrote.
|
|
2128
|
-
author: pick('author', 'from.name', 'username', 'reviewer.displayName', 'authorDisplayName', 'snippet.authorDisplayName', 'from', 'sender', 'senderHandle', 'senderDid') || 'unknown',
|
|
2129
|
-
text,
|
|
2137
|
+
author: pick('author', 'from.name', 'username', 'reviewer.displayName', 'authorDisplayName', 'snippet.authorDisplayName', 'from', 'sender', 'senderHandle', 'senderDid', ...(source === 'intercom' ? ['customer.name', 'customer.email'] : [])) || 'unknown',
|
|
2138
|
+
text: text || icText,
|
|
2130
2139
|
rating: rating == null ? undefined : rating,
|
|
2131
|
-
at: pick('created_time', 'createdAt', 'timestamp', 'createTime', 'created_utc', 'publishedAt', 'snippet.publishedAt', 'at', 'sentAt') || '',
|
|
2140
|
+
at: (source === 'intercom' ? pick('updatedAt', 'createdAt') : pick('created_time', 'createdAt', 'timestamp', 'createTime', 'created_utc', 'publishedAt', 'snippet.publishedAt', 'at', 'sentAt')) || '',
|
|
2132
2141
|
permalink: pick('permalink', 'permalink_url', 'url', 'link') || '',
|
|
2133
2142
|
// A MESSAGE THE BRAND ITSELF SENT NEEDS NO REPLY. `inbound === false` / `fromMe === true` is the DM way of
|
|
2134
2143
|
// saying what `reviewReply.comment` says on a review, and without it `unansweredOnly` would put the brand's
|
|
2135
2144
|
// own outgoing messages on the list of things it still has to answer.
|
|
2136
2145
|
answered: row.answered === true || !!pick('reviewReply.comment') || row.hasReplied === true
|
|
2137
|
-
|| row.inbound === false || row.fromMe === true
|
|
2146
|
+
|| row.inbound === false || row.fromMe === true
|
|
2147
|
+
// Intercom's waiting_since is set while the CUSTOMER waits on the team and cleared once a teammate replies.
|
|
2148
|
+
|| (source === 'intercom' && !row.waitingSince),
|
|
2138
2149
|
canReply: !!src.reply,
|
|
2139
2150
|
...(row.messageId || row.eventId ? { messageId: String(row.messageId || row.eventId) } : {}),
|
|
2140
2151
|
// WHAT THE DM IS (Meta's read routes, 2026-10-25): its kind, the sentence to read out, the media / story /
|
|
@@ -2200,7 +2211,7 @@ export const TOOL_GROUPS = {
|
|
|
2200
2211
|
// list in prose has no check that can hold it true, so the fix is to stop making a claim that can rot: the tools
|
|
2201
2212
|
// themselves are the roster, and enabling the group is what reveals them.
|
|
2202
2213
|
ads: 'Paid campaign management on every connected ad platform — build, budget, target and report on campaigns, ad sets, ads, keywords and audiences. Enable this group to see which platforms this workspace can reach.',
|
|
2203
|
-
files: 'Google Drive, Sheets, Docs, OneDrive and
|
|
2214
|
+
files: 'Google Drive, Sheets, Docs, OneDrive, Notion and Canva.',
|
|
2204
2215
|
workspace:'Profiles, memory, skills, saved creators, connectors and team.',
|
|
2205
2216
|
};
|
|
2206
2217
|
export const TOOL_GROUP_NAMES = ['core', ...Object.keys(TOOL_GROUPS)];
|
|
@@ -2452,7 +2463,7 @@ export function parseToolScope(raw) {
|
|
|
2452
2463
|
// account, not a transfer (an Apple budget order is a cap and spends nothing). The other Stripe writes (product, price,
|
|
2453
2464
|
// coupon, customer) create records and move nothing; tools/chatgpt-no-money-movement-check.mjs makes a NEW Stripe
|
|
2454
2465
|
// write fail until somebody classifies it.
|
|
2455
|
-
export const WIDGET_MONEY_MOVEMENT_TOOLS = Object.freeze(['refund_stripe_charge', 'create_stripe_subscription', 'cancel_stripe_subscription', 'create_stripe_payment_link']);
|
|
2466
|
+
export const WIDGET_MONEY_MOVEMENT_TOOLS = Object.freeze(['refund_stripe_charge', 'create_stripe_subscription', 'cancel_stripe_subscription', 'create_stripe_payment_link', 'update_stripe_payment_link']);
|
|
2456
2467
|
// THE 2026-10-25 SCAN, the tools a widget host is not offered for a reason of their own (the sentence a direct call gets):
|
|
2457
2468
|
// submit_tiktok_ads_verification — "appears to solicit a government-issued identifier": its INDIVIDUAL form takes the
|
|
2458
2469
|
// government ID number (identityNo) and a legal name. Entered in TikTok Ads Manager instead.
|
|
@@ -2881,6 +2892,7 @@ export const WITHHELD_FROM_DIRECTORY = new Set([
|
|
|
2881
2892
|
'hermoso_capabilities', 'list_creators', 'save_creator', 'update_saved_creator', 'delete_creator', 'mine_angles',
|
|
2882
2893
|
'refund_stripe_charge', 'create_stripe_payment_link', 'create_stripe_subscription', 'cancel_stripe_subscription',
|
|
2883
2894
|
'create_stripe_price', 'create_stripe_product', 'create_stripe_coupon', 'create_stripe_customer',
|
|
2895
|
+
'create_stripe_promotion_code', 'update_stripe_promotion_code', 'update_stripe_payment_link', // 2026-10-27: a code, and an edit to a link that collects money
|
|
2884
2896
|
// 2026-10-07 (plugin approval audit): the autopilot refill MAKES the posts it queues — set_post_refill's own description
|
|
2885
2897
|
// says "a fresh image, carousel or video for each" and run_post_refill with dryRun:false renders them — so it is a
|
|
2886
2898
|
// generation lane that lives in `channels`, and it was listed on the cage the 2026-09-25 resubmission went out on.
|
|
@@ -3108,6 +3120,7 @@ const sessionBound = (factory, ctx) => { const h = factory(ctx); try { h._hermos
|
|
|
3108
3120
|
// Mirrors applyToolGates exactly. `null` means "held out of the list on SIZE only", which is never a reason to
|
|
3109
3121
|
// refuse a call — only a reason not to carry the schema.
|
|
3110
3122
|
export const holdReasonFor = (name, ctx) => {
|
|
3123
|
+
if (heldForNoShopifyStoreOf(canonicalToolName(name), ctx.conn)) return 'shopify_store'; // a Shopify tool on an account with no Shopify store (roster-scope.mjs) — its own answer, not "not available yet"
|
|
3111
3124
|
if (toolWithheldByServer(name, ctx.conn)) return 'unavailable'; // the server does not offer this capability right now (toolWithheldByServer)
|
|
3112
3125
|
if (WITHHELD_FROM_WIDGET_HOSTS.has(name) && ctx.widgetHost) return 'host_policy';
|
|
3113
3126
|
if (EXECUTOR_TOOLS.has(name) && ctx.widgetHost) return 'host_policy'; // OpenAI's generic-executor rule — see EXECUTOR_TOOLS (its own sentence below)
|
|
@@ -3128,6 +3141,7 @@ export const holdReasonFor = (name, ctx) => {
|
|
|
3128
3141
|
export const holdHints = (name, why, ctx = null) => {
|
|
3129
3142
|
if (why === 'not_connected') { const p = toolProvider(name) || 'that'; return [{ do: `have the user connect "${p}" under Settings \u25b8 Connectors${Object.prototype.hasOwnProperty.call(KEY_CONNECTORS, p) ? ', or connect_connector right here if it is a paste-a-key account' : ''}, then call ${name} again`, why: `${name} needs the "${p}" connection and this workspace has not made it` }]; }
|
|
3130
3143
|
if (why === 'not_offered') return [{ do: `do not offer this capability and do not send the user to Settings \u25b8 Connectors`, why: `Hermoso does not offer the "${toolProvider(name) || 'required'}" connection yet, so there is nothing the user can connect` }];
|
|
3144
|
+
if (why === 'shopify_store') return [{ do: 'do not offer this here; if the user runs a Shopify store, tell them to install Hermoso from the Shopify App Store and open it from their Shopify admin', why: `${name} ${SHOPIFY_STORE_NOTE}` }];
|
|
3131
3145
|
if (why === 'unavailable') return [{ do: 'do not offer this capability; carry on with the tools that are listed', why: `${name} is not available in Hermoso yet` }];
|
|
3132
3146
|
if (why === 'host_policy' && executorLike(name)) return [{ do: 'call the Hermoso tools in your list directly', why: `on this host every Hermoso operation is its own listed tool, so ${name} is not offered` }];
|
|
3133
3147
|
if (why === 'host_policy') return [{ do: 'use the Hermoso app or another MCP client for this one', why: `${name} is withheld by this host's own policy, not by Hermoso` }];
|
|
@@ -3141,6 +3155,7 @@ export const holdReasonText = (name, why, ctx = null) => {
|
|
|
3141
3155
|
const reddit = prov === 'reddit' ? ' Reddit ADS are available through the reddit_ads tools; organic Reddit posting is not.' : '';
|
|
3142
3156
|
return `${name} is not available: Hermoso does not offer the "${prov}" connection yet${because}. There is nothing the user can connect, so do not point them at Settings ▸ Connectors and do not offer this capability.${reddit}`;
|
|
3143
3157
|
}
|
|
3158
|
+
if (why === 'shopify_store') return `${name} ${SHOPIFY_STORE_NOTE} Do not send the user to Settings \u25b8 Connectors for it; there is no Shopify tile to connect.`;
|
|
3144
3159
|
if (why === 'unavailable') return `${name} is not available in Hermoso yet. Do not offer it or point the user at a setting for it; nothing they can connect turns it on.`;
|
|
3145
3160
|
// ONE sentence, two reasons: OpenAI's generic-executor rule for EXECUTOR_TOOLS, its commerce policy for the rest.
|
|
3146
3161
|
if (why === 'host_policy' && WIDGET_WITHHELD_WHY[name]) return `${name} is not offered on this host: ${WIDGET_WITHHELD_WHY[name]}.`;
|
|
@@ -4485,7 +4500,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4485
4500
|
description: "EVERYTHING PEOPLE SAID TO THIS BRAND, across every connected channel, in one list: Facebook and "
|
|
4486
4501
|
+ "Instagram comments, Threads replies and mentions, YouTube and Reddit comments, Google Business reviews, "
|
|
4487
4502
|
+ "Bluesky replies and mentions, and X mentions — plus DIRECT MESSAGES on Meta (Messenger and Instagram "
|
|
4488
|
-
+ "Direct), Instagram, Bluesky, X and Telegram. Use this for 'what do I need to reply to', 'any new comments', 'any new DMs', 'how are people responding'. Each "
|
|
4503
|
+
+ "Direct), Instagram, Bluesky, X and Telegram. Support tickets too: the open conversations in the brand's Intercom inbox. Use this for 'what do I need to reply to', 'any new comments', 'any new DMs', 'how are people responding'. Each "
|
|
4489
4504
|
+ "item carries a composite id you hand straight to reply_to_inbox_item. A channel that is not connected is "
|
|
4490
4505
|
+ "skipped silently; a channel that FAILS to read is named in `notes` rather than dropped, so a short list is "
|
|
4491
4506
|
+ "never mistaken for a quiet week. Each DM conversation folds to ONE item (the newest thing the other person said). "
|
|
@@ -4544,7 +4559,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4544
4559
|
if (pageId && (src.provider === 'meta' || src.provider === 'instagram')) q.pageId = String(pageId).trim();
|
|
4545
4560
|
try {
|
|
4546
4561
|
const d = await apiGet(whole ? src.readAll : src.read, q);
|
|
4547
|
-
const rows = Array.isArray(d) ? d : (d.comments || d.replies || d.mentions || d.reviews || d.messages || d.events || d.items || d.data || []);
|
|
4562
|
+
const rows = Array.isArray(d) ? d : (d.comments || d.replies || d.mentions || d.reviews || d.messages || d.events || d.items || d.conversations || d.data || []);
|
|
4548
4563
|
// A SOURCE THAT READ ONLY PART OF ITSELF SAYS SO (2026-10-26, customer report: Instagram DMs at limit >= 32 came
|
|
4549
4564
|
// back as 0 items and NO note, because the DM inbox route reported its failure in `unreadable` + `note` and this
|
|
4550
4565
|
// loop only ever read the rows). Any route answering `unreadable` has its sentence carried into `notes`, on both
|
|
@@ -4582,7 +4597,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4582
4597
|
description: "Answer an inbox item BY ITS COMPOSITE ID — the `id` list_inbox returned, e.g. 'facebook:123_456' "
|
|
4583
4598
|
+ "or 'google_business:accounts/1/locations/2/reviews/3'. Routes to the right channel for you; you do not need "
|
|
4584
4599
|
+ "to know which reply tool a platform uses. The reply is posted as the brand — PUBLIC on every comment, reply, "
|
|
4585
|
-
+ "mention and review, and PRIVATE on the
|
|
4600
|
+
+ "mention and review, and PRIVATE on the DM sources and on Intercom (it goes to the customer from the brand's support inbox) — so show the user the "
|
|
4586
4601
|
+ "exact wording and get their go-ahead first either way. X is the one exception and says so: a reply there is a new post, "
|
|
4587
4602
|
+ "so it goes through post_to_x with replyTo.",
|
|
4588
4603
|
inputSchema: {
|
|
@@ -4618,12 +4633,20 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4618
4633
|
else if (t.source === 'bluesky_dm') payload.convoId = t.nativeId;
|
|
4619
4634
|
else if (t.source === 'x_dm') payload.conversationId = t.nativeId;
|
|
4620
4635
|
else if (t.source === 'telegram_dm') payload.chatId = t.nativeId;
|
|
4621
|
-
|
|
4636
|
+
// Intercom: the id rides in the PATH, and the route sends to the customer only with confirm:true (this tool's own
|
|
4637
|
+
// contract is that the user already approved the wording). Anything but a confirmed read-back is said as such.
|
|
4638
|
+
else if (t.source === 'intercom') payload.confirm = true;
|
|
4639
|
+
const route = t.route.includes(':id') ? t.route.replace(':id', encodeURIComponent(t.nativeId)) : t.route;
|
|
4640
|
+
const r = await apiPost(route, payload);
|
|
4641
|
+
if (t.source === 'intercom' && r?.confirmed !== true) {
|
|
4642
|
+
const n = r?.dryRun ? 'Intercom did not send the reply (it returned a preview instead); nothing reached the customer.' : (r?.note || 'Intercom accepted the reply but did not confirm it; read the conversation before sending again.');
|
|
4643
|
+
return ok(n, { ok: false, source: t.source, note: n });
|
|
4644
|
+
}
|
|
4622
4645
|
// A DM IS PRIVATE. Saying "replied publicly as the brand" about a direct message is wrong in the one
|
|
4623
4646
|
// direction that matters — it invites a caller to be more guarded than it needs to be about a private reply,
|
|
4624
4647
|
// and, worse, teaches that the same sentence covers both.
|
|
4625
|
-
const note = t.source.endsWith('_dm') ? `Sent a private reply to the ${t.label} as the brand.` : `Replied to the ${t.label} as the brand.`;
|
|
4626
|
-
return ok(note, { ok: true, source: t.source, id: r?.id || r?.messageId || r?.name || undefined, note });
|
|
4648
|
+
const note = (t.source.endsWith('_dm') || t.source === 'intercom') ? `Sent a private reply to the ${t.label} as the brand.` : `Replied to the ${t.label} as the brand.`;
|
|
4649
|
+
return ok(note, { ok: true, source: t.source, id: r?.id || r?.messageId || r?.partId || r?.name || undefined, note });
|
|
4627
4650
|
}));
|
|
4628
4651
|
|
|
4629
4652
|
// Back to core. The inbox + Bluesky tools above set the marker to `channels`, and the marker is sticky —
|
|
@@ -5694,6 +5717,22 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5694
5717
|
}));
|
|
5695
5718
|
|
|
5696
5719
|
server.group('channel_admin');
|
|
5720
|
+
// ── FIND A PLACE TO TAG ON FACEBOOK / INSTAGRAM (2026-10-27, gap sweep I-23). Meta's Pages search, opened to an app
|
|
5721
|
+
// only after Page Public Metadata Access is approved; until then the route answers with that state in words.
|
|
5722
|
+
server.registerTool('search_meta_places', {
|
|
5723
|
+
title: 'Find a place to tag on Facebook or Instagram',
|
|
5724
|
+
description: 'Find a PLACE to tag on a Facebook or Instagram post by name — returns Facebook Page ids that have a location (name + address), to pass as place (Facebook) or instagramLocationId / locationId (Instagram) on post_to_meta / schedule_post. Use it when the brand has a physical location (a shop, salon, restaurant, venue) or the user names one. Uses Meta\'s Pages search, which Meta opens only after approving Hermoso\'s Page Public Metadata Access feature (pending) — until then it says so. Free.',
|
|
5725
|
+
inputSchema: {
|
|
5726
|
+
q: z.string().describe('the place name, ideally with its city, e.g. "Blue Bottle Coffee Hayes Valley"'),
|
|
5727
|
+
limit: z.number().int().min(1).max(25).optional().describe('how many (1–25, default 10)'),
|
|
5728
|
+
},
|
|
5729
|
+
outputSchema: { query: z.string().optional(), count: z.number().optional(), places: z.array(z.any()).optional(), note: z.string().optional() },
|
|
5730
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
5731
|
+
}, wrap(async (a) => {
|
|
5732
|
+
const d = await apiGet('/api/meta/places', { q: a.q, ...(a.limit ? { limit: a.limit } : {}) });
|
|
5733
|
+
const lines = (d.places || []).map(p => `• ${p.name}${p.address ? ` — ${p.address}` : ''} — id ${p.id}`);
|
|
5734
|
+
return ok(d.count ? `${d.count} place(s) for “${d.query}”:\n${lines.join('\n')}\n${d.note}` : d.note, d);
|
|
5735
|
+
}));
|
|
5697
5736
|
server.registerTool('search_threads_locations', {
|
|
5698
5737
|
title: 'Find a place to tag on Threads',
|
|
5699
5738
|
description: 'Search Threads’ public place index by name (or by latitude+longitude) and get location ids. Use this when the brand has a PHYSICAL location — a restaurant, salon, gym, store — so the post can be geotagged to it. Pass the chosen id as post_to_meta(locationId) with target:"threads".',
|
|
@@ -5826,7 +5865,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5826
5865
|
|
|
5827
5866
|
server.registerTool('create_whatsapp_template', {
|
|
5828
5867
|
title: 'Create a WhatsApp message template',
|
|
5829
|
-
description: 'Submit a new WhatsApp message template for Meta’s review. This is the ONLY way a business can start a WhatsApp conversation: outside the 24-hour window that opens when a customer messages first, WhatsApp accepts approved templates and nothing else. CATEGORY IS NOT COSMETIC — MARKETING (anything promotional), UTILITY (a transactional follow-up to something the customer did) or AUTHENTICATION (a one-time passcode) decides both what Meta charges for the conversation and the policy the content is reviewed against, and Meta RE-CATEGORISES a template whose content does not match the claim; the reply reports the category Meta actually assigned, not the one requested. The name is lowercase letters, digits and underscores only. Language is never defaulted: en and en_US are different templates to Meta. Submitting does NOT make it sendable — it comes back PENDING and has to be re-read with list_whatsapp_templates. CAROUSEL TEMPLATES ARE SUPPORTED: add a “CAROUSEL” component whose “cards” array holds 2–10 cards, each card carrying its own HEADER (format IMAGE or VIDEO for a media carousel, PRODUCT for a catalog one), an optional card BODY of at most 160 characters — a different and far shorter limit than the message bubble’s 1024 — and up to 2 buttons. Meta requires every card to carry THE SAME components, and if any card has body text then all of them must. At creation a card is only its “components” array; “card_index” belongs to the send payload, not to this one. A MEDIA HEADER NEEDS AN UPLOADED ASSET: any IMAGE, VIDEO or DOCUMENT header — including every card header in a media carousel — must carry example: { header_handle: [ … ] } holding a handle from Meta’s Resumable Upload API, or Meta refuses the whole template with a bare “Invalid parameter”.
|
|
5868
|
+
description: 'Submit a new WhatsApp message template for Meta’s review. This is the ONLY way a business can start a WhatsApp conversation: outside the 24-hour window that opens when a customer messages first, WhatsApp accepts approved templates and nothing else. CATEGORY IS NOT COSMETIC — MARKETING (anything promotional), UTILITY (a transactional follow-up to something the customer did) or AUTHENTICATION (a one-time passcode) decides both what Meta charges for the conversation and the policy the content is reviewed against, and Meta RE-CATEGORISES a template whose content does not match the claim; the reply reports the category Meta actually assigned, not the one requested. The name is lowercase letters, digits and underscores only. Language is never defaulted: en and en_US are different templates to Meta. Submitting does NOT make it sendable — it comes back PENDING and has to be re-read with list_whatsapp_templates. CAROUSEL TEMPLATES ARE SUPPORTED: add a “CAROUSEL” component whose “cards” array holds 2–10 cards, each card carrying its own HEADER (format IMAGE or VIDEO for a media carousel, PRODUCT for a catalog one), an optional card BODY of at most 160 characters — a different and far shorter limit than the message bubble’s 1024 — and up to 2 buttons. Meta requires every card to carry THE SAME components, and if any card has body text then all of them must. At creation a card is only its “components” array; “card_index” belongs to the send payload, not to this one. A MEDIA HEADER NEEDS AN UPLOADED ASSET: any IMAGE, VIDEO or DOCUMENT header — including every card header in a media carousel — must carry example: { header_handle: [ … ] } holding a handle from Meta’s Resumable Upload API, or Meta refuses the whole template with a bare “Invalid parameter”. Pass `mediaUrl` on that header (a public JPEG/PNG for IMAGE, MP4 for VIDEO, PDF for DOCUMENT) and Hermoso uploads it through Meta\'s Resumable Upload API and fills the handle; a header with neither is refused locally and by name. A TEXT header needs no handle, and a PRODUCT card carousel takes its pictures from a connected catalog. 0 credits.',
|
|
5830
5869
|
inputSchema: {
|
|
5831
5870
|
wabaId: z.string().optional().describe('the WhatsApp Business Account id, from list_whatsapp_accounts. Optional when exactly one account is shared with this profile; required when several are, and refused by name for one that is not shared'),
|
|
5832
5871
|
name: z.string().describe('lowercase letters, digits and underscores only, e.g. "order_shipped" (max 512)'),
|
|
@@ -5863,7 +5902,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5863
5902
|
description: 'Send a WhatsApp message from one of the brand’s registered numbers to a customer. TWO KINDS, and which one is legal depends on time: pass `template` (an APPROVED template name) to START a conversation — the only thing WhatsApp accepts outside the 24-hour customer-service window — or `text` for a free-form reply INSIDE that window, which opens only when the customer messages the business first. Hermoso does not receive WhatsApp webhooks yet, so it CANNOT see whether that window is open: a free-form message is sent optimistically and Meta refuses it with a re-engagement error if it is closed, which the reply explains rather than reporting as a broken connection. THIS REACHES A REAL PERSON’S PHONE AND META BILLS THE BUSINESS for the conversation, so it is confirm-gated: show the user the exact message and the recipient, get an explicit yes, then call again with confirm:true. WhatsApp policy REQUIRES the business to hold the recipient’s opt-in first. An unapproved template is refused BEFORE the send, against the status Meta holds. ACCEPTED IS NOT DELIVERED — the reply reports what Meta accepted, and delivery arrives on a webhook we do not receive, so never tell the user it was delivered. THE VARIABLE COUNT IS CHECKED BEFORE ANYTHING IS DISPATCHED: Hermoso reads the template’s own components back from Meta and refuses a payload that supplies the wrong number of values, naming exactly what is missing — for free, before a real phone is involved, instead of Meta answering 132000 after the attempt. It refuses only what it can prove; anything it cannot determine is sent for Meta to judge. 0 Hermoso credits.',
|
|
5864
5903
|
inputSchema: {
|
|
5865
5904
|
phoneNumberId: z.string().describe('WHICH NUMBER IT SENDS FROM — from list_whatsapp_accounts (a WABA can have several)'),
|
|
5866
|
-
to: z.string().describe('the recipient in full international form, e.g. +14165550142'),
|
|
5905
|
+
to: z.string().describe('the recipient in full international form, e.g. +14165550142 — or, for someone who reached the business by WhatsApp username and shared no number, their business-scoped user id exactly as received (e.g. US.13491208655302741918). Authentication templates need the phone number.'),
|
|
5867
5906
|
template: z.string().optional().describe('an APPROVED template name — required to start a conversation outside the 24-hour window'),
|
|
5868
5907
|
language: z.string().optional().describe('which language of that template, e.g. en_US. Omitted, Hermoso uses the first one it finds and reports which.'),
|
|
5869
5908
|
wabaId: z.string().optional().describe('the account the template’s review status is read from. Optional — it defaults to the account that owns phoneNumberId, and a wabaId naming a DIFFERENT account is refused rather than silently preferred'),
|
|
@@ -5891,7 +5930,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5891
5930
|
template: z.string().optional().describe('an APPROVED template name. Required to SEND — a broadcast cannot be free-form text; omit only when reading a broadcast back with jobId'),
|
|
5892
5931
|
language: z.string().optional().describe('which language of that template, e.g. en_US. Omitted, Hermoso uses the first one it finds and reports which.'),
|
|
5893
5932
|
wabaId: z.string().optional().describe('the account the template is read from. Optional — it defaults to the account that owns phoneNumberId, and one naming a DIFFERENT account is refused'),
|
|
5894
|
-
recipients: z.array(z.any()).optional().describe('the list: either phone numbers in full international form ["+14165550142", …], or objects { to, components } when each person gets different template values. Duplicates are removed and counted; invalid numbers are refused by name unless skipInvalid is true'),
|
|
5933
|
+
recipients: z.array(z.any()).optional().describe('the list: either phone numbers in full international form ["+14165550142", …] (or business-scoped user ids, e.g. US.13491208655302741918, for people who reached you by username), or objects { to, components } when each person gets different template values. Duplicates are removed and counted; invalid numbers are refused by name unless skipInvalid is true'),
|
|
5895
5934
|
components: z.array(z.record(z.any())).optional().describe('template values SHARED by every recipient, in Meta’s component form. A per-recipient `components` overrides it. The count must match the template’s {{1}}, {{2}} placeholders — Hermoso checks that against the real template and refuses a provable mismatch before anything is dispatched'),
|
|
5896
5935
|
skipInvalid: z.boolean().optional().describe('drop unusable numbers instead of refusing the whole list. They are still named in the result — nothing is ever silently dropped'),
|
|
5897
5936
|
dryRun: z.boolean().optional().describe('cost nothing, send nothing, queue nothing: report exactly who would be messaged, what Meta charges for it, and whether it fits the 24-hour messaging limit'),
|
|
@@ -6130,6 +6169,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6130
6169
|
// to guess, so a schedule that cannot carry it can only fail at fire time with nobody watching.
|
|
6131
6170
|
boardId: z.string().optional().describe('PINTEREST — REQUIRED whenever pinterest is a channel: the board the Pin goes on, from list_pinterest_boards. The user picks it; a Pin on the wrong board is a public mistake. Scheduling pinterest without one is refused immediately.'),
|
|
6132
6171
|
chatId: z.string().optional().describe('TELEGRAM — REQUIRED whenever telegram is a channel: WHICH chat, group or channel the bot posts to. A public channel’s @username (@hermosoai) or the numeric id (a group is negative; a supergroup or channel starts with -100). There is no default and there cannot be one — the Telegram Bot API publishes no method that lists a bot’s chats — so scheduling telegram without one is refused up front. list_telegram_chats finds ids for chats that have messaged the bot in the last 24 hours.'),
|
|
6172
|
+
buttons: z.array(z.union([z.object({ text: z.string(), url: z.string(), style: z.enum(['danger', 'success', 'primary']).optional() }), z.array(z.object({ text: z.string(), url: z.string(), style: z.enum(['danger', 'success', 'primary']).optional() }))])).optional().describe('TELEGRAM ONLY — URL buttons under the post, as post_to_telegram takes them; never on an album.'),
|
|
6173
|
+
parseMode: z.enum(['HTML']).optional().describe("TELEGRAM ONLY — 'HTML' for Telegram's HTML tags; omit for plain text."),
|
|
6174
|
+
silent: z.boolean().optional().describe('TELEGRAM ONLY — no notification sound.'),
|
|
6175
|
+
disablePreview: z.boolean().optional().describe('TELEGRAM ONLY — no link preview.'),
|
|
6133
6176
|
linkedinOrganizationId: z.string().optional().describe('LINKEDIN — publish as a COMPANY PAGE instead of the connected personal profile. The organization id from list_linkedin_pages. Omit and it posts as the person: an unset id means the profile, never “probably the company”. A Page can also carry VIDEO, which a personal profile cannot.'),
|
|
6134
6177
|
pageId: z.string().optional().describe('FACEBOOK / INSTAGRAM / THREADS — which connected Facebook Page (and its linked Instagram) publishes, from list_meta_pages. Needed when the profile has more than one Page connected; with several and no id the post is refused at fire time rather than sent from the wrong brand.'),
|
|
6135
6178
|
locationId: z.string().optional().describe("GOOGLE BUSINESS PROFILE — which listing, e.g. 'locations/123' from list_business_locations. Needed when the account manages more than one storefront; it is never chosen for the user."),
|
|
@@ -6306,6 +6349,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6306
6349
|
aiGenerated: z.boolean().optional().describe('AI-CONTENT DISCLOSURE (Instagram / Facebook Reel is_ai_generated, TikTok is_aigc, YouTube containsSyntheticMedia, Pinterest ai_disclosures). OMIT IT and the value already on the post stays; a post that never had one is decided from provenance at publish: a Hermoso render is declared AI-generated, media that came through upload_file or from an external URL (the user\u2019s own photos or footage) is NOT. Pass true or false only to override.'),
|
|
6307
6350
|
boardId: z.string().optional().describe('PINTEREST — move the Pin to a different board (list_pinterest_boards)'),
|
|
6308
6351
|
chatId: z.string().optional().describe('TELEGRAM — send it to a different chat, group or channel (@username or numeric id). It can be changed but never cleared: telegram cannot publish without one.'),
|
|
6352
|
+
buttons: z.array(z.union([z.object({ text: z.string(), url: z.string(), style: z.enum(['danger', 'success', 'primary']).optional() }), z.array(z.object({ text: z.string(), url: z.string(), style: z.enum(['danger', 'success', 'primary']).optional() }))])).optional().describe('TELEGRAM ONLY — replace the URL buttons ([] removes them).'),
|
|
6353
|
+
parseMode: z.enum(['HTML']).optional().describe("TELEGRAM ONLY — 'HTML' for Telegram's HTML tags."),
|
|
6354
|
+
silent: z.boolean().optional().describe('TELEGRAM ONLY — no notification sound.'),
|
|
6355
|
+
disablePreview: z.boolean().optional().describe('TELEGRAM ONLY — no link preview.'),
|
|
6309
6356
|
linkedinOrganizationId: z.string().optional().describe('LINKEDIN — target a different company Page, or "" to post as the connected person instead'),
|
|
6310
6357
|
pageId: z.string().optional().describe('FACEBOOK / INSTAGRAM / THREADS — publish from a different connected Page (list_meta_pages)'),
|
|
6311
6358
|
locationId: z.string().optional().describe('GOOGLE BUSINESS — a different listing (list_business_locations)'),
|
|
@@ -8433,6 +8480,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8433
8480
|
name: z.string().describe('campaign name'),
|
|
8434
8481
|
adAccountId: z.string().describe('ad account id (digits or act_… — from list_meta_pages)'),
|
|
8435
8482
|
objective: z.enum(['OUTCOME_TRAFFIC', 'OUTCOME_AWARENESS', 'OUTCOME_ENGAGEMENT', 'OUTCOME_LEADS', 'OUTCOME_SALES', 'OUTCOME_APP_PROMOTION']).optional().describe('default OUTCOME_TRAFFIC'),
|
|
8483
|
+
bidStrategy: z.enum(['LOWEST_COST_WITHOUT_CAP', 'LOWEST_COST_WITH_BID_CAP', 'COST_CAP', 'LOWEST_COST_WITH_MIN_ROAS']).optional().describe('with dailyBudgetUsd: how the campaign bids across its ad sets — LOWEST_COST_WITHOUT_CAP (default: highest volume), LOWEST_COST_WITH_BID_CAP / COST_CAP (each ad set then needs bidAmountUsd), LOWEST_COST_WITH_MIN_ROAS (each ad set needs minRoas)'),
|
|
8436
8484
|
dailyBudgetUsd: z.number().optional().describe('optional campaign daily budget in USD (1–10000); real spend once ACTIVE. With it, the campaign holds the budget (Advantage+ campaign budget) and every ad set under it carries NONE — create_meta_adset / create_meta_ad then send no ad-set budget'),
|
|
8437
8485
|
productCatalogId: z.string().optional().describe('CATALOG (dynamic product) ADS — the PRODUCT CATALOG this campaign advertises, from list_meta_catalogs. This is the CAMPAIGN-level half of a catalog ad; the AD SET takes a productSetId and the creative takes the same set. Passing a product SET id here is refused by name with the level it belongs on. The binding is READ BACK from Meta before it is reported, so a 200 is never narrated as “bound”.'),
|
|
8438
8486
|
},
|
|
@@ -8657,6 +8705,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8657
8705
|
inputSchema: {
|
|
8658
8706
|
recipientId: z.string().describe('the PSID (Messenger) or IGSID (Instagram) to reply to — `replyTo` from read_meta_conversation'),
|
|
8659
8707
|
text: z.string().describe('the reply'),
|
|
8708
|
+
humanAgent: z.boolean().optional().describe('HUMAN AGENT — true when a PERSON on the team is personally answering a customer\'s question more than 24 hours after they last wrote (up to 7 days): Meta\'s HUMAN_AGENT tag. Never for automated, promotional or unrelated messages. Meta grants the tag only after approving Hermoso\'s Human Agent feature (App Review, pending); until then Meta refuses it and the reply says so.'),
|
|
8660
8709
|
conversationId: z.string().optional().describe('strongly recommended: it is what lets the 24-hour window be checked BEFORE sending rather than discovered by a refusal'),
|
|
8661
8710
|
platform: z.enum(['MESSENGER', 'INSTAGRAM']).optional(),
|
|
8662
8711
|
account: z.string().optional().describe('which Instagram account — an @handle or id from list_connector_accounts("instagram"). The way a DIRECT (Instagram Login) account is reached; omit for the Page-linked one.'),
|
|
@@ -9583,23 +9632,67 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9583
9632
|
|
|
9584
9633
|
server.group('channels');
|
|
9585
9634
|
server.registerTool('publish_to_shopify_product', {
|
|
9586
|
-
title: 'Publish an image onto a Shopify product',
|
|
9587
|
-
description: "Attach a finished
|
|
9635
|
+
title: 'Publish an image or video onto a Shopify product',
|
|
9636
|
+
description: "Attach a finished IMAGE or VIDEO to one of the merchant's Shopify product listings, as product media. Pass productId (from list_shopify_products, the gid://shopify/Product/… form) and a PUBLIC https mediaUrl, which is what every Hermoso render returns. Whether it is a video is read from the file (.mp4 / .mov / .webm, or the link's content type); pass kind to say so outright. An image is fetched by Shopify directly; a video is uploaded to Shopify for you (MP4, MOV or WEBM, up to Shopify's 1 GB and 10-minute product-video limits; a file over a limit is refused by name, never cut). Shopify processes media asynchronously: the reply is the status read back from Shopify, and READY means it is live on the listing while PROCESSING means it will appear when Shopify finishes. That is success, not a failure. Only works for accounts created by installing Hermoso from the Shopify App Store. Free: the render was already paid for.",
|
|
9588
9637
|
inputSchema: {
|
|
9589
9638
|
productId: z.string().describe('gid://shopify/Product/… from list_shopify_products'),
|
|
9590
|
-
|
|
9639
|
+
mediaUrl: z.string().optional().describe('any public https image or video URL: a Hermoso render works, and so does ANY file of your own brought in with upload_file'),
|
|
9640
|
+
imageUrl: z.string().optional().describe('older name for mediaUrl; still accepted'),
|
|
9641
|
+
kind: z.enum(['image', 'video']).optional().describe('only when the link does not say (no file extension and no content type)'),
|
|
9591
9642
|
alt: z.string().optional().describe('alt text for accessibility and SEO; defaults to a generic credit'),
|
|
9592
9643
|
},
|
|
9593
|
-
outputSchema: { shop: z.string().optional(), ok: z.boolean().optional(), productId: z.string().optional(), productUrl: z.string().optional(), media: z.any().optional() },
|
|
9644
|
+
outputSchema: { shop: z.string().optional(), ok: z.boolean().optional(), kind: z.string().optional(), productId: z.string().optional(), productTitle: z.string().optional(), productUrl: z.string().nullable().optional(), media: z.any().optional(), upload: z.any().optional(), note: z.string().optional() },
|
|
9594
9645
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
9595
9646
|
}, wrap(async (a) => {
|
|
9596
|
-
const d = await apiPost('/api/shopify/publish-to-product', { productId: a.productId,
|
|
9647
|
+
const d = await apiPost('/api/shopify/publish-to-product', { productId: a.productId, mediaUrl: a.mediaUrl || a.imageUrl, kind: a.kind, alt: a.alt });
|
|
9597
9648
|
const st = d.media?.status || 'UNKNOWN';
|
|
9598
|
-
return ok(`Published to ${d.productId} on ${d.shop}
|
|
9599
|
-
+ (st === 'PROCESSING' ? ' (Shopify is still processing it; it will appear on the listing shortly).' : '.')
|
|
9649
|
+
return ok(`${d.note || `Published to ${d.productId} on ${d.shop}.`}\nMedia ${d.media?.id || '?'} (${d.kind || 'image'}) is ${st}.`
|
|
9600
9650
|
+ (d.productUrl ? `\nListing: ${d.productUrl}` : ''), d);
|
|
9601
9651
|
}));
|
|
9602
9652
|
|
|
9653
|
+
// SHOPIFY ▸ MARKETING (2026-10-27) — record the ads Hermoso ran in the merchant's own Shopify marketing reports.
|
|
9654
|
+
// `analytics`: it is attribution bookkeeping inside the merchant's own admin and reaches nobody outside the account.
|
|
9655
|
+
server.group('analytics');
|
|
9656
|
+
const shopifyMoney = z.object({ amount: z.number().describe('MAJOR units: 40 means 40.00'), currency: z.string().describe('3-letter, e.g. USD') });
|
|
9657
|
+
server.registerTool('record_shopify_marketing_activity', {
|
|
9658
|
+
title: 'Record an ad or campaign in Shopify ▸ Marketing',
|
|
9659
|
+
description: "Record an ad or campaign the merchant is running (on Meta, Google, TikTok, email, anywhere) in their Shopify admin under Marketing, so Shopify's own reports attribute storefront visits and sales to it by its UTM. REPORTING ONLY: it publishes nothing and spends nothing. Pass title, remoteUrl (where the ad is managed, e.g. the Ads Manager link), the utm {campaign, source, medium} the ad's links carry, and optionally tactic (AD default, RETARGETING, POST, LINK, NEWSLETTER, …), channel (SOCIAL default, SEARCH, DISPLAY, EMAIL, REFERRAL), status (ACTIVE default, PAUSED, SCHEDULED, INACTIVE, DELETED_EXTERNALLY), budget {type DAILY|LIFETIME, amount, currency}, adSpend {amount, currency}, start / end / scheduledEnd dates and previewImageUrl. remoteId is YOUR stable id for the activity (the ad platform's campaign id is ideal); calling again with the same remoteId UPDATES that one row, and every field you leave out is KEPT from the existing record (Shopify's own upsert would erase it; the reply lists what was kept). Read back from Shopify. Needs the store to have approved Hermoso's marketing permission: if it has not, the reply says to open Hermoso once from the Shopify admin to approve it. Only works for accounts created by installing Hermoso from the Shopify App Store. Free.",
|
|
9660
|
+
inputSchema: {
|
|
9661
|
+
title: z.string().optional().describe('what the merchant sees in Shopify ▸ Marketing; required on the first recording'),
|
|
9662
|
+
remoteUrl: z.string().optional().describe('https link where this ad or campaign is managed or viewed; required on the first recording'),
|
|
9663
|
+
remoteId: z.string().optional().describe('your stable id for this activity (e.g. the ad platform campaign id); derived from platform + campaignId or the utm campaign when omitted'),
|
|
9664
|
+
platform: z.string().optional().describe('where it runs, e.g. meta, google_ads, tiktok_ads; only used to derive remoteId'),
|
|
9665
|
+
campaignId: z.string().optional().describe('the ad platform campaign id; only used to derive remoteId'),
|
|
9666
|
+
utm: z.object({ campaign: z.string(), source: z.string(), medium: z.string() }).optional().describe('the UTM the ad links carry; Shopify matches visits on all three'),
|
|
9667
|
+
tactic: z.enum(['AD', 'RETARGETING', 'POST', 'LINK', 'AFFILIATE', 'MESSAGE', 'NEWSLETTER', 'ABANDONED_CART', 'LOYALTY', 'NOTIFICATION', 'SEO', 'STOREFRONT_APP', 'TRANSACTIONAL']).optional(),
|
|
9668
|
+
channel: z.enum(['SOCIAL', 'SEARCH', 'DISPLAY', 'EMAIL', 'REFERRAL']).optional(),
|
|
9669
|
+
status: z.enum(['ACTIVE', 'PAUSED', 'SCHEDULED', 'INACTIVE', 'DELETED_EXTERNALLY', 'UNDEFINED']).optional(),
|
|
9670
|
+
budget: z.object({ type: z.enum(['DAILY', 'LIFETIME']).optional(), amount: z.number(), currency: z.string() }).optional(),
|
|
9671
|
+
adSpend: shopifyMoney.optional().describe('amount spent so far'),
|
|
9672
|
+
start: z.string().optional().describe('ISO date the ad started'), end: z.string().optional().describe('ISO date it ended'), scheduledEnd: z.string().optional(),
|
|
9673
|
+
previewImageUrl: z.string().optional().describe('https image shown next to the activity (the ad creative works)'),
|
|
9674
|
+
},
|
|
9675
|
+
outputSchema: { shop: z.string().optional(), ok: z.boolean().optional(), action: z.string().optional(), remoteId: z.string().optional(), activity: z.any().optional(), carriedForward: z.array(z.string()).optional(), note: z.string().optional() },
|
|
9676
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
9677
|
+
}, wrap(async (a) => { const d = await apiPost('/api/shopify/marketing-activity', a); return ok(d.note, d); }));
|
|
9678
|
+
server.registerTool('list_shopify_marketing_activities', {
|
|
9679
|
+
title: 'List Shopify marketing activities',
|
|
9680
|
+
description: "The marketing activities recorded in the merchant's Shopify ▸ Marketing by Hermoso (and other apps): title, tactic, channel, status, UTM, budget, ad spend, dates and the remoteId each was recorded under. Filter by remoteId or a Shopify search query (e.g. title:summer). Newest first; pass cursor for more. Read-only, free. Needs the store to have approved Hermoso's marketing permission. Only works for accounts created by installing Hermoso from the Shopify App Store.",
|
|
9681
|
+
inputSchema: { limit: z.number().optional().describe('1–100, default 25'), cursor: z.string().optional(), remoteId: z.string().optional(), query: z.string().optional().describe('Shopify search syntax, e.g. title:summer or tactic:AD') },
|
|
9682
|
+
outputSchema: { shop: z.string().optional(), ok: z.boolean().optional(), count: z.number().optional(), activities: z.array(z.any()).optional(), pageInfo: z.any().optional(), note: z.string().optional() },
|
|
9683
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
9684
|
+
}, wrap(async (a) => {
|
|
9685
|
+
const d = await apiGet('/api/shopify/marketing-activities', a);
|
|
9686
|
+
return ok(d.count ? `${d.note}\n${d.activities.map((x) => `• ${x.title} — ${x.tactic}/${x.channel} · ${x.statusLabel || x.status}${x.utm ? ` · utm_campaign=${x.utm.campaign}` : ''}${x.adSpend ? ` · spent ${x.adSpend.amount} ${x.adSpend.currency}` : ''} (remoteId ${x.remoteId || '—'}, ${x.id})`).join('\n')}` : d.note, d);
|
|
9687
|
+
}));
|
|
9688
|
+
server.registerTool('delete_shopify_marketing_activity', {
|
|
9689
|
+
title: 'Delete a Shopify marketing activity',
|
|
9690
|
+
description: "Remove a marketing activity Hermoso recorded from the merchant's Shopify ▸ Marketing, by remoteId or id. Shopify stops attributing visits and sales to it and it cannot be undone, so without confirm:true NOTHING is deleted and the reply names exactly what would go: show the user, get a yes, then call again with confirm:true. Read back afterwards. Only works for accounts created by installing Hermoso from the Shopify App Store. Free.",
|
|
9691
|
+
inputSchema: { remoteId: z.string().optional(), id: z.string().optional().describe('gid://shopify/MarketingActivity/…'), confirm: z.boolean().optional() },
|
|
9692
|
+
outputSchema: { shop: z.string().optional(), ok: z.boolean().optional(), deleted: z.boolean().optional(), id: z.string().optional(), title: z.string().optional(), note: z.string().optional() },
|
|
9693
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
9694
|
+
}, wrap(async (a) => { const d = await apiPost('/api/shopify/marketing-activity/delete', a); return ok(d.note, d); }));
|
|
9695
|
+
|
|
9603
9696
|
server.group('ads'); // back to Merchant Center — the product feed Shopping ads and retail PMax serve
|
|
9604
9697
|
server.registerTool('merchant_report', {
|
|
9605
9698
|
title: 'Merchant Center competitive visibility, best sellers and price benchmarks',
|
|
@@ -11972,6 +12065,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11972
12065
|
end: z.string().optional().describe('YYYYMMDD or YYYY-MM-DD (default today)'),
|
|
11973
12066
|
interval: z.number().optional().describe('-300000 realtime, -3600000 hourly, 1 daily, 7 weekly, 30 monthly'),
|
|
11974
12067
|
groupBy: z.string().optional(),
|
|
12068
|
+
groupBy2: z.string().optional().describe("a SECOND group-by (Amplitude's g2), e.g. groupBy gp:utm_campaign + groupBy2 country"),
|
|
12069
|
+
segments: z.array(z.object({ prop: z.string().optional(), op: z.string().optional(), values: z.array(z.string()).optional(), type: z.string().optional(), event_type: z.string().optional(), value: z.number().optional(), time_type: z.string().optional(), time_value: z.number().optional() }).passthrough()).optional().describe("FILTER to only these users (Amplitude's segment `s`): [{prop:'gp:utm_campaign', op:'is', values:['summer']}]. op is one of is, is not, contains, does not contain, less, less or equal, greater, greater or equal, set is, set is not. Custom user properties are gp:<name>; built-ins are country, city, region, platform, os, device_type, version, language, paying; prop userdata_cohort + a cohort id filters to a behavioural cohort. A \"who performed\" condition is {type:'event', event_type, op:'>=', value:1, time_type:'rolling', time_value:30} (did it at least once in the last 30 days); symbol operators only."),
|
|
11975
12070
|
limit: z.number().optional().describe('≤1000'),
|
|
11976
12071
|
},
|
|
11977
12072
|
outputSchema: { series: z.array(z.any()).optional(), seriesLabels: z.array(z.any()).optional(), xValues: z.array(z.any()).optional(), raw: z.any().optional(), note: z.string().optional() },
|
|
@@ -11990,6 +12085,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11990
12085
|
start: z.string().optional().describe('YYYYMMDD or YYYY-MM-DD (default 30 days ago)'),
|
|
11991
12086
|
end: z.string().optional().describe('YYYYMMDD or YYYY-MM-DD (default today)'),
|
|
11992
12087
|
groupBy: z.string().optional().describe('at most ONE — a second is refused'),
|
|
12088
|
+
users: z.enum(['active', 'new']).optional().describe("'new' = first-time users only (the acquisition funnel); Amplitude's default is active"),
|
|
12089
|
+
segments: z.array(z.object({ prop: z.string().optional(), op: z.string().optional(), values: z.array(z.string()).optional(), type: z.string().optional(), event_type: z.string().optional(), value: z.number().optional(), time_type: z.string().optional(), time_value: z.number().optional() }).passthrough()).optional().describe("FILTER to only these users (Amplitude's segment `s`): [{prop:'gp:utm_campaign', op:'is', values:['summer']}]. op is one of is, is not, contains, does not contain, less, less or equal, greater, greater or equal, set is, set is not. Custom user properties are gp:<name>; built-ins are country, city, region, platform, os, device_type, version, language, paying; prop userdata_cohort + a cohort id filters to a behavioural cohort. A \"who performed\" condition is {type:'event', event_type, op:'>=', value:1, time_type:'rolling', time_value:30} (did it at least once in the last 30 days); symbol operators only."),
|
|
11993
12090
|
},
|
|
11994
12091
|
outputSchema: { steps: z.array(z.any()).optional(), rows: z.array(z.any()).optional(), conversionWindowSeconds: z.number().optional(), mode: z.string().optional(), raw: z.any().optional(), note: z.string().optional(), note2: z.string().optional() },
|
|
11995
12092
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -12008,6 +12105,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12008
12105
|
retentionMode: z.enum(['n-day', 'bracket', 'rolling']).optional(),
|
|
12009
12106
|
brackets: z.array(z.number()).optional().describe('required when retentionMode is "bracket"'),
|
|
12010
12107
|
interval: z.number().optional().describe('1 daily, 7 weekly, 30 monthly'),
|
|
12108
|
+
groupBy: z.string().optional().describe('ONE property to group by'),
|
|
12109
|
+
segments: z.array(z.object({ prop: z.string().optional(), op: z.string().optional(), values: z.array(z.string()).optional(), type: z.string().optional(), event_type: z.string().optional(), value: z.number().optional(), time_type: z.string().optional(), time_value: z.number().optional() }).passthrough()).optional().describe("FILTER to only these users (Amplitude's segment `s`): [{prop:'gp:utm_campaign', op:'is', values:['summer']}]. op is one of is, is not, contains, does not contain, less, less or equal, greater, greater or equal, set is, set is not. Custom user properties are gp:<name>; built-ins are country, city, region, platform, os, device_type, version, language, paying; prop userdata_cohort + a cohort id filters to a behavioural cohort. A \"who performed\" condition is {type:'event', event_type, op:'>=', value:1, time_type:'rolling', time_value:30} (did it at least once in the last 30 days); symbol operators only."),
|
|
12011
12110
|
},
|
|
12012
12111
|
outputSchema: { retention: z.any().optional(), raw: z.any().optional(), note: z.string().optional() },
|
|
12013
12112
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -12023,6 +12122,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12023
12122
|
end: z.string().optional().describe('YYYYMMDD or YYYY-MM-DD (default today)'),
|
|
12024
12123
|
metric: z.enum(['active', 'new']).optional().describe('default active'),
|
|
12025
12124
|
interval: z.number().optional().describe('1 daily, 7 weekly, 30 monthly'),
|
|
12125
|
+
groupBy: z.string().optional().describe('ONE property to group by'),
|
|
12126
|
+
segments: z.array(z.object({ prop: z.string().optional(), op: z.string().optional(), values: z.array(z.string()).optional(), type: z.string().optional(), event_type: z.string().optional(), value: z.number().optional(), time_type: z.string().optional(), time_value: z.number().optional() }).passthrough()).optional().describe("FILTER to only these users (Amplitude's segment `s`): [{prop:'gp:utm_campaign', op:'is', values:['summer']}]. op is one of is, is not, contains, does not contain, less, less or equal, greater, greater or equal, set is, set is not. Custom user properties are gp:<name>; built-ins are country, city, region, platform, os, device_type, version, language, paying; prop userdata_cohort + a cohort id filters to a behavioural cohort. A \"who performed\" condition is {type:'event', event_type, op:'>=', value:1, time_type:'rolling', time_value:30} (did it at least once in the last 30 days); symbol operators only."),
|
|
12026
12127
|
},
|
|
12027
12128
|
outputSchema: { series: z.array(z.any()).optional(), xValues: z.array(z.any()).optional(), seriesLabels: z.array(z.any()).optional(), raw: z.any().optional(), note: z.string().optional() },
|
|
12028
12129
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -12138,6 +12239,75 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12138
12239
|
const x = d.annotation || {};
|
|
12139
12240
|
return ok(`${d.created ? 'Marked' : 'PostHog accepted'} "${x.content}" on the PostHog timeline at ${x.dateMarker} (id ${x.id}, ${x.scope} scope).${d.warning ? `\n⚠ ${d.warning}` : ''}`, d);
|
|
12140
12241
|
}));
|
|
12242
|
+
// MIXPANEL ANNOTATIONS + POSTHOG SURVEYS (2026-10-27). Mixpanel was the one analytics connector with no campaign-launch
|
|
12243
|
+
// marker; PostHog surveys are the customers' own words. Rules + citations: server.js mpAnnotations / phSurveys.
|
|
12244
|
+
server.registerTool('list_mixpanel_annotations', {
|
|
12245
|
+
title: 'List Mixpanel annotations',
|
|
12246
|
+
description: "The markers already on this Mixpanel project's charts (launches, campaigns, incidents) with their date, text, tags and who wrote them. READ THESE BEFORE EXPLAINING A CHANGE IN A CHART: an annotation is an explanation somebody already wrote down. fromDate / toDate (YYYY-MM-DD, both inclusive) narrow the window. Read-only, 0 credits. Needs Mixpanel connected.",
|
|
12247
|
+
inputSchema: { fromDate: z.string().optional().describe('YYYY-MM-DD, inclusive'), toDate: z.string().optional().describe('YYYY-MM-DD, inclusive') },
|
|
12248
|
+
outputSchema: { project: z.string().optional(), annotations: z.array(z.any()).optional(), count: z.number().optional(), window: z.any().optional(), note: z.string().optional() },
|
|
12249
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12250
|
+
}, wrap(async (a) => {
|
|
12251
|
+
const d = await apiGet('/api/mixpanel/annotations', a);
|
|
12252
|
+
return ok(d.count ? `${d.count} Mixpanel annotation(s) in project ${d.project}:\n${d.annotations.map(x => `- ${x.date} — ${x.description} (id ${x.id})${x.tags?.length ? ` [${x.tags.map(t => t.name).join(', ')}]` : ''}${x.createdBy ? ` · ${x.createdBy}` : ''}`).join('\n')}` : d.note, d);
|
|
12253
|
+
}));
|
|
12254
|
+
server.registerTool('create_mixpanel_annotation', {
|
|
12255
|
+
title: 'Mark the Mixpanel timeline',
|
|
12256
|
+
description: "MARK THE MIXPANEL TIMELINE: write what just happened into Mixpanel at the moment it happened, so every chart afterwards shows it in place. The same closed loop as create_posthog_annotation and create_amplitude_annotation, worth reaching for unprompted after a campaign goes live, a creative is swapped or a landing page changes. `date` is \"YYYY-MM-DD HH:mm:ss\" (Mixpanel's own format, charted in the PROJECT's timezone), \"YYYY-MM-DD\", or an ISO timestamp with its offset (converted to UTC); omit it for NOW. tags are numeric annotation-tag ids. The stored row is read back. Mixpanel only lets a service account with at least the ANALYST role write annotations; a lower role is refused by name, and that is a role change in Mixpanel, not a reconnect. 0 credits. Needs Mixpanel connected.",
|
|
12257
|
+
inputSchema: { description: z.string().describe('the text shown on the chart, e.g. "Meta: summer UGC campaign went live"'), date: z.string().optional().describe('"YYYY-MM-DD HH:mm:ss", "YYYY-MM-DD" or ISO with offset; default now'), tags: z.array(z.number()).optional().describe('annotation-tag ids') },
|
|
12258
|
+
outputSchema: { project: z.string().optional(), created: z.boolean().optional(), annotation: z.any().optional(), dateNote: z.string().optional(), warning: z.string().optional() },
|
|
12259
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
12260
|
+
}, wrap(async (a) => {
|
|
12261
|
+
const d = await apiPost('/api/mixpanel/annotation', a);
|
|
12262
|
+
const x = d.annotation || {};
|
|
12263
|
+
return ok(`${d.created ? 'Marked' : 'Mixpanel accepted'} "${x.description}" on the Mixpanel timeline at ${x.date} (id ${x.id}). ${d.dateNote || ''}${d.warning ? `\n⚠ ${d.warning}` : ''}`, d);
|
|
12264
|
+
}));
|
|
12265
|
+
server.registerTool('update_mixpanel_annotation', {
|
|
12266
|
+
title: 'Edit a Mixpanel annotation',
|
|
12267
|
+
description: "Change the text or tags of a Mixpanel annotation (annotationId from list_mixpanel_annotations). Mixpanel cannot move an annotation to a new date; to fix a wrong date, delete it and create a new one. The edited row is read back. Needs the ANALYST role on the service account. 0 credits. Needs Mixpanel connected.",
|
|
12268
|
+
inputSchema: { annotationId: z.number().describe('from list_mixpanel_annotations'), description: z.string().optional(), tags: z.array(z.number()).optional().describe('annotation-tag ids') },
|
|
12269
|
+
outputSchema: { project: z.string().optional(), updated: z.boolean().optional(), annotation: z.any().optional() },
|
|
12270
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
12271
|
+
}, wrap(async (a) => {
|
|
12272
|
+
const d = await apiPost('/api/mixpanel/annotation/update', a);
|
|
12273
|
+
const x = d.annotation || {};
|
|
12274
|
+
return ok(`Mixpanel annotation ${x.id} now reads "${x.description}" (${x.date})${x.tags?.length ? ` [${x.tags.map(t => t.name).join(', ')}]` : ''}, read back from Mixpanel.`, d);
|
|
12275
|
+
}));
|
|
12276
|
+
server.registerTool('delete_mixpanel_annotation', {
|
|
12277
|
+
title: 'Delete a Mixpanel annotation',
|
|
12278
|
+
description: "Permanently delete a Mixpanel annotation from every chart in the project. WITHOUT confirm:true nothing is deleted and you get the exact annotation that would go, to show the user; call again with confirm:true only after they say yes. The delete is verified by reading it back. Needs the ANALYST role on the service account. 0 credits. Needs Mixpanel connected.",
|
|
12279
|
+
inputSchema: { annotationId: z.number().describe('from list_mixpanel_annotations'), confirm: z.boolean().optional().describe('true only after the user approved deleting this exact annotation') },
|
|
12280
|
+
outputSchema: { project: z.string().optional(), dryRun: z.boolean().optional(), deleted: z.boolean().optional(), annotation: z.any().optional(), note: z.string().optional(), warning: z.string().optional() },
|
|
12281
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
12282
|
+
}, wrap(async (a) => {
|
|
12283
|
+
const d = await apiPost('/api/mixpanel/annotation/delete', a);
|
|
12284
|
+
if (d.dryRun) return ok(d.note, d);
|
|
12285
|
+
return ok(d.deleted ? `Deleted Mixpanel annotation ${d.annotation?.id} ("${d.annotation?.description}"); reading it back now returns not-found.` : `⚠ ${d.warning}`, d);
|
|
12286
|
+
}));
|
|
12287
|
+
server.registerTool('list_posthog_surveys', {
|
|
12288
|
+
title: 'PostHog surveys',
|
|
12289
|
+
description: "PostHog SURVEYS. Without surveyId: the project's surveys (name, type, running / complete / draft / archived, dates, questions); filter by status or search. With surveyId: that survey's questions (with their ids, for posthog_survey_responses) plus its stats: shown / dismissed / sent counts, response and dismissal rates, and per-question distributions and rating averages. NEEDS THE OPTIONAL survey:read SCOPE on the PostHog personal API key: a key without it connects fine and works for everything else, and this tool then says exactly which box to tick in PostHog > Settings > Personal API keys. That is never a reconnect. Read-only, 0 credits. Needs PostHog connected.",
|
|
12290
|
+
inputSchema: { surveyId: z.string().optional().describe('one survey: its questions and stats'), status: z.enum(['running', 'complete', 'draft']).optional(), search: z.string().optional(), archived: z.boolean().optional(), limit: z.number().optional().describe('1-100, default 25'), offset: z.number().optional(), dateFrom: z.string().optional().describe('stats window start, ISO'), dateTo: z.string().optional().describe('stats window end, ISO') },
|
|
12291
|
+
outputSchema: { project: z.string().optional(), surveys: z.array(z.any()).optional(), survey: z.any().optional(), stats: z.any().optional(), count: z.number().optional(), total: z.number().nullable().optional(), nextOffset: z.number().optional(), url: z.string().optional(), warning: z.string().optional(), note: z.string().optional() },
|
|
12292
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12293
|
+
}, wrap(async (a) => {
|
|
12294
|
+
const d = await apiGet('/api/posthog/surveys', a);
|
|
12295
|
+
if (d.survey) {
|
|
12296
|
+
const sv = d.survey, st = d.stats;
|
|
12297
|
+
return ok(`PostHog survey "${sv.name}" (${sv.id}, ${sv.status}, ${sv.type}):\n${sv.questions.map(q => `- Q${q.index + 1} [${q.type}] ${q.question} (questionId ${q.id})`).join('\n')}${st ? `\nStats: ${JSON.stringify(st.counts)} · rates ${JSON.stringify(st.rates)}${st.perQuestion?.length ? `\nPer question: ${JSON.stringify(st.perQuestion).slice(0, 2000)}` : ''}` : ''}${d.warning ? `\n⚠ ${d.warning}` : ''}`, d);
|
|
12298
|
+
}
|
|
12299
|
+
return ok(d.count ? `${d.count} PostHog survey(s) in project ${d.project}:\n${d.surveys.map(x => `- ${x.name} (${x.id}) · ${x.status} · ${x.type} · ${x.questions.length} question(s)`).join('\n')}${d.more ? `\n${d.more} (offset ${d.nextOffset})` : ''}` : d.note, d);
|
|
12300
|
+
}));
|
|
12301
|
+
server.registerTool('posthog_survey_responses', {
|
|
12302
|
+
title: 'PostHog survey responses',
|
|
12303
|
+
description: "THE ANSWERS people gave a PostHog survey, with the QUESTION TEXT resolved beside each answer: who (distinct_id), when, country, device and the page they were on. The customers' own words: the best raw input for ad hooks, angles and objections. questionId narrows to one question; with scoreLte / scoreGte on a rating question it pulls e.g. NPS detractors (scoreLte 6) or promoters (scoreGte 9). since / until are ISO timestamps. One page (limit 1-500, default 100) and the next offset when there is more. NEEDS THE OPTIONAL survey:read SCOPE on the PostHog personal API key (plus query:read, which every connection already has); without it this tool names the box to tick in PostHog, never a reconnect. Read-only, 0 credits. Needs PostHog connected.",
|
|
12304
|
+
inputSchema: { surveyId: z.string().describe('from list_posthog_surveys'), questionId: z.string().optional(), scoreLte: z.number().optional().describe('needs questionId; e.g. 6 for NPS detractors'), scoreGte: z.number().optional().describe('needs questionId; e.g. 9 for NPS promoters'), since: z.string().optional().describe('ISO timestamp'), until: z.string().optional().describe('ISO timestamp'), excludeArchived: z.boolean().optional(), limit: z.number().optional().describe('1-500, default 100'), offset: z.number().optional() },
|
|
12305
|
+
outputSchema: { project: z.string().optional(), surveyId: z.string().optional(), responses: z.array(z.any()).optional(), count: z.number().optional(), nextOffset: z.number().optional(), more: z.string().optional(), note: z.string().optional() },
|
|
12306
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12307
|
+
}, wrap(async (a) => {
|
|
12308
|
+
const d = await apiGet('/api/posthog/survey-responses', a);
|
|
12309
|
+
return ok(d.count ? `${d.count} response(s) to survey ${d.surveyId}:\n${d.responses.slice(0, 60).map(r => `- ${String(r.submittedAt).slice(0, 16)}${r.country ? ` ${r.country}` : ''}: ${r.answers.map(x => `"${x.question}" → ${Array.isArray(x.answer) ? x.answer.join(', ') : x.answer}`).join(' | ')}`).join('\n')}${d.count > 60 ? `\n(${d.count - 60} more in the structured result)` : ''}${d.more ? `\n${d.more} (offset ${d.nextOffset})` : ''}` : d.note, d);
|
|
12310
|
+
}));
|
|
12141
12311
|
server.registerTool('list_mixpanel_cohorts', {
|
|
12142
12312
|
title: 'List saved Mixpanel cohorts',
|
|
12143
12313
|
description: "THE SAVED COHORTS in the Mixpanel project — each with its id, name, description and CURRENT MEMBER COUNT. Two uses, and both matter for advertising: the counts alone answer \"how big is our converted audience\", and the id is what mixpanel_profiles needs to read the PEOPLE in one. A cohort hidden in Mixpanel's UI is still listed and flagged rather than dropped, because hidden is a display choice and it is still queryable by id. ONE CALL, and the cheapest thing on this connector — worth calling before mixpanel_profiles rather than guessing an id. Remember Mixpanel allows only 60 QUERIES PER HOUR across its entire Query API (5 concurrent), the tightest budget of any connector here, and this shares it with every other Mixpanel tool. Read-only, 0 credits.",
|
|
@@ -12172,6 +12342,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12172
12342
|
}));
|
|
12173
12343
|
// ---------- Vercel (2026-10-16): the brand's OWN websites — projects, deployments, domains, Web Analytics, and a landing page deployed from files ----------
|
|
12174
12344
|
const VC_NEEDS = ' Needs Vercel connected (Settings > Connectors > Vercel, or connect_connector with an access token).';
|
|
12345
|
+
// One renderer for page views and custom events; `by` may be one dimension or two (2026-10-27).
|
|
12346
|
+
const vcText = (d, events) => {
|
|
12347
|
+
const t = d.totals || {}, by = [].concat(d.by || []).join(' x ');
|
|
12348
|
+
const head = events
|
|
12349
|
+
? `Vercel custom events${d.eventName ? ` "${d.eventName}"` : ''} for ${d.projectId}, ${String(d.since).slice(0, 10)} to ${String(d.until).slice(0, 10)}: ${t.events ?? '?'} event(s), ${t.visitors ?? '?'} visitor(s).`
|
|
12350
|
+
: `Vercel Web Analytics for ${d.projectId}, ${String(d.since).slice(0, 10)} to ${String(d.until).slice(0, 10)}: ${t.pageviews ?? '?'} page views, ${t.visitors ?? '?'} visitors.`;
|
|
12351
|
+
const rows = d.rows ? `\nBy ${by}:\n` + d.rows.map(r => `- ${r.key ?? '(none)'}: ${(events ? r.events : r.pageviews) ?? '?'} ${events ? 'events' : 'views'}${r.visitors != null ? `, ${r.visitors} visitors` : ''}`).join('\n') : '';
|
|
12352
|
+
return `${head}${rows}${d.note ? `\n${d.note}` : ''}`;
|
|
12353
|
+
};
|
|
12175
12354
|
server.registerTool('list_vercel_projects', {
|
|
12176
12355
|
title: 'List Vercel projects',
|
|
12177
12356
|
description: "The brand's Vercel projects (its websites): name, id, framework, production URL and state, the latest deployment (URL, state, target, when), the linked git repo, and whether Web Analytics and Speed Insights are on. search filters by project name. Read-only, free." + VC_NEEDS,
|
|
@@ -12191,12 +12370,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12191
12370
|
target: z.enum(['production', 'preview', 'staging']).optional(),
|
|
12192
12371
|
state: z.enum(['BLOCKED', 'BUILDING', 'CANCELED', 'DELETED', 'ERROR', 'INITIALIZING', 'QUEUED', 'READY']).optional(),
|
|
12193
12372
|
limit: z.number().optional().describe('1-50, default 20'),
|
|
12373
|
+
until: z.string().optional().describe('the nextCursor a previous page returned — the next (older) page'),
|
|
12194
12374
|
},
|
|
12195
12375
|
outputSchema: { account: z.string().optional(), count: z.number().optional(), deployments: z.array(z.any()).optional(), nextCursor: z.string().nullable().optional() },
|
|
12196
12376
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
12197
12377
|
}, wrap(async (a = {}) => {
|
|
12198
12378
|
const d = await apiGet('/api/vercel/deployments', a);
|
|
12199
|
-
return ok(d.count ? `${d.count} deployment(s):\n` + (d.deployments || []).map(x => `- ${x.url || x.id}, ${x.state || '?'}, ${x.target}, ${x.name}${x.createdAt ? `, ${String(x.createdAt).slice(0, 16).replace('T', ' ')}` : ''} (${x.id})${x.error ? `: ${x.error}` : ''}`).join('\n') : 'No deployments match. That is a real empty list, not a failed read.', d);
|
|
12379
|
+
return ok(d.count ? `${d.count} deployment(s):\n` + (d.deployments || []).map(x => `- ${x.url || x.id}, ${x.state || '?'}, ${x.target}, ${x.name}${x.createdAt ? `, ${String(x.createdAt).slice(0, 16).replace('T', ' ')}` : ''} (${x.id})${x.error ? `: ${x.error}` : ''}`).join('\n') + (d.nextCursor ? `\n(older deployments: pass until=${d.nextCursor})` : '') : 'No deployments match. That is a real empty list, not a failed read.', d);
|
|
12200
12380
|
}));
|
|
12201
12381
|
server.registerTool('list_vercel_domains', {
|
|
12202
12382
|
title: 'List Vercel domains',
|
|
@@ -12213,19 +12393,37 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12213
12393
|
description: "Vercel WEB ANALYTICS for one project: page views and unique visitors over a window (default the last 30 days, production traffic), and with `by` a breakdown by requestPath (which pages), referrerHostname (where visitors came from), country, deviceType, osName, browserName, route, utmSource / utmMedium / utmCampaign / utmContent / utmTerm (which campaigns and ads brought them), or a time step (day, week, month). Use it to see which landing pages and ad campaigns actually bring traffic. Needs Web Analytics turned on for the project in Vercel. Read-only, free." + VC_NEEDS,
|
|
12214
12394
|
inputSchema: {
|
|
12215
12395
|
projectId: z.string().describe('project id or name (list_vercel_projects)'),
|
|
12216
|
-
by: z.
|
|
12396
|
+
by: z.string().optional().describe("one or TWO comma-separated dimensions (at most one time step): requestPath, referrerHostname, country, deviceType, osName, browserName, route, utmSource, utmMedium, utmCampaign, utmContent, utmTerm, environment, flags, flags/<key>, hour, day, week, month, year — e.g. 'day,utmCampaign'"),
|
|
12217
12397
|
days: z.number().optional().describe('1-366, default 30'),
|
|
12218
12398
|
since: z.string().optional().describe('YYYY-MM-DD (instead of days)'),
|
|
12219
12399
|
until: z.string().optional().describe('YYYY-MM-DD (default now)'),
|
|
12220
|
-
limit: z.number().optional().describe('rows in the breakdown, 1-
|
|
12400
|
+
limit: z.number().optional().describe('rows in the breakdown, 1-100, default 10'),
|
|
12221
12401
|
filter: z.string().optional().describe("Vercel's OData filter, e.g. utmSource eq 'facebook'"),
|
|
12222
12402
|
},
|
|
12223
|
-
outputSchema: { account: z.string().optional(), projectId: z.string().optional(), since: z.string().optional(), until: z.string().optional(), totals: z.any().optional(), by: z.
|
|
12403
|
+
outputSchema: { account: z.string().optional(), projectId: z.string().optional(), since: z.string().optional(), until: z.string().optional(), totals: z.any().optional(), by: z.any().optional(), rows: z.array(z.any()).optional(), note: z.string().optional() },
|
|
12224
12404
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
12225
12405
|
}, wrap(async (a) => {
|
|
12226
12406
|
const d = await apiGet('/api/vercel/analytics', a);
|
|
12227
|
-
|
|
12228
|
-
|
|
12407
|
+
return ok(vcText(d, false), d);
|
|
12408
|
+
}));
|
|
12409
|
+
server.registerTool('vercel_event_analytics', {
|
|
12410
|
+
title: 'Vercel custom events (conversions)',
|
|
12411
|
+
description: "Vercel CUSTOM EVENTS, the CONVERSIONS on a Vercel-hosted site (signups, purchases, any track('Name') from @vercel/analytics): the count and unique visitors over a window, and with `by` a breakdown by eventName, eventData/<property> (e.g. eventData/plan), utmSource / utmMedium / utmCampaign / utmContent / utmTerm (which ad or campaign drove the conversion), requestPath, country and the rest, or a time step. Up to TWO at once, e.g. by 'utmCampaign,eventName' or 'day,utmCampaign'. eventName narrows to one event. This is the conversion half of an ad report for a landing page on Vercel; vercel_web_analytics is the traffic half. Custom events need Web Analytics on and a Vercel Pro or Enterprise plan. Read-only, free." + VC_NEEDS,
|
|
12412
|
+
inputSchema: {
|
|
12413
|
+
projectId: z.string().describe('project id or name (list_vercel_projects)'),
|
|
12414
|
+
eventName: z.string().optional().describe("only this event, e.g. 'Signup'"),
|
|
12415
|
+
by: z.string().optional().describe('one or TWO comma-separated dimensions: eventName, eventData, eventData/<property>, utmSource, utmMedium, utmCampaign, utmContent, utmTerm, requestPath, referrerHostname, country, deviceType, osName, browserName, route, environment, flags, flags/<key>, or one time step hour/day/week/month/year'),
|
|
12416
|
+
days: z.number().optional().describe('1-366, default 30'),
|
|
12417
|
+
since: z.string().optional().describe('YYYY-MM-DD (instead of days)'),
|
|
12418
|
+
until: z.string().optional().describe('YYYY-MM-DD (default now)'),
|
|
12419
|
+
limit: z.number().optional().describe('rows in the breakdown, 1-100, default 10'),
|
|
12420
|
+
filter: z.string().optional().describe("Vercel's OData filter, e.g. utmSource eq 'facebook'"),
|
|
12421
|
+
},
|
|
12422
|
+
outputSchema: { account: z.string().optional(), projectId: z.string().optional(), eventName: z.string().optional(), since: z.string().optional(), until: z.string().optional(), totals: z.any().optional(), by: z.any().optional(), rows: z.array(z.any()).optional(), filter: z.string().optional(), note: z.string().optional() },
|
|
12423
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
12424
|
+
}, wrap(async (a) => {
|
|
12425
|
+
const d = await apiGet('/api/vercel/events', a);
|
|
12426
|
+
return ok(vcText(d, true), d);
|
|
12229
12427
|
}));
|
|
12230
12428
|
server.registerTool('deploy_vercel_page', {
|
|
12231
12429
|
title: 'Deploy a page to Vercel',
|
|
@@ -12351,15 +12549,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12351
12549
|
}, wrap(async (a) => { const d = await apiPost('/api/stripe/price', a); return ok(d.note, d); }));
|
|
12352
12550
|
server.registerTool('create_stripe_payment_link', {
|
|
12353
12551
|
title: 'create stripe payment link',
|
|
12354
|
-
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).",
|
|
12355
|
-
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() },
|
|
12552
|
+
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. allowPromotionCodes:true shows the promotion-code box at checkout — needed for any 'use code X' ad. referenceId and utm return a taggedUrl: put THAT URL in the ad, and get_stripe_payment_link_revenue (groupBy reference) then counts that ad's purchases. Returns the URL. LIVE key: confirm:true after showing the preview. Idempotent on its arguments. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
|
|
12553
|
+
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(), allowPromotionCodes: z.boolean().optional().describe('show the promotion-code box at checkout'), referenceId: z.string().optional().describe('letters, digits, dashes, underscores (up to 200) — tags the returned taggedUrl for one ad; Stripe stores it on each purchase'), utm: z.object({ source: z.string().optional(), medium: z.string().optional(), campaign: z.string().optional(), content: z.string().optional(), term: z.string().optional() }).optional().describe('UTM values on the taggedUrl (letters, digits, dashes, underscores, up to 150 each); Stripe passes them to the after-payment redirect only'), metadata: z.record(z.any()).optional(), confirm: z.boolean().optional() },
|
|
12356
12554
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
12357
12555
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
12358
12556
|
}, wrap(async (a) => { const d = await apiPost('/api/stripe/payment-link', a); return ok(d.note, d); }));
|
|
12359
12557
|
server.registerTool('create_stripe_coupon', {
|
|
12360
12558
|
title: 'create stripe coupon',
|
|
12361
|
-
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).",
|
|
12362
|
-
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() },
|
|
12559
|
+
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. A coupon alone has NO code a customer can type: pass `code` (letters, digits, dashes) to also mint the customer-typable promotion code in the same call, with optional codeMaxRedemptions, expiresAt, firstTimeOnly and minimumAmount. Read back. LIVE key: confirm:true after showing the preview. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
|
|
12560
|
+
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().describe('uses of the COUPON across every code'), code: z.string().optional().describe('the customer-typable promotion code, e.g. SUMMER20 — letters, digits and dashes only; refused (never altered) otherwise'), codeMaxRedemptions: z.number().optional().describe('uses of this CODE'), expiresAt: z.string().optional().describe('YYYY-MM-DD the code stops working'), firstTimeOnly: z.boolean().optional().describe("the code works only on a customer's first purchase"), minimumAmount: z.number().optional().describe('minimum order in MAJOR units for the code'), minimumAmountCurrency: z.string().optional(), confirm: z.boolean().optional() },
|
|
12363
12561
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
12364
12562
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
12365
12563
|
}, wrap(async (a) => { const d = await apiPost('/api/stripe/coupon', a); return ok(d.note, d); }));
|
|
@@ -12391,6 +12589,50 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12391
12589
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
12392
12590
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
12393
12591
|
}, wrap(async (a) => { const d = await apiPost('/api/stripe/refund', a); return ok(d.note, d); }));
|
|
12592
|
+
// ---------- Stripe promotion codes, payment-link edits and revenue per link (2026-10-27) ----------
|
|
12593
|
+
const stripeConnected = 'Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).';
|
|
12594
|
+
server.registerTool('list_stripe_promotion_codes', {
|
|
12595
|
+
title: 'list stripe promotion codes',
|
|
12596
|
+
description: `Promotion codes on the brand's Stripe account — the codes customers TYPE at checkout — with the coupon each applies, active or not, times redeemed / max, expiry, first-purchase-only and minimum-order rules. Filter by active, code or couponId. Read-only, free. ${stripeConnected}`,
|
|
12597
|
+
inputSchema: { active: z.boolean().optional(), code: z.string().optional(), couponId: z.string().optional(), limit: z.number().optional(), after: z.string().optional() },
|
|
12598
|
+
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional(), promotionCodes: z.array(z.any()).optional() },
|
|
12599
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12600
|
+
}, wrap(async (a) => { const d = await apiGet('/api/stripe/promotion-codes', a); return ok(d.count ? `${d.note}\n${d.promotionCodes.map(x => `• ${x.code} → coupon ${x.coupon}${x.active ? '' : ' · INACTIVE'} · redeemed ${x.timesRedeemed}${x.maxRedemptions ? '/' + x.maxRedemptions : ''}${x.expiresAt ? ` · expires ${x.expiresAt.slice(0, 10)}` : ''}${x.firstTimeOnly ? ' · first purchase only' : ''}${x.minimumAmount != null ? ` · min ${x.minimumAmount} ${x.minimumAmountCurrency}` : ''} (${x.id})`).join('\n')}` : d.note, d); }));
|
|
12601
|
+
server.registerTool('create_stripe_promotion_code', {
|
|
12602
|
+
title: 'create stripe promotion code',
|
|
12603
|
+
description: `Create a customer-typable promotion code (e.g. SUMMER20) for an existing Stripe coupon — the thing a "use code X" ad needs, since a coupon alone has no code. couponId (from list_stripe_coupons); optional code (letters, digits and dashes; Stripe generates one if omitted — a code with spaces or symbols is refused, never altered), maxRedemptions, expiresAt (YYYY-MM-DD), firstTimeOnly, minimumAmount + minimumAmountCurrency (major units), customerId. Read back. LIVE key: confirm:true after showing the preview. Use it on a payment link with allowPromotionCodes. ${stripeConnected}`,
|
|
12604
|
+
inputSchema: { couponId: z.string(), code: z.string().optional(), maxRedemptions: z.number().optional(), expiresAt: z.string().optional(), firstTimeOnly: z.boolean().optional(), minimumAmount: z.number().optional().describe('MAJOR units'), minimumAmountCurrency: z.string().optional(), customerId: z.string().optional().describe('restrict the code to one customer (cus_…)'), metadata: z.record(z.any()).optional(), confirm: z.boolean().optional() },
|
|
12605
|
+
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), promotionCode: z.any().optional() },
|
|
12606
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
12607
|
+
}, wrap(async (a) => { const d = await apiPost('/api/stripe/promotion-code', a); return ok(d.note, d); }));
|
|
12608
|
+
server.registerTool('update_stripe_promotion_code', {
|
|
12609
|
+
title: 'update stripe promotion code',
|
|
12610
|
+
description: `Switch a Stripe promotion code OFF (active:false) or back on (active:true), or change its metadata. Pass promotionCodeId (promo_…) or the code itself. Switching a code off ALWAYS needs confirm:true, test keys included, because anyone holding it from an ad is then refused at checkout. Read back; a code Stripe will not reactivate (its coupon is no longer valid) is reported, not claimed. ${stripeConnected}`,
|
|
12611
|
+
inputSchema: { promotionCodeId: z.string().optional(), code: z.string().optional(), active: z.boolean().optional(), metadata: z.record(z.any()).optional(), confirm: z.boolean().optional() },
|
|
12612
|
+
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), promotionCode: z.any().optional() },
|
|
12613
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
12614
|
+
}, wrap(async (a) => { const d = await apiPost('/api/stripe/promotion-code/update', a); return ok(d.note, d); }));
|
|
12615
|
+
server.registerTool('list_stripe_payment_links', {
|
|
12616
|
+
title: 'list stripe payment links',
|
|
12617
|
+
description: `Payment links on the brand's Stripe account: URL, active or not, whether the promotion-code box shows, what happens after payment, and their line items (price, amount). Filter active true/false. Read-only, free. ${stripeConnected}`,
|
|
12618
|
+
inputSchema: { active: z.boolean().optional(), limit: z.number().optional(), after: z.string().optional() },
|
|
12619
|
+
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional(), paymentLinks: z.array(z.any()).optional() },
|
|
12620
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12621
|
+
}, wrap(async (a) => { const d = await apiGet('/api/stripe/payment-links', a); return ok(d.count ? `${d.note}\n${d.paymentLinks.map(l => `• ${l.id} ${l.active ? 'active' : 'INACTIVE'}${l.allowPromotionCodes ? ' · codes allowed' : ''} — ${l.url}${l.lineItems ? ` — ${l.lineItems.map(li => `${li.quantity}× ${li.description || li.priceId}${li.amount != null ? ` ${li.amount} ${li.currency}` : ''}`).join(', ')}` : ''}`).join('\n')}` : d.note, d); }));
|
|
12622
|
+
server.registerTool('update_stripe_payment_link', {
|
|
12623
|
+
title: 'update stripe payment link',
|
|
12624
|
+
description: `Change a Stripe payment link (plink_…). active:false DEACTIVATES it — its URL then shows a "no longer available" page, in every ad that points at it — and ALWAYS needs confirm:true, test keys included; active:true reactivates it. allowPromotionCodes shows or hides the promotion-code box; afterCompletionUrl (redirect) or afterCompletionMessage (Stripe's confirmation page); inactiveMessage (up to 500 characters, shown while it is off); metadata. Read back. LIVE key: confirm:true after showing the preview. ${stripeConnected}`,
|
|
12625
|
+
inputSchema: { paymentLinkId: z.string(), active: z.boolean().optional(), allowPromotionCodes: z.boolean().optional(), afterCompletionUrl: z.string().optional(), afterCompletionMessage: z.string().optional(), inactiveMessage: z.string().optional(), metadata: z.record(z.any()).optional(), confirm: z.boolean().optional() },
|
|
12626
|
+
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), paymentLink: z.any().optional() },
|
|
12627
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
12628
|
+
}, wrap(async (a) => { const d = await apiPost('/api/stripe/payment-link/update', a); return ok(d.note, d); }));
|
|
12629
|
+
server.registerTool('get_stripe_payment_link_revenue', {
|
|
12630
|
+
title: 'stripe payment link revenue',
|
|
12631
|
+
description: `How much money the brand's Stripe PAYMENT LINKS made — the "what did the link in this ad earn" question. Paid purchases and revenue (MAJOR units, per currency) from completed checkouts between since and until (YYYY-MM-DD, default the last 30 days), for one paymentLinkId or every link. groupBy: link (default without an id — which link earned most), reference (default with an id — per ad, from the referenceId a taggedUrl carries), promotion_code, or day. For a subscription link only the first payment counts; renewals are in stripe_report. Says when the read is capped. Read-only, free. ${stripeConnected}`,
|
|
12632
|
+
inputSchema: { paymentLinkId: z.string().optional(), since: z.string().optional(), until: z.string().optional(), groupBy: z.enum(['link', 'reference', 'promotion_code', 'day']).optional() },
|
|
12633
|
+
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), paidPurchases: z.number().optional(), capped: z.boolean().optional(), totals: z.array(z.any()).optional(), rows: z.array(z.any()).optional() },
|
|
12634
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12635
|
+
}, wrap(async (a) => { const d = await apiGet('/api/stripe/payment-link-revenue', a); return ok(`${d.note}\n${(d.rows || []).map(r => `• ${r.key}${r.url ? ` (${r.url})` : ''}: ${r.purchases} purchase(s), ${r.revenue} ${r.currency}${r.discounts ? `, ${r.discounts} discounted` : ''}`).join('\n')}`, d); }));
|
|
12394
12636
|
// ---------- HubSpot (2026-09-09): the brand's OWN CRM — contacts, an upsert by email, lead forms → contacts, deals ----------
|
|
12395
12637
|
server.registerTool('list_hubspot_contacts', {
|
|
12396
12638
|
title: 'List or search HubSpot contacts',
|
|
@@ -12411,9 +12653,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12411
12653
|
}, wrap(async (a) => { const d = await apiPost('/api/hubspot/contact', a); return ok(d.note, d); }));
|
|
12412
12654
|
server.registerTool('sync_leads_to_hubspot', {
|
|
12413
12655
|
title: 'Push lead-form leads into HubSpot',
|
|
12414
|
-
description: "Push the leads
|
|
12415
|
-
inputSchema: { since: z.string().optional().describe('ISO date; default 7 days ago'), dryRun: z.boolean().optional().describe('list what would be synced and write nothing') },
|
|
12416
|
-
outputSchema: { ok: z.boolean().optional(), dryRun: z.boolean().optional(), since: z.string().optional(), gathered: z.number().optional(), perPlatform: z.any().optional(), unavailable: z.any().optional(), results: z.array(z.any()).optional(), note: z.string().optional() },
|
|
12656
|
+
description: "Push the leads the brand's LEAD FORMS collected into HubSpot as contacts — Meta, LinkedIn, TikTok (every published Instant Form, every lead region), X Ads, Snapchat and ChatGPT Ads (the last two: leads delivered to Hermoso by their lead webhooks). Each lead is upserted by email with lead status NEW and the platform + form + campaign written into the contact's Message property (a default HubSpot property, so no schema scope is needed). Synced lead ids are remembered per workspace, so a re-run, a scheduled run, a retry or a second agent never creates twins. Reports created / updated / already-synced / no-email per platform; a platform that is not connected, or cannot be read (Reddit has no API for leads), is REPORTED, not fatal. platforms narrows it. dryRun:true lists what would be synced and writes nothing. set_hubspot_lead_sync runs it automatically. Free. Needs HubSpot connected, plus the platforms whose leads you want.",
|
|
12657
|
+
inputSchema: { since: z.string().optional().describe('ISO date; default 7 days ago'), platforms: z.array(z.enum(['meta', 'linkedin', 'tiktok', 'x', 'snapchat', 'chatgpt', 'reddit'])).optional().describe('default every connected one'), tiktokRegions: z.array(z.enum(['us', 'eu', 'other'])).optional().describe('TikTok stores leads per region; default all three'), dryRun: z.boolean().optional().describe('list what would be synced and write nothing') },
|
|
12658
|
+
outputSchema: { ok: z.boolean().optional(), dryRun: z.boolean().optional(), since: z.string().optional(), platforms: z.array(z.string()).optional(), gathered: z.number().optional(), perPlatform: z.any().optional(), totals: z.any().optional(), unavailable: z.any().optional(), notConnected: z.array(z.string()).optional(), results: z.array(z.any()).optional(), note: z.string().optional() },
|
|
12417
12659
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
12418
12660
|
}, wrap(async (a) => {
|
|
12419
12661
|
const d = await apiPost('/api/hubspot/sync-leads', a);
|
|
@@ -12421,14 +12663,28 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12421
12663
|
}));
|
|
12422
12664
|
server.registerTool('hubspot_deals_report', {
|
|
12423
12665
|
title: 'HubSpot deals report',
|
|
12424
|
-
description: "Deals from the brand's HubSpot created in the window (default the last 90 days): count and amount by stage (labels resolved through the deal pipelines, closed-won recognised from the stage's own metadata), by original source, a monthly created / closed-won series, and totals. Capped at 2,000 deals and says so. Read-only, free. Needs HubSpot connected.",
|
|
12666
|
+
description: "Deals from the brand's HubSpot created in the window (default the last 90 days): count and amount by stage (labels resolved through the deal pipelines, closed-won recognised from the stage's own metadata), by original source, BY CAMPAIGN (source → platform → campaign from HubSpot's Original Source drill-downs, so PAID_SOCIAL splits into the actual campaigns), a monthly created / closed-won series, and totals. Capped at 2,000 deals and says so. Read-only, free. Needs HubSpot connected.",
|
|
12425
12667
|
inputSchema: { since: z.string().optional().describe('YYYY-MM-DD, default 90 days ago'), until: z.string().optional().describe('YYYY-MM-DD, default today') },
|
|
12426
|
-
outputSchema: { ok: z.boolean().optional(), since: z.string().optional(), until: z.string().optional(), byStage: z.array(z.any()).optional(), bySource: z.array(z.any()).optional(), monthly: z.array(z.any()).optional(), totals: z.any().optional(), capped: z.boolean().optional(), note: z.string().optional() },
|
|
12668
|
+
outputSchema: { ok: z.boolean().optional(), since: z.string().optional(), until: z.string().optional(), byStage: z.array(z.any()).optional(), bySource: z.array(z.any()).optional(), byCampaign: z.array(z.any()).optional(), monthly: z.array(z.any()).optional(), totals: z.any().optional(), capped: z.boolean().optional(), note: z.string().optional() },
|
|
12427
12669
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12428
12670
|
}, wrap(async (a) => {
|
|
12429
12671
|
const d = await apiPost('/api/hubspot/deals/report', a);
|
|
12430
|
-
return ok([d.note, `By stage: ${(d.byStage || []).map(x => `${x.pipeline ? x.pipeline + ' / ' : ''}${x.stage} ${x.count} (${x.amount})`).join('; ') || 'none'}`, `By source: ${(d.bySource || []).map(x => `${x.source} ${x.count} (${x.amount})`).join('; ') || 'none'}`, `Monthly: ${(d.monthly || []).map(m => `${m.month} created ${m.created} (${m.createdAmount}), won ${m.closedWon} (${m.closedWonAmount})`).join('; ') || 'none'}`].join('\n'), d);
|
|
12672
|
+
return ok([d.note, `By stage: ${(d.byStage || []).map(x => `${x.pipeline ? x.pipeline + ' / ' : ''}${x.stage} ${x.count} (${x.amount})`).join('; ') || 'none'}`, `By source: ${(d.bySource || []).map(x => `${x.source} ${x.count} (${x.amount})`).join('; ') || 'none'}`, `By campaign: ${(d.byCampaign || []).slice(0, 15).map(x => `${x.source} / ${x.platform} / ${x.campaign} ${x.count} (${x.amount})`).join('; ') || 'none'}`, `Monthly: ${(d.monthly || []).map(m => `${m.month} created ${m.created} (${m.createdAmount}), won ${m.closedWon} (${m.closedWonAmount})`).join('; ') || 'none'}`].join('\n'), d);
|
|
12431
12673
|
}));
|
|
12674
|
+
server.registerTool('get_hubspot_lead_sync', {
|
|
12675
|
+
title: 'Read the automatic HubSpot lead sync',
|
|
12676
|
+
description: "Whether the brand's lead-form leads are pushed into HubSpot AUTOMATICALLY: on or off, how often (everyMinutes), which platforms, when it runs next, and what the last run did — counts per platform, or why it did not run (HubSpot disconnected, a platform not readable). Read-only, free. Needs HubSpot connected.",
|
|
12677
|
+
inputSchema: {},
|
|
12678
|
+
outputSchema: { ok: z.boolean().optional(), enabled: z.boolean().optional(), everyMinutes: z.number().optional(), platforms: z.array(z.string()).optional(), nextRunAt: z.string().nullable().optional(), last: z.any().optional(), note: z.string().optional() },
|
|
12679
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
12680
|
+
}, wrap(async () => { const d = await apiGet('/api/hubspot/lead-sync/schedule', {}); return ok(d.note, d); }));
|
|
12681
|
+
server.registerTool('set_hubspot_lead_sync', {
|
|
12682
|
+
title: 'Turn the automatic HubSpot lead sync on or off',
|
|
12683
|
+
description: "Switch the AUTOMATIC HubSpot lead sync on or off for this brand: enabled, everyMinutes (a whole number from 15 to 1440; default 60 — anything outside that is refused, not clamped), optional platforms (meta, linkedin, tiktok, x, snapchat, chatgpt, reddit; default every connected one). Each run is sync_leads_to_hubspot reading from the last successful run, so a new lead reaches the CRM within minutes and nothing is ever created twice. If HubSpot is disconnected a run records that and the schedule backs off to once a day — no emails. runNow:true runs the first one straight away. Answers with the stored setting and the armed run, read back. Free. Needs HubSpot connected to switch on.",
|
|
12684
|
+
inputSchema: { enabled: z.boolean(), everyMinutes: z.number().optional().describe('15–1440, default 60'), platforms: z.array(z.enum(['meta', 'linkedin', 'tiktok', 'x', 'snapchat', 'chatgpt', 'reddit'])).optional().describe('omit for every connected one'), runNow: z.boolean().optional() },
|
|
12685
|
+
outputSchema: { ok: z.boolean().optional(), enabled: z.boolean().optional(), everyMinutes: z.number().optional(), platforms: z.array(z.string()).optional(), nextRunAt: z.string().nullable().optional(), last: z.any().optional(), note: z.string().optional() },
|
|
12686
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
12687
|
+
}, wrap(async (a) => { const d = await apiPost('/api/hubspot/lead-sync/schedule', a); return ok(d.note, d); }));
|
|
12432
12688
|
// ---------- Intercom (2026-10-19): the brand's OWN support inbox, the same routes the Studio tools call ----------
|
|
12433
12689
|
// Studio had these ten tools from 2026-10-12 and MCP/CLI had none (find_tools "intercom" found nothing): web-only is a
|
|
12434
12690
|
// defect. Each tool calls the app's /api/intercom route, whose worker is the one the Studio handler calls, so the
|
|
@@ -12568,6 +12824,49 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12568
12824
|
outputSchema: { workspace: z.string().optional(), days: z.number().optional(), read: z.number().optional(), snippets: z.array(z.any()).optional(), note: z.string().optional() },
|
|
12569
12825
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12570
12826
|
}, wrap(async (a) => { const d = await apiPost('/api/intercom/customer-language', a); return ok(`${d.note || ''}${(d.snippets || []).length ? `\n${d.snippets.map((s) => `- "${s.text}"`).join('\n')}` : ''}`, d); }));
|
|
12827
|
+
// ---------- Intercom HELP CENTER + NEWS (2026-10-27): the same /api/intercom routes the Studio tools call ----------
|
|
12828
|
+
// Drafts are written freely; anything a customer SEES (publish, unpublish, a live edit, a delete) needs confirm:true
|
|
12829
|
+
// after the preview the route returns. Every write is read back from Intercom. Rules: lib/intercom-content.mjs.
|
|
12830
|
+
const icArtLine = (x) => `"${x.title || '(untitled)'}" (${x.id}), ${x.state}${x.hasUnpublishedChanges ? ', changes staged' : ''}${x.url ? `: ${x.url}` : ''}`;
|
|
12831
|
+
const icNewsLine = (x) => `"${x.title || '(untitled)'}" (${x.id}), ${x.state}${(x.newsfeedIds || []).length ? ` | newsfeed ${x.newsfeedIds.join(', ')}` : ' | in no newsfeed'}${(x.labels || []).length ? ` | labels: ${x.labels.join(', ')}` : ''}`;
|
|
12832
|
+
const icContentText = (d) => {
|
|
12833
|
+
if (d.dryRun) return `${d.note || ''}\n${d.preview || ''}`;
|
|
12834
|
+
if (d.what === 'articles') return d.count ? `${d.count} Help Center article(s) (page ${d.page} of ${d.totalPages}):\n${d.articles.map((x) => `- ${icArtLine(x)}${x.preview ? `\n ${x.preview}` : ''}`).join('\n')}${d.nextPage ? `\n(more: pass page=${d.nextPage})` : ''}` : 'This Intercom workspace has no Help Center articles. That is a real empty list, not a failed read.';
|
|
12835
|
+
if (d.what === 'collections') return d.count ? `${d.count} collection(s):\n${d.collections.map((c) => `- ${c.name} (${c.id})`).join('\n')}` : 'No Help Center collections yet.';
|
|
12836
|
+
if (d.what === 'article') { const x = d.article || {}; return `${d.draft ? 'STAGED DRAFT of ' : ''}${icArtLine(x)}${x.description ? `\n${x.description}` : ''}\n\n${x.body || '(empty)'}`; }
|
|
12837
|
+
if (d.what === 'news') return d.count ? `${d.count} news item(s) (page ${d.page} of ${d.totalPages}):\n${d.items.map((x) => `- ${icNewsLine(x)}${x.preview ? `\n ${x.preview}` : ''}`).join('\n')}${d.nextPage ? `\n(more: pass page=${d.nextPage})` : ''}` : 'No news items in this Intercom workspace. That is a real empty list, not a failed read.';
|
|
12838
|
+
if (d.what === 'newsfeeds') return d.count ? `${d.count} newsfeed(s): ${d.newsfeeds.map((f) => `${f.name} (${f.id})`).join(', ')}` : 'This Intercom workspace has no newsfeeds.';
|
|
12839
|
+
if (d.what === 'news_item') { const x = d.item || {}; return `${icNewsLine(x)}\n\n${x.body || '(empty)'}`; }
|
|
12840
|
+
return d.note || '';
|
|
12841
|
+
};
|
|
12842
|
+
server.registerTool('list_intercom_articles', {
|
|
12843
|
+
title: 'Read the Intercom Help Center',
|
|
12844
|
+
description: `The brand's Intercom HELP CENTER. what:"articles" (default) lists articles newest-updated first: title, state (published or draft), whether changes are staged on a live article, link, preview. what:"article" + articleId reads one in full; what:"draft" + articleId reads the STAGED changes on a published article; what:"collections" lists the collections an article can sit in. page / perPage to page through. Needs the token's "Read and List articles" permission. Read-only, free. ${icNeeds}`,
|
|
12845
|
+
inputSchema: { what: z.enum(['articles', 'article', 'draft', 'collections']).optional(), articleId: z.string().optional(), page: z.number().optional(), perPage: z.number().optional().describe('1-150, default 25') },
|
|
12846
|
+
outputSchema: { workspace: z.string().optional(), what: z.string().optional(), count: z.number().optional(), articles: z.array(z.any()).optional(), collections: z.array(z.any()).optional(), article: z.any().optional(), nextPage: z.number().nullable().optional() },
|
|
12847
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12848
|
+
}, wrap(async (a) => { const d = await apiGet('/api/intercom/articles', a); return ok(icContentText(d), d); }));
|
|
12849
|
+
server.registerTool('manage_intercom_article', {
|
|
12850
|
+
title: 'Write an Intercom Help Center article',
|
|
12851
|
+
description: `Write a Help Center article in the brand's Intercom. action:"create" makes a DRAFT — title, body (markdown) or bodyHtml, description, collectionId, authorId (default: the connected teammate) — that nobody outside the team sees. action:"update" edits a draft in place; on a PUBLISHED article it STAGES the change as a draft, so the live article is untouched until it is published. action:"publish" puts a draft or the staged changes on the PUBLIC Help Center (and in Fin's answers), "unpublish" takes it off (it stays a draft), "delete" removes it: those three return a preview first and need confirm:true once the user has seen it. Every result is read back from Intercom. Needs "Read and Write Articles". Free. ${icNeeds}`,
|
|
12852
|
+
inputSchema: { action: z.enum(['create', 'update', 'publish', 'unpublish', 'delete']).optional().describe('default: create without articleId, update with one'), articleId: z.string().optional(), title: z.string().optional(), description: z.string().optional(), body: z.string().optional().describe('the article in markdown'), bodyHtml: z.string().optional().describe('the article in HTML, instead of body'), collectionId: z.string().optional(), authorId: z.string().optional(), locales: z.array(z.string()).optional().describe('multilingual Help Centers: the locales whose staged changes to publish'), confirm: z.boolean().optional() },
|
|
12853
|
+
outputSchema: { workspace: z.string().optional(), ok: z.boolean().optional(), action: z.string().optional(), dryRun: z.boolean().optional(), confirmed: z.boolean().optional(), staged: z.boolean().optional(), deleted: z.boolean().optional(), article: z.any().optional(), preview: z.string().optional(), note: z.string().optional() },
|
|
12854
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
12855
|
+
}, wrap(async (a) => { const d = await apiPost('/api/intercom/article', { ...a, confirm: a.confirm === true }); return ok(icContentText(d), d); }));
|
|
12856
|
+
server.registerTool('list_intercom_news', {
|
|
12857
|
+
title: 'Read Intercom news items',
|
|
12858
|
+
description: `The brand's Intercom NEWS — in-app announcements in the Messenger newsfeed. what:"news" (default) lists news items newest first with state (draft or live), newsfeeds and labels; what:"item" + newsItemId reads one in full; what:"newsfeeds" lists the newsfeeds an item can be published to. Needs "Read and list news items and newsfeeds". Read-only, free. ${icNeeds}`,
|
|
12859
|
+
inputSchema: { what: z.enum(['news', 'item', 'newsfeeds']).optional(), newsItemId: z.string().optional(), page: z.number().optional(), perPage: z.number().optional().describe('1-150, default 25') },
|
|
12860
|
+
outputSchema: { workspace: z.string().optional(), what: z.string().optional(), count: z.number().optional(), items: z.array(z.any()).optional(), newsfeeds: z.array(z.any()).optional(), item: z.any().optional(), nextPage: z.number().nullable().optional() },
|
|
12861
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12862
|
+
}, wrap(async (a) => { const d = await apiGet('/api/intercom/news', a); return ok(icContentText(d), d); }));
|
|
12863
|
+
server.registerTool('manage_intercom_news_item', {
|
|
12864
|
+
title: 'Write an Intercom news item',
|
|
12865
|
+
description: `Write an in-app NEWS item (a launch or offer announcement) in the brand's Intercom. action:"create" makes a DRAFT: title, body (plain text) or bodyHtml, labels, reactions (at most 5, one emoji each), coverImageUrl (.png or .jpg), deliverSilently, newsfeedIds, senderId (default: the connected teammate). action:"update" edits it and KEEPS its labels, reactions and newsfeeds unless you pass new ones (Intercom itself would clear them); editing a LIVE item needs confirm:true. action:"publish" (needs newsfeedIds, or a newsfeed it already has), "unpublish" and "delete" return a preview first and need confirm:true once the user has seen it. Read back from Intercom. Needs "Read and write news items and newsfeeds". Free. ${icNeeds}`,
|
|
12866
|
+
inputSchema: { action: z.enum(['create', 'update', 'publish', 'unpublish', 'delete']).optional().describe('default: create without newsItemId, update with one'), newsItemId: z.string().optional(), title: z.string().optional(), body: z.string().optional().describe('plain text'), bodyHtml: z.string().optional(), labels: z.array(z.string()).optional().describe('replaces the labels'), reactions: z.array(z.string()).optional().describe('replaces the reactions; at most 5'), newsfeedIds: z.array(z.string()).optional().describe('replaces the newsfeeds (list_intercom_news what:"newsfeeds")'), coverImageUrl: z.string().optional(), deliverSilently: z.boolean().optional(), senderId: z.string().optional(), confirm: z.boolean().optional() },
|
|
12867
|
+
outputSchema: { workspace: z.string().optional(), ok: z.boolean().optional(), action: z.string().optional(), dryRun: z.boolean().optional(), confirmed: z.boolean().optional(), deleted: z.boolean().optional(), item: z.any().optional(), preview: z.string().optional(), note: z.string().optional() },
|
|
12868
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
12869
|
+
}, wrap(async (a) => { const d = await apiPost('/api/intercom/news-item', { ...a, confirm: a.confirm === true }); return ok(icContentText(d), d); }));
|
|
12571
12870
|
server.group('ads'); // end of the measurement block — back to paid-campaign management
|
|
12572
12871
|
// ---------- Microsoft Advertising (Bing Ads): read + manage. Same spend law as Google — everything is created
|
|
12573
12872
|
// Paused, only an explicit confirm:true arms real money, and every narration comes from a READ-BACK.
|
|
@@ -12580,6 +12879,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12580
12879
|
path1: z.string().optional().describe('display-URL path segment, ≤15 chars, no "/"'),
|
|
12581
12880
|
path2: z.string().optional().describe('second display-URL path segment (only with path1)'),
|
|
12582
12881
|
};
|
|
12882
|
+
// THE AD ACCOUNT IS `adAccountId` (2026-10-27). On /v1 `accountId` names the WORKSPACE and a body may never carry it,
|
|
12883
|
+
// so the Microsoft tools could not name an ad account over REST at all. `accountId` stays as an alias HERE, for MCP
|
|
12884
|
+
// callers written before the rename; /v1 still refuses it by name and the server reads adAccountId first.
|
|
12885
|
+
const msAcct = {
|
|
12886
|
+
adAccountId: z.string().optional().describe('Microsoft ad account id — omit to use the single shared account'),
|
|
12887
|
+
accountId: z.string().optional().describe('older name for adAccountId, still accepted over MCP; over the REST API (/v1) use adAccountId'),
|
|
12888
|
+
};
|
|
12583
12889
|
const msKeywordShape = z.array(z.object({
|
|
12584
12890
|
text: z.string().describe('≤100 characters'),
|
|
12585
12891
|
matchType: z.enum(['Exact', 'Phrase', 'Broad']).optional().describe('default Phrase — Microsoft has no broad-match-modifier'),
|
|
@@ -12588,20 +12894,20 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12588
12894
|
}));
|
|
12589
12895
|
server.registerTool('list_microsoft_ads_campaigns', {
|
|
12590
12896
|
title: 'List Microsoft Advertising accounts / campaigns',
|
|
12591
|
-
description: 'Read the brand’s connected Microsoft Advertising (Bing Ads) account(s). Call with NO
|
|
12897
|
+
description: 'Read the brand’s connected Microsoft Advertising (Bing Ads) account(s). Call with NO adAccountId to list the accounts shared with this profile — do this first to pick a target. Call WITH adAccountId 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).',
|
|
12592
12898
|
inputSchema: {
|
|
12593
|
-
|
|
12899
|
+
...msAcct,
|
|
12594
12900
|
},
|
|
12595
12901
|
outputSchema: { accounts: z.array(z.any()).optional(), accountId: z.string().optional(), count: z.number().optional(), campaigns: z.array(z.any()).optional(), allCampaignTypes: z.boolean().optional() },
|
|
12596
12902
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12597
12903
|
}, wrap(async (a) => {
|
|
12598
|
-
if (!a.accountId) {
|
|
12904
|
+
if (!(a.adAccountId || a.accountId)) {
|
|
12599
12905
|
const d = await apiGet('/api/microsoft-ads/shared-accounts', {});
|
|
12600
12906
|
if (!(d.accounts || []).length) return ok('No Microsoft Advertising account is shared with this profile yet. Ask the user to tick the ones it may use in Settings ▸ Connectors ▸ Microsoft Ads ▸ Manage accounts (or call set_connector_accounts), then retry.', d);
|
|
12601
12907
|
const lines = (d.accounts || []).map(c => `• ${c.name} (${c.accountId})${c.number ? ` — ${c.number}` : ''}${c.selected ? ' [shared with this profile]' : ''}${c.status ? ` · ${c.status}` : ''}`);
|
|
12602
|
-
return ok(`${(d.accounts || []).length} Microsoft Advertising account(s) shared with this profile${d.sandbox ? ' (SANDBOX environment)' : ''}:\n${lines.join('\n') || '(none)'}\nPass an
|
|
12908
|
+
return ok(`${(d.accounts || []).length} Microsoft Advertising account(s) shared with this profile${d.sandbox ? ' (SANDBOX environment)' : ''}:\n${lines.join('\n') || '(none)'}\nPass an adAccountId to see its campaigns. Only accounts ticked in Settings ▸ Connectors can be managed.`, d);
|
|
12603
12909
|
}
|
|
12604
|
-
const d = await apiGet('/api/microsoft-ads/campaigns', {
|
|
12910
|
+
const d = await apiGet('/api/microsoft-ads/campaigns', { adAccountId: (a.adAccountId || a.accountId) });
|
|
12605
12911
|
const lines = (d.campaigns || []).map(c => `• ${c.name} (${c.id}) — ${c.status}${c.dailyBudget != null ? `, ${c.dailyBudget}/day${c.sharedBudget ? ' (SHARED budget — read-only here)' : ''}` : ''}${c.type ? `, ${c.type}` : ''}`);
|
|
12606
12912
|
return ok(`${d.count} campaign(s) on Microsoft Advertising account ${d.accountId}:\n${lines.join('\n') || '(none)'}${d.allCampaignTypes === false ? '\nNOTE: only SEARCH campaigns are listed — Microsoft refused the all-types filter, so other campaign types may exist.' : ''}`, d);
|
|
12607
12913
|
}));
|
|
@@ -12609,7 +12915,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12609
12915
|
title: 'Microsoft Advertising performance report',
|
|
12610
12916
|
description: 'Performance for a Microsoft Advertising account — impressions, clicks, CTR, average CPC, spend, conversions. `reportType` picks WHICH report, and that is the whole Microsoft reporting surface, not just campaigns: AdGroupPerformance, AdPerformance, KeywordPerformance, SearchQueryPerformance (the actual search terms people typed), GeographicPerformance, UserLocationPerformance, AgeGenderAudience and ProfessionalDemographicsAudience (LinkedIn-sourced job function and industry, inside Bing), ConversionPerformance, DestinationUrlPerformance, ShareOfVoice, AssetPerformance, ProductDimensionPerformance, SearchCampaignChangeHistory ("what changed on Tuesday") and ~30 more — an unknown name is refused WITH the full list rather than forwarded. `aggregation` controls the row grain (Summary / Daily / Hourly / Weekly / Monthly / Yearly / HourOfDay / DayOfWeek). Two reports keep far less history than the usual 36 months — AssetPerformance 30 days, ShareOfVoice 6 — and the reply says so, because an empty short-retention report is a retention limit, not an absence of delivery. Window via timePeriod (Today | Yesterday | LastSevenDays | Last14Days | Last30Days | ThisWeek | LastWeek | LastFourWeeks | ThisMonth | LastMonth | LastThreeMonths | LastSixMonths | ThisYear | LastYear | ThisWeekStartingMonday | LastWeekStartingMonday | LastFourWeeksStartingMonday) or since+until (YYYY-MM-DD) — default Last30Days. An unrecognised timePeriod is REFUSED, never silently swapped for another window. Microsoft generates reports ASYNCHRONOUSLY: this can return pending:true with a reportRequestId, and you must call again rather than reporting any numbers. A report that succeeds with ZERO rows genuinely means there was no delivery in that window — say exactly that; never present zeros as measured performance. Read-only, free.',
|
|
12611
12917
|
inputSchema: {
|
|
12612
|
-
|
|
12918
|
+
...msAcct,
|
|
12613
12919
|
timePeriod: z.string().optional().describe('predefined Microsoft window, default Last30Days — must be one of the values in the description; anything else is rejected'),
|
|
12614
12920
|
since: z.string().optional().describe('YYYY-MM-DD custom range start (with until)'),
|
|
12615
12921
|
until: z.string().optional().describe('YYYY-MM-DD custom range end'),
|
|
@@ -12633,7 +12939,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12633
12939
|
title: 'Microsoft Advertising keyword planner',
|
|
12634
12940
|
description: 'Microsoft Advertising\u2019s KEYWORD PLANNER — real monthly search volume, competition, suggested bid and ad impression share, expanded from seed keywords, a landing-page URL to mine, or a category. Run it BEFORE choosing keywords for a Microsoft campaign. locationIds is REQUIRED and deliberately not defaulted: a search volume with no market attached is a number nobody can act on, and inventing a country would silently answer about the wrong market — use microsoft_ads_geo_search to resolve a country or city name to an id, free. SAY THIS WHEN REPORTING: Competition is Microsoft\u2019s Low/Medium/High bucket, NOT a percentage; MonthlySearchCounts is a per-month series rather than one number; SuggestedBid is in the account currency. An empty result means Microsoft found no ideas for those seeds, never that nobody searches for them. Read-only, 0 credits.',
|
|
12635
12941
|
inputSchema: {
|
|
12636
|
-
|
|
12942
|
+
...msAcct,
|
|
12637
12943
|
keywords: z.array(z.string()).optional().describe('seed terms to expand from'),
|
|
12638
12944
|
url: z.string().optional().describe('a landing page for Microsoft to mine ideas from'),
|
|
12639
12945
|
categoryId: z.number().optional(),
|
|
@@ -12655,7 +12961,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12655
12961
|
title: 'Microsoft Advertising traffic estimates',
|
|
12656
12962
|
description: 'What a set of keywords would DELIVER on Microsoft Advertising at a given bid — estimated impressions, clicks, CTR, average CPC, average position and total cost. maxCpc is REQUIRED because a traffic estimate IS a function of the bid; estimating without one would be inventing the input. locationIds is REQUIRED for the same reason a search volume needs a market. SAY THIS WHEN REPORTING: Microsoft returns a MINIMUM and a MAXIMUM per keyword — quote the range, never average the two into a single figure — and every number here is a FORECAST, so never present it as measured performance. Read-only, 0 credits.',
|
|
12657
12963
|
inputSchema: {
|
|
12658
|
-
|
|
12964
|
+
...msAcct,
|
|
12659
12965
|
keywords: z.array(z.string()).describe('the keywords to estimate'),
|
|
12660
12966
|
maxCpc: z.number().describe('REQUIRED \u2014 the max CPC bid to estimate at, in the account currency'),
|
|
12661
12967
|
matchType: z.enum(['Exact', 'Phrase', 'Broad']).optional().describe('default Exact'),
|
|
@@ -12671,18 +12977,18 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12671
12977
|
server.registerTool('microsoft_ads_budget_opportunities', {
|
|
12672
12978
|
title: 'Where Microsoft says budget is capping delivery',
|
|
12673
12979
|
description: 'Where a Microsoft Advertising campaign is BUDGET-CONSTRAINED — Microsoft\u2019s own recommended budget against the current one, the estimated WEEKLY click and impression gain from raising it, and a budget/return curve. Omit campaignId for the whole account. SAY THIS WHEN REPORTING: these are Microsoft\u2019s FORECASTS, never measurements — a projected increase has not happened — and acting on one spends real money, so it takes set_microsoft_ads_budget and an explicit yes from the user. Microsoft EXCLUDES user-paused campaigns from this analysis, so a paused campaign is absent by design rather than well-funded. Read-only, 0 credits.',
|
|
12674
|
-
inputSchema: {
|
|
12980
|
+
inputSchema: { ...msAcct, campaignId: z.string().optional().describe('omit for the whole account') },
|
|
12675
12981
|
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), opportunities: z.array(z.any()).optional(), note: z.string().optional() },
|
|
12676
12982
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12677
12983
|
}, wrap(async (a) => {
|
|
12678
|
-
const d = await apiGet('/api/microsoft-ads/budget-opportunities', {
|
|
12984
|
+
const d = await apiGet('/api/microsoft-ads/budget-opportunities', { adAccountId: (a.adAccountId || a.accountId), campaignId: a.campaignId });
|
|
12679
12985
|
return ok(`${d.note}\n${JSON.stringify(d.opportunities || []).slice(0, 4000)}`, d);
|
|
12680
12986
|
}));
|
|
12681
12987
|
server.registerTool('microsoft_ads_geo_search', {
|
|
12682
12988
|
title: 'Find Microsoft Advertising location ids',
|
|
12683
12989
|
description: 'Resolve country / region / city names to the Microsoft Advertising location ids that create_microsoft_ads_campaign needs. Read-only, free, 0 credits. Use it when a location ask is ambiguous ("Springfield") — this returns EVERY candidate with its id so the USER can pick, and you never guess between two places. Accepts names, ISO country codes ("CA"), or numeric location ids. Pass `query` as ONE ask (a plain string) or SEVERAL (an array of strings) — a comma is part of a place\'s name ("Seattle, Washington, United States"), never a separator. Postal codes and neighbourhoods are not name-searchable — pass their numeric location id straight through; the campaign read-back reports the name Microsoft resolves for it.',
|
|
12684
12990
|
inputSchema: {
|
|
12685
|
-
|
|
12991
|
+
...msAcct,
|
|
12686
12992
|
// A STRING IS ACCEPTED, not only an array. The server has always normalized a lone string, but the schema
|
|
12687
12993
|
// REJECTED one before it reached the server \u2014 so the obvious single-location call ("Canada") failed validation
|
|
12688
12994
|
// on a parameter whose array-ness nothing explained. Commas are never split: they occur INSIDE real Microsoft
|
|
@@ -12699,7 +13005,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12699
13005
|
title: 'Build a Microsoft Advertising campaign (paused)',
|
|
12700
13006
|
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, 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.',
|
|
12701
13007
|
inputSchema: {
|
|
12702
|
-
|
|
13008
|
+
...msAcct,
|
|
12703
13009
|
name: z.string().describe('campaign name, ≤128 characters'),
|
|
12704
13010
|
dailyBudget: z.number().describe('daily budget in the account’s currency'),
|
|
12705
13011
|
budgetType: z.enum(['DailyBudgetStandard', 'DailyBudgetAccelerated', 'LifetimeBudgetStandard']).optional().describe('default DailyBudgetStandard; Accelerated is Audience-campaign only'),
|
|
@@ -12710,11 +13016,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12710
13016
|
feedLabel: z.string().optional().describe('Shopping: advertise every product with this feed label (≤20 characters). Not with salesCountryCode.'),
|
|
12711
13017
|
localInventoryAds: z.boolean().optional().describe('Shopping: enable local inventory ads (only where Microsoft has enabled the feature for the account)'),
|
|
12712
13018
|
dynamicSearchAds: z.object({ domainName: z.string().describe('the website, e.g. "example.com"'), language: z.string().describe('the pages\u2019 language: Dutch, English, French, German, Italian, Spanish or Swedish'), source: z.enum(['SystemIndex', 'AdvertiserSuppliedUrls', 'All']).optional(), dynamicDescriptionEnabled: z.boolean().optional() }).optional().describe('Dynamic search ads: required for campaignType DynamicSearchAds, optional on Search. Microsoft retired the DynamicSearchAds TYPE, so the campaign is a Search campaign carrying this setting.'),
|
|
12713
|
-
biddingScheme: z.enum(['MaxConversions', 'MaxConversionValue']).optional().describe('Performance Max
|
|
13019
|
+
biddingScheme: z.enum(['EnhancedCpc', 'MaxClicks', 'MaxConversions', 'MaxConversionValue', 'TargetImpressionShare', 'TargetCpa', 'TargetRoas']).optional().describe('bid strategy. Search takes any; Audience EnhancedCpc/MaxConversions; Shopping EnhancedCpc; Performance Max MaxConversions/MaxConversionValue. TargetCpa/TargetRoas are sent as MaxConversions/MaxConversionValue with the target (Microsoft converts them silently since 2025-08-04). Omitted = Microsoft\u2019s default, read back in the reply. Change it later with set_microsoft_ads_bidding'),
|
|
13020
|
+
maxCpc: z.number().optional().describe('bid cap for MaxClicks / MaxConversions / MaxConversionValue / TargetImpressionShare (not Performance Max)'),
|
|
13021
|
+
targetImpressionShare: z.number().optional().describe('1–100, with biddingScheme TargetImpressionShare'),
|
|
13022
|
+
targetAdPosition: z.enum(['FirstPage', 'MainLine', 'MainLine1']).optional().describe('with TargetImpressionShare; MainLine1 = the very top'),
|
|
12714
13023
|
targetCpa: z.number().optional().describe('Performance Max with MaxConversions: target cost per acquisition, account currency'),
|
|
12715
13024
|
targetRoas: z.number().optional().describe('Performance Max with MaxConversionValue: target return on ad spend as a ratio, e.g. 4 = 400%'),
|
|
12716
13025
|
finalUrlExpansionOptOut: z.boolean().optional().describe('Performance Max: true = serve only the final URLs you give; default false lets Microsoft use the whole domain'),
|
|
12717
|
-
timeZone: z.string().optional().describe('Microsoft time-zone enum
|
|
13026
|
+
timeZone: z.string().optional().describe('Microsoft time-zone enum (e.g. EasternTimeUSCanada); default the ad account\u2019s own. Microsoft marks a campaign time zone deprecated and may keep its own — the reply states the zone it stored'),
|
|
12718
13027
|
locations: z.array(z.string()).optional().describe('where the ads may serve — omit for worldwide (Microsoft’s default, warned about in the read-back), e.g. ["United States"] or ["Seattle, Washington, United States","CA"]. Resolved to Microsoft location ids BEFORE anything is created; an ambiguous or unknown one refuses the whole create and names it'),
|
|
12719
13028
|
excludeLocations: z.array(z.string()).optional().describe('locations to EXCLUDE from the targeted set'),
|
|
12720
13029
|
locationIntent: z.enum(['PeopleInOrSearchingForOrViewingPages', 'PeopleIn']).optional().describe('default PeopleInOrSearchingForOrViewingPages — someone OUTSIDE the target still sees the ad if they search for the place; PeopleIn restricts to people physically there'),
|
|
@@ -12732,7 +13041,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12732
13041
|
title: 'Add a Microsoft Advertising ad group',
|
|
12733
13042
|
description: 'Add an ad group to an existing Microsoft Advertising campaign — optionally with its responsive search ad and keywords in the same call. Created Paused by default. If the parent campaign is already LIVE (Active), creating this ad group Active starts REAL AD SPEND on the next auction, exactly like activating it — show the user what would begin serving, get an explicit yes, then pass confirm:true. Leaving it Paused never needs confirmation. Read back from Microsoft before you are told it exists.',
|
|
12734
13043
|
inputSchema: {
|
|
12735
|
-
|
|
13044
|
+
...msAcct,
|
|
12736
13045
|
campaignId: z.string().describe('the campaign this ad group belongs to'),
|
|
12737
13046
|
name: z.string().describe('ad group name, ≤256 characters'),
|
|
12738
13047
|
status: z.enum(['Active', 'Paused']).optional().describe('default Paused'),
|
|
@@ -12752,7 +13061,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12752
13061
|
title: 'Create a Microsoft responsive search ad',
|
|
12753
13062
|
description: 'Create the actual AD inside a Microsoft Advertising ad group — this is the object that carries the creative; a campaign or ad group alone shows nothing. It builds a RESPONSIVE SEARCH AD: 3–15 headlines (≤30 chars), 2–4 descriptions (≤90 chars), at least one finalUrl, optional path1/path2. Expanded text ads CANNOT be created any more — Microsoft rejects them outright. Created Paused; if the parent ad group and campaign are both Active, creating this Active starts REAL AD SPEND immediately — get an explicit yes and pass confirm:true. Read back from Microsoft, including its editorial status, before you are told it exists.',
|
|
12754
13063
|
inputSchema: {
|
|
12755
|
-
|
|
13064
|
+
...msAcct,
|
|
12756
13065
|
adGroupId: z.string().describe('the ad group this ad lives in'),
|
|
12757
13066
|
...msAdShape,
|
|
12758
13067
|
status: z.enum(['Active', 'Paused']).optional().describe('default Paused'),
|
|
@@ -12768,7 +13077,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12768
13077
|
title: 'Add Microsoft Advertising keywords',
|
|
12769
13078
|
description: 'Add keywords to a Microsoft Advertising ad group. Match types are Exact, Phrase and Broad — Microsoft has no broad-match-modifier. Keywords are added Paused unless you set status:"Active"; an Active keyword on a live ad group makes the campaign bid on a new term immediately, so that needs confirm:true. Note that per-keyword bids are honoured but ad-group / keyword BID STRATEGIES are silently ignored by Microsoft — they inherit the campaign’s. Only the keywords Microsoft confirms on the read-back are reported as added.',
|
|
12770
13079
|
inputSchema: {
|
|
12771
|
-
|
|
13080
|
+
...msAcct,
|
|
12772
13081
|
adGroupId: z.string().describe('the ad group to add them to'),
|
|
12773
13082
|
keywords: msKeywordShape.describe('the keywords'),
|
|
12774
13083
|
confirm: z.boolean().optional().describe('REQUIRED true to add an Active keyword to a LIVE ad group'),
|
|
@@ -12783,7 +13092,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12783
13092
|
title: 'Find Microsoft Advertising LinkedIn profile ids',
|
|
12784
13093
|
description: 'Find the LinkedIn profile ids Microsoft Advertising can target: a company (CompanyName), an Industry or a JobFunction. A company is found with Microsoft’s own company search (3+ characters); industries and job functions are matched against the profile list Microsoft publishes for them. Microsoft publishes no lookup for JobTitle or JobSeniority ids, so those cannot be searched here; an id you already have can still be targeted. Read-only, free. Pass the ids to set_microsoft_ads_profile_targeting.',
|
|
12785
13094
|
inputSchema: {
|
|
12786
|
-
|
|
13095
|
+
...msAcct,
|
|
12787
13096
|
profileType: z.enum(['CompanyName', 'Industry', 'JobFunction']),
|
|
12788
13097
|
query: z.string().describe('part of the name to find (3+ characters for a company)'),
|
|
12789
13098
|
languageLocale: z.string().optional().describe('en (default), fr, de, it, pt-BR, es or zh-Hant'),
|
|
@@ -12795,7 +13104,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12795
13104
|
title: 'Read a Microsoft Advertising campaign’s LinkedIn profile targeting',
|
|
12796
13105
|
description: 'Read the LinkedIn profile targeting on a Microsoft Advertising campaign: every company, industry, job function, seniority and job title criterion with its bid adjustment and criterion id, read from Microsoft. A profile type Microsoft would not read is named as unread, never reported as none. Read-only, free.',
|
|
12797
13106
|
inputSchema: {
|
|
12798
|
-
|
|
13107
|
+
...msAcct,
|
|
12799
13108
|
campaignId: z.string(),
|
|
12800
13109
|
},
|
|
12801
13110
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -12804,7 +13113,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12804
13113
|
title: 'Set LinkedIn profile targeting on a Microsoft Advertising campaign',
|
|
12805
13114
|
description: 'Target LinkedIn profiles on a Microsoft Advertising Search, Shopping or Dynamic Search Ads campaign: add companies, industries, job functions, seniorities or job titles, each with an optional bid adjustment (whole percent, -90 to 900), or change an existing adjustment by criterion id (list_microsoft_ads_profile_targeting shows it). Find profile ids with search_microsoft_ads_profiles. On an ACTIVE campaign this changes bids on the very next auction, so show the user the changes, get an explicit yes, then call with confirm:true. The reply is what Microsoft reads back, and a change that does not read back is named. Removing a profile criterion is not available here; an adjustment of 0 has no effect on bids.',
|
|
12806
13115
|
inputSchema: {
|
|
12807
|
-
|
|
13116
|
+
...msAcct,
|
|
12808
13117
|
campaignId: z.string(),
|
|
12809
13118
|
add: z.array(z.object({ profileType: z.enum(['CompanyName', 'Industry', 'JobFunction', 'JobSeniority', 'JobTitle']), profileId: z.string(), bidAdjustment: z.number().optional().describe('whole percent, -90 to 900; omit for none') })).optional(),
|
|
12810
13119
|
update: z.array(z.object({ criterionId: z.string(), bidAdjustment: z.number().describe('whole percent, -90 to 900') })).optional(),
|
|
@@ -12816,7 +13125,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12816
13125
|
title: 'Set a Microsoft Advertising daily budget',
|
|
12817
13126
|
description: 'Change a Microsoft Advertising campaign’s DAILY BUDGET (in the account’s currency). Raising it on a LIVE (Active) campaign increases real spend immediately — you MUST show the user the new daily amount, get an explicit yes, then call with confirm:true. If the campaign is on a SHARED budget its amount is read-only here and this refuses with an explanation rather than pretending to change it. Read back after the change.',
|
|
12818
13127
|
inputSchema: {
|
|
12819
|
-
|
|
13128
|
+
...msAcct,
|
|
12820
13129
|
campaignId: z.string().describe('the campaign whose budget changes'),
|
|
12821
13130
|
dailyBudget: z.number().describe('new daily budget in the account’s currency'),
|
|
12822
13131
|
confirm: z.boolean().optional().describe('REQUIRED true to change the budget of a LIVE campaign'),
|
|
@@ -12831,7 +13140,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12831
13140
|
title: 'Activate or pause a Microsoft Advertising campaign / ad group / ad',
|
|
12832
13141
|
description: 'Turn a Microsoft Advertising campaign, AD GROUP or AD on (Active) or off (Paused). Pass level:"campaign" + campaignId, level:"adGroup" + adGroupId, or level:"ad" + BOTH adGroupId and adId. ACTIVATING STARTS REAL AD SPEND — you MUST first show the user the campaign name + its daily budget, get an explicit yes, then call with status:"Active" and confirm:true. Pausing is always safe. Microsoft has only these two statuses — its Deleted state is internal-only and cannot be SET — so to remove something use delete_microsoft_ads_object, which is a real delete operation, not a status. The resulting status is READ BACK from Microsoft before you are told it took — and Microsoft may report BudgetPaused / BudgetAndManualPaused / Suspended instead, which the note names explicitly.',
|
|
12833
13142
|
inputSchema: {
|
|
12834
|
-
|
|
13143
|
+
...msAcct,
|
|
12835
13144
|
level: z.enum(['campaign', 'adGroup', 'ad']).optional().describe('what to change — default campaign'),
|
|
12836
13145
|
campaignId: z.string().optional().describe('campaign id (level:"campaign")'),
|
|
12837
13146
|
adGroupId: z.string().optional().describe('ad group id (level:"adGroup", or with adId for level:"ad")'),
|
|
@@ -12851,7 +13160,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12851
13160
|
title: 'Delete a Microsoft Advertising campaign / ad group / ad / keyword',
|
|
12852
13161
|
description: 'PERMANENTLY delete a Microsoft Advertising campaign, ad group, ad or keyword. This is a real delete — Microsoft removes the object and it stops being returned by every read, with no undelete and no documented recovery window. Pass level:"campaign" + campaignId, level:"adGroup" + adGroupId, level:"ad" + adGroupId AND adId, or level:"keyword" + adGroupId AND keywordId. CALL IT WITHOUT confirm FIRST: nothing is deleted, and you get back the object’s real name, its status and how many ad groups / ads / keywords go with it, read live from Microsoft — show the user exactly that. If the object has children, is Active, or has spent, confirming alone is NOT enough: you must also pass confirmName set to its exact name and confirmChildren set to the child count from that read-back, which is what proves you are deleting the object you think you are. A campaign that is paused, empty and never ran deletes on plain confirm:true. To stop delivery WITHOUT deleting, use set_microsoft_ads_status(status:"Paused") instead. The result is READ BACK from Microsoft: it says "deleted" only when the object no longer resolves, "not confirmed" if it does, and "could not tell" if the check itself failed — repeat that verbatim rather than claiming success.',
|
|
12853
13162
|
inputSchema: {
|
|
12854
|
-
|
|
13163
|
+
...msAcct,
|
|
12855
13164
|
level: z.enum(['campaign', 'adGroup', 'ad', 'keyword']).optional().describe('what to delete — default campaign'),
|
|
12856
13165
|
campaignId: z.string().optional().describe('campaign id (level:"campaign"; also the parent for level:"adGroup" if you know it)'),
|
|
12857
13166
|
adGroupId: z.string().optional().describe('ad group id (level:"adGroup"; REQUIRED as the parent for level:"ad" and level:"keyword")'),
|
|
@@ -12881,7 +13190,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12881
13190
|
title: "List Microsoft Merchant Center stores",
|
|
12882
13191
|
description: "List the brand's MICROSOFT MERCHANT CENTER stores \u2014 the product-feed side of Microsoft Advertising, the exact twin of Google Merchant Center. THIS IS WHERE THE merchantId COMES FROM: every other Microsoft Merchant tool needs it and there is no other way to discover it (Content API's own Store resource is closed-beta only, so Hermoso reads the list from Campaign Management instead, which is open to everyone). Each row carries the store name, its URL, and whether it is active, has a catalog and has product ads enabled. Microsoft's own caveat, which the note repeats: those flags are set inside Merchant Center and are read-only here \u2014 an inactive store can still be referenced by a Shopping campaign. An account with NO store has no product feed at all, and a store cannot be created through the API. Read-only, free, 0 credits.",
|
|
12883
13192
|
inputSchema: {
|
|
12884
|
-
|
|
13193
|
+
...msAcct,
|
|
12885
13194
|
},
|
|
12886
13195
|
outputSchema: { ok: z.boolean().optional(), accountId: z.string().optional(), customerId: z.string().optional(), count: z.number().optional(), stores: z.array(z.any()).optional(), note: z.string().optional() },
|
|
12887
13196
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
@@ -12893,7 +13202,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12893
13202
|
title: "List Microsoft Merchant Center product offers",
|
|
12894
13203
|
description: "Read the product offers in a Microsoft Merchant Center store \u2014 the feed a Microsoft Shopping campaign serves from. Pass productId to fetch ONE offer, or omit it to page the store (limit up to 250, then pass the returned pageToken). MICROSOFT'S PRODUCT IDS ARE COMPOSED AND CASE SENSITIVE: the id is channel:contentLanguage:targetCountry:offerId (e.g. Online:en:US:Sku123), not the bare offerId, and a re-cased id addresses nothing \u2014 always use the id this tool returned. Read-only, free, 0 credits.",
|
|
12895
13204
|
inputSchema: {
|
|
12896
|
-
|
|
13205
|
+
...msAcct,
|
|
12897
13206
|
merchantId: z.string().optional().describe('Microsoft Merchant Center store id from list_microsoft_merchant_stores \u2014 omit when the account has exactly one store'),
|
|
12898
13207
|
productId: z.string().optional().describe('fully qualified id (channel:contentLanguage:targetCountry:offerId, e.g. Online:en:US:Sku123) to fetch ONE offer'),
|
|
12899
13208
|
limit: z.number().optional().describe('up to 250, default 25'),
|
|
@@ -12909,7 +13218,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12909
13218
|
title: "Add or update Microsoft Merchant Center product offers",
|
|
12910
13219
|
description: "Add or update product offers in a Microsoft Merchant Center store. THE ONE THING TO KNOW BEFORE CALLING IT: Microsoft has NO partial update \u2014 'because an update is an insert operation, you must include all fields of the offer in the request' \u2014 so a title-only 'update' CLEARS every other field. Always send the whole offer. Ten fields are required: availability, channel, condition, contentLanguage, imageLink, link, offerId, price, targetCountry, title; brand/gtin/mpn are strongly recommended and their absence is set as identifierExists:false for you. Pass product for one offer or products[] for a batch (Hermoso caps a batch at 300 \u2014 Microsoft publishes two contradictory ceilings for this and we take the lower). Pass dryRun:true to validate against Microsoft without writing anything; Microsoft returns no ids on a dry run, so none is claimed. Feed edits cannot spend and cannot start an auction, but an offer goes through editorial review \u2014 being STORED is not the same as being served. Everything is read back from Microsoft. 0 credits.",
|
|
12911
13220
|
inputSchema: {
|
|
12912
|
-
|
|
13221
|
+
...msAcct,
|
|
12913
13222
|
merchantId: z.string().optional().describe('Microsoft Merchant Center store id from list_microsoft_merchant_stores \u2014 omit when the account has exactly one store'),
|
|
12914
13223
|
product: z.record(z.any()).optional().describe('ONE whole offer \u2014 availability, channel, condition, contentLanguage, imageLink, link, offerId, price{value,currency}, targetCountry, title are required; brand/gtin/mpn strongly recommended'),
|
|
12915
13224
|
products: z.array(z.record(z.any())).optional().describe('several whole offers in one batch, maximum 300'),
|
|
@@ -12926,7 +13235,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12926
13235
|
title: "Delete a Microsoft Merchant Center product offer",
|
|
12927
13236
|
description: "Delete one product offer from a Microsoft Merchant Center store. Pass productId as the FULLY QUALIFIED id (channel:contentLanguage:targetCountry:offerId), not the offerId. Call without confirm first \u2014 nothing is deleted and you get Microsoft's own better advice back: deleted products can take UP TO 12 HOURS to stop delivering, so if the goal is to stop showing it today, upsert it with availability 'out of stock' instead. The outcome is READ BACK by re-fetching the offer, and says 'deleted', 'not confirmed' or 'could not tell' \u2014 repeat that verbatim rather than claiming success from the delete's own empty response. 0 credits.",
|
|
12928
13237
|
inputSchema: {
|
|
12929
|
-
|
|
13238
|
+
...msAcct,
|
|
12930
13239
|
merchantId: z.string().optional().describe('Microsoft Merchant Center store id from list_microsoft_merchant_stores \u2014 omit when the account has exactly one store'),
|
|
12931
13240
|
productId: z.string().describe('the FULLY QUALIFIED id (channel:contentLanguage:targetCountry:offerId), not the offerId'),
|
|
12932
13241
|
confirm: z.boolean().optional().describe('REQUIRED true \u2014 call without it first to see Microsoft\u2019s own advice about the 12-hour delivery tail'),
|
|
@@ -12941,7 +13250,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12941
13250
|
title: "Why Microsoft Merchant Center products are not serving",
|
|
12942
13251
|
description: "Why a Microsoft Merchant Center store's products are NOT serving \u2014 the approved / disapproved / expiring counts plus the per-offer disapprovals and warnings with Microsoft's own issue codes. This is the tool to reach for when a Microsoft Shopping campaign is live and showing nothing. TWO CAVEATS THAT LOOK LIKE FAILURES AND ARE NOT, both repeated in the note: Microsoft returns detail rows ONLY for offers that are Disapproved or in Warning, so an EMPTY detail list is the healthy state; and a status change takes up to two hours to reach the summary, so a fresh upload legitimately shows as nothing yet. Read-only, free, 0 credits.",
|
|
12943
13252
|
inputSchema: {
|
|
12944
|
-
|
|
13253
|
+
...msAcct,
|
|
12945
13254
|
merchantId: z.string().optional().describe('Microsoft Merchant Center store id from list_microsoft_merchant_stores \u2014 omit when the account has exactly one store'),
|
|
12946
13255
|
limit: z.number().optional().describe('up to 250, default 25'),
|
|
12947
13256
|
pageToken: z.string().optional(),
|
|
@@ -12956,7 +13265,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12956
13265
|
title: "List Microsoft Merchant Center catalogs",
|
|
12957
13266
|
description: "List the catalogs inside a Microsoft Merchant Center store. Catalogs logically group products, and a Shopping campaign's product scope points at them. Each row says whether publishing is ENABLED \u2014 products in a catalog with publishing off do not serve at all, which is a common and invisible reason a feed appears healthy and delivers nothing. Products go into the store's default catalog unless a call names catalogId. Read-only, free, 0 credits.",
|
|
12958
13267
|
inputSchema: {
|
|
12959
|
-
|
|
13268
|
+
...msAcct,
|
|
12960
13269
|
merchantId: z.string().optional().describe('Microsoft Merchant Center store id from list_microsoft_merchant_stores \u2014 omit when the account has exactly one store'),
|
|
12961
13270
|
},
|
|
12962
13271
|
outputSchema: { ok: z.boolean().optional(), merchantId: z.string().optional(), count: z.number().optional(), catalogs: z.array(z.any()).optional(), note: z.string().optional() },
|
|
@@ -12969,7 +13278,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12969
13278
|
title: "Create, update or delete a Microsoft Merchant Center catalog",
|
|
12970
13279
|
description: "Create, update or delete a catalog in a Microsoft Merchant Center store. Creating needs name + market + isPublishingEnabled; updating needs catalogId + name + isPublishingEnabled (market is not settable after creation). isPublishingEnabled is NEVER defaulted on \u2014 products serve only when it is true, so publishing a catalogue is always an explicit choice. Names must be unique within the store and are capped at 70 characters. Pass deleteIt:true with catalogId and confirm:true to remove one. Every outcome is READ BACK from Microsoft. 0 credits.",
|
|
12971
13280
|
inputSchema: {
|
|
12972
|
-
|
|
13281
|
+
...msAcct,
|
|
12973
13282
|
merchantId: z.string().optional().describe('Microsoft Merchant Center store id from list_microsoft_merchant_stores \u2014 omit when the account has exactly one store'),
|
|
12974
13283
|
catalogId: z.string().optional().describe('set to UPDATE or DELETE an existing catalog; omit to create a new one'),
|
|
12975
13284
|
name: z.string().optional().describe('unique within the store, maximum 70 characters'),
|
|
@@ -12988,7 +13297,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12988
13297
|
title: 'Why a Microsoft ad or keyword was disapproved',
|
|
12989
13298
|
description: "WHY a Microsoft Advertising ad or keyword was DISAPPROVED: Microsoft's editorial reason codes, the word or phrase it objected to (term), where it appeared (location), the countries it applies to, and any appeal status, read live from Microsoft. Use it whenever a read-back shows editorialStatus Disapproved, or the user asks why a Bing ad is not running. Pass adGroupId plus adIds OR keywordIds (Microsoft will not mix ads and keywords in one request). The reply links Microsoft's table of what each code means. Free.",
|
|
12990
13299
|
inputSchema: {
|
|
12991
|
-
|
|
13300
|
+
...msAcct,
|
|
12992
13301
|
adGroupId: z.string().describe('the ad group the ads or keywords sit in'),
|
|
12993
13302
|
adIds: z.array(z.string()).optional().describe('the ads to explain'),
|
|
12994
13303
|
keywordIds: z.array(z.string()).optional().describe('or the keywords to explain (not both)'),
|
|
@@ -12999,11 +13308,110 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12999
13308
|
const d = await apiPost('/api/microsoft-ads/editorial-reasons', a);
|
|
13000
13309
|
return ok(d.note, d);
|
|
13001
13310
|
}));
|
|
13311
|
+
// ---------- Microsoft Advertising: negatives, bidding, conversion tracking, audiences, image + video ads (2026-10-27) ----------
|
|
13312
|
+
const msNegShape = z.array(z.object({ text: z.string().describe('≤100 characters'), matchType: z.enum(['Exact', 'Phrase']).optional().describe('default Phrase — Microsoft has no broad negative') }));
|
|
13313
|
+
server.registerTool('list_microsoft_ads_negative_keywords', {
|
|
13314
|
+
title: 'List Microsoft Advertising negative keywords',
|
|
13315
|
+
description: 'The NEGATIVE keywords (excluded searches) on a Microsoft Advertising campaign (campaignId) or ad group (adGroupId) with their ids and match types; with no id, the account’s SHARED negative keyword lists; with listId, the keywords in one list. Read-only, free.',
|
|
13316
|
+
inputSchema: { ...msAcct, campaignId: z.string().optional(), adGroupId: z.string().optional(), listId: z.string().optional().describe('a shared negative keyword list') },
|
|
13317
|
+
outputSchema: { ok: z.boolean().optional(), keywords: z.array(z.any()).optional(), lists: z.array(z.any()).optional(), sharedLists: z.array(z.string()).optional(), note: z.string().optional() },
|
|
13318
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
13319
|
+
}, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/negative-keywords/list', a); return ok(d.note, d); }));
|
|
13320
|
+
server.registerTool('add_microsoft_ads_negative_keywords', {
|
|
13321
|
+
title: 'Exclude searches on Microsoft Advertising (negative keywords)',
|
|
13322
|
+
description: 'EXCLUDE searches from a Microsoft Advertising campaign or ad group with negative keywords — Exact or Phrase only (Microsoft has no broad negative; default Phrase, which blocks every search containing the phrase), up to 20,000 per campaign or ad group. Broad-match keywords without negatives are the classic way to waste money: “stop paying for free” is this tool. It can only narrow spend, so it is never confirm-gated. Read back. Free.',
|
|
13323
|
+
inputSchema: { ...msAcct, campaignId: z.string().optional(), adGroupId: z.string().optional().describe('pass this OR campaignId'), keywords: msNegShape },
|
|
13324
|
+
outputSchema: { ok: z.boolean().optional(), added: z.array(z.any()).optional(), total: z.number().optional(), note: z.string().optional() },
|
|
13325
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
13326
|
+
}, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/negative-keywords', a); return ok(d.note, d); }));
|
|
13327
|
+
server.registerTool('remove_microsoft_ads_negative_keywords', {
|
|
13328
|
+
title: 'Remove Microsoft Advertising negative keywords',
|
|
13329
|
+
description: 'Remove negative keywords (by id, from list_microsoft_ads_negative_keywords) from a Microsoft Advertising campaign or ad group. On a LIVE campaign that lets those searches trigger paid ads again, so it needs confirm:true after the user agrees. Read back. Free.',
|
|
13330
|
+
inputSchema: { ...msAcct, campaignId: z.string().optional(), adGroupId: z.string().optional(), negativeKeywordIds: z.array(z.string()), confirm: z.boolean().optional().describe('REQUIRED true on a LIVE campaign') },
|
|
13331
|
+
outputSchema: { ok: z.boolean().optional(), removed: z.array(z.string()).optional(), stillThere: z.array(z.string()).optional(), note: z.string().optional() },
|
|
13332
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
13333
|
+
}, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/negative-keywords/remove', a); return ok(d.note, d); }));
|
|
13334
|
+
server.registerTool('manage_microsoft_ads_negative_keyword_list', {
|
|
13335
|
+
title: 'Manage a shared Microsoft negative keyword list',
|
|
13336
|
+
description: 'A SHARED negative keyword list on Microsoft Advertising — one set of exclusions reused across up to 20 campaigns. action: create (name + keywords; campaignIds attaches it straight away), add (keywords), remove (negativeKeywordIds), attach / detach (campaignIds), delete (permanent, confirm:true). Removing, detaching or deleting on a LIVE campaign lets those searches trigger paid ads again, so it needs confirm:true. Read back. Free.',
|
|
13337
|
+
inputSchema: { ...msAcct, action: z.enum(['create', 'add', 'remove', 'attach', 'detach', 'delete']), listId: z.string().optional().describe('every action but create'), name: z.string().optional().describe('create: ≤255 characters'), keywords: msNegShape.optional(), negativeKeywordIds: z.array(z.string()).optional(), campaignIds: z.array(z.string()).optional(), confirm: z.boolean().optional() },
|
|
13338
|
+
outputSchema: { ok: z.boolean().optional(), listId: z.string().optional(), keywords: z.array(z.any()).optional(), campaigns: z.array(z.string()).optional(), deleted: z.boolean().optional(), note: z.string().optional() },
|
|
13339
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
13340
|
+
}, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/negative-keyword-list', a); return ok(d.note, d); }));
|
|
13341
|
+
server.registerTool('set_microsoft_ads_bidding', {
|
|
13342
|
+
title: 'Change a Microsoft Advertising bid strategy',
|
|
13343
|
+
description: 'Change a Microsoft Advertising campaign’s BID STRATEGY, read back: EnhancedCpc, MaxClicks (maxCpc cap), MaxConversions (optional targetCpa), MaxConversionValue (optional targetRoas), TargetImpressionShare (targetImpressionShare + targetAdPosition). Search takes any; Audience EnhancedCpc / MaxConversions; Shopping EnhancedCpc; Performance Max MaxConversions / MaxConversionValue. TargetCpa and TargetRoas are sent as MaxConversions / MaxConversionValue with the target, which is what Microsoft converts them to since 2025-08-04. Conversion strategies need a UET tag and a conversion goal recording conversions. On a LIVE campaign it needs confirm:true. Free.',
|
|
13344
|
+
inputSchema: { ...msAcct, campaignId: z.string(), biddingScheme: z.enum(['EnhancedCpc', 'MaxClicks', 'MaxConversions', 'MaxConversionValue', 'TargetImpressionShare', 'TargetCpa', 'TargetRoas']), maxCpc: z.number().optional(), targetCpa: z.number().optional(), targetRoas: z.number().optional().describe('ratio, e.g. 4 = 400%'), targetImpressionShare: z.number().optional().describe('1–100'), targetAdPosition: z.enum(['FirstPage', 'MainLine', 'MainLine1']).optional(), confirm: z.boolean().optional().describe('REQUIRED true on a LIVE campaign') },
|
|
13345
|
+
outputSchema: { ok: z.boolean().optional(), campaignId: z.string().optional(), requested: z.string().optional(), bidding: z.any().optional(), note: z.string().optional() },
|
|
13346
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
13347
|
+
}, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/bidding', a); return ok(d.note, d); }));
|
|
13348
|
+
server.registerTool('list_microsoft_ads_uet_tags', {
|
|
13349
|
+
title: 'List Microsoft Advertising UET tags',
|
|
13350
|
+
description: 'The UET tags (Microsoft’s site tracking pixel) on this Microsoft Advertising customer: id, name, status (Active / Inactive / Unverified) and each tag’s install snippet. Read-only, free.',
|
|
13351
|
+
inputSchema: { ...msAcct, tagIds: z.array(z.string()).optional() },
|
|
13352
|
+
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), tags: z.array(z.any()).optional(), note: z.string().optional() },
|
|
13353
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
13354
|
+
}, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/uet-tags', a); return ok(d.note, d); }));
|
|
13355
|
+
server.registerTool('create_microsoft_ads_uet_tag', {
|
|
13356
|
+
title: 'Create a Microsoft Advertising UET tag',
|
|
13357
|
+
description: 'Create a UET tag (Microsoft’s site tracking pixel) and get its install snippet, read back. Url / Event / Duration / PagesViewedPerVisit conversion goals, remarketing lists and conversion bidding all need one. Name ≤100 characters, unique per customer. Free.',
|
|
13358
|
+
inputSchema: { ...msAcct, name: z.string(), description: z.string().optional() },
|
|
13359
|
+
outputSchema: { ok: z.boolean().optional(), tagId: z.string().optional(), tag: z.any().optional(), note: z.string().optional() },
|
|
13360
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
13361
|
+
}, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/uet-tag', a); return ok(d.note, d); }));
|
|
13362
|
+
server.registerTool('create_microsoft_ads_conversion_goal', {
|
|
13363
|
+
title: 'Create a Microsoft Advertising conversion goal',
|
|
13364
|
+
description: 'Create a Microsoft Advertising CONVERSION GOAL, read back. type: Url (a thank-you page: urlExpression + urlOperator), Event (a UET custom event: eventAction / eventCategory / eventLabel / eventValue), OfflineConversion (conversions uploaded from a CRM with send_microsoft_ads_offline_conversions — no tag; Microsoft asks for 2 hours before the first upload), Duration (minimumDurationSeconds) or PagesViewedPerVisit (minimumPagesViewed). Every type but OfflineConversion needs tagId (create_microsoft_ads_uet_tag). category is required for Url / Event / Offline. Free.',
|
|
13365
|
+
inputSchema: {
|
|
13366
|
+
...msAcct, name: z.string().describe('≤100 characters, unique per customer'), type: z.enum(['Url', 'Event', 'OfflineConversion', 'Duration', 'PagesViewedPerVisit']), tagId: z.string().optional(),
|
|
13367
|
+
category: z.enum(['AddToCart', 'BeginCheckout', 'BookAppointment', 'Contact', 'Download', 'GetDirections', 'OutboundClick', 'PageView', 'Purchase', 'RequestQuote', 'Signup', 'SubmitLeadForm', 'Subscribe', 'Other']).optional(),
|
|
13368
|
+
urlExpression: z.string().optional(), urlOperator: z.enum(['Equals', 'BeginsWith', 'Contains', 'RegularExpression']).optional(),
|
|
13369
|
+
eventAction: z.string().optional(), eventCategory: z.string().optional(), eventLabel: z.string().optional(), eventValue: z.number().optional(),
|
|
13370
|
+
minimumDurationSeconds: z.number().optional(), minimumPagesViewed: z.number().optional(),
|
|
13371
|
+
revenueType: z.enum(['FixedValue', 'VariableValue', 'NoValue']).optional(), revenue: z.number().optional().describe('FixedValue: the value of one conversion; VariableValue: the default'), currencyCode: z.string().optional(),
|
|
13372
|
+
countType: z.enum(['All', 'Unique']).optional(), conversionWindowDays: z.number().optional().describe('1–90'), scope: z.enum(['Account', 'Customer']).optional(),
|
|
13373
|
+
},
|
|
13374
|
+
outputSchema: { ok: z.boolean().optional(), goalId: z.string().optional(), goal: z.any().optional(), note: z.string().optional() },
|
|
13375
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
13376
|
+
}, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/conversion-goal', a); return ok(d.note, d); }));
|
|
13377
|
+
server.registerTool('list_microsoft_ads_audience_targeting', {
|
|
13378
|
+
title: 'Audiences on a Microsoft campaign or ad group',
|
|
13379
|
+
description: 'The audiences (customer lists, remarketing, in-market, custom, similar, combined, product) targeted on — or EXCLUDED from — a Microsoft Advertising campaign or ad group, with each one’s bid adjustment and criterion id. Read-only, free.',
|
|
13380
|
+
inputSchema: { ...msAcct, campaignId: z.string().optional(), adGroupId: z.string().optional() },
|
|
13381
|
+
outputSchema: { ok: z.boolean().optional(), audiences: z.array(z.any()).optional(), unreadTypes: z.array(z.string()).optional(), note: z.string().optional() },
|
|
13382
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
13383
|
+
}, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/audience-targeting/list', a); return ok(d.note, d); }));
|
|
13384
|
+
server.registerTool('set_microsoft_ads_audience_targeting', {
|
|
13385
|
+
title: 'Target or exclude a Microsoft audience',
|
|
13386
|
+
description: 'Put an audience to work on a Microsoft Advertising campaign or ad group — e.g. the customer list from create_microsoft_ads_customer_list, to retarget it or EXCLUDE existing customers. action add (audienceId, exclude, bidAdjustment -90..900 %) or remove (criterionIds from list_microsoft_ads_audience_targeting). Microsoft allows biddable audiences at the campaign OR the ad-group level of one campaign, not both; exclusions at both; Performance Max can only exclude. Targeting or removing on a LIVE campaign needs confirm:true. Read back. Free.',
|
|
13387
|
+
inputSchema: { ...msAcct, campaignId: z.string().optional(), adGroupId: z.string().optional(), action: z.enum(['add', 'remove']).optional().describe('default add'), audienceId: z.string().optional().describe('list_microsoft_ads_audiences'), exclude: z.boolean().optional(), bidAdjustment: z.number().optional().describe('-90 to 900 (%); 0 = target without changing the bid'), criterionIds: z.array(z.string()).optional(), confirm: z.boolean().optional() },
|
|
13388
|
+
outputSchema: { ok: z.boolean().optional(), criterion: z.any().optional(), removed: z.array(z.string()).optional(), note: z.string().optional() },
|
|
13389
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
13390
|
+
}, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/audience-targeting', a); return ok(d.note, d); }));
|
|
13391
|
+
server.registerTool('create_microsoft_ads_image_ad', {
|
|
13392
|
+
title: 'Create a Microsoft image ad from a render (paused)',
|
|
13393
|
+
description: 'Turn a Hermoso render (or any picture) into a Microsoft Advertising IMAGE ad: a responsive ad on the Microsoft Audience Network (an Audience campaign) or a multimedia ad on Search. One imageUrl is centre-cropped to the 1.91:1 and 1:1 pictures Microsoft requires (or pass images{landscape, square, logo}), uploaded, and the ad is built PAUSED and read back with its pictures. businessName ≤25, 1–15 headlines ≤30, 1–5 longHeadlines ≤90, 1–5 descriptions ≤90. Search multimedia ads get a callToAction button (default LearnMore); Audience ads have none. videoIds attach videos from upload_microsoft_ads_video. Creating it Active in a live ad group needs confirm:true. Free.',
|
|
13394
|
+
inputSchema: {
|
|
13395
|
+
...msAcct, adGroupId: z.string(), businessName: z.string(), headlines: z.array(z.string()), longHeadlines: z.array(z.string()), descriptions: z.array(z.string()), finalUrls: z.array(z.string()),
|
|
13396
|
+
imageUrl: z.string().optional().describe('one picture, cut to both shapes for you'), images: z.object({ landscape: z.string().optional(), square: z.string().optional(), logo: z.string().optional() }).optional(), logoUrl: z.string().optional(),
|
|
13397
|
+
callToAction: z.string().optional().describe('Search multimedia ads only, e.g. LearnMore, ShopNow, SignUp'), videoIds: z.array(z.string()).optional(), videoSubType: z.enum(['VideoMedia16X9', 'VideoMedia5X4', 'VideoMedia1X1', 'VideoMedia4X5', 'VideoMedia9X16']).optional(),
|
|
13398
|
+
status: z.enum(['Active', 'Paused']).optional().describe('default Paused'), confirm: z.boolean().optional(),
|
|
13399
|
+
},
|
|
13400
|
+
outputSchema: { ok: z.boolean().optional(), adId: z.string().optional(), adGroupId: z.string().optional(), status: z.string().optional(), editorialStatus: z.string().optional(), cuts: z.array(z.any()).optional(), note: z.string().optional() },
|
|
13401
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
13402
|
+
}, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/image-ad', a); return ok(d.note, d); }));
|
|
13403
|
+
server.registerTool('upload_microsoft_ads_video', {
|
|
13404
|
+
title: 'Upload a video to Microsoft Advertising',
|
|
13405
|
+
description: 'Upload videos (a Hermoso render or any direct-download https file) to Microsoft Advertising, or check ones already uploaded with videoIds: Draft (still processing), Active, or Failed with Microsoft’s reason. Microsoft enables video per account and refuses clips under 6 seconds. Use the ids in create_microsoft_ads_image_ad(videoIds). Free.',
|
|
13406
|
+
inputSchema: { ...msAcct, urls: z.array(z.string()).optional(), videoIds: z.array(z.string()).optional(), description: z.string().optional() },
|
|
13407
|
+
outputSchema: { ok: z.boolean().optional(), videos: z.array(z.any()).optional(), note: z.string().optional() },
|
|
13408
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
13409
|
+
}, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/video', a); return ok(d.note, d); }));
|
|
13002
13410
|
server.registerTool('list_microsoft_ads_audiences', {
|
|
13003
13411
|
title: "List Microsoft Advertising audiences (Customer Match lists)",
|
|
13004
13412
|
description: "List a Microsoft Advertising account's audiences \u2014 by default the CUSTOMER LISTS (Customer Match), with each row's membership duration and its current Search and Audience-network sizes. TWO MICROSOFT CAVEATS THAT LOOK LIKE FAILURES AND ARE NOT: a size is nil or empty for UP TO 48 HOURS while a list is being built, and Microsoft will not use an audience of fewer than 300 people at all \u2014 so never report a fresh list's 0 as a failed upload. Pass types[] to ask for other audience types. Read-only, free, 0 credits.",
|
|
13005
13413
|
inputSchema: {
|
|
13006
|
-
|
|
13414
|
+
...msAcct,
|
|
13007
13415
|
audienceIds: z.array(z.string()).optional().describe('up to 100 specific ids \u2014 omit for all of the requested type'),
|
|
13008
13416
|
types: z.array(z.string()).optional().describe('audience types, default CustomerList'),
|
|
13009
13417
|
},
|
|
@@ -13017,7 +13425,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13017
13425
|
title: "Create an empty Microsoft Advertising Customer Match list",
|
|
13018
13426
|
description: "Create an EMPTY Customer Match list on a Microsoft Advertising account, ready for apply_microsoft_ads_customer_list to fill. membershipDuration is 1\u2013390 days, or -1 for no expiration (Microsoft's default is 30). scope 'Account' (the default) makes it usable only by that ad account; scope 'Customer' makes it usable by every account under the manager \u2014 Hermoso derives the right parent id for whichever you choose, because getting that pair wrong mis-parents the list silently. An audience is a definition: it cannot spend and it cannot serve on its own. Read back from Microsoft. 0 credits.",
|
|
13019
13427
|
inputSchema: {
|
|
13020
|
-
|
|
13428
|
+
...msAcct,
|
|
13021
13429
|
name: z.string().describe('maximum 128 characters'),
|
|
13022
13430
|
description: z.string().optional().describe('maximum 1024 characters'),
|
|
13023
13431
|
membershipDuration: z.number().optional().describe('1\u2013390 days, or -1 for no expiration. Microsoft\u2019s default is 30'),
|
|
@@ -13033,7 +13441,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13033
13441
|
title: "Upload customer emails into a Microsoft Customer Match list",
|
|
13034
13442
|
description: "Upload customer emails into a Microsoft Advertising Customer Match list so campaigns can target or exclude them. YOU PASS PLAIN EMAIL ADDRESSES \u2014 Hermoso normalizes and SHA-256 hashes them locally and sends ONLY the digests, so no plaintext ever leaves the server; values that arrive already hashed (64 hex characters) are passed through untouched. Microsoft's own normalization is applied exactly as published: trim, remove all dots from the user portion, remove any +alias, lowercase, then SHA-256. YOU MUST GET THE USER'S AGREEMENT FIRST: show them https://about.ads.microsoft.com/en-us/legal/customer-match-terms and only then call with acceptTerms:true, which Microsoft says 'eliminates the need to accept terms through the Microsoft Advertising UI' \u2014 without it nothing is uploaded and nothing is hashed. action is Add (additive, the default), Remove or Replace. Maximum 1000 items per call; page larger lists. Email is the only supported identifier \u2014 Microsoft publishes Phone and CRM as 'Not currently supported' and both are refused by name. DO NOT read a size of 0 afterwards as a failure: Microsoft leaves it empty for up to 48 hours while the audience builds. 0 credits.",
|
|
13035
13443
|
inputSchema: {
|
|
13036
|
-
|
|
13444
|
+
...msAcct,
|
|
13037
13445
|
audienceId: z.string().describe('the customer list to write into'),
|
|
13038
13446
|
emails: z.array(z.string()).optional().describe('PLAIN email addresses \u2014 normalized and SHA-256 hashed inside Hermoso, never sent as plaintext. Already-hashed 64-hex values pass through untouched. Maximum 1000 per call'),
|
|
13039
13447
|
action: z.enum(['Add', 'Remove', 'Replace']).optional().describe('Add (default) is additive across calls'),
|
|
@@ -13056,6 +13464,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13056
13464
|
const AL_CM_NOTE = ' Needs the Campaign Management key, which exists once AppLovin enables API access on the account; without it the answer says how to get it.';
|
|
13057
13465
|
const alIds = z.array(z.string()).optional();
|
|
13058
13466
|
const alOut = { ok: z.boolean().optional(), id: z.string().optional(), verified: z.boolean().optional(), note: z.string().optional() };
|
|
13467
|
+
// Per-creative-set brand override (the end card): AppLovin's BrandInfo object, all five fields required.
|
|
13468
|
+
const alBrand = {
|
|
13469
|
+
brandInfo: z.object({ title: z.string().describe('≤25 characters'), description: z.string().describe('≤75 characters'), buttonLabel: z.string().describe('CTA, ≤13 characters'), buttonColor: z.string().describe('"#RRGGBB"'), iconAssetId: z.string().describe('an uploaded IMG_ICON asset id') }).optional().describe('override the account brand on THIS creative set — how a brand A/B tests the end card'),
|
|
13470
|
+
useAccountBrandInfo: z.boolean().optional().describe('true = go back to the account-level brand info'),
|
|
13471
|
+
};
|
|
13059
13472
|
server.registerTool('list_applovin_ads_campaigns', {
|
|
13060
13473
|
title: 'List AppLovin Ads campaigns',
|
|
13061
13474
|
description: 'The brand’s AppLovin Ads campaigns: id, name, LIVE/PAUSED, daily budget, goal, countries, landing page. Rename or edit one with update_applovin_ads_campaign; pause or go live with set_applovin_ads_status. Read-only, free.' + AL_CM_NOTE,
|
|
@@ -13120,7 +13533,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13120
13533
|
server.registerTool('create_applovin_ads_creative_set', {
|
|
13121
13534
|
title: 'Create an AppLovin creative set (paused)',
|
|
13122
13535
|
description: 'Create an AppLovin creative set from uploaded asset ids, born PAUSED, optionally inside a campaign. It must hold EITHER an HTML playable OR a portrait image (IMG_INTER_P) plus a video; Hermoso checks that against AppLovin’s own asset types before creating it.' + AL_CM_NOTE,
|
|
13123
|
-
inputSchema: { name: z.string(), assetIds: z.array(z.string()).describe('AppLovin asset ids'), campaignId: z.string().optional(), countries: alIds, languages: alIds.describe('e.g. ENGLISH'), url: z.string().optional().describe('landing page for this set only (WEB)') },
|
|
13536
|
+
inputSchema: { name: z.string(), assetIds: z.array(z.string()).describe('AppLovin asset ids'), campaignId: z.string().optional(), countries: alIds, languages: alIds.describe('e.g. ENGLISH'), url: z.string().optional().describe('landing page for this set only (WEB)'), ...alBrand },
|
|
13124
13537
|
outputSchema: { ...alOut, creativeSet: z.any().optional(), pausedAfterCreate: z.boolean().optional() },
|
|
13125
13538
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
13126
13539
|
}, wrap(async (a) => { const d = await apiPost('/api/applovin-ads/creative-set', a); return ok(d.note, d); }));
|
|
@@ -13134,7 +13547,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13134
13547
|
server.registerTool('update_applovin_ads_creative_set', {
|
|
13135
13548
|
title: 'Edit an AppLovin creative set',
|
|
13136
13549
|
description: 'Edit an AppLovin creative set, read back: rename it, change its countries or languages (an empty list means all), its landing page (url; "" falls back to the campaign’s), replace its assets (assetIds, checked against AppLovin’s own asset types first), or add it to more campaigns (addToCampaignIds, up to 20). Adding a LIVE set to a LIVE campaign needs confirm:true. Status is set_applovin_ads_status.' + AL_CM_NOTE,
|
|
13137
|
-
inputSchema: { creativeSetId: z.string(), name: z.string().optional(), countries: alIds.describe('ISO codes; [] = all countries'), languages: alIds.describe('e.g. ENGLISH; [] = all'), url: z.string().optional().describe('landing page for this set; "" = the campaign’s'), assetIds: alIds.describe('REPLACES the set’s assets'), addToCampaignIds: alIds.describe('also run it in these campaigns (up to 20)'), confirm: z.boolean().optional().describe('required to add a LIVE set to a LIVE campaign') },
|
|
13550
|
+
inputSchema: { creativeSetId: z.string(), name: z.string().optional(), countries: alIds.describe('ISO codes; [] = all countries'), languages: alIds.describe('e.g. ENGLISH; [] = all'), url: z.string().optional().describe('landing page for this set; "" = the campaign’s'), assetIds: alIds.describe('REPLACES the set’s assets'), addToCampaignIds: alIds.describe('also run it in these campaigns (up to 20)'), ...alBrand, confirm: z.boolean().optional().describe('required to add a LIVE set to a LIVE campaign') },
|
|
13138
13551
|
outputSchema: { ...alOut, changed: z.array(z.string()).optional(), creativeSet: z.any().optional() },
|
|
13139
13552
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
13140
13553
|
}, wrap(async (a) => { const d = await apiPost('/api/applovin-ads/creative-set/update', a); return ok(d.note, d); }));
|
|
@@ -13145,6 +13558,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13145
13558
|
outputSchema: { ...alOut, sourceId: z.string().optional(), campaignId: z.string().optional(), version: z.string().optional(), creativeSet: z.any().optional(), pausedAfterCreate: z.boolean().optional() },
|
|
13146
13559
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
13147
13560
|
}, wrap(async (a) => { const d = await apiPost('/api/applovin-ads/creative-set/clone', a); return ok(d.note, d); }));
|
|
13561
|
+
server.registerTool('applovin_ads_change_history', {
|
|
13562
|
+
title: 'AppLovin campaign change history',
|
|
13563
|
+
description: 'Who changed what on the brand’s AppLovin campaigns, and when: creation, edits (field by field, before and after), status changes and ARCHIVING — including archived campaigns that list_applovin_ads_campaigns no longer returns. One campaign (campaignId) or the whole account; since/until must fall within the latest 30 UTC days and go together (neither = the last 7 days). Read-only, free.' + AL_CM_NOTE,
|
|
13564
|
+
inputSchema: { campaignId: z.string().optional().describe('ONE campaign id; omit for every campaign'), since: z.string().optional().describe('YYYY-MM-DD UTC, with until'), until: z.string().optional().describe('YYYY-MM-DD UTC, with since'), page: z.number().optional().describe('from 1'), size: z.number().optional().describe('1–100, default 100') },
|
|
13565
|
+
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), page: z.number().optional(), changes: z.array(z.any()).optional(), note: z.string().optional() },
|
|
13566
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
13567
|
+
}, wrap(async (a) => { const d = await apiPost('/api/applovin-ads/change-history', a); return ok(d.note, d); }));
|
|
13148
13568
|
server.registerTool('list_applovin_ads_catalogs', {
|
|
13149
13569
|
title: 'List AppLovin catalogs',
|
|
13150
13570
|
description: 'The AppLovin account’s product catalogs and their variant sets: the catalogId and variantSetId a dynamic-ads campaign (create_applovin_ads_campaign) needs. Website accounts. Read-only, free.' + AL_CM_NOTE,
|
|
@@ -13176,7 +13596,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13176
13596
|
since: z.string().optional().describe('YYYY-MM-DD, default 7 days ago'), until: z.string().optional().describe('YYYY-MM-DD, default today'),
|
|
13177
13597
|
columns: alIds.describe('e.g. day, campaign, campaign_id_external, creative_set, country, impressions, clicks, ctr, cost, sales, roas_7d, chka_usd_7d'),
|
|
13178
13598
|
attribution: z.enum(['click', 'click_and_view']).optional().describe('web only, default click'), filters: z.record(z.array(z.string())).optional().describe('column -> values'),
|
|
13179
|
-
cohort: z.boolean().optional().describe('attribute to impression day'), notZero: z.boolean().optional().describe('drop rows whose metrics are all zero'), limit: z.number().optional().describe('1–10000, default 1000'), offset: z.number().optional(),
|
|
13599
|
+
cohort: z.boolean().optional().describe('attribute to impression day'), notZero: z.boolean().optional().describe('drop rows whose metrics are all zero'), having: z.string().optional().describe('server-side numeric filter, e.g. "cost > 0 AND roas_7d < 1": report columns, numbers, > < >= <= = !=, AND, OR, parentheses (AppLovin warns it slows the report)'), limit: z.number().optional().describe('1–10000, default 1000'), offset: z.number().optional(),
|
|
13180
13600
|
},
|
|
13181
13601
|
outputSchema: { ok: z.boolean().optional(), type: z.string().optional(), since: z.string().optional(), until: z.string().optional(), columns: z.array(z.string()).optional(), count: z.number().optional(), rows: z.array(z.any()).optional(), totals: z.any().optional(), note: z.string().optional() },
|
|
13182
13602
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -13213,6 +13633,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13213
13633
|
targetUrl: z.string().describe('the landing page (must not block OAI-AdsBot / OAI-SearchBot in robots.txt)'),
|
|
13214
13634
|
imageUrl: z.string().optional().describe('public https URL of a STILL image — every text ad needs imageUrl OR fileId (ChatGPT Ads refuses an ad with no creative.file_id unless the campaign is a product feed; measured 2026-09-06); a video URL is refused, this channel has no video format'),
|
|
13215
13635
|
fileId: z.string().optional().describe('reuse an image ChatGPT Ads already holds: the fileId list_openai_ads_campaigns returns for an existing ad (list with adGroupId). Wins over imageUrl; use it to copy an ad exactly'),
|
|
13636
|
+
imageUrls: z.array(z.string()).optional().describe('4–6 public https URLs of SQUARE stills, in display order, for an ad with MULTIPLE images (one title, body and landing page shared by all). OpenAI enables this per ad account, and with automatic bidding only if the account is also enabled for that — otherwise use a fixed bid. Not with imageUrl/fileId or price'),
|
|
13637
|
+
fileIds: z.array(z.string()).optional().describe('the same as imageUrls with images already on the account (4–6 file ids); may be mixed with imageUrls, ids first. On an edit, omit both to keep the ad\u2019s current images; a new list replaces them all'),
|
|
13216
13638
|
price: z.string().optional().describe('optional price string shown on the card'),
|
|
13217
13639
|
});
|
|
13218
13640
|
server.registerTool('list_openai_ads_campaigns', {
|
|
@@ -13337,7 +13759,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13337
13759
|
title: 'Export a Microsoft Advertising account as a bulk file',
|
|
13338
13760
|
description: 'Export a Microsoft Advertising account as a BULK FILE — one spreadsheet holding whatever slice of the account you ask for. This is the ONLY way to read most of a Microsoft account: roughly 185 of Microsoft’s record types are reachable here and nowhere else in Hermoso — sitelink / callout / structured-snippet / image / logo / price / promotion ad extensions, labels, shared negative keyword lists, bid strategies, audiences and their associations, experiments, seasonality adjustments, conversion goals, asset groups and listing groups, feeds. Pass entities[] (default Campaigns, AdGroups, Ads, Keywords), campaignIds[] to narrow it, or since for a DELTA of what changed (Microsoft refuses anything older than 30 days). The reply carries the WHOLE FILE as a string in `file` — edit that and pass it straight to microsoft_ads_bulk_upload, which is the round trip this exists for. A bulk export is a SNAPSHOT of what Microsoft held when it ran, never a live view, so do not quote a status from it as current after a change. Microsoft builds it asynchronously: a large account can come back pending:true with a downloadRequestId — call again with that id rather than resubmitting, which would export the whole account a second time. Read-only, 0 credits.',
|
|
13339
13761
|
inputSchema: {
|
|
13340
|
-
|
|
13762
|
+
...msAcct,
|
|
13341
13763
|
entities: z.array(z.string()).optional().describe('Microsoft DownloadEntity names — Campaigns, AdGroups, Ads, Keywords (the default), plus SitelinkAdExtensions, CalloutAdExtensions, Labels, Budgets, BidStrategies, AssetGroups, ConversionGoal, Experiments, Audiences and ~180 more. An unknown one is refused BY NAME, because Microsoft answers it with a deserialization error that names no field and reads like an outage'),
|
|
13342
13764
|
campaignIds: z.array(z.string()).optional().describe('export only these campaigns (max 1000) instead of the whole account'),
|
|
13343
13765
|
since: z.string().optional().describe('ISO date — export only what CHANGED since then. Microsoft refuses a delta older than 30 days, and refuses one at all alongside QualityScoreData / BidSuggestionsData'),
|
|
@@ -13359,7 +13781,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13359
13781
|
title: 'Apply a Microsoft Advertising bulk file (spend-gated)',
|
|
13360
13782
|
description: 'Apply a Microsoft Advertising BULK FILE — edit hundreds of campaigns, ad groups, ads, keywords, extensions or labels in one request. Pass `file` as the CSV/TSV string; microsoft_ads_bulk_download returns exactly that shape in its `file` field. THE SPEND GATE, AND IT IS UNLIKE EVERY OTHER TOOL HERE. A bulk file carries a Status column, so one upload can turn campaigns ON — and confirm:true ALONE IS NOT ENOUGH for a file: it proves you meant to upload something, not that you know what THIS file does, and it reads identically for a file that changes two bids and one that activates fifty campaigns. So: call it ONCE with no confirmation. Nothing is uploaded, and you are told exactly how many objects the file would ACTIVATE (they can then spend real money) and DELETE (which Microsoft never undoes — a deleted object is never returned by any read again), each named with its row number, type, id and name. SHOW THAT LIST TO THE USER, get an explicit yes, then call again with confirm:true, confirmActivations:<n> and confirmDeletions:<n>. If the activations are incidental — a file you round-tripped out of a download and only meant to edit bids in — pass pauseInstead:true instead and those rows are written as Paused, so everything else lands with nothing armed; it is refused when the file also deletes, because pausing is a safe substitute for activating and there is none for deleting. Partial success is normal on this API: rows Microsoft rejects come back listed individually and the correct ones ARE applied, so never describe a CompletedWithErrors upload as a failure. 0 credits.',
|
|
13361
13783
|
inputSchema: {
|
|
13362
|
-
|
|
13784
|
+
...msAcct,
|
|
13363
13785
|
file: z.string().describe('the bulk file itself, CSV or TSV, whose first column header must be "Type"'),
|
|
13364
13786
|
fileType: z.enum(['Csv', 'Tsv']).optional().describe('inferred from the file when omitted'),
|
|
13365
13787
|
confirm: z.boolean().optional().describe('the user has SEEN what this file activates and deletes and said yes. Not sufficient on its own — the two echo counts below are also required'),
|
|
@@ -13381,7 +13803,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13381
13803
|
title: 'Microsoft Advertising conversion goals',
|
|
13382
13804
|
description: 'The conversion goals on a Microsoft Advertising account — what the account COUNTS as a conversion, and therefore what every conversion-optimised bid strategy on it is optimising toward. Flags which ones are OFFLINE goals: those are the only goals send_microsoft_ads_offline_conversions can post to, and its conversionName must match one of their names EXACTLY, including case and spacing. An account with no goals at all has no conversion tracking — say that plainly rather than reporting an empty list, because it means smart bidding there has nothing to aim at. Read-only, 0 credits.',
|
|
13383
13805
|
inputSchema: {
|
|
13384
|
-
|
|
13806
|
+
...msAcct,
|
|
13385
13807
|
goalIds: z.array(z.string()).optional().describe('specific goal ids — omit for all of them'),
|
|
13386
13808
|
goalType: z.string().optional().describe('filter to one Microsoft ConversionGoalType, e.g. OfflineConversion'),
|
|
13387
13809
|
},
|
|
@@ -13395,7 +13817,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13395
13817
|
title: 'Send offline + enhanced conversions to Microsoft',
|
|
13396
13818
|
description: 'Tell Microsoft Advertising about conversions that happened OFF the website — a phone sale, an in-store purchase, a lead that closed weeks after the click — so smart bidding on that account stops optimising against only what converted online. Each row needs conversionName (an existing OFFLINE conversion goal, matched by name — list_microsoft_ads_conversion_goals shows them), conversionTime (within the last 90 days AND after the click), and an identifier: either microsoftClickId (the msclkid Microsoft appends to the landing-page URL) or, for ENHANCED conversions, a plain email and/or phone. YOU PASS PLAIN VALUES AND THE HASHING HAPPENS SERVER-SIDE: emails are normalized and SHA-256’d to Microsoft’s own published five-step spec, which is verified against Microsoft’s own worked digests. A PHONE MUST ALREADY CARRY ITS COUNTRY CODE (+14255551234) and is refused otherwise — Microsoft’s spec says "normalize to E.164 format with country code" and stops there, and guessing which country a bare 4255551234 belongs to is not a mistake you would ever see: the upload succeeds and matches nobody. SAY THIS WHEN REPORTING: accepted is not the same as visible. Microsoft takes up to 6 hours to show offline conversion data and applies nothing at all in the first 2 hours after a goal is created, and a conversion only counts inside the goal’s own conversion window. Duplicates are silently ignored (the first wins), so re-sending a batch is safe and changes nothing. 0 credits.',
|
|
13397
13819
|
inputSchema: {
|
|
13398
|
-
|
|
13820
|
+
...msAcct,
|
|
13399
13821
|
conversionName: z.string().optional().describe('default goal name for every row that does not carry its own'),
|
|
13400
13822
|
conversions: z.array(z.object({
|
|
13401
13823
|
conversionName: z.string().optional().describe('must match an existing Microsoft OFFLINE conversion goal exactly'),
|
|
@@ -13419,7 +13841,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13419
13841
|
title: 'Who else is bidding on the same Microsoft auctions',
|
|
13420
13842
|
description: 'Who ELSE is competing for the same Microsoft Advertising auctions — the other advertisers’ domains with their impression share, overlap rate, position-above rate, top-of-page rate and outranking share. microsoft_ads_report tells you what the account did; this tells you who it did it against, which is the input to a bid or a creative decision rather than a report. Scope it with entityType Account (default, the whole account) / Campaign / AdGroup / Keyword plus entityIds (max 200), and optionally segment by up to three of Day, DayOfWeek, Device. SAY THIS WHEN REPORTING: every figure is a share of the auctions YOUR ads entered, never a measure of a competitor’s whole account or budget — a competitor missing from the list did not necessarily not bid. An empty result means there was no delivery to compare in that window, which is MISSING data; never report it as "nobody is bidding against you". Read-only, 0 credits.',
|
|
13421
13843
|
inputSchema: {
|
|
13422
|
-
|
|
13844
|
+
...msAcct,
|
|
13423
13845
|
entityType: z.enum(['Account', 'Campaign', 'AdGroup', 'Keyword']).optional().describe('default Account'),
|
|
13424
13846
|
entityIds: z.array(z.string()).optional().describe('REQUIRED for Campaign / AdGroup / Keyword, max 200. Microsoft only defaults to the whole account for entityType Account'),
|
|
13425
13847
|
since: z.string().optional().describe('YYYY-MM-DD, default 30 days back'),
|
|
@@ -13446,7 +13868,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13446
13868
|
title: 'What Microsoft suggests changing on the account',
|
|
13447
13869
|
description: 'What Microsoft Advertising ITSELF suggests changing on the account: budget raises, new keywords, broad-match widenings, conflicting negative keywords to remove, and responsive search ads or extra headlines Microsoft has written. Each row carries the campaign and ad group it touches plus Microsoft’s OWN estimate of what applying it would add to clicks, impressions, conversions and COST; a budget recommendation states the current and the recommended daily amount, so the money is a vendor fact rather than a guess. SAY THIS WHEN REPORTING: every Microsoft recommendation INCREASES what the account buys, that is what they are for, so none of them is a free win, and an empty list means Microsoft has no ADVICE (usually too little delivery to compute any), never that the account is optimal. Dismissed recommendations are hidden unless you ask for them. Read-only, 0 credits.',
|
|
13448
13870
|
inputSchema: {
|
|
13449
|
-
|
|
13871
|
+
...msAcct,
|
|
13450
13872
|
types: z.array(z.string()).optional().describe('narrow to some of ADD_BROAD_MATCH_KEYWORD, CAMPAIGN_BUDGET, KEYWORD, REMOVE_CONFLICTING_NEGATIVE_KEYWORD, RESPONSIVE_SEARCH_AD, RESPONSIVE_SEARCH_AD_ASSET, all six by default. An unknown name is refused BY NAME, because Microsoft answers one with a deserialization error that names no field'),
|
|
13451
13873
|
limit: z.number().optional().describe('Microsoft’s MaxCount, omit for everything'),
|
|
13452
13874
|
includeDismissed: z.boolean().optional().describe('also show recommendations already dismissed'),
|
|
@@ -13461,7 +13883,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13461
13883
|
title: 'Apply Microsoft recommendations (spend-gated)',
|
|
13462
13884
|
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.',
|
|
13463
13885
|
inputSchema: {
|
|
13464
|
-
|
|
13886
|
+
...msAcct,
|
|
13465
13887
|
recommendationIds: z.array(z.string()).describe('ids from microsoft_ads_recommendations, max 100. There is deliberately no "apply everything"'),
|
|
13466
13888
|
confirm: z.boolean().optional().describe('the user has SEEN the pre-flight list and said yes. Not sufficient on its own, both echo numbers below are also required'),
|
|
13467
13889
|
confirmCount: z.number().optional().describe('echo back how many recommendations this applies: the exact count the un-confirmed call reported'),
|
|
@@ -13475,7 +13897,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13475
13897
|
title: 'Dismiss Microsoft recommendations',
|
|
13476
13898
|
description: 'Dismiss Microsoft Advertising recommendations by id so Microsoft stops offering them. IT CANNOT SPEND and it changes nothing about what the account runs (it takes advice off the list), so it takes no confirmation at all, which is the point: reach for this rather than applying something just to clear it. Nothing is destroyed either: Microsoft still returns a dismissed recommendation from a read, flagged as dismissed, and microsoft_ads_recommendations will show it again with includeDismissed. Ids Microsoft has already withdrawn are reported and are not an error, they are already off the list, which is the outcome you wanted. 0 credits.',
|
|
13477
13899
|
inputSchema: {
|
|
13478
|
-
|
|
13900
|
+
...msAcct,
|
|
13479
13901
|
recommendationIds: z.array(z.string()).describe('ids from microsoft_ads_recommendations, max 100'),
|
|
13480
13902
|
},
|
|
13481
13903
|
outputSchema: { ok: z.boolean().optional(), accountId: z.string().optional(), dismissed: z.number().optional(), rejected: z.array(z.string()).optional(), missing: z.array(z.string()).optional(), note: z.string().optional() },
|
|
@@ -13485,7 +13907,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13485
13907
|
title: 'Is Microsoft changing this ad account by itself?',
|
|
13486
13908
|
description: 'Whether Microsoft is allowed to change this ad account BY ITSELF, unattended: the auto-apply opt-in status for each of the five recommendation types Microsoft supports. THIS IS THE READ THAT ANSWERS "is something changing my account when nobody is looking?", and it is worth running on any Microsoft account you inherit: an account can already be opted in without anyone at the brand having done it, and there is no other way to find out from here. A type Microsoft returns no status for is reported as UNKNOWN, never as off. TWO THINGS THAT CATCH PEOPLE OUT AND ARE STATED IN THE REPLY: these five type names are a DIFFERENT, case-sensitive vocabulary from the six microsoft_ads_recommendations uses (Microsoft cannot auto-apply a budget recommendation at all, there is no such type), and three of the five have no readable recommendation, so there is no way to preview what auto-apply would do for those. Read-only, 0 credits.',
|
|
13487
13909
|
inputSchema: {
|
|
13488
|
-
|
|
13910
|
+
...msAcct,
|
|
13489
13911
|
types: z.array(z.string()).optional().describe('narrow to some of ResponsiveSearchAdsOpportunity, MultiMediaAdsOpportunity, RemoveConflictingNegativeKeywordOpportunity, FixConversionGoalSettingsOpportunity, CreateConversionGoalOpportunity, all five by default'),
|
|
13490
13912
|
},
|
|
13491
13913
|
outputSchema: { ok: z.boolean().optional(), accountId: z.string().optional(), status: z.record(z.any()).optional(), on: z.array(z.string()).optional(), off: z.array(z.string()).optional(), unread: z.array(z.string()).optional(), note: z.string().optional() },
|
|
@@ -13495,7 +13917,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13495
13917
|
title: 'Let Microsoft change the account by itself, or stop it (per type)',
|
|
13496
13918
|
description: 'Switch Microsoft’s auto-apply on or off, per recommendation type. SWITCHING ONE ON IS UNLIKE EVERY OTHER WRITE IN HERMOSO: it is STANDING PERMISSION FOR MICROSOFT TO CHANGE THIS AD ACCOUNT ON ITS OWN, indefinitely, with nobody watching, publishing search and multimedia ads it wrote under the brand’s name, DELETING negative keywords so the account starts buying searches it currently blocks, or creating and changing conversion goals so every smart-bidding strategy on the account re-aims. It has no expiry and no schedule you can inspect, and nothing in Hermoso will ever turn it off for the user. AND THERE IS NOTHING TO PREVIEW: Microsoft decides later, on evidence that does not exist yet, so no count, no dry run and no list can tell you what it will do, which is exactly why a blast-radius echo would be meaningless here and each type has to be named instead. To turn any type ON: confirm:true AND confirmTypes listing every type being turned on, individually. There is no "all" and no default; naming four of five leaves the fifth refused BY NAME. TURNING IT OFF IS NEVER GATED, set those types to false and it goes straight through, because that only ever reduces what Microsoft does unattended. A type already in the state you asked for is not sent at all. THE ANSWER IS THE READ-BACK: the result re-reads Microsoft and says done / not done / could not tell PER TYPE, and you report that, never the fact that the call returned. 0 credits.',
|
|
13497
13919
|
inputSchema: {
|
|
13498
|
-
|
|
13920
|
+
...msAcct,
|
|
13499
13921
|
optIns: z.record(z.boolean()).describe('map of Microsoft auto-apply type name to true/false, e.g. {"RemoveConflictingNegativeKeywordOpportunity": false}. Case-sensitive keys: ResponsiveSearchAdsOpportunity, MultiMediaAdsOpportunity, RemoveConflictingNegativeKeywordOpportunity, FixConversionGoalSettingsOpportunity, CreateConversionGoalOpportunity'),
|
|
13500
13922
|
confirm: z.boolean().optional().describe('required to turn ANY type ON, after the user has been told what Microsoft will be allowed to do unattended. Never needed to turn one off'),
|
|
13501
13923
|
confirmTypes: z.array(z.string()).optional().describe('every type you are turning ON, named individually. That is the echo which proves you know WHICH permission you are granting, where a count could not'),
|
|
@@ -14009,6 +14431,19 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14009
14431
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
14010
14432
|
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/insights/search-terms', a); return ok(`${d.count} search-term row(s):\n${d.note}`, d); }));
|
|
14011
14433
|
|
|
14434
|
+
server.registerTool('apple_ads_rejection_reasons', {
|
|
14435
|
+
title: 'Why an Apple Ads creative or brand was rejected',
|
|
14436
|
+
description: 'WHY an Apple Ads creative, custom product page or Maps brand was REJECTED: Apple’s reason code, type and level (default or custom product page, or a locale of one), the asset or product page it applies to, the country and language, and Apple’s comment — read live. Use it whenever list_apple_ads_creatives shows systemStatus INVALID, or the user asks why an Apple ad is not running. Pass adamId for an app’s reasons, mapsBrandId for a Maps brand’s policy reasons, or rejectionReasonId for one reason. Read-only, free.',
|
|
14437
|
+
inputSchema: {
|
|
14438
|
+
adamId: z.string().optional().describe('the app’s App Store id — every reason Apple holds for it'),
|
|
14439
|
+
mapsBrandId: z.string().optional().describe('a Maps brand id (list_apple_ads_brands) — its policy rejection reasons. Not `brandId`: over the REST API that name means a Hermoso brand'),
|
|
14440
|
+
rejectionReasonId: z.string().optional().describe('one reason by id'),
|
|
14441
|
+
limit: z.number().optional(), offset: z.number().optional(),
|
|
14442
|
+
},
|
|
14443
|
+
outputSchema: { ok: z.boolean().optional(), kind: z.string().optional(), count: z.number().optional(), total: z.number().nullable().optional(), reasons: z.array(z.any()).optional(), note: z.string().optional() },
|
|
14444
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
14445
|
+
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/rejection-reasons', a); return ok(d.note, d); }));
|
|
14446
|
+
|
|
14012
14447
|
server.registerTool('apple_ads_change_history', {
|
|
14013
14448
|
title: 'Apple Ads change history',
|
|
14014
14449
|
description: 'Who changed what on this Apple Ads account, and when. Call it with start and/or end to get one row per TRANSACTION GROUP — a single user action that may have touched several fields, which is what changeCount counts — with the user, the entity and the event type (CREATE / UPDATE / DELETE). Then pass a row’s detailId back as detailId to get the field-by-field before and after. THE TIME BOUND IS MANDATORY: Apple answers 400 without one, so pass start (meaning "since then"), end ("up to then"), or both. Narrow with campaignId, adGroupId, entityType, eventType or userId. Useful for "why did this campaign’s budget change overnight" and for reconstructing what an agent did on the account. On a CREATE event the old values are empty by design; a DELETE is implemented as a record update so its old values ARE populated. Read-only, free.',
|
|
@@ -14935,9 +15370,19 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14935
15370
|
inputSchema: {}, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
14936
15371
|
}, wrap(async () => oaiNote(await apiGet('/api/openai-ads/lead-sync'))));
|
|
14937
15372
|
server.registerTool('set_openai_ads_lead_sync', {
|
|
14938
|
-
title: 'Provision ChatGPT Ads lead delivery (webhook)', description: 'Provision lead delivery for this ChatGPT Ads account to
|
|
14939
|
-
inputSchema: { destinationUrl: z.string(), signingSecret: z.string().optional() }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
15373
|
+
title: 'Provision ChatGPT Ads lead delivery (webhook)', description: 'Provision lead delivery for this ChatGPT Ads account. OMIT destinationUrl to deliver to HERMOSO: leads land in Hermoso’s own signed receiver, list_openai_ads_leads reads them and sync_leads_to_hubspot pushes them into HubSpot (no secret to store). Or pass your own https destinationUrl (a managed webhook), optionally with your own signingSecret; then THE SIGNING SECRET IS RETURNED ONCE — every lead webhook is signed with it. Needs ChatGPT Ads connected.',
|
|
15374
|
+
inputSchema: { destinationUrl: z.string().optional().describe('omit to deliver the leads to Hermoso'), signingSecret: z.string().optional() }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
14940
15375
|
}, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/lead-sync', a))));
|
|
15376
|
+
server.registerTool('list_openai_ads_leads', {
|
|
15377
|
+
title: 'ChatGPT Ads leads received',
|
|
15378
|
+
description: "The ChatGPT Ads leads delivered to Hermoso for this brand, newest first: each lead's answers keyed by the form's question labels (and by email / phone / full_name), the form id, the campaign / ad group / ad ids, submittedAt. ChatGPT Ads has no endpoint that LISTS leads — they exist here only after set_openai_ads_lead_sync was called WITHOUT a destinationUrl, and only from then on. Synthetic test submissions are hidden unless includeSynthetic. These are real people's contact details. Read-only, free.",
|
|
15379
|
+
inputSchema: { formId: z.string().optional(), since: z.string().optional().describe('ISO date'), limit: z.number().optional().describe('default 100, max 1000'), includeSynthetic: z.boolean().optional() },
|
|
15380
|
+
outputSchema: { count: z.number().optional(), leads: z.array(z.any()).optional(), subscriptions: z.array(z.any()).optional(), note: z.string().optional() },
|
|
15381
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
15382
|
+
}, wrap(async (a) => {
|
|
15383
|
+
const d = await apiGet('/api/openai-ads/leads', a);
|
|
15384
|
+
return ok(`${d.note}${(d.leads || []).length ? '\n' + d.leads.slice(0, 40).map(l => `· ${Object.entries(l.answers || {}).map(([k, v]) => `${k}: ${v}`).join(', ')} — form ${l.formId || '?'}, ${l.submittedAt || l.receivedAt}${l.synthetic ? ' [test]' : ''}`).join('\n') : ''}`, d);
|
|
15385
|
+
}));
|
|
14941
15386
|
server.registerTool('delete_openai_ads_lead_sync', {
|
|
14942
15387
|
title: 'Delete a ChatGPT Ads lead-sync subscription', description: 'Delete the lead-sync subscription: every lead form on the account stops delivering leads. confirm:true required.',
|
|
14943
15388
|
inputSchema: { subscriptionId: z.string(), confirm: z.boolean().optional() }, annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
@@ -19118,7 +19563,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
19118
19563
|
}));
|
|
19119
19564
|
server.registerTool('create_snapchat_ads_lead_form', {
|
|
19120
19565
|
title: 'Create a Snapchat lead form',
|
|
19121
|
-
description: "Create the in-app form a Snapchat lead ad opens. Snapchat's rules are refused BY NAME, never repaired: FIRST_NAME and LAST_NAME are required, plus at least one of EMAIL or PHONE_NUMBER; ADDRESS and POSTAL_CODE cannot both be on one form; title max 25 characters, description max 180, legal title 35 / description 80, at most 2 consent checkboxes, only one question may use preferred options. A form spends nothing; it is read back from Snapchat. Snapchat has NO endpoint that lists leads afterwards — to receive them, turn on delivery with manage_snapchat_ads_lead_webhook(action:'subscribe'). Free.",
|
|
19566
|
+
description: "Create the in-app form a Snapchat lead ad opens. Snapchat's rules are refused BY NAME, never repaired: FIRST_NAME and LAST_NAME are required, plus at least one of EMAIL or PHONE_NUMBER; ADDRESS and POSTAL_CODE cannot both be on one form; title max 25 characters, description max 180, legal title 35 / description 80, at most 2 consent checkboxes, only one question may use preferred options, and an end page (endPage) is required. A form spends nothing; it is read back from Snapchat. Snapchat has NO endpoint that lists leads afterwards — to receive them, turn on delivery with manage_snapchat_ads_lead_webhook(action:'subscribe'). Free.",
|
|
19122
19567
|
inputSchema: {
|
|
19123
19568
|
adAccountId: z.string().optional(),
|
|
19124
19569
|
name: z.string().describe('the form name in Ads Manager'),
|
|
@@ -19128,7 +19573,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
19128
19573
|
privacyPolicyUrl: z.string().optional().describe('linked on the confirmation screen'),
|
|
19129
19574
|
legal: z.object({ title: z.string(), description: z.string(), consents: z.array(z.union([z.string(), z.object({ text: z.string(), required: z.boolean().optional() })])).optional() }).optional().describe('legal disclosure: title max 35, description max 80, up to 2 consent checkboxes'),
|
|
19130
19575
|
bannerMediaId: z.string().optional().describe('an IMAGE media id already on the ad account; 75:23, 750x230 to 1875x575, 5MB'),
|
|
19131
|
-
endPage: z.object({ callToAction: z.enum(['VIEW_WEBSITE', 'BOOK_NOW', 'LEARN_MORE', 'DONATE', 'SPECIAL_OFFER', 'SCHEDULE_NOW', 'BUY_TICKETS', 'TEST_DRIVE', 'APPLY_NOW', 'GET_COUPON', 'CLAIM_SAMPLE', 'FREE_TRIAL']),
|
|
19576
|
+
endPage: z.object({ url: z.string().describe('where its button goes'), callToAction: z.enum(['VIEW_WEBSITE', 'BOOK_NOW', 'LEARN_MORE', 'DONATE', 'SPECIAL_OFFER', 'SCHEDULE_NOW', 'BUY_TICKETS', 'TEST_DRIVE', 'APPLY_NOW', 'GET_COUPON', 'CLAIM_SAMPLE', 'FREE_TRIAL']).optional().describe('default VIEW_WEBSITE — the end page has its own list, not the creative one'), headline: z.string().optional().describe('defaults to the form title'), description: z.string().optional().describe('defaults to the form description') }).describe('REQUIRED by Snapchat (E25022): the screen shown after someone submits'),
|
|
19132
19577
|
},
|
|
19133
19578
|
outputSchema: { adAccountId: z.string().optional(), formId: z.string().optional(), form: z.any().optional(), verified: z.boolean().optional(), note: z.string().optional() },
|
|
19134
19579
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
@@ -20193,6 +20638,92 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
20193
20638
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20194
20639
|
}, wrap(async (a) => { const d = await apiPost('/api/notion/schedule', a); return ok(d.note, d); }));
|
|
20195
20640
|
|
|
20641
|
+
// DATABASE ROWS (2026-10-27): read a Notion database the team keeps (a content calendar) and change one row's
|
|
20642
|
+
// properties (mark it Posted, put the live link in). Rules + citations: lib/notion.mjs. Free.
|
|
20643
|
+
server.group('files');
|
|
20644
|
+
server.registerTool('query_notion_database', {
|
|
20645
|
+
title: 'Read a Notion database',
|
|
20646
|
+
description: "Read the ROWS of a Notion database the user shared with Hermoso — e.g. the team's content calendar: each row's title, id, link and every property as a plain value (status, date, channels, caption, links), plus the database's columns with their types and options. Filter with match {\"Status\": \"Scheduled\"} (typed from the database's own columns) or a raw Notion filter object; sort with sortBy (a column, or created_time / last_edited_time) and direction. Up to 100 rows per call, cursor for more; the answer says when Notion stopped at its 10,000-result limit. Use it to import a calendar and schedule its posts, or to find the row update_notion_row changes. Read-only, free. Needs Notion connected (Settings > Connectors > Notion).",
|
|
20647
|
+
inputSchema: { databaseId: z.string().optional().describe('database id or link (list_notion_pages type:"database")'), dataSourceId: z.string().optional(), match: z.record(z.any()).optional().describe('{"Column": value} — equality for status/select/checkbox/number/date, contains for text/multi-select'), filter: z.record(z.any()).optional().describe('a raw Notion filter object'), sortBy: z.string().optional(), direction: z.enum(['ascending', 'descending']).optional(), limit: z.number().optional().describe('1-100, default 50'), cursor: z.string().optional(), archived: z.boolean().optional().describe('read archived rows instead') },
|
|
20648
|
+
outputSchema: { ok: z.boolean().optional(), database: z.string().optional(), dataSourceId: z.string().optional(), schema: z.any().optional(), count: z.number().optional(), rows: z.array(z.any()).optional(), nextCursor: z.string().nullable().optional(), incomplete: z.boolean().optional(), note: z.string().optional() },
|
|
20649
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
20650
|
+
}, wrap(async (a) => {
|
|
20651
|
+
const d = await apiPost('/api/notion/query', a);
|
|
20652
|
+
const cols = Object.entries(d.schema || {}).map(([k, v]) => `${k} (${v.type}${v.options ? `: ${v.options.join(' / ')}` : ''})`).join(', ');
|
|
20653
|
+
const show = (v) => (Array.isArray(v) ? v.join(', ') : v);
|
|
20654
|
+
return ok(`${d.note}\nColumns: ${cols}${d.count ? `\n${d.rows.map((r) => `- ${r.title} [${r.id}] ${Object.entries(r.properties).filter(([, v]) => v !== null && v !== '' && !(Array.isArray(v) && !v.length)).map(([k, v]) => `${k}: ${show(v)}`).join(' | ')}`).join('\n')}` : ''}${d.nextCursor ? `\n(more: cursor=${d.nextCursor})` : ''}`, d);
|
|
20655
|
+
}));
|
|
20656
|
+
server.registerTool('update_notion_row', {
|
|
20657
|
+
title: 'Update a Notion database row',
|
|
20658
|
+
description: "Change properties on ONE row of a Notion database (pageId from query_notion_database) — e.g. mark a calendar row Status \"Posted\" and put the live post link in its URL column. properties is {\"Column\": value} by column name: text, number, checkbox, select (an existing option or a new one), status (must be an existing option), multi-select (array; replaces the whole value), date (ISO, or {start, end}), url, email, phone; null clears. Validated against the database's own columns before anything is sent and refused by name otherwise, never coerced; columns you do not name keep their values. Read back from Notion. trash:true moves the row to Notion's trash (restorable; returns a preview first and needs confirm:true), trash:false restores it. Free.",
|
|
20659
|
+
inputSchema: { pageId: z.string().describe('the row id or link'), properties: z.record(z.any()).optional().describe('{"Column": value}'), trash: z.boolean().optional(), confirm: z.boolean().optional() },
|
|
20660
|
+
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), title: z.string().optional(), url: z.string().optional(), confirmed: z.boolean().optional(), dryRun: z.boolean().optional(), changed: z.array(z.string()).optional(), row: z.any().optional(), inTrash: z.boolean().optional(), note: z.string().optional() },
|
|
20661
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
20662
|
+
}, wrap(async (a) => { const d = await apiPost('/api/notion/row', { ...a, confirm: a.confirm === true }); return ok(d.note, d); }));
|
|
20663
|
+
|
|
20664
|
+
// ---------- Canva (2026-10-27): the user's OWN Canva, the same /api/canva routes the Studio tools call ----------
|
|
20665
|
+
// Renders go in (an upload, and for an image a new editable design); finished designs come back out into the Library.
|
|
20666
|
+
// Free (no model call). Nothing here deletes or overwrites anything in Canva.
|
|
20667
|
+
server.group('files');
|
|
20668
|
+
server.registerTool('send_to_canva', {
|
|
20669
|
+
title: 'Send to Canva',
|
|
20670
|
+
description: "Send Hermoso renders to the user's Canva: each is uploaded to their Canva Uploads and, for an IMAGE, opened as a new editable Canva design (Canva makes designs from images only; a video lands in Uploads to drag into any design). Pass Library ids (L0… from list_library), Hermoso render links, or any public https image/video link (upload_file turns a local file into one); optional title, folderId (list_canva_folder / create_canva_folder), asDesign:false for the upload only. Answers with each design's edit link. Free. Needs Canva connected (Settings > Connectors > Canva).",
|
|
20671
|
+
inputSchema: { ids: z.array(z.string()).optional().describe('Library ids, e.g. L0'), urls: z.array(z.string()).optional().describe('render links or any public https image/video link'), title: z.string().optional(), folderId: z.string().optional(), asDesign: z.boolean().optional().describe('default true: an image becomes a new design') },
|
|
20672
|
+
outputSchema: { ok: z.boolean().optional(), sent: z.number().optional(), failed: z.number().optional(), results: z.array(z.any()).optional(), note: z.string().optional() },
|
|
20673
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20674
|
+
}, wrap(async (a) => { const d = await apiPost('/api/canva/send', a); return ok(d.note, d); }));
|
|
20675
|
+
server.registerTool('list_canva_designs', {
|
|
20676
|
+
title: 'List Canva designs',
|
|
20677
|
+
description: "The user's Canva designs, newest first or by search: title, page count, edit link, view link and design id. query matches titles and content; ownership any|owned|shared; sort relevance|modified_descending|modified_ascending|title_ascending|title_descending. Read-only, free.",
|
|
20678
|
+
inputSchema: { query: z.string().optional(), ownership: z.enum(['any', 'owned', 'shared']).optional(), sort: z.enum(['relevance', 'modified_descending', 'modified_ascending', 'title_descending', 'title_ascending']).optional(), limit: z.number().optional().describe('1-100, default 25'), continuation: z.string().optional() },
|
|
20679
|
+
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), designs: z.array(z.any()).optional(), continuation: z.string().nullable().optional(), note: z.string().optional() },
|
|
20680
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
20681
|
+
}, wrap(async (a) => {
|
|
20682
|
+
const d = await apiGet('/api/canva/designs', a);
|
|
20683
|
+
return ok(d.count ? `${d.note}\n${(d.designs || []).map((x) => `• ${x.title} (id ${x.id}${x.pages ? `, ${x.pages} page${x.pages === 1 ? '' : 's'}` : ''}) ${x.editUrl}`).join('\n')}${d.continuation ? `\n(more: continuation=${d.continuation})` : ''}` : d.note, d);
|
|
20684
|
+
}));
|
|
20685
|
+
server.registerTool('export_canva_design', {
|
|
20686
|
+
title: 'Export a Canva design',
|
|
20687
|
+
description: "Export one of the user's Canva designs (designId or its Canva link) and save the files into the Hermoso Library, ready to post, schedule or use as ad creative. format png (default), jpg, pdf, mp4, gif or pptx; pages [1,3] for some pages; quality = 1-100 for JPG or e.g. vertical_1080p for MP4 (defaults follow the design's shape); proQuality:true for Canva's pro quality (fails on unlicensed premium elements). Free.",
|
|
20688
|
+
inputSchema: { designId: z.string().describe('design id or Canva link'), format: z.enum(['png', 'jpg', 'pdf', 'mp4', 'gif', 'pptx']).optional(), pages: z.array(z.number()).optional(), quality: z.union([z.string(), z.number()]).optional(), width: z.number().optional(), height: z.number().optional(), transparentBackground: z.boolean().optional(), proQuality: z.boolean().optional() },
|
|
20689
|
+
outputSchema: { ok: z.boolean().optional(), pending: z.boolean().optional(), designId: z.string().optional(), title: z.string().optional(), format: z.string().optional(), files: z.array(z.any()).optional(), landed: z.boolean().optional(), note: z.string().optional() },
|
|
20690
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20691
|
+
}, wrap(async (a) => { const d = await apiPost('/api/canva/export', a); return ok(d.note, d); }));
|
|
20692
|
+
server.registerTool('list_canva_brand_templates', {
|
|
20693
|
+
title: 'List Canva brand templates',
|
|
20694
|
+
description: "The user's Canva brand templates (needs Canva Pro, Teams or Enterprise). With brandTemplateId it names that template's autofill fields and their types instead. autofillOnly:true lists only templates with fields. Read-only, free.",
|
|
20695
|
+
inputSchema: { query: z.string().optional(), brandTemplateId: z.string().optional(), autofillOnly: z.boolean().optional(), limit: z.number().optional(), continuation: z.string().optional() },
|
|
20696
|
+
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), templates: z.array(z.any()).optional(), template: z.any().optional(), fields: z.array(z.any()).optional(), note: z.string().optional() },
|
|
20697
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
20698
|
+
}, wrap(async (a) => {
|
|
20699
|
+
const d = await apiGet('/api/canva/brand-templates', a);
|
|
20700
|
+
return ok(d.templates ? `${d.note}\n${d.templates.map((t) => `• ${t.title} (id ${t.id})`).join('\n')}` : d.note, d);
|
|
20701
|
+
}));
|
|
20702
|
+
server.registerTool('autofill_canva_template', {
|
|
20703
|
+
title: 'Autofill a Canva template',
|
|
20704
|
+
description: "Fill a Canva brand template's fields into a NEW Canva design: data = { fieldName: text, or an image/video as a Library id or link }. Media is uploaded to Canva first. Needs Canva Pro, Teams or Enterprise (Canva's autofill). Answers with the new design's edit link. Free.",
|
|
20705
|
+
inputSchema: { brandTemplateId: z.string(), data: z.record(z.any()).describe('{ field: value }'), title: z.string().optional() },
|
|
20706
|
+
outputSchema: { ok: z.boolean().optional(), pending: z.boolean().optional(), design: z.any().optional(), note: z.string().optional() },
|
|
20707
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20708
|
+
}, wrap(async (a) => { const d = await apiPost('/api/canva/autofill', a); return ok(d.note, d); }));
|
|
20709
|
+
server.registerTool('list_canva_folder', {
|
|
20710
|
+
title: 'List a Canva folder',
|
|
20711
|
+
description: "What is in one of the user's Canva folders: folderId root (their Projects, default), uploads, or a folder id. itemTypes design, folder, image, brand_template. Canva does not list videos here. Read-only, free.",
|
|
20712
|
+
inputSchema: { folderId: z.string().optional(), itemTypes: z.array(z.enum(['design', 'folder', 'image', 'brand_template'])).optional(), limit: z.number().optional(), continuation: z.string().optional() },
|
|
20713
|
+
outputSchema: { ok: z.boolean().optional(), folderId: z.string().optional(), count: z.number().optional(), items: z.array(z.any()).optional(), continuation: z.string().nullable().optional(), note: z.string().optional() },
|
|
20714
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
20715
|
+
}, wrap(async (a) => {
|
|
20716
|
+
const d = await apiGet('/api/canva/folder', { ...a, ...(Array.isArray(a.itemTypes) ? { itemTypes: a.itemTypes.join(',') } : {}) });
|
|
20717
|
+
return ok(d.count ? `${d.note}\n${(d.items || []).map((x) => `• [${x.type}] ${x.title} (id ${x.id})${x.editUrl ? ` ${x.editUrl}` : ''}`).join('\n')}` : d.note, d);
|
|
20718
|
+
}));
|
|
20719
|
+
server.registerTool('create_canva_folder', {
|
|
20720
|
+
title: 'Create a Canva folder',
|
|
20721
|
+
description: "Create a folder in the user's Canva (in their Projects by default, or inside parentFolderId). send_to_canva's folderId then files renders into it. Free.",
|
|
20722
|
+
inputSchema: { name: z.string(), parentFolderId: z.string().optional().describe('root (default), uploads or a folder id') },
|
|
20723
|
+
outputSchema: { ok: z.boolean().optional(), folder: z.any().optional(), note: z.string().optional() },
|
|
20724
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20725
|
+
}, wrap(async (a) => { const d = await apiPost('/api/canva/folders', a); return ok(d.note, d); }));
|
|
20726
|
+
|
|
20196
20727
|
// ---------- Google Docs: export copy / brief / report as a doc the app creates (drive.file scope) ----------
|
|
20197
20728
|
server.group('files');
|
|
20198
20729
|
server.registerTool('create_doc', {
|
|
@@ -20428,6 +20959,25 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
20428
20959
|
const d = await apiPost('/api/onedrive/file/delete', a);
|
|
20429
20960
|
return ok(`File ${a.fileId} moved to the OneDrive recycle bin.`, d);
|
|
20430
20961
|
}));
|
|
20962
|
+
// ONEDRIVE SHARING (2026-10-27): hand a render to a client or teammate as a link. Rules: server.js onedriveShare.
|
|
20963
|
+
server.registerTool('share_onedrive_file', {
|
|
20964
|
+
title: 'Share a OneDrive file as a link',
|
|
20965
|
+
description: "Create a SHARING LINK to a OneDrive file or folder (fileId from list_onedrive_files): type view (read-only, default), edit, or embed (an <iframe> link, personal OneDrive only); scope anonymous (anyone with the link, default), organization (work or school OneDrive only) or users (only people you invite); optional expiresAt (date or ISO) and password (on a personal OneDrive both need a Microsoft 365 subscription; the refusal says so). An ANONYMOUS EDIT link lets anyone holding it change or delete the file, so it needs confirm:true after you show the user that; every other link is created straight away. If Hermoso already made the same kind of link, OneDrive returns that one and the reply says it was reused. Read back from the item's permission list. 0 credits. Needs OneDrive connected.",
|
|
20966
|
+
inputSchema: { fileId: z.string(), type: z.enum(['view', 'edit', 'embed']).optional(), scope: z.enum(['anonymous', 'organization', 'users']).optional(), expiresAt: z.string().optional().describe('e.g. 2026-11-30 or 2026-11-30T17:00:00Z'), password: z.string().optional().describe('personal OneDrive with Microsoft 365'), confirm: z.boolean().optional().describe('required for an anonymous edit link') },
|
|
20967
|
+
outputSchema: { ok: z.boolean().optional(), dryRun: z.boolean().optional(), fileId: z.string().optional(), name: z.string().optional(), link: z.any().optional(), reused: z.boolean().optional(), verified: z.boolean().optional(), note: z.string().optional() },
|
|
20968
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
20969
|
+
}, wrap(async (a) => { const d = await apiPost('/api/onedrive/share', a); return ok(d.note, d); }));
|
|
20970
|
+
server.registerTool('onedrive_file_permissions', {
|
|
20971
|
+
title: 'Who can open a OneDrive file',
|
|
20972
|
+
description: "action list (default): every sharing link and person with access to a OneDrive file or folder: link URL, view or edit, scope, expiry, password, whether it is inherited from a parent folder. action remove: revoke one (permissionId from the list); anyone relying on it loses access at once and the link cannot be restored, so WITHOUT confirm:true nothing is removed and you get what would go. An inherited permission is removed on the parent folder, and the owner's own access cannot be removed. Removal is verified by reading the list back. 0 credits. Needs OneDrive connected.",
|
|
20973
|
+
inputSchema: { fileId: z.string(), action: z.enum(['list', 'remove']).optional(), permissionId: z.string().optional().describe('for remove'), confirm: z.boolean().optional().describe('required true for remove') },
|
|
20974
|
+
outputSchema: { ok: z.boolean().optional(), dryRun: z.boolean().optional(), fileId: z.string().optional(), permissions: z.array(z.any()).optional(), count: z.number().optional(), removed: z.boolean().optional(), permission: z.any().optional(), note: z.string().optional() },
|
|
20975
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
20976
|
+
}, wrap(async (a) => {
|
|
20977
|
+
const d = await apiPost('/api/onedrive/permissions', a);
|
|
20978
|
+
if (d.permissions && !d.dryRun && d.removed === undefined) return ok(d.count ? `${d.note}\n${d.permissions.map(p => `- ${p.id}: ${p.url ? `${p.type} link ${p.url}` : (p.owner ? 'owner' : p.grantedTo || p.type)}${p.scope ? ` (${p.scope})` : ''} · ${p.roles.join('/')}${p.hasPassword ? ' · password' : ''}${p.expires ? ` · expires ${String(p.expires).slice(0, 10)}` : ''}${p.inherited ? ' · inherited' : ''}`).join('\n')}` : d.note, d);
|
|
20979
|
+
return ok(d.note, d);
|
|
20980
|
+
}));
|
|
20431
20981
|
// MICROSOFT GRAPH SERVER-SIDE CONVERSION (2026-08-04). Free breadth on a permission every connected user already
|
|
20432
20982
|
// granted — Graph converts ~130 formats and we were paying ffmpeg and headless Chrome to approximate some of it.
|
|
20433
20983
|
server.registerTool('convert_onedrive_file', {
|
|
@@ -22481,7 +23031,7 @@ function memoryNoteVerdict(text) {
|
|
|
22481
23031
|
// answer about the app. It is the OPPOSITE instruction to the permission gap above: nothing the user does fixes
|
|
22482
23032
|
// it, so it is never phrased as a reconnect. The sentence is the SERVER'S (`awaitingApprovalNote`).
|
|
22483
23033
|
const waitingNote = ''; // a feature not offered yet is not listed on the connection (owner decision, 2026-10-02)
|
|
22484
|
-
const lines = on.map(c => ` • ${c.provider}${c.agentLabel ? ` — ${c.agentLabel}` : ''} (${c.status || 'active'})${c.grantMode === 'posting' ? ' · POSTING ONLY' : ''}${c.scopeDrift?.status === 'missing' ? ` ⚠ missing ${c.scopeDrift.missing.length} permission(s) — needs a reconnect` : ''}`);
|
|
23034
|
+
const lines = on.map(c => ` • ${c.provider}${c.agentLabel ? ` — ${c.agentLabel}` : ''} (${c.status || 'active'})${c.grantMode === 'posting' ? ' · POSTING ONLY' : ''}${c.scopeDrift?.status === 'missing' ? ` ⚠ missing ${c.scopeDrift.missing.length} permission(s) — needs a reconnect` : ''}${c.needsReconnect && c.revokedReason ? ` ⚠ RECONNECT NEEDED — ${c.revokedReason}` : ''}${c.deliveryWarning?.why ? ` ⚠ ${String(c.deliveryWarning.badge || 'not delivering').toUpperCase()} — ${c.deliveryWarning.why}` : ''}`);
|
|
22485
23035
|
const postingNote = posting.length
|
|
22486
23036
|
? '\n\n' + posting.map(c => `ℹ ${c.grantModeNote}`).join('\n')
|
|
22487
23037
|
+ '\nAds, catalog, WhatsApp and Business-portfolio tools will refuse on this connection, and that is the user\'s deliberate choice rather than a fault — do not retry them. Widening it is an OAuth consent screen, so the user does it in a browser: Workspace ▸ Connectors ▸ Meta ▸ Reconnect with everything, or open ' + linkFor('meta') + '.'
|
|
@@ -22494,6 +23044,46 @@ function memoryNoteVerdict(text) {
|
|
|
22494
23044
|
const safe = { ...d, connectors: (d.connectors || []).map(({ accountLabel, ...c }) => c) };
|
|
22495
23045
|
return ok(`${on.length} connected:\n${lines.join('\n') || ' (none)'}\nAvailable to connect through a sign-in screen (needs a browser; the link is ${linkFor('<provider>')}): ${(d.providers || []).join(', ') || '(none configured)'}.\nPaste-a-key, ${widget ? 'connected in the Hermoso app under Workspace ▸ Connectors (keys are never pasted into this chat)' : 'connectable from here with connect_connector or in the app'}: ${Object.keys(KEY_CONNECTORS).join(', ')}.${on.length ? '' : (widget ? emptyWidget : empty)}${gapNote}${waitingNote}${postingNote}`, safe);
|
|
22496
23046
|
}));
|
|
23047
|
+
// ── ALERTS (2026-10-27): Slack / Discord / a signed generic webhook, for renders, scheduled posts, drafts waiting for
|
|
23048
|
+
// approval, competitor-watch findings, new leads and low credits. Rules: lib/alert-sinks.mjs; routes /api/alerts*.
|
|
23049
|
+
const alertSinkEnum = z.enum(['slack', 'discord', 'webhook']);
|
|
23050
|
+
// CHANNEL_ADMIN, NOT WORKSPACE (2026-10-27): these configure an already-connected Slack / Discord / webhook sink, the
|
|
23051
|
+
// same job as the webhook and template tools in channel_admin. In `workspace` they were on every default roster —
|
|
23052
|
+
// including a brand with nothing connected, since three possible sinks cannot be gated on one provider.
|
|
23053
|
+
server.group('channel_admin');
|
|
23054
|
+
server.registerTool('list_alert_settings', {
|
|
23055
|
+
title: 'Alert settings and recent deliveries',
|
|
23056
|
+
description: "Where this brand's ALERTS go and whether they are arriving: for each alert channel (Slack, Discord, a generic webhook) whether it is connected, which event groups it receives (renders, posts, approvals, watch, credits, leads — default all), its health (delivering / NOT DELIVERING / stopped because the channel was deleted or revoked — reconnect by pasting a new URL in the app), whether the webhook is signed, and the last deliveries with their HTTP status and any retry queued. Read-only, free.",
|
|
23057
|
+
inputSchema: {},
|
|
23058
|
+
outputSchema: { sinks: z.array(z.any()).optional(), groups: z.any().optional(), log: z.array(z.any()).optional(), note: z.string().optional() },
|
|
23059
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
23060
|
+
}, wrap(async () => {
|
|
23061
|
+
const d = await apiGet('/api/alerts');
|
|
23062
|
+
const rows = (d.log || []).slice(0, 15).map((l) => ` • ${String(l.at).slice(0, 16).replace('T', ' ')} ${l.provider} ${l.event}: ${l.outcome}${l.status ? ` (HTTP ${l.status})` : ''}${l.reason ? ` — ${l.reason}` : ''}${l.retryAt ? ` · retry at ${String(l.retryAt).slice(0, 16).replace('T', ' ')}` : ''}`);
|
|
23063
|
+
const sinks = (d.sinks || []).filter((x) => x.connected).map((x) => ` • ${x.provider}: ${x.state}${x.note ? ` — ${x.note}` : ''} · events: ${(x.events || []).join(', ') || 'none'}${x.provider === 'webhook' ? ` · ${x.signed ? 'signed' : 'UNSIGNED (rotate_webhook_signing_secret creates a secret)'}` : ''}`);
|
|
23064
|
+
return ok(`${d.note || ''}\n${sinks.join('\n')}${rows.length ? `\nRecent deliveries:\n${rows.join('\n')}` : ''}`, d);
|
|
23065
|
+
}));
|
|
23066
|
+
server.registerTool('set_alert_events', {
|
|
23067
|
+
title: 'Choose which alerts a channel receives',
|
|
23068
|
+
description: "Choose which event groups one alert channel receives. provider: slack, discord or webhook. events: any of renders (a render finished or failed), posts (a scheduled post published, partly published or failed), approvals (autopilot drafts waiting for review), watch (new competitor ads found by the competitor watch), credits (credits running low), leads (a new lead-form lead from Meta, LinkedIn or Snapchat) — or \"all\". An empty list keeps the channel connected and sends it nothing. Unknown names are refused by name. Read back from the stored setting. Free.",
|
|
23069
|
+
inputSchema: { provider: alertSinkEnum, events: z.union([z.array(z.enum(['renders', 'posts', 'approvals', 'watch', 'credits', 'leads', 'all'])), z.literal('all')]).describe('event groups, or "all"') },
|
|
23070
|
+
outputSchema: { provider: z.string().optional(), events: z.array(z.string()).nullable().optional(), note: z.string().optional() },
|
|
23071
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
23072
|
+
}, wrap(async (a) => { const d = await apiPost('/api/alerts/events', a); return ok(d.note, d); }));
|
|
23073
|
+
server.registerTool('send_test_alert', {
|
|
23074
|
+
title: 'Send a test alert',
|
|
23075
|
+
description: "Send a test alert to one alert channel (provider: slack, discord or webhook) or to every connected one, and report what each answered (delivered, or the HTTP status and the channel's own words). A test that gets through also clears a NOT DELIVERING or stopped state. The webhook test is signed like every event. Free.",
|
|
23076
|
+
inputSchema: { provider: alertSinkEnum.optional().describe('omit to test every connected alert channel') },
|
|
23077
|
+
outputSchema: { results: z.array(z.any()).optional(), note: z.string().optional() },
|
|
23078
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
23079
|
+
}, wrap(async (a) => { const d = await apiPost('/api/alerts/test', a); return ok(d.note, d); }));
|
|
23080
|
+
server.registerTool('rotate_webhook_signing_secret', {
|
|
23081
|
+
title: 'Rotate the webhook signing secret',
|
|
23082
|
+
description: "Create (or replace) the signing secret for this brand's generic alert webhook and return it ONCE — it is never readable again, so hand it to the user to store. Every event carries Standard Webhooks headers: webhook-id, webhook-timestamp and webhook-signature = v1,<base64 HMAC-SHA256 of \"id.timestamp.body\"> keyed by the base64 part after whsec_. On a rotation the OLD secret keeps signing alongside the new one for 24 hours (two signatures in the header), so a receiver being updated never rejects an event. Free.",
|
|
23083
|
+
inputSchema: {},
|
|
23084
|
+
outputSchema: { signingSecret: z.string().optional(), previousStillSignsUntil: z.string().nullable().optional(), note: z.string().optional() },
|
|
23085
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
23086
|
+
}, wrap(async () => { const d = await apiPost('/api/alerts/webhook-secret', {}); return ok(`${d.note}\nSigning secret: ${d.signingSecret}`, d); }));
|
|
22497
23087
|
// ── CONNECTOR WRITES. Connecting needs a browser (OAuth consent) and is correctly NOT headless — but the other two
|
|
22498
23088
|
// halves of connector management are, and were web-only: DISCONNECTING, and choosing WHICH accounts a brand may
|
|
22499
23089
|
// act as. The second is a permission decision that fails closed: a Page/ad account not in the list is one nothing
|
|
@@ -22667,6 +23257,7 @@ function memoryNoteVerdict(text) {
|
|
|
22667
23257
|
const CONN_PICKER_IDS = Object.keys(CONN_ACCOUNT_PICKERS);
|
|
22668
23258
|
const connIdentityLines = (rows) => rows.map(r => ` ${r.selected ? '[x]' : '[ ]'} ${r.name} (id: ${r.id}, ${r.type})${r.detail ? ` — ${r.detail}` : ''}`).join('\n');
|
|
22669
23259
|
|
|
23260
|
+
server.group('workspace');
|
|
22670
23261
|
server.registerTool('list_connector_accounts', {
|
|
22671
23262
|
title: 'List a connector’s accounts',
|
|
22672
23263
|
description: `Show every identity a connected account can act as, and which ones this PROFILE is currently allowed to use — Facebook Pages + Instagram + Meta ad accounts, Google Ads customers, LinkedIn company Pages (and the personal profile), Pinterest ad accounts, Microsoft Advertising accounts. One person often administers several; only the ticked ones can be posted to or spent from. Call this before set_connector_accounts, and let the USER pick — never guess. Providers: ${CONN_PICKER_IDS.join(', ')}. Read-only, free.`,
|