hermoso 0.1.462 → 0.1.464

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mcp/tools.mjs CHANGED
@@ -257,7 +257,7 @@ export const CAPABILITY_MAP = [
257
257
  'A) AD SPY / RESEARCH — spy on the ads already winning in any market, then mine them. find_competitors · competitor_teardown · pull_competitor_ads · research_ads (open brief) · ad libraries search_meta_ads / search_google_ads / search_linkedin_ads · organic social search_tiktok / search_instagram / search_youtube / search_reddit / search_threads · search_instagram_hashtag (LISTENING on the brand’s OWN Meta credentials rather than a scraper: the real public posts carrying a hashtag, with their captions — feed them into mine_angles or write the next post from the language you found. “recent” is the LAST 24 HOURS only, so a huge tag legitimately returns zero on a quiet day; ask again with edge “top” before saying anything about how busy it is) · instagram_profile (any Instagram @handle → the account’s NUMERIC Instagram id from Meta itself, plus its real name, bio, follower and post counts — Meta’s own numbers, not a scraper’s. It is also the ONLY way to get the id manage_meta_partnership_creator’s allowTagging list requires; professional accounts only) · find_instagram_marketplace_creators / instagram_marketplace_creator / list_instagram_marketplace_audiences (Instagram’s own creator marketplace searched as the brand, with Meta’s first-party creator insights; find_creators ranks by public posts and takes marketplace:true to add it) · fetch_social_data (any allowlisted endpoint) · mine_angles · analyze_video · check_ad_policy · list_skills / get_skill (teardowns + creative playbooks).',
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
- '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).',
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_alert_settings / set_alert_events / send_test_alert (where ALERTS go: Slack, Discord or a signed webhook, for renders, posts, approvals, competitor findings, leads and low credits) · list_team / invite_member / remove_member / set_role (who else can work in this profile).',
261
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');
@@ -340,8 +340,8 @@ export const MCP_INSTRUCTIONS = [
340
340
  '• AD SPY / RESEARCH: find_competitors, competitor_teardown, pull_competitor_ads, research_ads; ad libraries search_meta_ads / search_google_ads / search_linkedin_ads; organic search_tiktok / search_instagram / search_youtube / search_reddit / search_threads; fetch_social_data; mine_angles; analyze_video; check_ad_policy; list_skills / get_skill.',
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
- '• 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, 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.',
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_alert_settings / set_alert_events / send_test_alert (where alerts go: Slack, Discord or a signed webhook); 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, 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). Google Merchant Center: manage_merchant_settings fixes the shipping, return, business-info and program settings that stop an account serving. 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 Notion.',
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)];
@@ -2223,7 +2234,7 @@ export const TOOL_GROUP_NAMES = ['core', ...Object.keys(TOOL_GROUPS)];
2223
2234
  // names `create` (or all); this figure is what that session pays.
2224
2235
  // create RE-MEASURED 2026-10-06 (42000 → 55000, measured 54,974 by tools/tool-group-truth-check.mjs): make_static_ads
2225
2236
  // joined it (on-demand), on top of the create tools added since 09-24 that had used up the 1.3× tolerance.
2226
- export const TOOL_GROUP_TOKENS = { core: 4300, research: 6600, create: 55000, channels: 37100, channel_admin: 66300, analytics: 39100, files: 10600, workspace: 9200, ads: 246700 };
2237
+ export const TOOL_GROUP_TOKENS = { core: 4600, research: 8300, create: 62200, channels: 53200, channel_admin: 100900, analytics: 56800, files: 17800, workspace: 10300, ads: 346000 }; // re-measured 2026-10-27 (tools/tool-group-truth-check.mjs §8)
2227
2238
 
2228
2239
  // THE DEFAULT ROSTER IS EVERYTHING EXCEPT `ads` AND `analytics`. Paid-campaign management across every ad platform
2229
2240
  // is ~236K tokens on its own — more than everything else put together — because each platform carries a full
@@ -2498,9 +2509,9 @@ const executorLike = (name) => EXECUTOR_TOOLS.has(name) || WIDGET_GENERIC_PROXIE
2498
2509
  // caller's own credits is metering, not a change to the environment the tool acts on, so a research read that
2499
2510
  // bills stays read-only. destructiveHint is TRUE on draft_brand: save:true replaces the saved brand profile.
2500
2511
  export const EXECUTOR_TOOLS = new Set(['find_tools', 'call_tool', 'enable_tools']);
2501
- export const WIDGET_HOST_RENAMES = Object.freeze({ clone_video: 'remake_video_for_brand' });
2512
+ export const WIDGET_HOST_RENAMES = Object.freeze({ clone_video: 'remake_video_for_brand', click_to_ad: 'plan_video_ad_from_product_link' }); // click_to_ad: the scan (2026-10-27) read the name as promotional / not what it does — it PLANS the ad, render_ad makes it
2502
2513
  export const WIDGET_HOST_ALIAS_OF = Object.freeze(Object.fromEntries(Object.entries(WIDGET_HOST_RENAMES).map(([from, to]) => [to, from])));
2503
- export const WIDGET_HOST_ALIAS_TITLES = Object.freeze({ remake_video_for_brand: 'Remake a video for your brand' });
2514
+ export const WIDGET_HOST_ALIAS_TITLES = Object.freeze({ remake_video_for_brand: 'Remake a video for your brand', plan_video_ad_from_product_link: 'Plan a video ad from a product link' });
2504
2515
  // ── ONE OPERATION, ONE TOOL, ON A WIDGET HOST (2026-10-18) ─────────────────────────────────────────────────────
2505
2516
  // OpenAI's daily scan HELD 18 tools that take an `action` enum and do different things per value: "This tool appears
2506
2517
  // to execute operations that are not individually exposed. Please expose each model-callable operation upfront as a
@@ -4337,7 +4348,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4337
4348
  server.group('channels');
4338
4349
  server.registerTool('post_to_bluesky', {
4339
4350
  title: 'Post to Bluesky',
4340
- description: "Use this when the user wants to post to Bluesky. Publishing costs no credits. Publish a post to Bluesky as the connected account (connected under Settings ▸ Connectors with a Bluesky APP PASSWORD, never the account password). Text up to 300 characters — Bluesky ALSO caps a post at 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; Hermoso checks both before spending the round trip and says which limit and by how much. @MENTIONS written as the full handle (@alice.bsky.social) are looked up and tagged, so that account is notified; one that does not resolve stays plain text and the reply names it. MEDIA: either up to 10 images (imageUrls + altText; 5–10 post as a Bluesky gallery, more than 10 is refused, never cut; a picture over Bluesky's 2 MB limit is re-encoded to fit and the reply says so) OR one MP4 video (videoUrl + videoAlt), never both — a Bluesky post record carries a single embed and images and video are two different embed types. Video is MP4 only, up to 300MB at Bluesky's end (Hermoso can fetch up to 150MB from a URL), with optional WebVTT caption tracks; the aspect ratio is measured from the file. Bluesky requires a CONFIRMED EMAIL on the account before it will process any video — if it is unconfirmed you get a refusal saying so, and reconnecting will not help. Links in the text are made clickable automatically. LINK CARDS: Bluesky does NOT scrape links, so a URL posted bare renders as plain blue text — the client composing the post has to build the card. Hermoso builds one AUTOMATICALLY when the post has a URL and NO media: it fetches the page, uses its title/description and uploads its image as the card thumbnail. Pass linkCard:false to suppress it, or linkCard:{uri,title,description,thumbUrl} to control it (give both title and description and the page is not fetched at all). A POST CARRIES ONE EMBED, so a card and images/video cannot both ride: if you pass linkCard explicitly ALONGSIDE media the call is REFUSED by name rather than silently dropping one, and if the URL was merely in the text the MEDIA WINS and the reply says the card was skipped (the link stays clickable either way). Returns the post's public bsky.app URL. Connect at Settings > Connectors > Bluesky.",
4351
+ description: "Use this when the user wants to post to Bluesky. Publishing costs no credits. Publish a post to Bluesky as the connected account. Text up to 300 characters — Bluesky ALSO caps a post at 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; Hermoso checks both before spending the round trip and says which limit and by how much. @MENTIONS written as the full handle (@alice.bsky.social) are looked up and tagged as Bluesky mentions; one that does not resolve stays plain text and the reply names it. MEDIA: either up to 10 images (imageUrls + altText; 5–10 post as a Bluesky gallery, more than 10 is refused, never cut; a picture over Bluesky's 2 MB limit is re-encoded to fit and the reply says so) OR one MP4 video (videoUrl + videoAlt), never both — a Bluesky post record carries a single embed and images and video are two different embed types. Video is MP4 only, up to 300MB at Bluesky's end (Hermoso can fetch up to 150MB from a URL), with optional WebVTT caption tracks; the aspect ratio is measured from the file. Bluesky requires a CONFIRMED EMAIL on the account before it will process any video — if it is unconfirmed you get a refusal saying so, and reconnecting will not help. Links in the text are made clickable automatically. LINK CARDS: Bluesky does NOT scrape links, so a URL posted bare renders as plain blue text — the client composing the post has to build the card. Hermoso builds one AUTOMATICALLY when the post has a URL and NO media: it fetches the page, uses its title/description and uploads its image as the card thumbnail. Pass linkCard:false to suppress it, or linkCard:{uri,title,description,thumbUrl} to control it (give both title and description and the page is not fetched at all). A POST CARRIES ONE EMBED, so a card and images/video cannot both ride: if you pass linkCard explicitly ALONGSIDE media the call is REFUSED by name rather than silently dropping one, and if the URL was merely in the text the MEDIA WINS and the reply says the card was skipped (the link stays clickable either way). Returns the post's public bsky.app URL. Connect at Settings > Connectors > Bluesky.",
4341
4352
  inputSchema: {
4342
4353
  account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the profile has more than one bluesky account connected (several and none named is refused by name, never guessed); omit when there is one."),
4343
4354
  brand: HOOK_ATTR.brand,
@@ -4399,7 +4410,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4399
4410
  server.group('channels');
4400
4411
  server.registerTool('post_to_telegram', {
4401
4412
  title: 'Post to Telegram',
4402
- description: "Use this when the user wants to post to a Telegram channel or group. Publishing costs no credits. Publish to a Telegram channel, group or chat as the brand's own bot. WHICH CHAT IS ALWAYS REQUIRED AND IS NEVER GUESSED: pass chatId as the public channel's @username (e.g. @hermosoai) or its numeric id (a group is negative; a supergroup or channel starts with -100). There is no default and there cannot be one — the Telegram Bot API publishes no method that lists the chats a bot belongs to, so Hermoso genuinely cannot know them. list_telegram_chats reports the chats that have MESSAGED the bot in the last 24 hours, which is a shortcut for finding an id and is NOT a roster: a chat missing from it can still be posted to. TEXT: up to 4096 characters on a text-only message, but only 1024 the moment ANY photo or video is attached — a caption is not a message, and Hermoso refuses the over-long one before spending the round trip and says which budget applied. MEDIA: one image (imageUrl), one video (videoUrl), or an ALBUM of 2–10 (imageUrls, in order) in which photos and videos may be MIXED — pass a videoUrl alongside imageUrls and it joins the album as one more item. Hermoso uploads the bytes rather than handing Telegram a link, which is what buys the larger ceilings: 10MB per photo and 50MB per video, where a link would be 5MB and 20MB. THE BOT MUST BE IN THE CHAT — an administrator with Post Messages for a channel, an unrestricted member for a group; if it is not, Telegram refuses and the error says to add it rather than to reconnect. Returns the message id and, for a PUBLIC chat, its t.me link — a private group has no public web link, so `url` comes back null rather than as a link that would 404 for whoever you hand it to. 0 credits. Connect at Settings > Connectors > Telegram.",
4413
+ description: "Use this when the user wants to post to a Telegram channel or group. Publishing costs no credits. Publish to a Telegram channel, group or chat as the brand's own bot. WHICH CHAT IS ALWAYS REQUIRED AND IS NEVER GUESSED: pass chatId as the public channel's @username (e.g. @yourchannel) or its numeric id (a group is negative; a supergroup or channel starts with -100). There is no default and there cannot be one — the Telegram Bot API publishes no method that lists the chats a bot belongs to, so Hermoso genuinely cannot know them. list_telegram_chats reports the chats that have MESSAGED the bot in the last 24 hours, which is a shortcut for finding an id and is NOT a roster: a chat missing from it can still be posted to. TEXT: up to 4096 characters on a text-only message, but only 1024 the moment ANY photo or video is attached — a caption is not a message, and Hermoso refuses the over-long one before spending the round trip and says which budget applied. MEDIA: one image (imageUrl), one video (videoUrl), or an ALBUM of 2–10 (imageUrls, in order) in which photos and videos may be MIXED — pass a videoUrl alongside imageUrls and it joins the album as one more item. Hermoso uploads the bytes rather than handing Telegram a link, which is what buys the larger ceilings: 10MB per photo and 50MB per video, where a link would be 5MB and 20MB. THE BOT MUST BE IN THE CHAT — an administrator with Post Messages for a channel, an unrestricted member for a group; if it is not, Telegram refuses and the error says to add it rather than to reconnect. Returns the message id and, for a PUBLIC chat, its t.me link — a private group has no public web link, so `url` comes back null rather than as a link that would 404 for whoever you hand it to. 0 credits. Connect at Settings > Connectors > Telegram.",
4403
4414
  inputSchema: {
4404
4415
  account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the profile has more than one telegram account connected (several and none named is refused by name, never guessed); omit when there is one."),
4405
4416
  brand: HOOK_ATTR.brand,
@@ -4489,7 +4500,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4489
4500
  description: "EVERYTHING PEOPLE SAID TO THIS BRAND, across every connected channel, in one list: Facebook and "
4490
4501
  + "Instagram comments, Threads replies and mentions, YouTube and Reddit comments, Google Business reviews, "
4491
4502
  + "Bluesky replies and mentions, and X mentions — plus DIRECT MESSAGES on Meta (Messenger and Instagram "
4492
- + "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 "
4493
4504
  + "item carries a composite id you hand straight to reply_to_inbox_item. A channel that is not connected is "
4494
4505
  + "skipped silently; a channel that FAILS to read is named in `notes` rather than dropped, so a short list is "
4495
4506
  + "never mistaken for a quiet week. Each DM conversation folds to ONE item (the newest thing the other person said). "
@@ -4548,7 +4559,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4548
4559
  if (pageId && (src.provider === 'meta' || src.provider === 'instagram')) q.pageId = String(pageId).trim();
4549
4560
  try {
4550
4561
  const d = await apiGet(whole ? src.readAll : src.read, q);
4551
- 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 || []);
4552
4563
  // A SOURCE THAT READ ONLY PART OF ITSELF SAYS SO (2026-10-26, customer report: Instagram DMs at limit >= 32 came
4553
4564
  // back as 0 items and NO note, because the DM inbox route reported its failure in `unreadable` + `note` and this
4554
4565
  // loop only ever read the rows). Any route answering `unreadable` has its sentence carried into `notes`, on both
@@ -4586,7 +4597,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4586
4597
  description: "Answer an inbox item BY ITS COMPOSITE ID — the `id` list_inbox returned, e.g. 'facebook:123_456' "
4587
4598
  + "or 'google_business:accounts/1/locations/2/reviews/3'. Routes to the right channel for you; you do not need "
4588
4599
  + "to know which reply tool a platform uses. The reply is posted as the brand — PUBLIC on every comment, reply, "
4589
- + "mention and review, and PRIVATE on the two DM sources (meta_dm, bluesky_dm) — so show the user 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 "
4590
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, "
4591
4602
  + "so it goes through post_to_x with replyTo.",
4592
4603
  inputSchema: {
@@ -4622,12 +4633,20 @@ function buildTools(rawServer, opts = {}, sink = null) {
4622
4633
  else if (t.source === 'bluesky_dm') payload.convoId = t.nativeId;
4623
4634
  else if (t.source === 'x_dm') payload.conversationId = t.nativeId;
4624
4635
  else if (t.source === 'telegram_dm') payload.chatId = t.nativeId;
4625
- const r = await apiPost(t.route, payload);
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
+ }
4626
4645
  // A DM IS PRIVATE. Saying "replied publicly as the brand" about a direct message is wrong in the one
4627
4646
  // direction that matters — it invites a caller to be more guarded than it needs to be about a private reply,
4628
4647
  // and, worse, teaches that the same sentence covers both.
4629
- 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.`;
4630
- 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 });
4631
4650
  }));
4632
4651
 
4633
4652
  // Back to core. The inbox + Bluesky tools above set the marker to `channels`, and the marker is sticky —
@@ -4703,7 +4722,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4703
4722
  const _rmLine = _rm ? `\nRemake engines (recast_motion, restyle_video, edit_video engine): auto picks, for a clip with no real-looking person, ${Object.entries(_rm.defaults || {}).map(([k, v]) => `${k} → ${v}`).join(', ')}; with a person in the clip, ${Object.entries(_rm.faceDefaults || {}).map(([k, v]) => `${k} → ${v}`).join(', ')}${_rm.personClipLibraryDefaults ? ` (a person clip our face library can carry, i.e. one of our renders or faceRoute on a paid plan: ${Object.entries(_rm.personClipLibraryDefaults).map(([k, v]) => `${k} → ${v}`).join(', ')})` : ''}. seedance ${(_rm.seconds || []).join('-')}s, up to ${_rm.imagesMax} pictures, ${Object.entries(_rm.seedanceCredits || {}).map(([res, m]) => `${res} ${Object.entries(m).map(([s, c]) => `${s}s ${c}cr`).join(' / ')}`).join('; ')}${_rm.wanCredits ? `. wan clips up to ${_rm.wanMaxSourceSeconds}s, ${Object.entries(_rm.wanCredits).map(([res, m]) => `${res} ${Object.entries(m).map(([s, c]) => `${s}s ${c}cr`).join(' / ')}`).join('; ')}${_rm.wanLabel ? ` (${_rm.wanLabel})` : ''}` : ''}${_rm.wanPrimeCredits ? `. wan-prime (Wan 3.0 Prime by name) ${Object.entries(_rm.wanPrimeCredits).map(([res, m]) => `${res} ${Object.entries(m).map(([s, c]) => `${s}s ${c}cr`).join(' / ')}`).join('; ')}` : ''}` : '';
4704
4723
  const _je = d.jobEngines; // the certified engine per job (watched side-by-side tests, 2026-10-19)
4705
4724
  const _jeLine = _je && _je.defaults ? `\nDefault engine per job (watched side-by-side tests): ${Object.entries(_je.defaults).map(([k, v]) => `${k} → ${v}`).join(', ')}; a talking ad is one take of up to ${_je.talkingMaxSeconds}s. A model the user picks by name is kept; never switch it.` : '';
4706
- const text = `Image: ${d.image ? img : 'unavailable'}\nVideo: ${d.video ? vid : 'unavailable'}\n${_lenLine}\nExtend a clip (generate_video extend:true + refVideo): by default it cuts to a NEW SHOT of what happens next (new angle, same scene, any i2v model, default ${d.jobEngines?.defaults?.extend || 'certified'})${_extLine ? `; extendMode:'continue' keeps the SAME take instead, on: ${_extLine}` : ''}${_shotLine ? `\nMulti-shot clips (generate_video shots): ${_shotLine}` : ''}${_rsLine ? `\nRestyle looks (restyle_video style): ${_rsLine}` : ''}${_rmLine}${_jeLine}\n${_pickLine}\n${_resLine}${d.pricing ? `\nPRICES ABOVE ARE THIS ACCOUNT'S OWN: ${d.pricing.memberRate ? 'no markup on image, video and voice models (the model maker\'s price + a 10% service fee, 1 credit = $0.02)' : 'standard prices (every paid plan pays the model maker\'s price + 10% on image, video and voice models; model rows carry memberCredits)'}.` : ''}${_motionLine}${_avLine}\nVoice engines (generate_voice): ${voice}\nWriting models (generate_text): ${llm}\nPRICES: every credit figure above is an estimate. ${PRICE_ESTIMATE_LINE}\ncanEdit:${d.canEdit} canAvatar:${d.canAvatar}\nRecipes (${(d.recipes || []).length}): ${(d.recipes || []).slice(0, 20).map(r => r.id).join(', ')}…\n\n${CAPABILITY_MAP}`; return ok(text + connLine, d);
4725
+ const text = `Image: ${d.image ? img : 'unavailable'}\nVideo: ${d.video ? vid : 'unavailable'}\n${_lenLine}\nExtend a clip (generate_video extend:true + refVideo): by default it cuts to a NEW SHOT of what happens next (new angle, same scene, any i2v model, default ${d.jobEngines?.defaults?.extend || 'certified'})${_extLine ? `; extendMode:'continue' keeps the SAME take instead, on: ${_extLine}` : ''}${_shotLine ? `\nMulti-shot clips (generate_video shots): ${_shotLine}` : ''}${_rsLine ? `\nRestyle looks (restyle_video style): ${_rsLine}` : ''}${_rmLine}${_jeLine}\n${_pickLine}\n${_resLine}${d.pricing ? `\nPRICES ABOVE ARE THIS ACCOUNT'S OWN: ${d.pricing.memberRate ? 'no markup on image, video and voice models (exactly the model maker\'s price, 1 credit = $0.02; no fee is added to a run or at checkout)' : 'standard prices (every paid plan pays exactly the model maker\'s price on image, video and voice models; model rows carry memberCredits)'}.` : ''}${_motionLine}${_avLine}\nVoice engines (generate_voice): ${voice}\nWriting models (generate_text): ${llm}\nPRICES: every credit figure above is an estimate. ${PRICE_ESTIMATE_LINE}\ncanEdit:${d.canEdit} canAvatar:${d.canAvatar}\nRecipes (${(d.recipes || []).length}): ${(d.recipes || []).slice(0, 20).map(r => r.id).join(', ')}…\n\n${CAPABILITY_MAP}`; return ok(text + connLine, d);
4707
4726
  }));
4708
4727
 
4709
4728
  server.registerTool('hermoso_credits', {
@@ -4745,7 +4764,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4745
4764
  quote_token: z.string().optional().describe('the quoteToken returned by the quote step — REQUIRED (with confirm:true) to charge; it binds the exact pack + price you quoted (10-minute validity) and makes a retried confirm idempotent'),
4746
4765
  },
4747
4766
  outputSchema: {
4748
- packs: z.array(z.any()).optional().describe('available credit packs ({id, credits, priceUsd}) when listing'),
4767
+ packs: z.array(z.any()).optional().describe('available credit packs ({id, credits, priceUsd}) when listing; credits are what THIS account is granted'),
4749
4768
  quote: z.any().optional().describe('the one-click charge quote ({packId, credits, priceUsd, card}) awaiting confirm:true'),
4750
4769
  ok: z.boolean().optional().describe('true when a one-click top-up charge succeeded'),
4751
4770
  credits: z.number().optional().describe('credits added by a completed top-up (or bought by the checkout link)'),
@@ -4756,9 +4775,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
4756
4775
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, // confirm:true charges the saved card (one-click top-up); link path charges nothing
4757
4776
  }, wrap(async ({ pack, confirm, quote_token }) => {
4758
4777
  const cfg = await apiGet('/api/billing/config');
4778
+ // NOTHING IS ADDED AT CHECKOUT (2026-10-27): a pack's price is what the card is charged, and /api/billing/config hands
4779
+ // each pack with the credits THIS account is granted (server-decided; see docs/fee-at-checkout-2026-10-27.md). The quote
4780
+ // binds both, so the human is told exactly what they pay and what they get.
4759
4781
  const packs = (cfg.packs || []).map(p => ({ id: p.id, credits: p.credits, priceUsd: p.priceUsd }));
4782
+ const priceText = (p) => `$${p.priceUsd}`;
4760
4783
  if (!pack) {
4761
- const lines = packs.map(p => `• ${p.id} — ${p.credits.toLocaleString()} credits · $${p.priceUsd}`).join('\n') || '(no packs configured)';
4784
+ const lines = packs.map(p => `• ${p.id} — ${p.credits.toLocaleString()} credits · ${priceText(p)}`).join('\n') || '(no packs configured)';
4762
4785
  let _up = null; try { _up = (await apiGet('/api/billing/status'))?.planUpsell || null; } catch {}
4763
4786
  return ok(`Credit packs you can buy:\n${lines}\n\nCall buy_credits again with pack="<id>". With a saved card it's a one-click charge (you'll be asked to confirm); otherwise you get a checkout link for your human.${_up ? `\n\n${_up.line} ${_up.note} Top-up credits use standard prices; upgrade_plan lists the monthly plans.` : ''}\n\nTip: to never run out again, turn on auto-reload (set_auto_reload) — it auto-tops-up from the saved card the moment your balance runs low.`, { packs });
4764
4787
  }
@@ -4779,21 +4802,21 @@ function buildTools(rawServer, opts = {}, sink = null) {
4779
4802
  const mintQuote = () => {
4780
4803
  const t = `qt1|${match.id}|${match.priceUsd}|${Math.floor(Date.now() / 1000) + 600}|${(globalThis.crypto?.randomUUID?.() || String(Date.now())).slice(0, 8)}`;
4781
4804
  _mintedQuotes.add(t); if (_mintedQuotes.size > 50) _mintedQuotes.delete(_mintedQuotes.values().next().value); // bounded
4782
- return ok(`Ready to charge ${card} $${match.priceUsd} for ${match.credits.toLocaleString()} credits (one-click, no redirect — same as the app's Add credits button). Confirm with your human if they haven't already asked for this, then call buy_credits again with pack="${match.id}", confirm:true and quote_token="${t}" (valid 10 minutes).`, { quote: { packId: match.id, credits: match.credits, priceUsd: match.priceUsd, card: st.card || null, quoteToken: t, expiresInSeconds: 600 } });
4805
+ return ok(`Ready to charge ${card} ${priceText(match)} for ${match.credits.toLocaleString()} credits (one-click, no redirect — same as the app's Add credits button). Confirm with your human if they haven't already asked for this, then call buy_credits again with pack="${match.id}", confirm:true and quote_token="${t}" (valid 10 minutes).`, { quote: { packId: match.id, credits: match.credits, priceUsd: match.priceUsd, card: st.card || null, quoteToken: t, expiresInSeconds: 600 } });
4783
4806
  };
4784
4807
  if (!confirm || !quote_token) return mintQuote();
4785
4808
  const qp = String(quote_token).split('|');
4786
4809
  if (!_mintedQuotes.has(String(quote_token)) || qp[0] !== 'qt1' || qp.length < 5 || qp[1] !== match.id || Number(qp[3]) < Math.floor(Date.now() / 1000) || Number(qp[2]) !== match.priceUsd) return mintQuote(); // UNMINTED (forged/other-process — c3d6081 review: format-only validation was trivially forgeable, the token MUST come from a real quote in THIS process; a restart just re-quotes) / expired / wrong-pack / price-moved → fresh quote, never a surprise charge
4787
4810
  let d;
4788
- try { d = await apiPost('/api/billing/topup', { packId: match.id, idempotencyKey: String(quote_token), expectedPriceUsd: match.priceUsd }); } // expectedPriceUsd: server-side price binding (409s if the catalog moved under the quote)
4811
+ try { d = await apiPost('/api/billing/topup', { packId: match.id, idempotencyKey: String(quote_token), expectedPriceUsd: match.priceUsd, expectedCredits: match.credits }); } // expectedPriceUsd: server-side price binding (409s if the catalog moved under the quote)
4789
4812
  catch (e) {
4790
4813
  if (e?.status === 403) return ok(`This key doesn't have billing-admin rights on the account, so it can't charge the saved card. Ask a workspace admin to top up (the app’s Billing page → Add credits, or their own buy_credits call).`, { packs });
4791
4814
  throw e;
4792
4815
  }
4793
- return ok(`Done — charged ${card} $${match.priceUsd}; ${match.credits.toLocaleString()} credits are on the account now. (Receipt lands in Billing → invoice history.)`, d);
4816
+ return ok(`Done — charged ${card} ${priceText(match)}; ${match.credits.toLocaleString()} credits are on the account now. (Receipt lands in Billing → invoice history.)`, d);
4794
4817
  }
4795
4818
  const d = await apiPost('/api/billing/checkout-link', { packId: pack });
4796
- return ok(`Checkout link for ${match.credits.toLocaleString()} credits ($${d.amountUsd ?? match.priceUsd}):\n${d.url}\n\nGive this URL to your human to pay on Stripe's secure page — credits post automatically once payment completes, and their card saves for one-click top-ups (in-app AND via this tool) from then on. Nothing is charged until they pay.`, d);
4819
+ return ok(`Checkout link for ${Number(d.credits ?? match.credits).toLocaleString()} credits ($${d.amountUsd ?? match.priceUsd}):\n${d.url}\n\nGive this URL to your human to pay on Stripe's secure page — credits post automatically once payment completes, and their card saves for one-click top-ups (in-app AND via this tool) from then on. Nothing is charged until they pay.`, d);
4797
4820
  }));
4798
4821
 
4799
4822
  // BILLING SURFACE (read → top-up → plan/auto-reload): hermoso_credits (balance) → buy_credits (top-up link) →
@@ -4864,7 +4887,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4864
4887
  : d.paymentMethodOnFile ? `yes${d.card ? ` (${d.card.brand} ····${d.card.last4})` : ''}` : 'no';
4865
4888
  const _per = d.plan?.period === 'yr' ? 'yr' : 'mo';
4866
4889
  const _price = _per === 'yr' ? (d.plan?.priceUsd ?? d.plan?.monthlyUsd) : (d.plan?.monthlyUsd ?? d.plan?.priceUsd);
4867
- const _pr = d.plan?.memberRate ? `\nPricing: no markup on image, video and voice models (the model maker\'s price + a 10% service fee, 1 credit = $0.02)${d.plan?.legacyPrice ? '; this plan keeps its legacy price (upgrade_plan lists the plans on sale; once left it cannot be chosen again)' : ''}` : d.plan?.nextRenewal ? `\nPricing: standard until the next renewal; from then on the same price with no markup on models and ${d.plan.nextRenewal.credits} credits a month` : (d.plan?.monthlyUsd || 0) > 0 ? '' : d.planUpsell ? `\nPricing: standard. ${d.planUpsell.line} ${d.planUpsell.note} Free and top-up credits use standard prices.` : '';
4890
+ const _pr = d.plan?.memberRate ? `\nPricing: no markup on image, video and voice models (exactly the model maker\'s price, 1 credit = $0.02; no fee is added to a run or at checkout)${d.plan?.legacyPrice ? '; this plan keeps its legacy price (upgrade_plan lists the plans on sale; once left it cannot be chosen again)' : ''}` : d.plan?.nextRenewal ? `\nPricing: standard until the next renewal; from then on the same price with no markup on models and ${d.plan.nextRenewal.credits} credits a month` : (d.plan?.monthlyUsd || 0) > 0 ? '' : d.planUpsell ? `\nPricing: standard. ${d.planUpsell.line} ${d.planUpsell.note} Free and top-up credits use standard prices.` : '';
4868
4891
  const text = `Plan: ${d.plan?.label} ($${_price}/${_per})${_pr}\nBalance: ${d.balanceCredits} credits\nAuto-reload: ${arLine}\nCard on file: ${cardLine}\nYour billing role: ${d.role}${d.isAdmin ? ' — you can change the plan / auto-reload' : ' — read-only; ask an admin to change the plan or auto-reload'}${withheld && d.billingNote ? `\n${d.billingNote}` : ''}`;
4869
4892
  return ok(text, d);
4870
4893
  }));
@@ -4925,6 +4948,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4925
4948
  thresholdCredits: z.number().nullable().optional().describe('reload triggers below this balance'),
4926
4949
  reloadCredits: z.number().nullable().optional().describe('credits added per reload'),
4927
4950
  reloadPack: z.any().optional().describe('the pack charged on each reload'),
4951
+ reloadUsd: z.number().optional().describe('what each reload charges the card'),
4928
4952
  capUsd: z.any().optional().describe('monthly auto-reload spend cap in USD, if set'),
4929
4953
  status: z.string().optional().describe('auto-reload status detail'),
4930
4954
  guidance: z.string().optional().describe('instructions when the change must be made in the app'),
@@ -4933,7 +4957,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4933
4957
  }, wrap(async ({ enabled, thresholdCredits, reloadCredits }) => {
4934
4958
  const d = await apiPost('/api/billing/autoreload-config', { enabled, thresholdCredits, reloadCredits });
4935
4959
  if (d.needsCard) return ok(d.guidance || 'Add a card on file first (in the app), then auto-reload can use it.', d);
4936
- if (d.applied) return ok(`Auto-reload ${d.enabled ? `ON — reloads${d.reloadCredits != null ? ' +' + d.reloadCredits.toLocaleString() + ' credits' : ''} when the balance drops below ${d.thresholdCredits} credits` : 'OFF'}.`, d);
4960
+ if (d.applied) return ok(`Auto-reload ${d.enabled ? `ON — reloads${d.reloadCredits != null ? ' +' + d.reloadCredits.toLocaleString() + ' credits' : ''}${Number(d.reloadUsd) > 0 ? ` ($${d.reloadUsd} each time)` : ''} when the balance drops below ${d.thresholdCredits} credits` : 'OFF'}.`, d);
4937
4961
  return ok(d.guidance || 'Manage auto-reload in the app: Billing → Auto-reload.', d);
4938
4962
  }));
4939
4963
 
@@ -5698,22 +5722,6 @@ function buildTools(rawServer, opts = {}, sink = null) {
5698
5722
  }));
5699
5723
 
5700
5724
  server.group('channel_admin');
5701
- // ── FIND A PLACE TO TAG ON FACEBOOK / INSTAGRAM (2026-10-27, gap sweep I-23). Meta's Pages search, opened to an app
5702
- // only after Page Public Metadata Access is approved; until then the route answers with that state in words.
5703
- server.registerTool('search_meta_places', {
5704
- title: 'Find a place to tag on Facebook or Instagram',
5705
- 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.',
5706
- inputSchema: {
5707
- q: z.string().describe('the place name, ideally with its city, e.g. "Blue Bottle Coffee Hayes Valley"'),
5708
- limit: z.number().int().min(1).max(25).optional().describe('how many (1–25, default 10)'),
5709
- },
5710
- outputSchema: { query: z.string().optional(), count: z.number().optional(), places: z.array(z.any()).optional(), note: z.string().optional() },
5711
- annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
5712
- }, wrap(async (a) => {
5713
- const d = await apiGet('/api/meta/places', { q: a.q, ...(a.limit ? { limit: a.limit } : {}) });
5714
- const lines = (d.places || []).map(p => `• ${p.name}${p.address ? ` — ${p.address}` : ''} — id ${p.id}`);
5715
- return ok(d.count ? `${d.count} place(s) for “${d.query}”:\n${lines.join('\n')}\n${d.note}` : d.note, d);
5716
- }));
5717
5725
  server.registerTool('search_threads_locations', {
5718
5726
  title: 'Find a place to tag on Threads',
5719
5727
  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".',
@@ -5975,6 +5983,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5975
5983
  instagramPoll: z.array(z.string()).optional().describe('INSTAGRAM — attach a POLL: 2 to 4 answers of 1–25 characters each, and the caption IS the question (so a caption is required). Feed photos, carousels and Reels only (never a story), one caption add-on per post (not with instagramCommentPrompt), and only on an account connected through Meta (a Facebook Page with a linked Instagram). WRITE-ONCE: Instagram cannot add, change or remove it after publishing, so show the user the answers first.'),
5976
5984
  instagramPollExtended: z.boolean().optional().describe('INSTAGRAM — give that poll Instagram’s extended voting duration instead of its default 3 days (Instagram’s changelog says it then stays open indefinitely). Only with instagramPoll.'),
5977
5985
  instagramCommentPrompt: z.boolean().optional().describe('INSTAGRAM — make the caption a COMMENT PROMPT that people answer in the comments. Same limits as instagramPoll, and never together with it.'),
5986
+ firstComment: z.string().optional().describe('FIRST COMMENT — posted on the new post as the same account right after it publishes (Facebook or Instagram; not Threads, not a story, not an unpublished post). Up to 2,000 characters, refused rather than cut. If the comment fails the post stays live and firstComment in the result says why.'),
5978
5987
  shareToFeed: z.boolean().optional().describe('INSTAGRAM REEL — true puts the Reel in the Feed grid as well as the Reels tab. REELS ONLY. Left unset it follows Instagram’s own default; Hermoso does not flip it either way on the user’s behalf.'),
5979
5988
  audioName: z.string().optional().describe('INSTAGRAM REEL — the name of the Reel’s audio track, which is what viewers tap through to. REELS ONLY.'),
5980
5989
  audioId: z.string().optional().describe('INSTAGRAM REEL — put one of Instagram’s OWN licensed music tracks under the Reel: the `id` search_instagram_audio returns. Instagram mixes it in when the Reel is published; nothing is downloaded or re-rendered. REELS ONLY, and only on an Instagram account connected through Meta (a Facebook Page with a linked Instagram), which is Instagram’s own rule — the Instagram connector cannot take it and is refused by name.'),
@@ -6136,6 +6145,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
6136
6145
  instagramPoll: z.array(z.string()).optional().describe('INSTAGRAM — attach a POLL: 2 to 4 answers of 1–25 characters each, and the caption IS the question (so a caption is required). Feed photos, carousels and Reels only (never a story), one caption add-on per post (not with instagramCommentPrompt), and only on an account connected through Meta (a Facebook Page with a linked Instagram). WRITE-ONCE: Instagram cannot add, change or remove it after publishing, so show the user the answers first.'),
6137
6146
  instagramPollExtended: z.boolean().optional().describe('INSTAGRAM — give that poll Instagram’s extended voting duration instead of its default 3 days (Instagram’s changelog says it then stays open indefinitely). Only with instagramPoll.'),
6138
6147
  instagramCommentPrompt: z.boolean().optional().describe('INSTAGRAM — make the caption a COMMENT PROMPT that people answer in the comments. Same limits as instagramPoll, and never together with it.'),
6148
+ firstComment: z.string().optional().describe('FIRST COMMENT — posted on each new post as the same account right after it publishes, on Facebook, Instagram and LinkedIn company Pages (LinkedIn needs its comments authorization, provider linkedin_community). Other channels publish without it and their result row says so. Up to 2,000 characters, refused rather than cut; never on a story. A failed comment never undoes the post.'),
6139
6149
  shareToFeed: z.boolean().optional().describe('INSTAGRAM REEL — true puts the Reel in the Feed grid as well as the Reels tab. REELS ONLY. Left unset it follows Instagram’s own default; Hermoso does not flip it either way on the user’s behalf.'),
6140
6150
  audioName: z.string().optional().describe('INSTAGRAM REEL — the name of the Reel’s audio track, which is what viewers tap through to. REELS ONLY.'),
6141
6151
  audioId: z.string().optional().describe('INSTAGRAM REEL — put one of Instagram’s OWN licensed music tracks under the Reel: the `id` search_instagram_audio returns. Instagram mixes it in when the Reel is published; nothing is downloaded or re-rendered. REELS ONLY, and only on an Instagram account connected through Meta (a Facebook Page with a linked Instagram), which is Instagram’s own rule — the Instagram connector cannot take it and is refused by name.'),
@@ -6149,11 +6159,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
6149
6159
  // ── WHICH ACCOUNT (server-side SCHED_ID_FIELDS). Every one of these is an answer the publish helper REFUSES
6150
6160
  // to guess, so a schedule that cannot carry it can only fail at fire time with nobody watching.
6151
6161
  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.'),
6152
- 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.'),
6153
- 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 when it fires, exactly as post_to_telegram takes them: [{text:'Shop now', url:'https://…'}] one per row, or rows [[a,b],[c]]; https://, http:// or tg:// urls, optional style danger|success|primary. A single photo, a video or a text post; an ALBUM cannot carry buttons, so that pair is refused now rather than at fire time. Refused when telegram is not a channel of the post."),
6154
- parseMode: z.enum(['HTML']).optional().describe("TELEGRAM ONLY — 'HTML' formats the Telegram text with Telegram's HTML tags (<b>, <i>, <u>, <s>, <a href=\"…\">, <code>, <pre>, <blockquote>, <tg-spoiler>) when it fires; omit for plain text. A literal <, > or & must then be written &lt; &gt; &amp;. Markdown is not parsed. Length is counted on the visible text, as Telegram counts it."),
6155
- silent: z.boolean().optional().describe('TELEGRAM ONLY — deliver the post without a notification sound when it fires.'),
6156
- disablePreview: z.boolean().optional().describe('TELEGRAM ONLY — hide Telegram\u2019s link preview under a TEXT post when it fires.'),
6162
+ 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 (@yourchannel) or the numeric id (a group is negative; a supergroup or channel starts with -100). There is no default and there cannot be one — the Telegram Bot API publishes no method that lists a bot’s chats — so scheduling telegram without one is refused up front. list_telegram_chats finds ids for chats that have messaged the bot in the last 24 hours.'),
6163
+ 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.'),
6164
+ parseMode: z.enum(['HTML']).optional().describe("TELEGRAM ONLY — 'HTML' for Telegram's HTML tags; omit for plain text."),
6165
+ silent: z.boolean().optional().describe('TELEGRAM ONLY — no notification sound.'),
6166
+ disablePreview: z.boolean().optional().describe('TELEGRAM ONLY — no link preview.'),
6157
6167
  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.'),
6158
6168
  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.'),
6159
6169
  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."),
@@ -6312,6 +6322,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
6312
6322
  instagramPoll: z.array(z.string()).optional().describe('INSTAGRAM — attach a POLL: 2 to 4 answers of 1–25 characters each, and the caption IS the question (so a caption is required). Feed photos, carousels and Reels only (never a story), one caption add-on per post (not with instagramCommentPrompt), and only on an account connected through Meta (a Facebook Page with a linked Instagram). WRITE-ONCE: Instagram cannot add, change or remove it after publishing, so show the user the answers first.'),
6313
6323
  instagramPollExtended: z.boolean().optional().describe('INSTAGRAM — give that poll Instagram’s extended voting duration instead of its default 3 days (Instagram’s changelog says it then stays open indefinitely). Only with instagramPoll.'),
6314
6324
  instagramCommentPrompt: z.boolean().optional().describe('INSTAGRAM — make the caption a COMMENT PROMPT that people answer in the comments. Same limits as instagramPoll, and never together with it.'),
6325
+ firstComment: z.string().optional().describe('FIRST COMMENT — posted on each new post as the same account right after it publishes, on Facebook, Instagram and LinkedIn company Pages (LinkedIn needs its comments authorization, provider linkedin_community). Other channels publish without it and their result row says so. Up to 2,000 characters, refused rather than cut; never on a story. A failed comment never undoes the post.'),
6315
6326
  shareToFeed: z.boolean().optional().describe('INSTAGRAM REEL — whether the Reel also shows in the Feed grid.'),
6316
6327
  audioName: z.string().optional().describe('INSTAGRAM REEL — replaces the audio track name; an empty string removes it.'),
6317
6328
  audioId: z.string().optional().describe('INSTAGRAM REEL — put one of Instagram’s OWN licensed music tracks under the Reel: the `id` search_instagram_audio returns. Instagram mixes it in when the Reel is published; nothing is downloaded or re-rendered. REELS ONLY; an empty string removes it, and only on an Instagram account connected through Meta (a Facebook Page with a linked Instagram), which is Instagram’s own rule — the Instagram connector cannot take it and is refused by name.'),
@@ -6330,10 +6341,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
6330
6341
  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.'),
6331
6342
  boardId: z.string().optional().describe('PINTEREST — move the Pin to a different board (list_pinterest_boards)'),
6332
6343
  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.'),
6333
- 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 the post carries when it fires ([] removes them). Same shape and rules as schedule_post.'),
6334
- parseMode: z.enum(['HTML']).optional().describe("TELEGRAM ONLY — 'HTML' to format the Telegram text with Telegram's HTML tags when it fires."),
6335
- silent: z.boolean().optional().describe('TELEGRAM ONLY — true/false: deliver it without a notification sound.'),
6336
- disablePreview: z.boolean().optional().describe('TELEGRAM ONLY — true/false: hide the link preview under a text post.'),
6344
+ 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).'),
6345
+ parseMode: z.enum(['HTML']).optional().describe("TELEGRAM ONLY — 'HTML' for Telegram's HTML tags."),
6346
+ silent: z.boolean().optional().describe('TELEGRAM ONLY — no notification sound.'),
6347
+ disablePreview: z.boolean().optional().describe('TELEGRAM ONLY — no link preview.'),
6337
6348
  linkedinOrganizationId: z.string().optional().describe('LINKEDIN — target a different company Page, or "" to post as the connected person instead'),
6338
6349
  pageId: z.string().optional().describe('FACEBOOK / INSTAGRAM / THREADS — publish from a different connected Page (list_meta_pages)'),
6339
6350
  locationId: z.string().optional().describe('GOOGLE BUSINESS — a different listing (list_business_locations)'),
@@ -8704,7 +8715,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
8704
8715
  title: 'Subscribe a Facebook Page to real-time events',
8705
8716
  description: 'Have Meta PUSH events to Hermoso instead of Hermoso polling for them. Subscribe a Page to `feed` (comments, posts, likes and shares — this is what makes a new comment ARRIVE rather than be polled for), `mention` (someone mentions the Page), `leadgen` (a lead-ad form submission delivered the moment it happens, which turns "read the leads when asked" into "the leads arrive"), and `messages` (an inbound Messenger or Instagram DM — THE ONLY WAY to be told about one, and Meta’s 24-hour reply window starts the moment it arrives, so being told rather than polling is time on the clock). Read what has arrived with list_meta_webhook_events. THE ANSWER IS READ BACK FROM META, never the 200: Meta can accept a subscription and record FEWER fields than you asked for, and a caller told "subscribed to leadgen" whose leads never arrive has no way to discover why — so any field Meta did not record is named. Fields Hermoso does not consume (message_reactions, messaging_postbacks, message_echoes) are refused by name with the reason, so an agent gets a fact rather than "unsupported". 0 credits.',
8706
8717
  inputSchema: {
8707
- fields: z.array(z.enum(['feed', 'mention', 'leadgen', 'messages', 'marketing_messages', 'marketing_message_echoes', 'marketing_message_deliveries', 'marketing_message_reads', 'marketing_message_clicks', 'marketing_message_delivery_failed'])).describe('what to be told about. `marketing_messages` is shorthand for the five marketing_message_* outcome fields (sent / delivered / read / clicked / failed of a paid Messenger marketing message, read with get_messenger_marketing_message_status). Pass every field the Page should keep: Meta does not document whether a subscribe merges or replaces the list, and the read-back shows what it recorded. At least one — Meta marks it required, and defaulting it would decide on the brand’s behalf what they hear about.'),
8718
+ fields: z.array(z.enum(['feed', 'mention', 'leadgen', 'messages', 'marketing_messages', 'marketing_message_echoes', 'marketing_message_deliveries', 'marketing_message_reads', 'marketing_message_clicks', 'marketing_message_delivery_failed'])).describe('what to be told about. `marketing_messages` is shorthand for the five marketing_message_* outcome fields (sent / delivered / read / clicked / failed of a paid Messenger marketing message, read with get_messenger_marketing_message_status). Pass every field the Page should keep: Meta does not document whether a subscribe merges or replaces the list, and the read-back shows what it recorded. DM automation’s own fields (messaging_postbacks, messaging_referrals) are kept when already present, so subscribing never breaks it. At least one — Meta marks it required, and defaulting it would decide on the brand’s behalf what they hear about.'),
8708
8719
  pageId: z.string().optional().describe('Facebook Page id — omit when only one Page is connected'),
8709
8720
  },
8710
8721
  outputSchema: { pageId: z.string().optional(), page: z.string().optional(), asked: z.array(z.string()).optional(), subscribed: z.boolean().nullable().optional(), fields: z.array(z.string()).optional(), summary: z.string().optional(), retry: z.string().optional(), callback: z.string().optional() },
@@ -9636,10 +9647,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
9636
9647
  server.group('analytics');
9637
9648
  const shopifyMoney = z.object({ amount: z.number().describe('MAJOR units: 40 means 40.00'), currency: z.string().describe('3-letter, e.g. USD') });
9638
9649
  server.registerTool('record_shopify_marketing_activity', {
9639
- title: 'Record an ad or campaign in Shopify ▸ Marketing',
9650
+ title: 'Record an ad or campaign in Shopify > Marketing',
9640
9651
  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.",
9641
9652
  inputSchema: {
9642
- title: z.string().optional().describe('what the merchant sees in Shopify ▸ Marketing; required on the first recording'),
9653
+ title: z.string().optional().describe('what the merchant sees in Shopify > Marketing; required on the first recording'),
9643
9654
  remoteUrl: z.string().optional().describe('https link where this ad or campaign is managed or viewed; required on the first recording'),
9644
9655
  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'),
9645
9656
  platform: z.string().optional().describe('where it runs, e.g. meta, google_ads, tiktok_ads; only used to derive remoteId'),
@@ -9658,7 +9669,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
9658
9669
  }, wrap(async (a) => { const d = await apiPost('/api/shopify/marketing-activity', a); return ok(d.note, d); }));
9659
9670
  server.registerTool('list_shopify_marketing_activities', {
9660
9671
  title: 'List Shopify marketing activities',
9661
- 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.",
9672
+ 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.",
9662
9673
  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') },
9663
9674
  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() },
9664
9675
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -9668,7 +9679,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
9668
9679
  }));
9669
9680
  server.registerTool('delete_shopify_marketing_activity', {
9670
9681
  title: 'Delete a Shopify marketing activity',
9671
- 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.",
9682
+ 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.",
9672
9683
  inputSchema: { remoteId: z.string().optional(), id: z.string().optional().describe('gid://shopify/MarketingActivity/…'), confirm: z.boolean().optional() },
9673
9684
  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() },
9674
9685
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
@@ -10601,7 +10612,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
10601
10612
  }));
10602
10613
  server.registerTool('google_ads_recommendations', {
10603
10614
  title: 'Google Ads recommendations + optimization score',
10604
- description: 'Google Ads’ own advice for the account: the optimization score (account and per campaign), every open recommendation with what applying it would change (budget $X → $Y, a keyword, a bid target, a Google-written ad) and Google’s weekly impact estimate, each tagged spend (changes what the account buys) or serves (changes what an ad shows), plus which types Google is auto-applying by itself. An empty list means Google has no advice yet, never that the account is optimal. Ids go stale fast, so act on a fresh read. Read-only, 0 credits.',
10615
+ description: 'Google Ads’ own advice for the account: the optimization score (account and per campaign), every open recommendation with what applying it would change (budget $X -> $Y, a keyword, a bid target, a Google-written ad) and Google’s weekly impact estimate, each tagged spend (changes what the account buys) or serves (changes what an ad shows), plus which types Google is auto-applying by itself. An empty list means Google has no advice yet, never that the account is optimal. Ids go stale fast, so act on a fresh read. Read-only, 0 credits.',
10605
10616
  inputSchema: {
10606
10617
  customerId: z.string().optional().describe('10-digit account id, omit for the brand’s default account'),
10607
10618
  campaignId: z.string().optional().describe('only recommendations for this campaign'),
@@ -12047,7 +12058,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
12047
12058
  interval: z.number().optional().describe('-300000 realtime, -3600000 hourly, 1 daily, 7 weekly, 30 monthly'),
12048
12059
  groupBy: z.string().optional(),
12049
12060
  groupBy2: z.string().optional().describe("a SECOND group-by (Amplitude's g2), e.g. groupBy gp:utm_campaign + groupBy2 country"),
12050
- 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, time_type:'allTime'|'forEachInterval'|'currentInterval'}."),
12061
+ 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."),
12051
12062
  limit: z.number().optional().describe('≤1000'),
12052
12063
  },
12053
12064
  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() },
@@ -12067,7 +12078,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
12067
12078
  end: z.string().optional().describe('YYYYMMDD or YYYY-MM-DD (default today)'),
12068
12079
  groupBy: z.string().optional().describe('at most ONE — a second is refused'),
12069
12080
  users: z.enum(['active', 'new']).optional().describe("'new' = first-time users only (the acquisition funnel); Amplitude's default is active"),
12070
- 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, time_type:'allTime'|'forEachInterval'|'currentInterval'}."),
12081
+ 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."),
12071
12082
  },
12072
12083
  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() },
12073
12084
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -12087,7 +12098,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
12087
12098
  brackets: z.array(z.number()).optional().describe('required when retentionMode is "bracket"'),
12088
12099
  interval: z.number().optional().describe('1 daily, 7 weekly, 30 monthly'),
12089
12100
  groupBy: z.string().optional().describe('ONE property to group by'),
12090
- 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, time_type:'allTime'|'forEachInterval'|'currentInterval'}."),
12101
+ 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."),
12091
12102
  },
12092
12103
  outputSchema: { retention: z.any().optional(), raw: z.any().optional(), note: z.string().optional() },
12093
12104
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -12104,7 +12115,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
12104
12115
  metric: z.enum(['active', 'new']).optional().describe('default active'),
12105
12116
  interval: z.number().optional().describe('1 daily, 7 weekly, 30 monthly'),
12106
12117
  groupBy: z.string().optional().describe('ONE property to group by'),
12107
- 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, time_type:'allTime'|'forEachInterval'|'currentInterval'}."),
12118
+ 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."),
12108
12119
  },
12109
12120
  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() },
12110
12121
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -12234,7 +12245,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
12234
12245
  }));
12235
12246
  server.registerTool('create_mixpanel_annotation', {
12236
12247
  title: 'Mark the Mixpanel timeline',
12237
- 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.",
12248
+ 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 (written in the project's timezone so the mark sits at that exact instant, read back to prove it); 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.",
12238
12249
  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') },
12239
12250
  outputSchema: { project: z.string().optional(), created: z.boolean().optional(), annotation: z.any().optional(), dateNote: z.string().optional(), warning: z.string().optional() },
12240
12251
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
@@ -12627,8 +12638,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
12627
12638
  }));
12628
12639
  server.registerTool('create_hubspot_contact', {
12629
12640
  title: 'Create or update a HubSpot contact',
12630
- description: "Create a contact in the brand's HubSpot by email, or UPDATE the contact that already has that email — HubSpot's duplicate answer carries the existing id, so this is an UPSERT and never makes a twin. Optional firstname, lastname, phone, company, website, lifecyclestage, hs_lead_status, plus any other writable contact property in `properties` (by internal name). The contact is READ BACK after the write; the reply says created or updated. Free. Needs HubSpot connected.",
12631
- inputSchema: { email: z.string().describe('the contact email — the key HubSpot de-duplicates on'), firstname: z.string().optional(), lastname: z.string().optional(), phone: z.string().optional(), company: z.string().optional(), website: z.string().optional(), lifecyclestage: z.string().optional(), hs_lead_status: z.string().optional(), properties: z.record(z.any()).optional().describe('any other writable contact properties, by internal name') },
12641
+ description: "Create a contact in the brand's HubSpot by email, or UPDATE the contact that already has that email — HubSpot's duplicate answer carries the existing id, so this is an UPSERT and never makes a twin. Optional firstname, lastname, phone, company, website, lifecyclestage, hs_lead_status, ownerId (the sales rep to assign it to — an owner id from list_hubspot_owners), plus any other writable contact property in `properties` (by internal name). The contact is READ BACK after the write; the reply says created or updated. Free. Needs HubSpot connected.",
12642
+ inputSchema: { email: z.string().describe('the contact email — the key HubSpot de-duplicates on'), firstname: z.string().optional(), lastname: z.string().optional(), phone: z.string().optional(), company: z.string().optional(), website: z.string().optional(), lifecyclestage: z.string().optional(), hs_lead_status: z.string().optional(), ownerId: z.string().optional().describe('HubSpot owner id (digits) from list_hubspot_owners'), properties: z.record(z.any()).optional().describe('any other writable contact properties, by internal name') },
12632
12643
  outputSchema: { ok: z.boolean().optional(), action: z.string().optional(), contact: z.any().optional(), note: z.string().optional() },
12633
12644
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
12634
12645
  }, wrap(async (a) => { const d = await apiPost('/api/hubspot/contact', a); return ok(d.note, d); }));
@@ -12644,13 +12655,33 @@ function buildTools(rawServer, opts = {}, sink = null) {
12644
12655
  }));
12645
12656
  server.registerTool('hubspot_deals_report', {
12646
12657
  title: 'HubSpot deals report',
12647
- 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.",
12658
+ 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), BY OWNER (the sales rep holding each deal, by name), a monthly created / closed-won series, and totals. Capped at 2,000 deals and says so. Read-only, free. Needs HubSpot connected.",
12648
12659
  inputSchema: { since: z.string().optional().describe('YYYY-MM-DD, default 90 days ago'), until: z.string().optional().describe('YYYY-MM-DD, default today') },
12649
- 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() },
12660
+ 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(), byOwner: z.array(z.any()).optional(), ownersNote: z.string().optional(), monthly: z.array(z.any()).optional(), totals: z.any().optional(), capped: z.boolean().optional(), note: z.string().optional() },
12650
12661
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
12651
12662
  }, wrap(async (a) => {
12652
12663
  const d = await apiPost('/api/hubspot/deals/report', a);
12653
- 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);
12664
+ 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'}`, `By owner: ${(d.byOwner || []).map(x => `${x.owner} ${x.count} (${x.amount})`).join('; ') || 'none'}${d.ownersNote ? ' — ' + d.ownersNote : ''}`, `Monthly: ${(d.monthly || []).map(m => `${m.month} created ${m.created} (${m.createdAmount}), won ${m.closedWon} (${m.closedWonAmount})`).join('; ') || 'none'}`].join('\n'), d);
12665
+ }));
12666
+ server.registerTool('list_hubspot_companies', {
12667
+ title: 'List or search HubSpot companies',
12668
+ description: "COMPANIES (accounts) from the brand's OWN HubSpot CRM, newest first: name, domain, website, LinkedIn company page, industry, employees, annual revenue, lifecycle stage, lead status, country/city, owner (rep name), original source + drill-downs, associated contacts and deals, total revenue — plus a de-duplicated `domains` list, which is what LinkedIn company targeting and account-based (ABM) audiences are built from. search matches name / domain / website / phone; filters lifecycleStage (e.g. customer, opportunity, salesqualifiedlead), industry (HubSpot's internal value, e.g. COMPUTER_SOFTWARE), ownerId, createdSince (YYYY-MM-DD). Read-only, free. Needs HubSpot connected.",
12669
+ inputSchema: { search: z.string().optional().describe('name, domain, website or phone'), lifecycleStage: z.string().optional().describe('internal value, e.g. customer, opportunity, salesqualifiedlead, lead'), industry: z.string().optional().describe("HubSpot's internal industry value, e.g. COMPUTER_SOFTWARE"), ownerId: z.string().optional().describe('owner id from list_hubspot_owners'), createdSince: z.string().optional().describe('YYYY-MM-DD'), limit: z.number().optional().describe('1–100, default 25'), after: z.string().optional().describe('pagination cursor from the previous page') },
12670
+ outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), total: z.number().nullable().optional(), companies: z.array(z.any()).optional(), domains: z.array(z.string()).optional(), after: z.string().nullable().optional(), ownersNote: z.string().optional(), note: z.string().optional() },
12671
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
12672
+ }, wrap(async (a) => {
12673
+ const d = await apiGet('/api/hubspot/companies', a);
12674
+ return ok(d.count ? `${d.note}\n${d.companies.map(c => `• ${c.name || '(no name)'}${c.domain ? ` ${c.domain}` : ''}${c.lifecycleStage ? ` · ${c.lifecycleStage}` : ''}${c.industry ? ` · ${c.industry}` : ''}${c.employees != null ? ` · ${c.employees} staff` : ''}${c.ownerName || c.ownerId ? ` · owner ${c.ownerName || c.ownerId}` : ''}${c.source ? ` · ${c.source}` : ''}${c.deals ? ` · ${c.deals} deal(s)` : ''} (${c.id})`).join('\n')}${d.after ? '\n(more: pass after=' + d.after + ')' : ''}${d.ownersNote ? '\n' + d.ownersNote : ''}` : d.note, d);
12675
+ }));
12676
+ server.registerTool('list_hubspot_owners', {
12677
+ title: 'List HubSpot owners (sales reps)',
12678
+ description: "The sales reps (OWNERS) in the brand's HubSpot: id, name, email, teams. An owner's id is what create_hubspot_contact's ownerId assigns a lead to, and what list_hubspot_companies filters by. search narrows by name or email; archived:true lists DEACTIVATED owners instead. Read-only, free. Needs HubSpot connected.",
12679
+ inputSchema: { search: z.string().optional().describe('name or email contains'), archived: z.boolean().optional().describe('true = deactivated owners only') },
12680
+ outputSchema: { ok: z.boolean().optional(), archived: z.boolean().optional(), count: z.number().optional(), owners: z.array(z.any()).optional(), capped: z.boolean().optional(), note: z.string().optional() },
12681
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
12682
+ }, wrap(async (a) => {
12683
+ const d = await apiGet('/api/hubspot/owners', a);
12684
+ return ok(d.count ? `${d.note}\n${d.owners.map(o => `• ${o.name}${o.email ? ` <${o.email}>` : ''}${o.teams.length ? ` · ${o.teams.map(t => t.name).join(', ')}` : ''} (id ${o.id})`).join('\n')}` : d.note, d);
12654
12685
  }));
12655
12686
  server.registerTool('get_hubspot_lead_sync', {
12656
12687
  title: 'Read the automatic HubSpot lead sync',
@@ -12817,6 +12848,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
12817
12848
  path1: z.string().optional().describe('display-URL path segment, ≤15 chars, no "/"'),
12818
12849
  path2: z.string().optional().describe('second display-URL path segment (only with path1)'),
12819
12850
  };
12851
+ // THE AD ACCOUNT IS `adAccountId` (2026-10-27). On /v1 `accountId` names the WORKSPACE and a body may never carry it,
12852
+ // so the Microsoft tools could not name an ad account over REST at all. `accountId` stays as an alias HERE, for MCP
12853
+ // callers written before the rename; /v1 still refuses it by name and the server reads adAccountId first.
12854
+ const msAcct = {
12855
+ adAccountId: z.string().optional().describe('Microsoft ad account id — omit to use the single shared account'),
12856
+ accountId: z.string().optional().describe('older name for adAccountId (MCP only)'),
12857
+ };
12820
12858
  const msKeywordShape = z.array(z.object({
12821
12859
  text: z.string().describe('≤100 characters'),
12822
12860
  matchType: z.enum(['Exact', 'Phrase', 'Broad']).optional().describe('default Phrase — Microsoft has no broad-match-modifier'),
@@ -12825,20 +12863,20 @@ function buildTools(rawServer, opts = {}, sink = null) {
12825
12863
  }));
12826
12864
  server.registerTool('list_microsoft_ads_campaigns', {
12827
12865
  title: 'List Microsoft Advertising accounts / campaigns',
12828
- description: 'Read the brand’s connected Microsoft Advertising (Bing Ads) account(s). Call with NO accountId to list the accounts shared with this profile — do this first to pick a target. Call WITH accountId to list that account’s campaigns (id, name, status, daily budget, campaign type, and whether the budget is SHARED). Microsoft statuses are Active / Paused — never Google’s ENABLED — and Microsoft also sets BudgetPaused, BudgetAndManualPaused and Suspended on its own, so report the status you read rather than assuming a paused campaign was paused by a person. Read-only, free. Needs Microsoft Advertising connected (Settings > Connectors > Microsoft Advertising).',
12866
+ 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).',
12829
12867
  inputSchema: {
12830
- accountId: z.string().optional().describe('Microsoft ad account id — omit to list the accounts shared with this profile'),
12868
+ ...msAcct,
12831
12869
  },
12832
12870
  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() },
12833
12871
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
12834
12872
  }, wrap(async (a) => {
12835
- if (!a.accountId) {
12873
+ if (!(a.adAccountId || a.accountId)) {
12836
12874
  const d = await apiGet('/api/microsoft-ads/shared-accounts', {});
12837
12875
  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);
12838
12876
  const lines = (d.accounts || []).map(c => `• ${c.name} (${c.accountId})${c.number ? ` — ${c.number}` : ''}${c.selected ? ' [shared with this profile]' : ''}${c.status ? ` · ${c.status}` : ''}`);
12839
- return ok(`${(d.accounts || []).length} Microsoft Advertising account(s) shared with this profile${d.sandbox ? ' (SANDBOX environment)' : ''}:\n${lines.join('\n') || '(none)'}\nPass an accountId to see its campaigns. Only accounts ticked in Settings ▸ Connectors can be managed.`, d);
12877
+ 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);
12840
12878
  }
12841
- const d = await apiGet('/api/microsoft-ads/campaigns', { accountId: a.accountId });
12879
+ const d = await apiGet('/api/microsoft-ads/campaigns', { adAccountId: (a.adAccountId || a.accountId) });
12842
12880
  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}` : ''}`);
12843
12881
  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);
12844
12882
  }));
@@ -12846,7 +12884,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
12846
12884
  title: 'Microsoft Advertising performance report',
12847
12885
  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.',
12848
12886
  inputSchema: {
12849
- accountId: z.string().optional().describe('Microsoft ad account id — omit to use the single shared account'),
12887
+ ...msAcct,
12850
12888
  timePeriod: z.string().optional().describe('predefined Microsoft window, default Last30Days — must be one of the values in the description; anything else is rejected'),
12851
12889
  since: z.string().optional().describe('YYYY-MM-DD custom range start (with until)'),
12852
12890
  until: z.string().optional().describe('YYYY-MM-DD custom range end'),
@@ -12870,7 +12908,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
12870
12908
  title: 'Microsoft Advertising keyword planner',
12871
12909
  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.',
12872
12910
  inputSchema: {
12873
- accountId: z.string().optional(),
12911
+ ...msAcct,
12874
12912
  keywords: z.array(z.string()).optional().describe('seed terms to expand from'),
12875
12913
  url: z.string().optional().describe('a landing page for Microsoft to mine ideas from'),
12876
12914
  categoryId: z.number().optional(),
@@ -12892,7 +12930,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
12892
12930
  title: 'Microsoft Advertising traffic estimates',
12893
12931
  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.',
12894
12932
  inputSchema: {
12895
- accountId: z.string().optional(),
12933
+ ...msAcct,
12896
12934
  keywords: z.array(z.string()).describe('the keywords to estimate'),
12897
12935
  maxCpc: z.number().describe('REQUIRED \u2014 the max CPC bid to estimate at, in the account currency'),
12898
12936
  matchType: z.enum(['Exact', 'Phrase', 'Broad']).optional().describe('default Exact'),
@@ -12908,18 +12946,18 @@ function buildTools(rawServer, opts = {}, sink = null) {
12908
12946
  server.registerTool('microsoft_ads_budget_opportunities', {
12909
12947
  title: 'Where Microsoft says budget is capping delivery',
12910
12948
  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.',
12911
- inputSchema: { accountId: z.string().optional(), campaignId: z.string().optional().describe('omit for the whole account') },
12949
+ inputSchema: { ...msAcct, campaignId: z.string().optional().describe('omit for the whole account') },
12912
12950
  outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), opportunities: z.array(z.any()).optional(), note: z.string().optional() },
12913
12951
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
12914
12952
  }, wrap(async (a) => {
12915
- const d = await apiGet('/api/microsoft-ads/budget-opportunities', { accountId: a.accountId, campaignId: a.campaignId });
12953
+ const d = await apiGet('/api/microsoft-ads/budget-opportunities', { adAccountId: (a.adAccountId || a.accountId), campaignId: a.campaignId });
12916
12954
  return ok(`${d.note}\n${JSON.stringify(d.opportunities || []).slice(0, 4000)}`, d);
12917
12955
  }));
12918
12956
  server.registerTool('microsoft_ads_geo_search', {
12919
12957
  title: 'Find Microsoft Advertising location ids',
12920
12958
  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.',
12921
12959
  inputSchema: {
12922
- accountId: z.string().optional().describe('Microsoft ad account id \u2014 omit to use the brand\u2019s single shared account'),
12960
+ ...msAcct,
12923
12961
  // A STRING IS ACCEPTED, not only an array. The server has always normalized a lone string, but the schema
12924
12962
  // REJECTED one before it reached the server \u2014 so the obvious single-location call ("Canada") failed validation
12925
12963
  // on a parameter whose array-ness nothing explained. Commas are never split: they occur INSIDE real Microsoft
@@ -12936,7 +12974,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
12936
12974
  title: 'Build a Microsoft Advertising campaign (paused)',
12937
12975
  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.',
12938
12976
  inputSchema: {
12939
- accountId: z.string().optional().describe('Microsoft ad account id — omit to use the single shared account'),
12977
+ ...msAcct,
12940
12978
  name: z.string().describe('campaign name, ≤128 characters'),
12941
12979
  dailyBudget: z.number().describe('daily budget in the account’s currency'),
12942
12980
  budgetType: z.enum(['DailyBudgetStandard', 'DailyBudgetAccelerated', 'LifetimeBudgetStandard']).optional().describe('default DailyBudgetStandard; Accelerated is Audience-campaign only'),
@@ -12947,11 +12985,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
12947
12985
  feedLabel: z.string().optional().describe('Shopping: advertise every product with this feed label (≤20 characters). Not with salesCountryCode.'),
12948
12986
  localInventoryAds: z.boolean().optional().describe('Shopping: enable local inventory ads (only where Microsoft has enabled the feature for the account)'),
12949
12987
  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.'),
12950
- biddingScheme: z.enum(['MaxConversions', 'MaxConversionValue']).optional().describe('Performance Max only. Omitted = Microsoft\u2019s default (MaxConversions), read back in the reply.'),
12988
+ 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'),
12989
+ maxCpc: z.number().optional().describe('bid cap for MaxClicks / MaxConversions / MaxConversionValue / TargetImpressionShare (not Performance Max)'),
12990
+ targetImpressionShare: z.number().optional().describe('1–100, with biddingScheme TargetImpressionShare'),
12991
+ targetAdPosition: z.enum(['FirstPage', 'MainLine', 'MainLine1']).optional().describe('with TargetImpressionShare; MainLine1 = the very top'),
12951
12992
  targetCpa: z.number().optional().describe('Performance Max with MaxConversions: target cost per acquisition, account currency'),
12952
12993
  targetRoas: z.number().optional().describe('Performance Max with MaxConversionValue: target return on ad spend as a ratio, e.g. 4 = 400%'),
12953
12994
  finalUrlExpansionOptOut: z.boolean().optional().describe('Performance Max: true = serve only the final URLs you give; default false lets Microsoft use the whole domain'),
12954
- timeZone: z.string().optional().describe('Microsoft time-zone enum — Microsoft requires one; default PacificTimeUSCanadaTijuana'),
12995
+ 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'),
12955
12996
  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'),
12956
12997
  excludeLocations: z.array(z.string()).optional().describe('locations to EXCLUDE from the targeted set'),
12957
12998
  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'),
@@ -12969,7 +13010,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
12969
13010
  title: 'Add a Microsoft Advertising ad group',
12970
13011
  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.',
12971
13012
  inputSchema: {
12972
- accountId: z.string().optional().describe('Microsoft ad account id — omit to use the single shared account'),
13013
+ ...msAcct,
12973
13014
  campaignId: z.string().describe('the campaign this ad group belongs to'),
12974
13015
  name: z.string().describe('ad group name, ≤256 characters'),
12975
13016
  status: z.enum(['Active', 'Paused']).optional().describe('default Paused'),
@@ -12989,7 +13030,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
12989
13030
  title: 'Create a Microsoft responsive search ad',
12990
13031
  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.',
12991
13032
  inputSchema: {
12992
- accountId: z.string().optional().describe('Microsoft ad account id — omit to use the single shared account'),
13033
+ ...msAcct,
12993
13034
  adGroupId: z.string().describe('the ad group this ad lives in'),
12994
13035
  ...msAdShape,
12995
13036
  status: z.enum(['Active', 'Paused']).optional().describe('default Paused'),
@@ -13005,7 +13046,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13005
13046
  title: 'Add Microsoft Advertising keywords',
13006
13047
  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.',
13007
13048
  inputSchema: {
13008
- accountId: z.string().optional().describe('Microsoft ad account id — omit to use the single shared account'),
13049
+ ...msAcct,
13009
13050
  adGroupId: z.string().describe('the ad group to add them to'),
13010
13051
  keywords: msKeywordShape.describe('the keywords'),
13011
13052
  confirm: z.boolean().optional().describe('REQUIRED true to add an Active keyword to a LIVE ad group'),
@@ -13020,7 +13061,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13020
13061
  title: 'Find Microsoft Advertising LinkedIn profile ids',
13021
13062
  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.',
13022
13063
  inputSchema: {
13023
- accountId: z.string().optional().describe('Microsoft ad account id; omit to use the single shared account'),
13064
+ ...msAcct,
13024
13065
  profileType: z.enum(['CompanyName', 'Industry', 'JobFunction']),
13025
13066
  query: z.string().describe('part of the name to find (3+ characters for a company)'),
13026
13067
  languageLocale: z.string().optional().describe('en (default), fr, de, it, pt-BR, es or zh-Hant'),
@@ -13032,7 +13073,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13032
13073
  title: 'Read a Microsoft Advertising campaign’s LinkedIn profile targeting',
13033
13074
  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.',
13034
13075
  inputSchema: {
13035
- accountId: z.string().optional().describe('Microsoft ad account id; omit to use the single shared account'),
13076
+ ...msAcct,
13036
13077
  campaignId: z.string(),
13037
13078
  },
13038
13079
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -13041,7 +13082,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13041
13082
  title: 'Set LinkedIn profile targeting on a Microsoft Advertising campaign',
13042
13083
  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.',
13043
13084
  inputSchema: {
13044
- accountId: z.string().optional().describe('Microsoft ad account id; omit to use the single shared account'),
13085
+ ...msAcct,
13045
13086
  campaignId: z.string(),
13046
13087
  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(),
13047
13088
  update: z.array(z.object({ criterionId: z.string(), bidAdjustment: z.number().describe('whole percent, -90 to 900') })).optional(),
@@ -13053,7 +13094,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13053
13094
  title: 'Set a Microsoft Advertising daily budget',
13054
13095
  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.',
13055
13096
  inputSchema: {
13056
- accountId: z.string().optional().describe('Microsoft ad account id — omit to use the single shared account'),
13097
+ ...msAcct,
13057
13098
  campaignId: z.string().describe('the campaign whose budget changes'),
13058
13099
  dailyBudget: z.number().describe('new daily budget in the account’s currency'),
13059
13100
  confirm: z.boolean().optional().describe('REQUIRED true to change the budget of a LIVE campaign'),
@@ -13068,7 +13109,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13068
13109
  title: 'Activate or pause a Microsoft Advertising campaign / ad group / ad',
13069
13110
  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.',
13070
13111
  inputSchema: {
13071
- accountId: z.string().optional().describe('Microsoft ad account id — omit to use the single shared account'),
13112
+ ...msAcct,
13072
13113
  level: z.enum(['campaign', 'adGroup', 'ad']).optional().describe('what to change — default campaign'),
13073
13114
  campaignId: z.string().optional().describe('campaign id (level:"campaign")'),
13074
13115
  adGroupId: z.string().optional().describe('ad group id (level:"adGroup", or with adId for level:"ad")'),
@@ -13088,7 +13129,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13088
13129
  title: 'Delete a Microsoft Advertising campaign / ad group / ad / keyword',
13089
13130
  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.',
13090
13131
  inputSchema: {
13091
- accountId: z.string().optional().describe('Microsoft ad account id — omit to use the single shared account'),
13132
+ ...msAcct,
13092
13133
  level: z.enum(['campaign', 'adGroup', 'ad', 'keyword']).optional().describe('what to delete — default campaign'),
13093
13134
  campaignId: z.string().optional().describe('campaign id (level:"campaign"; also the parent for level:"adGroup" if you know it)'),
13094
13135
  adGroupId: z.string().optional().describe('ad group id (level:"adGroup"; REQUIRED as the parent for level:"ad" and level:"keyword")'),
@@ -13118,7 +13159,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13118
13159
  title: "List Microsoft Merchant Center stores",
13119
13160
  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.",
13120
13161
  inputSchema: {
13121
- accountId: z.string().optional().describe('Microsoft ad account id \u2014 omit to use the brand\u2019s single shared account'),
13162
+ ...msAcct,
13122
13163
  },
13123
13164
  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() },
13124
13165
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
@@ -13130,7 +13171,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13130
13171
  title: "List Microsoft Merchant Center product offers",
13131
13172
  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.",
13132
13173
  inputSchema: {
13133
- accountId: z.string().optional().describe('Microsoft ad account id \u2014 omit to use the brand\u2019s single shared account'),
13174
+ ...msAcct,
13134
13175
  merchantId: z.string().optional().describe('Microsoft Merchant Center store id from list_microsoft_merchant_stores \u2014 omit when the account has exactly one store'),
13135
13176
  productId: z.string().optional().describe('fully qualified id (channel:contentLanguage:targetCountry:offerId, e.g. Online:en:US:Sku123) to fetch ONE offer'),
13136
13177
  limit: z.number().optional().describe('up to 250, default 25'),
@@ -13146,7 +13187,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13146
13187
  title: "Add or update Microsoft Merchant Center product offers",
13147
13188
  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.",
13148
13189
  inputSchema: {
13149
- accountId: z.string().optional().describe('Microsoft ad account id \u2014 omit to use the brand\u2019s single shared account'),
13190
+ ...msAcct,
13150
13191
  merchantId: z.string().optional().describe('Microsoft Merchant Center store id from list_microsoft_merchant_stores \u2014 omit when the account has exactly one store'),
13151
13192
  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'),
13152
13193
  products: z.array(z.record(z.any())).optional().describe('several whole offers in one batch, maximum 300'),
@@ -13163,7 +13204,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13163
13204
  title: "Delete a Microsoft Merchant Center product offer",
13164
13205
  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.",
13165
13206
  inputSchema: {
13166
- accountId: z.string().optional().describe('Microsoft ad account id \u2014 omit to use the brand\u2019s single shared account'),
13207
+ ...msAcct,
13167
13208
  merchantId: z.string().optional().describe('Microsoft Merchant Center store id from list_microsoft_merchant_stores \u2014 omit when the account has exactly one store'),
13168
13209
  productId: z.string().describe('the FULLY QUALIFIED id (channel:contentLanguage:targetCountry:offerId), not the offerId'),
13169
13210
  confirm: z.boolean().optional().describe('REQUIRED true \u2014 call without it first to see Microsoft\u2019s own advice about the 12-hour delivery tail'),
@@ -13178,7 +13219,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13178
13219
  title: "Why Microsoft Merchant Center products are not serving",
13179
13220
  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.",
13180
13221
  inputSchema: {
13181
- accountId: z.string().optional().describe('Microsoft ad account id \u2014 omit to use the brand\u2019s single shared account'),
13222
+ ...msAcct,
13182
13223
  merchantId: z.string().optional().describe('Microsoft Merchant Center store id from list_microsoft_merchant_stores \u2014 omit when the account has exactly one store'),
13183
13224
  limit: z.number().optional().describe('up to 250, default 25'),
13184
13225
  pageToken: z.string().optional(),
@@ -13193,7 +13234,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13193
13234
  title: "List Microsoft Merchant Center catalogs",
13194
13235
  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.",
13195
13236
  inputSchema: {
13196
- accountId: z.string().optional().describe('Microsoft ad account id \u2014 omit to use the brand\u2019s single shared account'),
13237
+ ...msAcct,
13197
13238
  merchantId: z.string().optional().describe('Microsoft Merchant Center store id from list_microsoft_merchant_stores \u2014 omit when the account has exactly one store'),
13198
13239
  },
13199
13240
  outputSchema: { ok: z.boolean().optional(), merchantId: z.string().optional(), count: z.number().optional(), catalogs: z.array(z.any()).optional(), note: z.string().optional() },
@@ -13206,7 +13247,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13206
13247
  title: "Create, update or delete a Microsoft Merchant Center catalog",
13207
13248
  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.",
13208
13249
  inputSchema: {
13209
- accountId: z.string().optional().describe('Microsoft ad account id \u2014 omit to use the brand\u2019s single shared account'),
13250
+ ...msAcct,
13210
13251
  merchantId: z.string().optional().describe('Microsoft Merchant Center store id from list_microsoft_merchant_stores \u2014 omit when the account has exactly one store'),
13211
13252
  catalogId: z.string().optional().describe('set to UPDATE or DELETE an existing catalog; omit to create a new one'),
13212
13253
  name: z.string().optional().describe('unique within the store, maximum 70 characters'),
@@ -13225,7 +13266,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13225
13266
  title: 'Why a Microsoft ad or keyword was disapproved',
13226
13267
  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.",
13227
13268
  inputSchema: {
13228
- accountId: z.string().optional().describe('Microsoft ad account id \u2014 omit to use the brand\u2019s single shared account'),
13269
+ ...msAcct,
13229
13270
  adGroupId: z.string().describe('the ad group the ads or keywords sit in'),
13230
13271
  adIds: z.array(z.string()).optional().describe('the ads to explain'),
13231
13272
  keywordIds: z.array(z.string()).optional().describe('or the keywords to explain (not both)'),
@@ -13236,11 +13277,110 @@ function buildTools(rawServer, opts = {}, sink = null) {
13236
13277
  const d = await apiPost('/api/microsoft-ads/editorial-reasons', a);
13237
13278
  return ok(d.note, d);
13238
13279
  }));
13280
+ // ---------- Microsoft Advertising: negatives, bidding, conversion tracking, audiences, image + video ads (2026-10-27) ----------
13281
+ 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') }));
13282
+ server.registerTool('list_microsoft_ads_negative_keywords', {
13283
+ title: 'List Microsoft Advertising negative keywords',
13284
+ 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.',
13285
+ inputSchema: { ...msAcct, campaignId: z.string().optional(), adGroupId: z.string().optional(), listId: z.string().optional().describe('a shared negative keyword list') },
13286
+ 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() },
13287
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
13288
+ }, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/negative-keywords/list', a); return ok(d.note, d); }));
13289
+ server.registerTool('add_microsoft_ads_negative_keywords', {
13290
+ title: 'Exclude searches on Microsoft Advertising (negative keywords)',
13291
+ 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.',
13292
+ inputSchema: { ...msAcct, campaignId: z.string().optional(), adGroupId: z.string().optional().describe('pass this OR campaignId'), keywords: msNegShape },
13293
+ outputSchema: { ok: z.boolean().optional(), added: z.array(z.any()).optional(), total: z.number().optional(), note: z.string().optional() },
13294
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
13295
+ }, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/negative-keywords', a); return ok(d.note, d); }));
13296
+ server.registerTool('remove_microsoft_ads_negative_keywords', {
13297
+ title: 'Remove Microsoft Advertising negative keywords',
13298
+ 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.',
13299
+ 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') },
13300
+ outputSchema: { ok: z.boolean().optional(), removed: z.array(z.string()).optional(), stillThere: z.array(z.string()).optional(), note: z.string().optional() },
13301
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
13302
+ }, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/negative-keywords/remove', a); return ok(d.note, d); }));
13303
+ server.registerTool('manage_microsoft_ads_negative_keyword_list', {
13304
+ title: 'Manage a shared Microsoft negative keyword list',
13305
+ 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.',
13306
+ 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() },
13307
+ 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() },
13308
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
13309
+ }, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/negative-keyword-list', a); return ok(d.note, d); }));
13310
+ server.registerTool('set_microsoft_ads_bidding', {
13311
+ title: 'Change a Microsoft Advertising bid strategy',
13312
+ 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.',
13313
+ 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') },
13314
+ outputSchema: { ok: z.boolean().optional(), campaignId: z.string().optional(), requested: z.string().optional(), bidding: z.any().optional(), note: z.string().optional() },
13315
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
13316
+ }, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/bidding', a); return ok(d.note, d); }));
13317
+ server.registerTool('list_microsoft_ads_uet_tags', {
13318
+ title: 'List Microsoft Advertising UET tags',
13319
+ 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.',
13320
+ inputSchema: { ...msAcct, tagIds: z.array(z.string()).optional() },
13321
+ outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), tags: z.array(z.any()).optional(), note: z.string().optional() },
13322
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
13323
+ }, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/uet-tags', a); return ok(d.note, d); }));
13324
+ server.registerTool('create_microsoft_ads_uet_tag', {
13325
+ title: 'Create a Microsoft Advertising UET tag',
13326
+ 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.',
13327
+ inputSchema: { ...msAcct, name: z.string(), description: z.string().optional() },
13328
+ outputSchema: { ok: z.boolean().optional(), tagId: z.string().optional(), tag: z.any().optional(), note: z.string().optional() },
13329
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
13330
+ }, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/uet-tag', a); return ok(d.note, d); }));
13331
+ server.registerTool('create_microsoft_ads_conversion_goal', {
13332
+ title: 'Create a Microsoft Advertising conversion goal',
13333
+ 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.',
13334
+ inputSchema: {
13335
+ ...msAcct, name: z.string().describe('≤100 characters, unique per customer'), type: z.enum(['Url', 'Event', 'OfflineConversion', 'Duration', 'PagesViewedPerVisit']), tagId: z.string().optional(),
13336
+ category: z.enum(['AddToCart', 'BeginCheckout', 'BookAppointment', 'Contact', 'Download', 'GetDirections', 'OutboundClick', 'PageView', 'Purchase', 'RequestQuote', 'Signup', 'SubmitLeadForm', 'Subscribe', 'Other']).optional(),
13337
+ urlExpression: z.string().optional(), urlOperator: z.enum(['Equals', 'BeginsWith', 'Contains', 'RegularExpression']).optional(),
13338
+ eventAction: z.string().optional(), eventCategory: z.string().optional(), eventLabel: z.string().optional(), eventValue: z.number().optional(),
13339
+ minimumDurationSeconds: z.number().optional(), minimumPagesViewed: z.number().optional(),
13340
+ 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(),
13341
+ countType: z.enum(['All', 'Unique']).optional(), conversionWindowDays: z.number().optional().describe('1–90'), scope: z.enum(['Account', 'Customer']).optional(),
13342
+ },
13343
+ outputSchema: { ok: z.boolean().optional(), goalId: z.string().optional(), goal: z.any().optional(), note: z.string().optional() },
13344
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
13345
+ }, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/conversion-goal', a); return ok(d.note, d); }));
13346
+ server.registerTool('list_microsoft_ads_audience_targeting', {
13347
+ title: 'Audiences on a Microsoft campaign or ad group',
13348
+ 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.',
13349
+ inputSchema: { ...msAcct, campaignId: z.string().optional(), adGroupId: z.string().optional() },
13350
+ outputSchema: { ok: z.boolean().optional(), audiences: z.array(z.any()).optional(), unreadTypes: z.array(z.string()).optional(), note: z.string().optional() },
13351
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
13352
+ }, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/audience-targeting/list', a); return ok(d.note, d); }));
13353
+ server.registerTool('set_microsoft_ads_audience_targeting', {
13354
+ title: 'Target or exclude a Microsoft audience',
13355
+ 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.',
13356
+ 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() },
13357
+ outputSchema: { ok: z.boolean().optional(), criterion: z.any().optional(), removed: z.array(z.string()).optional(), note: z.string().optional() },
13358
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
13359
+ }, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/audience-targeting', a); return ok(d.note, d); }));
13360
+ server.registerTool('create_microsoft_ads_image_ad', {
13361
+ title: 'Create a Microsoft image ad from a render (paused)',
13362
+ 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.',
13363
+ inputSchema: {
13364
+ ...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()),
13365
+ 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(),
13366
+ 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(),
13367
+ status: z.enum(['Active', 'Paused']).optional().describe('default Paused'), confirm: z.boolean().optional(),
13368
+ },
13369
+ 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() },
13370
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
13371
+ }, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/image-ad', a); return ok(d.note, d); }));
13372
+ server.registerTool('upload_microsoft_ads_video', {
13373
+ title: 'Upload a video to Microsoft Advertising',
13374
+ 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.',
13375
+ inputSchema: { ...msAcct, urls: z.array(z.string()).optional(), videoIds: z.array(z.string()).optional(), description: z.string().optional() },
13376
+ outputSchema: { ok: z.boolean().optional(), videos: z.array(z.any()).optional(), note: z.string().optional() },
13377
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
13378
+ }, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/video', a); return ok(d.note, d); }));
13239
13379
  server.registerTool('list_microsoft_ads_audiences', {
13240
13380
  title: "List Microsoft Advertising audiences (Customer Match lists)",
13241
13381
  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.",
13242
13382
  inputSchema: {
13243
- accountId: z.string().optional().describe('Microsoft ad account id \u2014 omit to use the brand\u2019s single shared account'),
13383
+ ...msAcct,
13244
13384
  audienceIds: z.array(z.string()).optional().describe('up to 100 specific ids \u2014 omit for all of the requested type'),
13245
13385
  types: z.array(z.string()).optional().describe('audience types, default CustomerList'),
13246
13386
  },
@@ -13254,7 +13394,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13254
13394
  title: "Create an empty Microsoft Advertising Customer Match list",
13255
13395
  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.",
13256
13396
  inputSchema: {
13257
- accountId: z.string().optional().describe('Microsoft ad account id \u2014 omit to use the brand\u2019s single shared account'),
13397
+ ...msAcct,
13258
13398
  name: z.string().describe('maximum 128 characters'),
13259
13399
  description: z.string().optional().describe('maximum 1024 characters'),
13260
13400
  membershipDuration: z.number().optional().describe('1\u2013390 days, or -1 for no expiration. Microsoft\u2019s default is 30'),
@@ -13270,7 +13410,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13270
13410
  title: "Upload customer emails into a Microsoft Customer Match list",
13271
13411
  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.",
13272
13412
  inputSchema: {
13273
- accountId: z.string().optional().describe('Microsoft ad account id \u2014 omit to use the brand\u2019s single shared account'),
13413
+ ...msAcct,
13274
13414
  audienceId: z.string().describe('the customer list to write into'),
13275
13415
  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'),
13276
13416
  action: z.enum(['Add', 'Remove', 'Replace']).optional().describe('Add (default) is additive across calls'),
@@ -13293,6 +13433,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
13293
13433
  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.';
13294
13434
  const alIds = z.array(z.string()).optional();
13295
13435
  const alOut = { ok: z.boolean().optional(), id: z.string().optional(), verified: z.boolean().optional(), note: z.string().optional() };
13436
+ // Per-creative-set brand override (the end card): AppLovin's BrandInfo object, all five fields required.
13437
+ const alBrand = {
13438
+ 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'),
13439
+ useAccountBrandInfo: z.boolean().optional().describe('true = go back to the account-level brand info'),
13440
+ };
13296
13441
  server.registerTool('list_applovin_ads_campaigns', {
13297
13442
  title: 'List AppLovin Ads campaigns',
13298
13443
  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,
@@ -13357,7 +13502,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13357
13502
  server.registerTool('create_applovin_ads_creative_set', {
13358
13503
  title: 'Create an AppLovin creative set (paused)',
13359
13504
  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,
13360
- 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)') },
13505
+ 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 },
13361
13506
  outputSchema: { ...alOut, creativeSet: z.any().optional(), pausedAfterCreate: z.boolean().optional() },
13362
13507
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
13363
13508
  }, wrap(async (a) => { const d = await apiPost('/api/applovin-ads/creative-set', a); return ok(d.note, d); }));
@@ -13371,7 +13516,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13371
13516
  server.registerTool('update_applovin_ads_creative_set', {
13372
13517
  title: 'Edit an AppLovin creative set',
13373
13518
  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,
13374
- 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') },
13519
+ 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') },
13375
13520
  outputSchema: { ...alOut, changed: z.array(z.string()).optional(), creativeSet: z.any().optional() },
13376
13521
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
13377
13522
  }, wrap(async (a) => { const d = await apiPost('/api/applovin-ads/creative-set/update', a); return ok(d.note, d); }));
@@ -13382,6 +13527,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
13382
13527
  outputSchema: { ...alOut, sourceId: z.string().optional(), campaignId: z.string().optional(), version: z.string().optional(), creativeSet: z.any().optional(), pausedAfterCreate: z.boolean().optional() },
13383
13528
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
13384
13529
  }, wrap(async (a) => { const d = await apiPost('/api/applovin-ads/creative-set/clone', a); return ok(d.note, d); }));
13530
+ server.registerTool('applovin_ads_change_history', {
13531
+ title: 'AppLovin campaign change history',
13532
+ 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,
13533
+ 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') },
13534
+ outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), page: z.number().optional(), changes: z.array(z.any()).optional(), note: z.string().optional() },
13535
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
13536
+ }, wrap(async (a) => { const d = await apiPost('/api/applovin-ads/change-history', a); return ok(d.note, d); }));
13385
13537
  server.registerTool('list_applovin_ads_catalogs', {
13386
13538
  title: 'List AppLovin catalogs',
13387
13539
  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,
@@ -13413,7 +13565,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13413
13565
  since: z.string().optional().describe('YYYY-MM-DD, default 7 days ago'), until: z.string().optional().describe('YYYY-MM-DD, default today'),
13414
13566
  columns: alIds.describe('e.g. day, campaign, campaign_id_external, creative_set, country, impressions, clicks, ctr, cost, sales, roas_7d, chka_usd_7d'),
13415
13567
  attribution: z.enum(['click', 'click_and_view']).optional().describe('web only, default click'), filters: z.record(z.array(z.string())).optional().describe('column -> values'),
13416
- 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(),
13568
+ 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(),
13417
13569
  },
13418
13570
  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() },
13419
13571
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -13450,6 +13602,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
13450
13602
  targetUrl: z.string().describe('the landing page (must not block OAI-AdsBot / OAI-SearchBot in robots.txt)'),
13451
13603
  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'),
13452
13604
  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'),
13605
+ 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'),
13606
+ 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'),
13453
13607
  price: z.string().optional().describe('optional price string shown on the card'),
13454
13608
  });
13455
13609
  server.registerTool('list_openai_ads_campaigns', {
@@ -13510,6 +13664,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
13510
13664
  return ok(`${d.note}\n${(d.results || []).map(r => `• ${r.canonicalName || r.name} — id ${r.id} (${r.type}${r.countryCode ? `, ${r.countryCode}` : ''})`).join('\n')}`, d);
13511
13665
  }));
13512
13666
  server.group('ads');
13667
+ // X ADS NAMES ITS AD ACCOUNT adAccountId (2026-10-27). Over the REST API (/v1) `accountId` names a Hermoso workspace and
13668
+ // the tenancy belt refuses it in every body, so an X Ads tool that took `accountId` could not be called over /v1 at
13669
+ // all. `accountId` stays accepted over MCP as the older name; the server reads adAccountId first (xAdsNamedAccount).
13670
+ const xAcct = {
13671
+ adAccountId: z.string().optional().describe('X ad account id (list_x_ads_accounts); omit if only one is shared'),
13672
+ accountId: z.string().optional().describe('older name for adAccountId (MCP only)'),
13673
+ };
13513
13674
  server.registerTool('list_x_ads_accounts', {
13514
13675
  title: 'List X ad accounts',
13515
13676
  description: 'List the X (Twitter) ad accounts this profile can act on, with the PERMISSION LEVEL held on each so you can tell an admin grant from a read-only one before attempting a write. X grants API access PER AD ACCOUNT, not per app: the customer adds Hermoso’s X user to their ad account at business.x.com -> Account access, and it appears here. Read-only, free.',
@@ -13522,8 +13683,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
13522
13683
  }, wrap(async () => { const d = await apiGet('/api/x-ads/shared-accounts', {}); return ok(d.note, d); }));
13523
13684
  server.registerTool('list_x_ads_campaigns', {
13524
13685
  title: 'List X ads campaigns',
13525
- description: 'List campaigns on an X ad account — status, budgets, and whether X considers each servable. Omit accountId when only one account is reachable and it resolves itself. Read-only, free.',
13526
- inputSchema: { accountId: z.string().optional(), limit: z.number().optional() },
13686
+ description: 'List campaigns on an X ad account — status, budgets, and whether X considers each servable. Omit adAccountId when only one account is reachable and it resolves itself. Read-only, free.',
13687
+ inputSchema: { ...xAcct, limit: z.number().optional() },
13527
13688
  outputSchema: { accountId: z.string().optional(), count: z.number().optional(), campaigns: z.array(z.any()).optional(), note: z.string().optional() },
13528
13689
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
13529
13690
  }, wrap(async (a) => {
@@ -13534,7 +13695,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13534
13695
  title: 'Build an X ads campaign (paused)',
13535
13696
  description: 'Create a campaign on X (Twitter). ALWAYS CREATED PAUSED with no override — it spends NOTHING until set_x_ads_status(confirm:true). 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. Requires a funding instrument (a payment method on the X ad account) — omit fundingInstrumentId to be shown the usable ones, and if there are none this refuses with that reason instead of failing at X. Budgets are in the ad account’s own currency. Everything is READ BACK from X before you are told it exists; print the returned note verbatim.',
13536
13697
  inputSchema: {
13537
- accountId: z.string().describe('from list_x_ads_accounts'),
13698
+ ...xAcct,
13538
13699
  name: z.string(),
13539
13700
  fundingInstrumentId: z.string().optional().describe('omit to be shown the account’s usable funding instruments'),
13540
13701
  dailyBudget: z.number().optional().describe('in the ad account’s currency'),
@@ -13549,7 +13710,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13549
13710
  title: 'Pause or activate an X campaign or line item',
13550
13711
  description: 'Pause or ACTIVATE an X ads CAMPAIGN (campaignId) or ONE LINE ITEM inside it (lineItemId) — pass exactly one. ACTIVATING STARTS REAL SPEND on the next auction, so it requires confirm:true — this is the only switch on X that arms money. Tell the user the budget and what will start spending BEFORE you pass confirm. DELIVERY ON X IS THE AND OF BOTH LEVELS: an ACTIVE line item under a PAUSED campaign serves nothing, so the result reads the PARENT back too and states whether anything can actually spend rather than letting you infer it. Pausing a line item is the REVERSIBLE way to take one ad group out of delivery — deleting it is not.',
13551
13712
  inputSchema: {
13552
- accountId: z.string(),
13713
+ ...xAcct,
13553
13714
  campaignId: z.string().optional().describe('the whole campaign — pass this OR lineItemId'),
13554
13715
  lineItemId: z.string().optional().describe('one ad group — pass this OR campaignId'),
13555
13716
  status: z.enum(['ACTIVE', 'PAUSED']),
@@ -13574,7 +13735,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13574
13735
  title: 'Export a Microsoft Advertising account as a bulk file',
13575
13736
  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.',
13576
13737
  inputSchema: {
13577
- accountId: z.string().optional().describe('Microsoft ad account id — omit to use the single shared account'),
13738
+ ...msAcct,
13578
13739
  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'),
13579
13740
  campaignIds: z.array(z.string()).optional().describe('export only these campaigns (max 1000) instead of the whole account'),
13580
13741
  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'),
@@ -13596,7 +13757,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13596
13757
  title: 'Apply a Microsoft Advertising bulk file (spend-gated)',
13597
13758
  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.',
13598
13759
  inputSchema: {
13599
- accountId: z.string().optional().describe('Microsoft ad account id — omit to use the single shared account'),
13760
+ ...msAcct,
13600
13761
  file: z.string().describe('the bulk file itself, CSV or TSV, whose first column header must be "Type"'),
13601
13762
  fileType: z.enum(['Csv', 'Tsv']).optional().describe('inferred from the file when omitted'),
13602
13763
  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'),
@@ -13618,7 +13779,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13618
13779
  title: 'Microsoft Advertising conversion goals',
13619
13780
  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.',
13620
13781
  inputSchema: {
13621
- accountId: z.string().optional(),
13782
+ ...msAcct,
13622
13783
  goalIds: z.array(z.string()).optional().describe('specific goal ids — omit for all of them'),
13623
13784
  goalType: z.string().optional().describe('filter to one Microsoft ConversionGoalType, e.g. OfflineConversion'),
13624
13785
  },
@@ -13632,7 +13793,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13632
13793
  title: 'Send offline + enhanced conversions to Microsoft',
13633
13794
  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.',
13634
13795
  inputSchema: {
13635
- accountId: z.string().optional(),
13796
+ ...msAcct,
13636
13797
  conversionName: z.string().optional().describe('default goal name for every row that does not carry its own'),
13637
13798
  conversions: z.array(z.object({
13638
13799
  conversionName: z.string().optional().describe('must match an existing Microsoft OFFLINE conversion goal exactly'),
@@ -13656,7 +13817,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13656
13817
  title: 'Who else is bidding on the same Microsoft auctions',
13657
13818
  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.',
13658
13819
  inputSchema: {
13659
- accountId: z.string().optional(),
13820
+ ...msAcct,
13660
13821
  entityType: z.enum(['Account', 'Campaign', 'AdGroup', 'Keyword']).optional().describe('default Account'),
13661
13822
  entityIds: z.array(z.string()).optional().describe('REQUIRED for Campaign / AdGroup / Keyword, max 200. Microsoft only defaults to the whole account for entityType Account'),
13662
13823
  since: z.string().optional().describe('YYYY-MM-DD, default 30 days back'),
@@ -13683,7 +13844,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13683
13844
  title: 'What Microsoft suggests changing on the account',
13684
13845
  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.',
13685
13846
  inputSchema: {
13686
- accountId: z.string().optional().describe('Microsoft ad account id, omit to use the single shared account'),
13847
+ ...msAcct,
13687
13848
  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'),
13688
13849
  limit: z.number().optional().describe('Microsoft’s MaxCount, omit for everything'),
13689
13850
  includeDismissed: z.boolean().optional().describe('also show recommendations already dismissed'),
@@ -13698,7 +13859,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13698
13859
  title: 'Apply Microsoft recommendations (spend-gated)',
13699
13860
  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.',
13700
13861
  inputSchema: {
13701
- accountId: z.string().optional().describe('Microsoft ad account id, omit to use the single shared account'),
13862
+ ...msAcct,
13702
13863
  recommendationIds: z.array(z.string()).describe('ids from microsoft_ads_recommendations, max 100. There is deliberately no "apply everything"'),
13703
13864
  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'),
13704
13865
  confirmCount: z.number().optional().describe('echo back how many recommendations this applies: the exact count the un-confirmed call reported'),
@@ -13712,7 +13873,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13712
13873
  title: 'Dismiss Microsoft recommendations',
13713
13874
  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.',
13714
13875
  inputSchema: {
13715
- accountId: z.string().optional().describe('Microsoft ad account id, omit to use the single shared account'),
13876
+ ...msAcct,
13716
13877
  recommendationIds: z.array(z.string()).describe('ids from microsoft_ads_recommendations, max 100'),
13717
13878
  },
13718
13879
  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() },
@@ -13722,7 +13883,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13722
13883
  title: 'Is Microsoft changing this ad account by itself?',
13723
13884
  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.',
13724
13885
  inputSchema: {
13725
- accountId: z.string().optional().describe('Microsoft ad account id, omit to use the single shared account'),
13886
+ ...msAcct,
13726
13887
  types: z.array(z.string()).optional().describe('narrow to some of ResponsiveSearchAdsOpportunity, MultiMediaAdsOpportunity, RemoveConflictingNegativeKeywordOpportunity, FixConversionGoalSettingsOpportunity, CreateConversionGoalOpportunity, all five by default'),
13727
13888
  },
13728
13889
  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() },
@@ -13732,7 +13893,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13732
13893
  title: 'Let Microsoft change the account by itself, or stop it (per type)',
13733
13894
  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.',
13734
13895
  inputSchema: {
13735
- accountId: z.string().optional().describe('Microsoft ad account id, omit to use the single shared account'),
13896
+ ...msAcct,
13736
13897
  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'),
13737
13898
  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'),
13738
13899
  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'),
@@ -14005,13 +14166,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
14005
14166
 
14006
14167
  server.registerTool('create_apple_ads_creative', {
14007
14168
  title: 'Create an Apple Ads creative',
14008
- description: 'Create an Apple Ads creative — what a person sees before and after tapping the ad. Pick creativeType: DEFAULT_PRODUCT_PAGE renders the app’s standard App Store listing and needs only adamId; CUSTOM_PRODUCT_PAGE renders a custom page built in App Store Connect and needs adamId AND productPageId (from list_apple_ads_product_pages); LOCAL_ADS_SEARCH_CREATIVE is Apple Maps and needs brandId (from list_apple_ads_brands) plus assetIds you uploaded with upload_apple_ads_asset — that is the one creative type where YOUR OWN IMAGE is the ad. YOU CANNOT SUPPLY YOUR OWN IMAGERY FOR AN APP STORE AD — Apple renders the pre-tap ad from the product page itself, so the way to change how an App Store ad LOOKS is to edit that page (or build a Custom Product Page) in App Store Connect; say that plainly rather than looking for a parameter that does not exist. The destination type is derived from the creative type and never asked for. A creative SPENDS NOTHING and has no status of its own: it serves only once an ad references it and that ad, its ad group and its campaign are all ENABLED. Apple validates asynchronously, so a fresh creative is usually PENDING for a moment and an ad may only reference a VALID one — that is a wait, not a failure. Type and destination are immutable afterwards. Free.',
14169
+ description: 'Create an Apple Ads creative — what a person sees before and after tapping the ad. Pick creativeType: DEFAULT_PRODUCT_PAGE renders the app’s standard App Store listing and needs only adamId; CUSTOM_PRODUCT_PAGE renders a custom page built in App Store Connect and needs adamId AND productPageId (from list_apple_ads_product_pages); LOCAL_ADS_SEARCH_CREATIVE is Apple Maps and needs mapsBrandId (from list_apple_ads_brands) plus assetIds you uploaded with upload_apple_ads_asset — that is the one creative type where YOUR OWN IMAGE is the ad. YOU CANNOT SUPPLY YOUR OWN IMAGERY FOR AN APP STORE AD — Apple renders the pre-tap ad from the product page itself, so the way to change how an App Store ad LOOKS is to edit that page (or build a Custom Product Page) in App Store Connect; say that plainly rather than looking for a parameter that does not exist. The destination type is derived from the creative type and never asked for. A creative SPENDS NOTHING and has no status of its own: it serves only once an ad references it and that ad, its ad group and its campaign are all ENABLED. Apple validates asynchronously, so a fresh creative is usually PENDING for a moment and an ad may only reference a VALID one — that is a wait, not a failure. Type and destination are immutable afterwards. Free.',
14009
14170
  inputSchema: {
14010
14171
  name: z.string().describe('REQUIRED.'),
14011
14172
  creativeType: z.enum(['DEFAULT_PRODUCT_PAGE', 'CUSTOM_PRODUCT_PAGE', 'LOCAL_ADS_SEARCH_CREATIVE']).optional().describe('Default DEFAULT_PRODUCT_PAGE.'),
14012
14173
  adamId: z.string().optional().describe('REQUIRED for App Store creatives — the App Store id.'),
14013
14174
  productPageId: z.string().optional().describe('REQUIRED for CUSTOM_PRODUCT_PAGE; refused on DEFAULT_PRODUCT_PAGE, which is the app’s standard listing.'),
14014
- brandId: z.string().optional().describe('Apple Maps only — the brand this creative belongs to.'),
14175
+ mapsBrandId: z.string().optional().describe('Apple Maps only — the brand this creative belongs to.'), brandId: z.string().optional().describe('older name for mapsBrandId (MCP only)'),
14015
14176
  creativeSubtype: z.enum(['BUSINESS_LOGO', 'BUSINESS_ASSET']).optional().describe('Apple Maps only. Default BUSINESS_LOGO.'),
14016
14177
  assetIds: z.array(z.string()).optional().describe('Apple Maps only — ids from upload_apple_ads_asset.'),
14017
14178
  localizedText: z.record(z.any()).optional().describe('Apple Maps only — promo copy per locale, e.g. {"en-US":"Visit us today"}.'),
@@ -14049,10 +14210,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
14049
14210
 
14050
14211
  server.registerTool('upload_apple_ads_asset', {
14051
14212
  title: 'Upload an Apple Ads image asset',
14052
- description: 'Upload an image into the Apple Ads asset library for an APPLE MAPS brand creative. APPLE MAPS ONLY, and that is Apple’s rule rather than ours: it refuses uploads for App Store apps outright, because an App Store ad is rendered from the app’s product page in App Store Connect and not from an image anyone uploads. That refusal is stated up front instead of being sent and rejected, so if you want a different-looking App Store ad the answer is App Store Connect, not this tool. PNG, JPG or HEIC. Pass imageUrl (upload_file turns a local file into one) and brandId, which list_apple_ads_brands returns. Apple processes the upload asynchronously, so re-read it with list_apple_ads_assets until its eligibility says it is ready before referencing it in a creative. Uploading spends nothing. Free.',
14213
+ description: 'Upload an image into the Apple Ads asset library for an APPLE MAPS brand creative. APPLE MAPS ONLY, and that is Apple’s rule rather than ours: it refuses uploads for App Store apps outright, because an App Store ad is rendered from the app’s product page in App Store Connect and not from an image anyone uploads. That refusal is stated up front instead of being sent and rejected, so if you want a different-looking App Store ad the answer is App Store Connect, not this tool. PNG, JPG or HEIC. Pass imageUrl (upload_file turns a local file into one) and mapsBrandId, which list_apple_ads_brands returns. Apple processes the upload asynchronously, so re-read it with list_apple_ads_assets until its eligibility says it is ready before referencing it in a creative. Uploading spends nothing. Free.',
14053
14214
  inputSchema: {
14054
14215
  imageUrl: z.string().describe('REQUIRED — a PNG, JPG or HEIC image Hermoso can fetch.'),
14055
- brandId: z.string().describe('REQUIRED — the Apple Maps brand this asset belongs to.'),
14216
+ mapsBrandId: z.string().optional().describe('REQUIRED: the Apple Maps brand this asset belongs to.'), brandId: z.string().optional().describe('older name for mapsBrandId (MCP only)'),
14056
14217
  filename: z.string().optional().describe('Decides the format Apple is told; defaults to the name in the URL.'),
14057
14218
  promotedObjectType: z.enum(['BUSINESS_BRAND']).optional().describe('Only BUSINESS_BRAND is accepted.'),
14058
14219
  },
@@ -14062,10 +14223,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
14062
14223
 
14063
14224
  server.registerTool('list_apple_ads_assets', {
14064
14225
  title: 'List Apple Ads image assets',
14065
- description: 'Images in the Apple Ads asset library with format, orientation, dimensions and eligibility — use it to check whether an uploaded asset has finished processing before referencing it in a creative. Filter by brandId (Apple’s promotedObjectId), promotedObjectType or assetType, or pass id for one. CROPS AND OTHER VARIANTS NEVER APPEAR IN A LIST: Apple omits them from query results, so a variant has to be fetched by its own id. Querying covers both App Store apps and Apple Maps brands even though only Maps assets can be uploaded. Read-only, free.',
14226
+ description: 'Images in the Apple Ads asset library with format, orientation, dimensions and eligibility — use it to check whether an uploaded asset has finished processing before referencing it in a creative. Filter by mapsBrandId (Apple’s promotedObjectId), promotedObjectType or assetType, or pass id for one. CROPS AND OTHER VARIANTS NEVER APPEAR IN A LIST: Apple omits them from query results, so a variant has to be fetched by its own id. Querying covers both App Store apps and Apple Maps brands even though only Maps assets can be uploaded. Read-only, free.',
14066
14227
  inputSchema: {
14067
14228
  id: z.string().optional().describe('Fetch one asset — the only way to see a crop or variant.'),
14068
- brandId: z.string().optional().describe('Scope to one brand or app (promotedObjectId).'),
14229
+ mapsBrandId: z.string().optional().describe('Scope to one brand or app (promotedObjectId).'), brandId: z.string().optional().describe('older name for mapsBrandId (MCP only)'),
14069
14230
  promotedObjectType: z.enum(['APPSTORE_APP', 'BUSINESS_BRAND']).optional(),
14070
14231
  assetType: z.enum(['IMAGE']).optional(),
14071
14232
  limit: z.number().optional(), offset: z.number().optional(),
@@ -14081,9 +14242,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
14081
14242
  // targeting, and Brand → Asset → Creative → Ad.
14082
14243
  server.registerTool('list_apple_ads_brands', {
14083
14244
  title: 'List Apple Maps brands',
14084
- description: 'The Apple Maps brands on this Apple Ads account. A BRAND IS THE ROOT OF EVERY ADS-ON-APPLE-MAPS CAMPAIGN and its id is what three other calls need: it is the promotedObjectId of a BUSINESS_BRAND campaign, the brandId upload_apple_ads_asset uploads against, and the brandId a LOCAL_ADS_SEARCH_CREATIVE carries. A brand must read eligibility ELIGIBLE before Apple will let a campaign use it. YOU CANNOT CREATE A BRAND HERE and that is Apple’s design, not a gap: brands come from Apple Business Connect, and Ads on Apple Maps additionally need the ad account to carry productFeatures BUSINESS_BRAND_MANUAL plus a BUSINESS_BRAND delegation, neither of which any API can grant. AN EMPTY LIST IS THE NORMAL ANSWER FOR AN APP STORE ADVERTISER and does not mean anything is broken — App Store campaigns need no brand at all. Read-only, free.',
14245
+ description: 'The Apple Maps brands on this Apple Ads account. A BRAND IS THE ROOT OF EVERY ADS-ON-APPLE-MAPS CAMPAIGN and its id is what three other calls need: it is the promotedObjectId of a BUSINESS_BRAND campaign, the mapsBrandId upload_apple_ads_asset uploads against, and the mapsBrandId a LOCAL_ADS_SEARCH_CREATIVE carries. A brand must read eligibility ELIGIBLE before Apple will let a campaign use it. YOU CANNOT CREATE A BRAND HERE and that is Apple’s design, not a gap: brands come from Apple Business Connect, and Ads on Apple Maps additionally need the ad account to carry productFeatures BUSINESS_BRAND_MANUAL plus a BUSINESS_BRAND delegation, neither of which any API can grant. AN EMPTY LIST IS THE NORMAL ANSWER FOR AN APP STORE ADVERTISER and does not mean anything is broken — App Store campaigns need no brand at all. Read-only, free.',
14085
14246
  inputSchema: {
14086
- brandId: z.string().optional().describe('Fetch one brand by id.'),
14247
+ mapsBrandId: z.string().optional().describe('Fetch one brand by id.'), brandId: z.string().optional().describe('older name for mapsBrandId (MCP only)'),
14087
14248
  name: z.string().optional(), countryOrRegion: z.string().optional().describe('Two-letter code, e.g. "US".'),
14088
14249
  eligibilityStatus: z.string().optional().describe('Usually ELIGIBLE — only an ELIGIBLE brand can be used in a campaign.'),
14089
14250
  limit: z.number().optional(), offset: z.number().optional(),
@@ -14094,10 +14255,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
14094
14255
 
14095
14256
  server.registerTool('list_apple_ads_locations', {
14096
14257
  title: 'List Apple Maps business locations',
14097
- description: 'The physical business locations behind an Apple Maps brand — the shops, restaurants or service centres an Ads-on-Apple-Maps campaign promotes. Filter by brandId (do: without it Apple returns every location across every brand, which can be a very large page), plus status, eligibility or address. LOCATIONS ARE READ-ONLY THROUGH THIS API BY APPLE’S DESIGN: Apple Business Connect creates and maintains them, so a missing shop, a wrong address or a closure is fixed there and appears here afterwards — there is no create, update or delete to look for. Only a location with status OPEN and eligibility ELIGIBLE is targetable. You do not target locations directly: collect the ids you want, put them in a location group with create_apple_ads_location_group, then point an ad group at that group. Read-only, free.',
14258
+ description: 'The physical business locations behind an Apple Maps brand — the shops, restaurants or service centres an Ads-on-Apple-Maps campaign promotes. Filter by mapsBrandId (do: without it Apple returns every location across every brand, which can be a very large page), plus status, eligibility or address. LOCATIONS ARE READ-ONLY THROUGH THIS API BY APPLE’S DESIGN: Apple Business Connect creates and maintains them, so a missing shop, a wrong address or a closure is fixed there and appears here afterwards — there is no create, update or delete to look for. Only a location with status OPEN and eligibility ELIGIBLE is targetable. You do not target locations directly: collect the ids you want, put them in a location group with create_apple_ads_location_group, then point an ad group at that group. Read-only, free.',
14098
14259
  inputSchema: {
14099
14260
  id: z.string().optional().describe('Fetch one location.'),
14100
- brandId: z.string().optional().describe('Strongly recommended — scopes to one brand.'),
14261
+ mapsBrandId: z.string().optional().describe('Strongly recommended — scopes to one brand.'), brandId: z.string().optional().describe('older name for mapsBrandId (MCP only)'),
14101
14262
  status: z.enum(['OPEN', 'OPENING_SOON', 'CLOSED', 'MOVED', 'TEMPORARILY_CLOSED']).optional().describe('Usually OPEN — only OPEN locations are targetable.'),
14102
14263
  eligibilityStatus: z.enum(['ELIGIBLE', 'INELIGIBLE', 'LIMITED', 'PENDING', 'UNDEFINED']).optional(),
14103
14264
  countryOrRegion: z.string().optional().describe('Two-letter code.'),
@@ -14111,9 +14272,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
14111
14272
 
14112
14273
  server.registerTool('list_apple_ads_location_groups', {
14113
14274
  title: 'List Apple Maps location groups',
14114
- description: 'The location groups on this Apple Ads account — named sets of a brand’s shops that an ad group targets. Each row carries its type (STATIC or DYNAMIC), Apple’s systemStatus and groupTotal, the number of locations currently in it. READ groupTotal BEFORE TARGETING A GROUP: a DYNAMIC group sits at PENDING while Apple evaluates its rules, and a group that evaluated to ZERO is Apple’s one silent failure here — it reports no error, so a rule spelled "CA" instead of "California" builds an empty group that looks fine. Soft-deleted groups are excluded unless you pass includeDeleted. There is no filter for "which groups contain this location": query by brandId and inspect membership. Read-only, free.',
14275
+ description: 'The location groups on this Apple Ads account — named sets of a brand’s shops that an ad group targets. Each row carries its type (STATIC or DYNAMIC), Apple’s systemStatus and groupTotal, the number of locations currently in it. READ groupTotal BEFORE TARGETING A GROUP: a DYNAMIC group sits at PENDING while Apple evaluates its rules, and a group that evaluated to ZERO is Apple’s one silent failure here — it reports no error, so a rule spelled "CA" instead of "California" builds an empty group that looks fine. Soft-deleted groups are excluded unless you pass includeDeleted. There is no filter for "which groups contain this location": query by mapsBrandId and inspect membership. Read-only, free.',
14115
14276
  inputSchema: {
14116
- id: z.string().optional().describe('Fetch one group.'), brandId: z.string().optional(),
14277
+ id: z.string().optional().describe('Fetch one group.'), mapsBrandId: z.string().optional(), brandId: z.string().optional().describe('older name for mapsBrandId (MCP only)'),
14117
14278
  name: z.string().optional().describe('Substring match.'),
14118
14279
  groupType: z.enum(['STATIC', 'DYNAMIC']).optional(),
14119
14280
  includeDeleted: z.boolean().optional().describe('Deleted groups are hidden by default.'),
@@ -14125,10 +14286,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
14125
14286
 
14126
14287
  server.registerTool('create_apple_ads_location_group', {
14127
14288
  title: 'Create an Apple Maps location group',
14128
- description: 'Create a location group — the set of a brand’s shops an Apple Maps ad group promotes. TWO KINDS. STATIC takes an explicit locationIds list (from list_apple_ads_locations) and is VALID immediately. DYNAMIC takes rules and maintains itself as the brand opens and closes shops, but starts PENDING while Apple evaluates them — wait for systemStatus VALID before targeting it. A RULE THAT MATCHES NOTHING IS APPLE’S ONE SILENT FAILURE HERE: it returns no error and simply builds a group with groupTotal 0, so adminArea must be the FULL English name ("California", never "CA") and locality must be countryOrRegion|adminArea|locality ("US|New York|Brooklyn", never "Brooklyn") — both shapes are refused here before the group is built. A group SPENDS NOTHING and does not decide who sees the ad or how near they are: it only scopes WHICH shops the ad group promotes, while radius and geo targeting on the ad group decide reach. brandId and the ad account are locked in permanently at creation. Target it afterwards with create_apple_ads_ad_group(targeting.locationGroupIds) or update_apple_ads_object(level:"adgroup"). Free.',
14289
+ description: 'Create a location group — the set of a brand’s shops an Apple Maps ad group promotes. TWO KINDS. STATIC takes an explicit locationIds list (from list_apple_ads_locations) and is VALID immediately. DYNAMIC takes rules and maintains itself as the brand opens and closes shops, but starts PENDING while Apple evaluates them — wait for systemStatus VALID before targeting it. A RULE THAT MATCHES NOTHING IS APPLE’S ONE SILENT FAILURE HERE: it returns no error and simply builds a group with groupTotal 0, so adminArea must be the FULL English name ("California", never "CA") and locality must be countryOrRegion|adminArea|locality ("US|New York|Brooklyn", never "Brooklyn") — both shapes are refused here before the group is built. A group SPENDS NOTHING and does not decide who sees the ad or how near they are: it only scopes WHICH shops the ad group promotes, while radius and geo targeting on the ad group decide reach. mapsBrandId and the ad account are locked in permanently at creation. Target it afterwards with create_apple_ads_ad_group(targeting.locationGroupIds) or update_apple_ads_object(level:"adgroup"). Free.',
14129
14290
  inputSchema: {
14130
14291
  name: z.string().describe('REQUIRED.'),
14131
- brandId: z.string().describe('REQUIRED — from list_apple_ads_brands. Immutable afterwards.'),
14292
+ mapsBrandId: z.string().optional().describe('REQUIRED: from list_apple_ads_brands. Immutable afterwards.'), brandId: z.string().optional().describe('older name for mapsBrandId (MCP only)'),
14132
14293
  groupType: z.enum(['STATIC', 'DYNAMIC']).optional().describe('Default STATIC. Immutable afterwards.'),
14133
14294
  locationIds: z.array(z.string()).optional().describe('REQUIRED for STATIC — ids from list_apple_ads_locations.'),
14134
14295
  rules: z.array(z.object({ field: z.enum(['adminArea', 'locality', 'postalCode', 'locationId']), operator: z.enum(['EQUALS', 'NOT_EQUALS', 'IN', 'NOT_IN']), value: z.any() })).optional().describe('REQUIRED for DYNAMIC. value is a string for EQUALS/NOT_EQUALS and an array for IN/NOT_IN.'),
@@ -14246,6 +14407,19 @@ function buildTools(rawServer, opts = {}, sink = null) {
14246
14407
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
14247
14408
  }, 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); }));
14248
14409
 
14410
+ server.registerTool('apple_ads_rejection_reasons', {
14411
+ title: 'Why an Apple Ads creative or brand was rejected',
14412
+ 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.',
14413
+ inputSchema: {
14414
+ adamId: z.string().optional().describe('the app’s App Store id — every reason Apple holds for it'),
14415
+ 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'),
14416
+ rejectionReasonId: z.string().optional().describe('one reason by id'),
14417
+ limit: z.number().optional(), offset: z.number().optional(),
14418
+ },
14419
+ 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() },
14420
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
14421
+ }, wrap(async (a) => { const d = await apiPost('/api/apple-ads/rejection-reasons', a); return ok(d.note, d); }));
14422
+
14249
14423
  server.registerTool('apple_ads_change_history', {
14250
14424
  title: 'Apple Ads change history',
14251
14425
  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.',
@@ -14398,7 +14572,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14398
14572
  title: 'Change an X campaign budget or settings',
14399
14573
  description: "Change a LIVE X campaign — budget, name, delivery pacing. THIS IS HOW YOU THROTTLE OR RAISE SPEND on a running campaign without rebuilding it, and lowering dailyBudget is the fastest way to slow money down short of pausing. Budgets are in the ad account's own currency. Only fields X actually applies are offered: startTime/endTime are DEPRECATED on an X campaign (its schedule lives on the LINE ITEM — use update_x_ads_line_item) and frequency capping needs an X account feature we cannot enable, so both are refused BY NAME with the reason instead of being sent and silently ignored. THE READ-BACK IS A DIFF against the before-state: a field X did not move is reported as REFUSED, never counted as applied. Print the returned note verbatim.",
14400
14574
  inputSchema: {
14401
- accountId: z.string(), campaignId: z.string().describe('from list_x_ads_campaigns'),
14575
+ ...xAcct, campaignId: z.string().describe('from list_x_ads_campaigns'),
14402
14576
  name: z.string().optional(),
14403
14577
  dailyBudget: z.number().optional().describe("in the ad account's currency"),
14404
14578
  totalBudget: z.number().optional(),
@@ -14412,7 +14586,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14412
14586
  title: 'Change an X line item (ad group)',
14413
14587
  description: "Change an X line item — BID, bid strategy, SCHEDULE, goal or name. An X campaign's start and end times are deprecated, so the schedule genuinely lives HERE. Lowering `bidAmount` is also the fix when X refuses a campaign budget with “Bid is too close to Budget”. NOT changeable after creation: `objective` and `productType` — X answers 200 and silently keeps the old value (measured), so both are refused BY NAME here and a different objective means a NEW line item. Changing `goal` also requires `bidAmount` (X refuses the goal alone) and that is stated up front rather than relayed. A line-item dailyBudget/totalBudget is only legal when the parent campaign is NOT budget-optimised — that is the campaign's setting, so those two are forwarded and X's own refusal names the remedy rather than Hermoso blocking a legal edit. THE READ-BACK IS A DIFF against an independent re-read: a field X accepted but did not move is reported as REFUSED. Print the returned note verbatim.",
14414
14588
  inputSchema: {
14415
- accountId: z.string(), lineItemId: z.string().describe('from list_x_ads_line_items'),
14589
+ ...xAcct, lineItemId: z.string().describe('from list_x_ads_line_items'),
14416
14590
  name: z.string().optional(),
14417
14591
  bidAmount: z.number().optional().describe("in the ad account's currency"),
14418
14592
  bidStrategy: z.enum(['AUTO', 'GUARANTEED', 'MAX', 'TARGET']).optional(),
@@ -14427,7 +14601,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14427
14601
  server.registerTool('list_x_ads_promoted_tweets', {
14428
14602
  title: 'List X promoted posts',
14429
14603
  description: "List the promoted posts (the CREATIVES) attached to an X line item, or across the whole ad account. Two things only this can tell you: each post's APPROVAL STATUS, so an ad X rejected — which can never serve however the statuses are set — is visible rather than mysterious; and the promoted-tweet ID, which is the only way to remove one. NOTE a promoted post cannot be PAUSED on X (its PUT accepts only an approval appeal), so the reversible way to stop it is to pause its LINE ITEM. Read-only, free.",
14430
- inputSchema: { accountId: z.string(), lineItemId: z.string().optional().describe('scope to one line item'), limit: z.number().optional() },
14604
+ inputSchema: { ...xAcct, lineItemId: z.string().optional().describe('scope to one line item'), limit: z.number().optional() },
14431
14605
  outputSchema: { accountId: z.string().optional(), count: z.number().optional(), promotedTweets: z.array(z.any()).optional(), note: z.string().optional() },
14432
14606
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
14433
14607
  }, wrap(async (a) => {
@@ -14437,7 +14611,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14437
14611
  server.registerTool('list_x_ads_targeting', {
14438
14612
  title: 'List an X line item’s targeting',
14439
14613
  description: "List every targeting criterion an X line item carries, WITH THE ID of each — the id needed to remove one with delete_x_ads_object. READ THE EMPTY CASE CORRECTLY: no targeting criteria on X means the line item is UNRESTRICTED and will reach the broadest possible audience once ACTIVE — it does NOT mean it cannot serve. Read-only, free.",
14440
- inputSchema: { accountId: z.string(), lineItemId: z.string() },
14614
+ inputSchema: { ...xAcct, lineItemId: z.string() },
14441
14615
  outputSchema: { accountId: z.string().optional(), lineItemId: z.string().optional(), count: z.number().optional(), targeting: z.array(z.any()).optional(), note: z.string().optional() },
14442
14616
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
14443
14617
  }, wrap(async (a) => {
@@ -14447,7 +14621,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14447
14621
  server.registerTool('list_x_ads_funding_instruments', {
14448
14622
  title: 'List X ad account payment methods',
14449
14623
  description: "List the funding instruments (payment methods) on an X ad account — type, currency, credit limit, credit remaining, and whether each can currently fund a campaign. THIS IS THE ANSWER TO “why is my X campaign not delivering?” whenever the cause is a cancelled card or an exhausted credit line, which is invisible from the campaign itself, and it shows what a campaign will spend against BEFORE anyone activates it. Hermoso cannot add a payment method — that is done at ads.x.com. Read-only, free.",
14450
- inputSchema: { accountId: z.string() },
14624
+ inputSchema: { ...xAcct },
14451
14625
  outputSchema: { accountId: z.string().optional(), count: z.number().optional(), usableCount: z.number().optional(), fundingInstruments: z.array(z.any()).optional(), note: z.string().optional() },
14452
14626
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
14453
14627
  }, wrap(async (a) => { const d = await apiGet('/api/x-ads/funding-instruments', a); return ok(d.note, d); }));
@@ -14473,7 +14647,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14473
14647
  title: 'Delete an X ads object (permanent, cascades)',
14474
14648
  description: "PERMANENTLY delete an X campaign, line item, promoted post, promoted account or targeting criterion — or ARCHIVE a lead form (it stops collecting and its leads can no longer be exported, so run get_x_ads_leads first), or remove a card, a Media Library item (type media: id is the media_key) or a custom audience (custom_audience). X CASCADES AND PUBLISHES NO UNDO: deleting a campaign destroys its line items and their promoted posts too — verified live, the children answer 404 the moment the parent is deleted. RUN IT WITHOUT confirm FIRST: that deletes nothing and reports the REAL blast radius read back off X (what is underneath it, whether it is live, whether it has spent). Show the user exactly that, get an unambiguous yes, then call again with confirm:true — and, for anything with children / live delivery / real spend, also confirmName set to its exact name and confirmChildren set to the real count, because confirm:true alone proves you meant to delete SOMETHING and cannot prove you aimed at the right object. TO STOP DELIVERY WITHOUT DESTROYING ANYTHING use set_x_ads_status PAUSED, which is reversible — except for a promoted post, which cannot be paused on X at all, so pause its LINE ITEM instead. Removing a targeting criterion WIDENS the audience rather than narrowing it. The result is confirmed by re-reading the object, never by X's 200.",
14475
14649
  inputSchema: {
14476
- accountId: z.string(),
14650
+ ...xAcct,
14477
14651
  type: z.enum(['campaign', 'line_item', 'promoted_tweet', 'targeting_criterion', 'promoted_account', 'lead_form', 'card', 'media', 'custom_audience']),
14478
14652
  id: z.string().describe('campaign/line-item id, or the id from list_x_ads_promoted_tweets / list_x_ads_targeting'),
14479
14653
  lineItemId: z.string().optional().describe('REQUIRED for type "targeting_criterion" — X cannot look one up without its line item'),
@@ -14491,7 +14665,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14491
14665
  server.registerTool('list_x_ads_line_items', {
14492
14666
  title: 'List X ads line items',
14493
14667
  description: "List the line items on an X ad account — X's name for an ad group, and the level that carries the objective, the placements, the bid, the targeting and the creatives. Pass campaignId to scope it to one campaign. `servable` is X's own verdict on whether the line item could run. Read-only, free.",
14494
- inputSchema: { accountId: z.string().describe('from list_x_ads_accounts'), campaignId: z.string().optional(), limit: z.number().optional() },
14668
+ inputSchema: { ...xAcct, campaignId: z.string().optional(), limit: z.number().optional() },
14495
14669
  outputSchema: { accountId: z.string().optional(), count: z.number().optional(), lineItems: z.array(z.any()).optional(), note: z.string().optional() },
14496
14670
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
14497
14671
  }, wrap(async (a) => {
@@ -14502,7 +14676,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14502
14676
  title: 'Build an X ads line item (paused)',
14503
14677
  description: "Create a line item — X's ad group — under a campaign. ALWAYS CREATED PAUSED with no override. THE TREE ON X IS campaign -> line item -> promoted post, and a campaign ALONE CANNOT SERVE: this is the middle level, and it still cannot serve until you attach a post with create_x_ads_promoted_tweet. Targeting attaches HERE (add_x_ads_targeting), never to the campaign. `objective` is validated before dispatch because X answers an invalid one with a 500 that reads like an outage; note that WEBSITE_CONVERSIONS and SITE_VISITS are `goal` values and are NOT objectives. `bidStrategy` MAX/TARGET require a bidAmount; AUTO lets X set it. Omitting startTime records now, and the read-back says so. Everything is READ BACK from X before you are told it exists; print the returned note verbatim.",
14504
14678
  inputSchema: {
14505
- accountId: z.string().describe('from list_x_ads_accounts'),
14679
+ ...xAcct,
14506
14680
  campaignId: z.string().describe('from create_x_ads_campaign or list_x_ads_campaigns'),
14507
14681
  name: z.string().optional(),
14508
14682
  objective: z.enum(['APP_ENGAGEMENTS', 'APP_INSTALLS', 'ENGAGEMENTS', 'FOLLOWERS', 'PREROLL_VIEWS', 'REACH', 'VIDEO_VIEWS', 'WEBSITE_CLICKS']).describe('one per campaign (X refuses a second objective). For an X LEAD AD use WEBSITE_CLICKS with a lead-form card \u2014 X retired LEAD_GENERATION'),
@@ -14522,7 +14696,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14522
14696
  title: 'Attach an X account to a follower line item (paused)',
14523
14697
  description: 'THE CREATIVE OF AN X FOLLOWER CAMPAIGN. A line item with objective FOLLOWERS (productType PROMOTED_ACCOUNT, which create_x_ads_line_item now defaults to for FOLLOWERS) promotes an X ACCOUNT, and until one is attached here it has nothing to show and never serves. Omit userId and the ad account\u2019s own promotable account is used; if it may promote several, the choice is refused rather than guessed (list_x_ads_promotable_users shows them). A line item of any other product type is refused for free, naming X\u2019s INCOMPATIBLE_LINE_ITEM rule. You may ALSO attach a post with create_x_ads_promoted_tweet, which X adds as a mobile timeline placement. The attachment is READ BACK from the line item before it is reported. Nothing spends: the campaign and line item stay PAUSED until set_x_ads_status(confirm:true). X cannot edit a promoted account, only remove it (delete_x_ads_object type:"promoted_account"). Free.',
14524
14698
  inputSchema: {
14525
- accountId: z.string(), lineItemId: z.string().describe('a PROMOTED_ACCOUNT line item (objective FOLLOWERS) from create_x_ads_line_item'),
14699
+ ...xAcct, lineItemId: z.string().describe('a PROMOTED_ACCOUNT line item (objective FOLLOWERS) from create_x_ads_line_item'),
14526
14700
  userId: z.string().optional().describe('the numeric X user id to promote \u2014 omit for the ad account\u2019s own promotable account'),
14527
14701
  },
14528
14702
  outputSchema: { accountId: z.string().optional(), lineItemId: z.string().optional(), promotedAccountId: z.string().optional(), userId: z.string().optional(), approvalStatus: z.string().nullable().optional(), verified: z.boolean().optional(), note: z.string().optional() },
@@ -14531,7 +14705,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14531
14705
  server.registerTool('list_x_ads_promotable_users', {
14532
14706
  title: 'List the X accounts an ad account may promote',
14533
14707
  description: 'The X accounts this ad account may promote \u2014 FULL (can be promoted for followers with create_x_ads_promoted_account and have its posts promoted) or RETWEETS_ONLY (posts only). Read-only, free.',
14534
- inputSchema: { accountId: z.string() },
14708
+ inputSchema: { ...xAcct },
14535
14709
  outputSchema: { accountId: z.string().optional(), users: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
14536
14710
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
14537
14711
  }, wrap(async (a) => { const d = await apiGet('/api/x-ads/promotable-users', a); return ok(`${d.note}\n${(d.users || []).map(u => `\u00b7 user ${u.userId} \u2014 ${u.type}`).join('\n')}`, d); }));
@@ -14543,15 +14717,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
14543
14717
  server.registerTool('list_x_ads_lead_forms', {
14544
14718
  title: 'List X lead forms',
14545
14719
  description: "List the lead forms on an X ad account (or one, with leadFormId) — fields, privacy policy, and the customer-list audience X builds from each form's leads. Read-only, free.",
14546
- inputSchema: { accountId: z.string(), leadFormId: z.string().optional(), withDeleted: z.boolean().optional().describe('include archived forms'), limit: z.number().optional() },
14720
+ inputSchema: { ...xAcct, leadFormId: z.string().optional(), withDeleted: z.boolean().optional().describe('include archived forms'), limit: z.number().optional() },
14547
14721
  outputSchema: { accountId: z.string().optional(), leadForms: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
14548
14722
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
14549
14723
  }, wrap(async (a) => { const d = await apiGet('/api/x-ads/lead-forms', a); return ok(`${d.note}\n${(d.leadForms || []).map(f => `• ${f.name} — id ${f.id}${f.deleted ? ' (archived)' : ''}, fields ${(f.fields || []).join(', ')}`).join('\n')}`, d); }));
14550
14724
  server.registerTool('create_x_ads_lead_form', {
14551
14725
  title: 'Create an X lead form',
14552
- description: "Create an X lead form — the native form an X lead ad opens, so people sign up without leaving X. The X lead ad chain: lead form → create_x_ads_card(leadFormId) → create_x_ads_post(cardUri, lineItemId) on a WEBSITE_CLICKS line item (X retired the LEAD_GENERATION objective) → get_x_ads_leads. Needs name, purpose, fields (at least one required), and an https privacy policy URL + company name. X ties every form to a conversion event: an ad account with no X Pixel needs conversionEventId, and the refusal says so plainly. X also builds a customer-list audience of the form's leads. Read back from X. Free.",
14726
+ description: "Create an X lead form — the native form an X lead ad opens, so people sign up without leaving X. The X lead ad chain: lead form -> create_x_ads_card(leadFormId) -> create_x_ads_post(cardUri, lineItemId) on a WEBSITE_CLICKS line item (X retired the LEAD_GENERATION objective) -> get_x_ads_leads. Needs name, purpose, fields (at least one required), and an https privacy policy URL + company name. X ties every form to a conversion event: an ad account with no X Pixel needs conversionEventId, and the refusal says so plainly. X also builds a customer-list audience of the form's leads. Read back from X. Free.",
14553
14727
  inputSchema: {
14554
- accountId: z.string(), name: z.string(), purpose: z.string().describe('what the leads are used for (shown to the person)'),
14728
+ ...xAcct, name: z.string(), purpose: z.string().describe('what the leads are used for (shown to the person)'),
14555
14729
  fields: z.array(xLeadField).describe('at least one must not be optional'), privacyPolicyUrl: z.string().describe('https'), companyName: z.string(),
14556
14730
  headline: z.string().optional().describe('intro card headline'), description: z.string().optional().describe('intro card text'),
14557
14731
  thankYouHeadline: z.string().optional(), thankYouDescription: z.string().optional(), thankYouUrl: z.string().optional().describe('https link on the thank-you button'), thankYouButton: z.string().optional(),
@@ -14564,7 +14738,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14564
14738
  title: 'Change an X lead form',
14565
14739
  description: "Change an X lead form. Only the fields you pass change — but X replaces the fields list and each section (intro, thank-you page, privacy policy) WHOLE, so pass the complete new value of any you touch. An archived form cannot be changed. Read back from X. Free.",
14566
14740
  inputSchema: {
14567
- accountId: z.string(), leadFormId: z.string(), name: z.string().optional(), purpose: z.string().optional(), fields: z.array(xLeadField).optional(),
14741
+ ...xAcct, leadFormId: z.string(), name: z.string().optional(), purpose: z.string().optional(), fields: z.array(xLeadField).optional(),
14568
14742
  privacyPolicyUrl: z.string().optional(), companyName: z.string().optional(), headline: z.string().optional(), description: z.string().optional(),
14569
14743
  thankYouHeadline: z.string().optional(), thankYouDescription: z.string().optional(), thankYouUrl: z.string().optional(), thankYouButton: z.string().optional(), conversionEventId: z.string().optional(),
14570
14744
  },
@@ -14574,21 +14748,21 @@ function buildTools(rawServer, opts = {}, sink = null) {
14574
14748
  server.registerTool('get_x_ads_leads', {
14575
14749
  title: 'Read the leads from an X lead form',
14576
14750
  description: "Read the leads submitted to an X lead form over a window (default the last 30 days) — each lead's answers keyed by field (EMAIL, FULL_NAME…). X builds the export as a job; this waits up to ~45s and, if X is still working, returns an exportId to collect later with no new job. X lets only an ACCOUNT ADMIN export leads, and refuses an ARCHIVED form, so export before archiving. format csv/xlsx returns a download link instead. The result is personal information. Free.",
14577
- inputSchema: { accountId: z.string(), leadFormId: z.string().optional(), startTime: z.string().optional().describe('ISO 8601'), endTime: z.string().optional().describe('ISO 8601; default now'), days: z.number().optional().describe('window length when startTime is omitted; default 30'), format: z.enum(['json', 'csv', 'xlsx']).optional(), exportId: z.string().optional().describe('collect an export that was still running') },
14751
+ inputSchema: { ...xAcct, leadFormId: z.string().optional(), startTime: z.string().optional().describe('ISO 8601'), endTime: z.string().optional().describe('ISO 8601; default now'), days: z.number().optional().describe('window length when startTime is omitted; default 30'), format: z.enum(['json', 'csv', 'xlsx']).optional(), exportId: z.string().optional().describe('collect an export that was still running') },
14578
14752
  outputSchema: { accountId: z.string().optional(), exportId: z.string().optional(), leadFormId: z.string().optional(), state: z.string().optional(), count: z.number().optional(), leads: z.array(z.any()).nullable().optional(), downloadUrl: z.string().optional(), window: z.any().optional(), note: z.string().optional() },
14579
14753
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
14580
14754
  }, wrap(async (a) => { const d = await apiGet('/api/x-ads/leads', a); return ok(`${d.note}${d.leads ? `\n${JSON.stringify(d.leads.slice(0, 50).map(l => ({ submittedAt: l.submittedAt, fields: l.fields })))}` : ''}`, d); }));
14581
14755
  server.registerTool('upload_x_ads_media', {
14582
14756
  title: 'Upload media to an X ad account',
14583
14757
  description: "Upload an image or video (any URL — a Hermoso render, an upload_file URL, a public file) to an X ad account and return its media_key, for create_x_ads_card or create_x_ads_post. Added to the ad account's Media Library and read back. A card image must be 1:1 ≥600×600, 1.91:1 ≥600×314, 16:9 ≥600×337, 9:16 ≥600×1066, 2:3 ≥600×900 or 4:5 ≥600×750. Free.",
14584
- inputSchema: { accountId: z.string(), url: z.string(), name: z.string().optional().describe('label in the Media Library') },
14758
+ inputSchema: { ...xAcct, url: z.string(), name: z.string().optional().describe('label in the Media Library') },
14585
14759
  outputSchema: { accountId: z.string().optional(), mediaKey: z.string().optional(), mediaId: z.string().optional(), mediaType: z.string().optional(), width: z.number().nullable().optional(), height: z.number().nullable().optional(), note: z.string().optional() },
14586
14760
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
14587
14761
  }, wrap(async (a) => { const d = await apiPost('/api/x-ads/media', a); return ok(d.note, d); }));
14588
14762
  server.registerTool('list_x_ads_media', {
14589
14763
  title: 'List an X ad account’s Media Library',
14590
14764
  description: "List the X ad account's Media Library — media keys usable in cards and promoted-only posts. Read-only, free.",
14591
- inputSchema: { accountId: z.string(), mediaType: z.enum(['IMAGE', 'VIDEO', 'GIF']).optional(), limit: z.number().optional() },
14765
+ inputSchema: { ...xAcct, mediaType: z.enum(['IMAGE', 'VIDEO', 'GIF']).optional(), limit: z.number().optional() },
14592
14766
  outputSchema: { accountId: z.string().optional(), media: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
14593
14767
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
14594
14768
  }, wrap(async (a) => { const d = await apiGet('/api/x-ads/media', a); return ok(`${d.note}\n${(d.media || []).map(m => `• ${m.name || '(untitled)'} — ${m.mediaKey} (${m.mediaType} ${m.width}×${m.height})`).join('\n')}`, d); }));
@@ -14596,7 +14770,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14596
14770
  title: 'Build an X ad card',
14597
14771
  description: "Create an X card — the big image/video with a headline or button that an X ad clicks through on. ONE destination: websiteUrl (a website card, with a headline `title`), leadFormId (opens an X lead form — headline, or a button with buttonLabel), or an app (iosAppId/googlePlayAppId + countryCode, a button). Media: mediaKey, or 2–6 mediaKeys for a swipeable card (upload_x_ads_media). utm {source,medium,campaign,content,term} is appended to websiteUrl without overwriting the URL's own. Read back from X; a card is used by create_x_ads_post(cardUri). Free.",
14598
14772
  inputSchema: {
14599
- accountId: z.string(), name: z.string(), mediaKey: z.string().optional(), mediaKeys: z.array(z.string()).optional(),
14773
+ ...xAcct, name: z.string(), mediaKey: z.string().optional(), mediaKeys: z.array(z.string()).optional(),
14600
14774
  title: z.string().optional().describe('headline, for website and lead-form cards'), websiteUrl: z.string().optional(), utm: xUtm.optional(),
14601
14775
  leadFormId: z.string().optional(), buttonLabel: z.enum(['BOOK', 'CONNECT', 'INSTALL', 'OPEN', 'ORDER', 'PLAY', 'SHOP']).optional(),
14602
14776
  iosAppId: z.string().optional(), googlePlayAppId: z.string().optional(), countryCode: z.string().optional(), iosDeepLink: z.string().optional(), googlePlayDeepLink: z.string().optional(),
@@ -14607,7 +14781,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14607
14781
  server.registerTool('list_x_ads_cards', {
14608
14782
  title: 'List X ad cards',
14609
14783
  description: 'List the cards on an X ad account with their card_uri and destination. Read-only, free.',
14610
- inputSchema: { accountId: z.string(), query: z.string().optional().describe('name prefix'), limit: z.number().optional() },
14784
+ inputSchema: { ...xAcct, query: z.string().optional().describe('name prefix'), limit: z.number().optional() },
14611
14785
  outputSchema: { accountId: z.string().optional(), cards: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
14612
14786
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
14613
14787
  }, wrap(async (a) => { const d = await apiGet('/api/x-ads/cards', a); return ok(`${d.note}\n${(d.cards || []).map(c => `• ${c.name} — ${c.cardUri} (${c.cardType}) → ${c.destination || '?'}`).join('\n')}`, d); }));
@@ -14615,7 +14789,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14615
14789
  title: 'Write a promoted-only X post for ads',
14616
14790
  description: "Write an X post FOR ADS — PROMOTED-ONLY by default (X's 'nullcast'): it never appears on the brand's timeline or in followers' feeds and serves only as an ad, so you can test several creatives without spamming followers. Carry a card (cardUri from create_x_ads_card) OR up to 4 media (mediaKeys from upload_x_ads_media). Posted as the ad account's own promotable user (asUserId to choose). Pass lineItemId to attach it straight to a line item (which stays PAUSED). promotedOnly:false makes it a normal public post and needs confirmPublic:true. Read back from X. Free.",
14617
14791
  inputSchema: {
14618
- accountId: z.string(), text: z.string().optional(), cardUri: z.string().optional(), mediaKeys: z.array(z.string()).optional(),
14792
+ ...xAcct, text: z.string().optional(), cardUri: z.string().optional(), mediaKeys: z.array(z.string()).optional(),
14619
14793
  name: z.string().optional().describe('internal name in ads.x.com'), promotedOnly: z.boolean().optional().describe('default true'), confirmPublic: z.boolean().optional(),
14620
14794
  asUserId: z.string().optional(), conversationSettings: z.enum(['EVERYONE', 'FOLLOWING', 'MENTIONED_USERS']).optional(),
14621
14795
  videoTitle: z.string().optional(), videoDescription: z.string().optional(), videoCta: z.enum(['VISIT_SITE', 'WATCH_NOW']).optional(), videoCtaUrl: z.string().optional(), utm: xUtm.optional(),
@@ -14627,14 +14801,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
14627
14801
  server.registerTool('list_x_ads_posts', {
14628
14802
  title: 'List the posts an X ad account can promote',
14629
14803
  description: 'List the posts an X ad account can promote — promoted-only ones by default (timeline NULLCAST), or ORGANIC / ALL. Read-only, free.',
14630
- inputSchema: { accountId: z.string(), timeline: z.enum(['ALL', 'NULLCAST', 'ORGANIC']).optional(), tweetType: z.enum(['DRAFT', 'PUBLISHED', 'SCHEDULED']).optional(), tweetIds: z.array(z.string()).optional(), asUserId: z.string().optional(), limit: z.number().optional() },
14804
+ inputSchema: { ...xAcct, timeline: z.enum(['ALL', 'NULLCAST', 'ORGANIC']).optional(), tweetType: z.enum(['DRAFT', 'PUBLISHED', 'SCHEDULED']).optional(), tweetIds: z.array(z.string()).optional(), asUserId: z.string().optional(), limit: z.number().optional() },
14631
14805
  outputSchema: { accountId: z.string().optional(), posts: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
14632
14806
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
14633
14807
  }, wrap(async (a) => { const d = await apiGet('/api/x-ads/posts', a); return ok(`${d.note}\n${(d.posts || []).map(p => `• ${p.id}${p.nullcast ? ' (promoted-only)' : ''}${p.cardUri ? ` card ${p.cardUri}` : ''} — ${String(p.text || '').slice(0, 100)}`).join('\n')}`, d); }));
14634
14808
  server.registerTool('preview_x_ads_post', {
14635
14809
  title: 'Preview an X ad post',
14636
14810
  description: 'See X posts exactly as the ad will render — X returns a preview page per post (works for promoted-only posts). Read-only, free.',
14637
- inputSchema: { accountId: z.string(), tweetIds: z.array(z.string()), tweetType: z.enum(['DRAFT', 'PUBLISHED', 'SCHEDULED']).optional() },
14811
+ inputSchema: { ...xAcct, tweetIds: z.array(z.string()), tweetType: z.enum(['DRAFT', 'PUBLISHED', 'SCHEDULED']).optional() },
14638
14812
  outputSchema: { accountId: z.string().optional(), previews: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
14639
14813
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
14640
14814
  }, wrap(async (a) => { const d = await apiGet('/api/x-ads/preview', a); return ok(d.note, d); }));
@@ -14649,21 +14823,21 @@ function buildTools(rawServer, opts = {}, sink = null) {
14649
14823
  server.registerTool('list_x_ads_audiences', {
14650
14824
  title: 'List X custom audiences',
14651
14825
  description: 'List the custom audiences (customer lists) on an X ad account — size, whether each can be targeted yet and why not. Read-only, free.',
14652
- inputSchema: { accountId: z.string(), audienceIds: z.array(z.string()).optional(), query: z.string().optional().describe('name prefix'), shared: z.boolean().optional().describe('lists shared with this account instead of owned'), limit: z.number().optional() },
14826
+ inputSchema: { ...xAcct, audienceIds: z.array(z.string()).optional(), query: z.string().optional().describe('name prefix'), shared: z.boolean().optional().describe('lists shared with this account instead of owned'), limit: z.number().optional() },
14653
14827
  outputSchema: { accountId: z.string().optional(), audiences: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
14654
14828
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
14655
14829
  }, wrap(async (a) => { const d = await apiGet('/api/x-ads/audiences', a); return ok(`${d.note}\n${(d.audiences || []).map(x => `• ${x.name} — id ${x.id}, ${x.size != null ? `~${x.size} people` : 'size not reported'}${x.targetable === false ? ` (not targetable yet: ${(x.notTargetableBecause || []).join(', ')})` : ''}`).join('\n')}`, d); }));
14656
14830
  server.registerTool('create_x_ads_audience', {
14657
14831
  title: 'Create an X custom audience',
14658
14832
  description: "Create an X custom audience (a customer list for retargeting or exclusion), optionally filled with people in the same call. Pass ordinary emails, phones (E.164), X handles, X user ids or device ids — they are normalised and SHA-256 hashed on Hermoso's server before X sees them; 64-hex hashes pass as is; anything unusable is named, never sent. X needs up to 48h and about 100 matched people before it can be targeted (add_x_ads_targeting with targetingType CUSTOM_AUDIENCE). Read back. Free.",
14659
- inputSchema: { accountId: z.string(), name: z.string().describe('unique per account'), description: z.string().optional(), ...xPeople, expiresAt: z.string().optional().describe('ISO 8601; X defaults to 13 months') },
14833
+ inputSchema: { ...xAcct, name: z.string().describe('unique per account'), description: z.string().optional(), ...xPeople, expiresAt: z.string().optional().describe('ISO 8601; X defaults to 13 months') },
14660
14834
  outputSchema: { accountId: z.string().optional(), audienceId: z.string().optional(), audience: z.any().optional(), users: z.any().optional(), note: z.string().optional() },
14661
14835
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
14662
14836
  }, wrap(async (a) => { const d = await apiPost('/api/x-ads/audience', a); return ok(d.note, d); }));
14663
14837
  server.registerTool('update_x_ads_audience', {
14664
14838
  title: 'Change an X custom audience',
14665
14839
  description: 'Change an X custom audience: rename / redescribe it, and/or ADD or REMOVE people (operation add|remove) — same identifiers as create_x_ads_audience, hashed on our server. X reports how many it processed and Hermoso relays it; a mismatch is flagged as not fully applied. Read back. Free.',
14666
- inputSchema: { accountId: z.string(), audienceId: z.string(), name: z.string().optional(), description: z.string().optional(), operation: z.enum(['add', 'remove']).optional(), ...xPeople, effectiveAt: z.string().optional(), expiresAt: z.string().optional() },
14840
+ inputSchema: { ...xAcct, audienceId: z.string(), name: z.string().optional(), description: z.string().optional(), operation: z.enum(['add', 'remove']).optional(), ...xPeople, effectiveAt: z.string().optional(), expiresAt: z.string().optional() },
14667
14841
  outputSchema: { accountId: z.string().optional(), audienceId: z.string().optional(), audience: z.any().optional(), users: z.any().optional(), note: z.string().optional() },
14668
14842
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
14669
14843
  }, wrap(async (a) => { const d = await apiPost('/api/x-ads/audience-update', a); return ok(d.note, d); }));
@@ -14671,7 +14845,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14671
14845
  title: 'Send conversions to X (Conversion API)',
14672
14846
  description: "Report conversions to X server-side (the X Conversion API) — purchases, sign-ups, leads — so X attributes them to the ads that caused them and conversion bidding (goal WEBSITE_CONVERSIONS_V2) has something to optimise toward. pixelId and each eventId come from X Events Manager. Each event needs a click id (twclid from the landing URL), an email or an E.164 phone — emails and phones are SHA-256 hashed on Hermoso's server; IP + user agent only count together or beside one of those. Send conversionId to deduplicate against the X Pixel. Free.",
14673
14847
  inputSchema: {
14674
- accountId: z.string(), pixelId: z.string().describe('the X Pixel (event source) id from Events Manager'), eventId: z.string().optional().describe('default Event ID for every event'),
14848
+ ...xAcct, pixelId: z.string().describe('the X Pixel (event source) id from Events Manager'), eventId: z.string().optional().describe('default Event ID for every event'),
14675
14849
  events: z.array(z.object({
14676
14850
  eventId: z.string().optional(), conversionTime: z.string().optional().describe('ISO 8601; default now'), twclid: z.string().optional(),
14677
14851
  email: z.string().optional(), phone: z.string().optional(), ipAddress: z.string().optional(), userAgent: z.string().optional(),
@@ -14686,7 +14860,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14686
14860
  title: 'Attach a post to an X line item (paused)',
14687
14861
  description: "Attach an existing X post to a line item so the line item has a creative — the LAST piece of the tree, and without it nothing can ever serve however the statuses are set. The tweet id is the number at the end of the post URL (x.com/<handle>/status/<id>), not the URL. Several ids may be attached at once; X creates one promoted-tweet row per post. ATTACHMENT IS VERIFIED by re-reading the line item's own promoted posts rather than by trusting X's echo, and if that read cannot run the note says UNCONFIRMED instead of claiming success. Everything Hermoso built above this is PAUSED, so attaching a post starts no spend. Print the returned note verbatim.",
14688
14862
  inputSchema: {
14689
- accountId: z.string(), lineItemId: z.string().describe('from create_x_ads_line_item'),
14863
+ ...xAcct, lineItemId: z.string().describe('from create_x_ads_line_item'),
14690
14864
  tweetIds: z.array(z.string()).describe('post ids — digits only, from the end of the post URL'),
14691
14865
  },
14692
14866
  outputSchema: { accountId: z.string().optional(), lineItemId: z.string().optional(), promotedTweetIds: z.array(z.string()).optional(), verifiedCount: z.number().nullable().optional(), note: z.string().optional() },
@@ -14696,7 +14870,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14696
14870
  title: 'Target an X ads line item',
14697
14871
  description: "Add targeting criteria to an X LINE ITEM. Targeting does NOT attach to a campaign on X — a campaign carries only budget and funding. Pass locationIds resolved with x_ads_geo_search, and/or criteria[] for any of X's other 37 targeting vocabularies when you already hold the ids. X takes ONE criterion per API call, so a set is several calls: each is reported individually, a partial failure NAMES what did not apply, and the line item's FULL targeting is read back afterwards so the answer is what the line item carries rather than what was sent. Adds no spend — the line item stays PAUSED.",
14698
14872
  inputSchema: {
14699
- accountId: z.string(), lineItemId: z.string(),
14873
+ ...xAcct, lineItemId: z.string(),
14700
14874
  locationIds: z.array(z.string()).optional().describe('opaque ids from x_ads_geo_search'),
14701
14875
  criteria: z.array(z.record(z.any())).optional().describe('[{targetingType, targetingValue, operatorType?}] — operatorType defaults to EQ'),
14702
14876
  },
@@ -14717,7 +14891,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14717
14891
  title: 'X ads performance report',
14718
14892
  description: "Performance stats for X campaigns, line items or promoted posts. `placement` here is SINGULAR and comes from a FOUR-value set (ALL_ON_TWITTER / PUBLISHER_NETWORK / SPOTLIGHT / TREND) — deliberately not the twelve placements a line item accepts; do not carry one across. THE TRAP THIS REPORTS: an entity id that does not exist on the account answers 200 with every metric null, which is indistinguishable from a real zero, so an all-null response is FLAGGED rather than narrated as zero performance. Max 20 ids per call. Read-only, free.",
14719
14893
  inputSchema: {
14720
- accountId: z.string(),
14894
+ ...xAcct,
14721
14895
  entity: z.enum(['ACCOUNT', 'CAMPAIGN', 'FUNDING_INSTRUMENT', 'LINE_ITEM', 'MEDIA_CREATIVE', 'ORGANIC_TWEET', 'PROMOTED_ACCOUNT', 'PROMOTED_TWEET']).optional().describe('default CAMPAIGN'),
14722
14896
  entityIds: z.array(z.string()).describe('max 20'),
14723
14897
  startTime: z.string().optional().describe('ISO 8601; defaults to 7 days before endTime'),
@@ -15627,7 +15801,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
15627
15801
  }, wrap(async (a) => { const d = await apiPost('/api/pinterest/customer-list/update', a); return ok(d.note, d); }));
15628
15802
  server.registerTool('pinterest_ads_keyword_ideas', {
15629
15803
  title: 'Pinterest keyword ideas',
15630
- description: 'Pinterest KEYWORD RESEARCH through the Pinterest Ads connection. mode:"related" (default): terms related to each of your terms ("workout" → "yoga workout", "workout motivation"); mode:"suggested": popular searches that BEGIN with the term; mode:"metrics": Pinterest-wide search volume for up to 2,000 keywords in one country. Feeds ad-group KEYWORD targeting and Pin titles. Read-only, 0 credits.',
15804
+ description: 'Pinterest KEYWORD RESEARCH through the Pinterest Ads connection. mode:"related" (default): terms related to each of your terms ("workout" -> "yoga workout", "workout motivation"); mode:"suggested": popular searches that BEGIN with the term; mode:"metrics": Pinterest-wide search volume for up to 2,000 keywords in one country. Feeds ad-group KEYWORD targeting and Pin titles. Read-only, 0 credits.',
15631
15805
  inputSchema: { adAccountId: pinAcct, mode: z.enum(['related', 'suggested', 'metrics']).optional(), terms: z.array(z.string()), limit: z.number().optional().describe('suggested: 1–10'), countryCode: z.string().optional().describe('metrics: two-letter country, default US') },
15632
15806
  outputSchema: { ok: z.boolean().optional(), mode: z.string().optional(), count: z.number().optional(), groups: z.array(z.any()).optional(), terms: z.array(z.any()).optional(), keywords: z.array(z.any()).optional(), note: z.string().optional() },
15633
15807
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -18313,6 +18487,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
18313
18487
  : d.videos ? d.videos.map(v => `• ${v.shareUrl || v.videoId}`).join('\n') : (d.keywords || []).join(' · ');
18314
18488
  return ok(`${d.note}\n${body}`, d);
18315
18489
  }));
18490
+ // → `channel_admin` (2026-10-27). These three spend the ACCOUNT-HOLDER token on /api/tiktok-account/ (the brand's
18491
+ // own posts, Spark candidates among them, and hashtag ideas for an organic post) — the same grant and the same
18492
+ // group as the account-holder run above. They were written inside the ads span and inherited `ads` by position.
18493
+ server.group('channel_admin');
18316
18494
  server.registerTool('tiktok_video_insights', {
18317
18495
  title: 'TikTok post insights',
18318
18496
  description: 'Per-POST analytics for the connected TikTok account: views, reach, likes, comments, shares, saves, average watch time, full-watch rate, the RETENTION CURVE (share still watching at each second — the hook read), traffic sources (For You, Follow, Search, Sound, profile…), new followers, profile views, website clicks, lead submissions and the audience by country, gender and new-vs-returning. Newest posts first, up to 20 a page (cursor for more), or name videoIds. Data lags 24-48 hours. Needs the TikTok ACCOUNT authorization; an older one says which permission to re-authorize for. Read-only, free.',
@@ -18354,6 +18532,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
18354
18532
  const d = await apiGet('/api/tiktok-account/hashtag-suggestions', a);
18355
18533
  return ok(`${d.note}\n${(d.suggestions || []).map(h => `#${h.hashtag}${h.posts != null ? ` (${h.posts} posts)` : ''}`).join(' ')}`, d);
18356
18534
  }));
18535
+ server.group('ads'); // back to the TikTok ADVERTISER surface
18357
18536
  // ══ AUTOMATED RULES (2026-08-19) ══════════════════════════════════════════════════════════════════════════════
18358
18537
  // Held since the 2026-08-19 approval and recorded as DELIBERATELY unbuilt — "a rule is standing permission to
18359
18538
  // move money with no human in the loop, and every spend switch in this product is confirm-gated for exactly
@@ -19121,7 +19300,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
19121
19300
  }));
19122
19301
  server.registerTool('snapchat_ads_report', {
19123
19302
  title: 'Snapchat ad performance',
19124
- description: 'Read Snapchat ad performance — impressions, spend, swipes and video quartiles — at ad account, campaign, ad squad or ad level. THREE THINGS TO KNOW BEFORE CALLING: granularity is required (TOTAL is the default); DAY and HOUR granularity REQUIRE startTime and endTime AND both must land exactly on the start of an hour, which Snapchat refuses otherwise and Hermoso refuses for free before spending the call; and SPEND COMES BACK IN MICRO-CURRENCY, so divide by 1,000,000 before quoting money to anyone. Read-only, free.',
19303
+ description: 'Read Snapchat ad performance — impressions, spend, swipes and video quartiles — at ad account, campaign, ad squad or ad level. THREE THINGS TO KNOW BEFORE CALLING: granularity is required (TOTAL is the default); DAY and HOUR granularity REQUIRE startTime and endTime AND both must land exactly on the start of an hour, which Snapchat refuses otherwise and Hermoso refuses for free before spending the call; and SPEND COMES BACK IN MICRO-CURRENCY, so divide by 1,000,000 before quoting money to anyone. It changes nothing on the ad account; with async:true it starts a report file on Snapchat that is kept for seven days. Free.',
19125
19304
  inputSchema: {
19126
19305
  adAccountId: z.string().optional(),
19127
19306
  level: z.enum(['adaccount', 'campaign', 'adsquad', 'ad']).optional().describe('default campaign'),
@@ -19131,18 +19310,20 @@ function buildTools(rawServer, opts = {}, sink = null) {
19131
19310
  endTime: z.string().optional().describe('ISO 8601, on the start of an hour'),
19132
19311
  fields: z.string().optional().describe('comma-separated metrics — default impressions,spend,swipes'),
19133
19312
  breakdown: z.string().optional().describe('object-level breakdown: ad, adsquad (campaign stats only) or campaign (ad-account stats only)'),
19134
- swipeUpAttributionWindow: z.enum(['1_DAY', '7_DAY', '28_DAY']).optional().describe('how long after a SWIPE a conversion still counts. Omit to use the ad account default. Changing it changes the numbers, not just the report.'),
19313
+ swipeUpAttributionWindow: z.enum(['1_DAY', '7_DAY', '28_DAY']).optional().describe('how long after a SWIPE a conversion still counts in this report. Omit to use the ad account default. It only changes how this report counts conversions; the ad account itself is not changed.'),
19135
19314
  viewAttributionWindow: z.enum(['none', '1_HOUR', '3_HOUR', '6_HOUR', '1_DAY', '7_DAY']).optional().describe("how long after a VIEW (no swipe) a conversion still counts; 'none' attributes no view-throughs at all. Omit to use the ad account default."),
19136
19315
  reportDimension: z.enum(['country', 'country,os', 'region', 'dma', 'gender', 'age', 'age,gender', 'os', 'os,country', 'make', 'lifestyle_category']).optional().describe('break the numbers down by AUDIENCE — age, gender, age,gender, country, region, dma, os, device make or interest (lifestyle_category). Not with HOUR. region / dma / make / lifestyle_category / the os+country pairs carry DELIVERY metrics only (no conversion_* fields)'),
19137
19316
  conversionSourceTypes: z.string().optional().describe('comma-separated: web, app, offline, total, total_off_platform, total_on_platform — splits conversions by where they happened'),
19138
19317
  actionReportTime: z.enum(['conversion', 'impression']).optional().describe('count a conversion on the day it happened (conversion, default) or on the day of the ad impression (impression)'),
19139
19318
  omitEmpty: z.boolean().optional().describe('drop days/entities with zero on every metric'),
19140
- async: z.boolean().optional().describe('for long ranges or wide breakdowns: Snapchat builds a CSV/Excel file instead and this returns a reportRunId'),
19319
+ async: z.boolean().optional().describe('for long ranges or wide breakdowns: starts a report run on Snapchat that builds a downloadable CSV/Excel file (kept seven days) and returns its reportRunId'),
19141
19320
  asyncFormat: z.enum(['csv', 'excel']).optional(),
19142
19321
  reportRunId: z.string().optional().describe('fetch an async report started earlier — same level and id'),
19143
19322
  },
19144
19323
  outputSchema: { adAccountId: z.string().optional(), level: z.string().optional(), id: z.string().optional(), granularity: z.string().optional(), rows: z.array(z.any()).optional(), read: z.boolean().optional(), reportDimension: z.string().optional(), reportRunId: z.string().nullable().optional(), asyncStatus: z.string().nullable().optional(), downloadUrl: z.string().nullable().optional(), note: z.string().optional() },
19145
- annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
19324
+ // readOnlyHint FALSE (OpenAI scan 2026-10-27, held twice as "readOnlyHint true but a user-visible side effect"): async:true
19325
+ // STARTS a report run on Snapchat — a stored, downloadable file — so the honest hint is not read-only. Not destructive.
19326
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
19146
19327
  }, wrap(async (a) => {
19147
19328
  const d = await apiGet('/api/snapchat-ads/report', a);
19148
19329
  if (d.reportRunId || d.downloadUrl) return ok(d.note, d);
@@ -19187,7 +19368,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
19187
19368
  }));
19188
19369
  server.registerTool('upload_snapchat_ads_creative', {
19189
19370
  title: 'Upload a render to Snapchat as media + creative',
19190
- description: 'PUT A FINISHED HERMOSO RENDER ONTO THE SNAPCHAT AD ACCOUNT so an ad can point at it — create_snapchat_ads_ad needs the creativeId it returns. Pass the public https url of a render; SNAPCHAT HAS NO UPLOAD-FROM-URL, so Hermoso fetches the bytes and posts them as multipart. This does TWO things in one call — it uploads the MEDIA and then builds the CREATIVE that wraps it — because a Snapchat ad points at a CREATIVE and never at a media id, and stopping after the upload leaves an asset nothing can use. A PUBLIC PROFILE IS MANDATORY: Snapchat has required profile_properties on every ad creative since 2024-02-26, so profileId is REQUIRED and a call without one is refused BEFORE any bytes move (which is what stops an orphan media row being left on the ad account for a creative that could never be created). Get one from list_snapchat_ads_profiles, or read it in Snapchat Ads Manager. headline is required too. COPY LIMITS ARE SHORT AND ENFORCED: headline max 34 characters, brandName max 32, and over-long copy is REFUSED rather than truncated, because shipping words nobody wrote is worse than a refusal. To build a SECOND creative on media already uploaded, pass mediaId instead of url — nothing is downloaded or uploaded again. A file over 32MB is refused by name (Snapchat requires a chunked upload flow above that which Hermoso does not implement). Free.',
19371
+ description: 'PUT A FINISHED HERMOSO RENDER ONTO THE SNAPCHAT AD ACCOUNT so an ad can point at it — create_snapchat_ads_ad needs the creativeId it returns. Pass the public https url of a render; SNAPCHAT HAS NO UPLOAD-FROM-URL, so Hermoso fetches the bytes and posts them as multipart. This does TWO things in one call — it uploads the MEDIA and then builds the CREATIVE that wraps it — because a Snapchat ad points at a CREATIVE and never at a media id, and stopping after the upload leaves an asset nothing can use. A PUBLIC PROFILE IS MANDATORY: Snapchat has required profile_properties on every ad creative since 2024-02-26, so snapProfileId is REQUIRED and a call without one is refused BEFORE any bytes move (which is what stops an orphan media row being left on the ad account for a creative that could never be created). Get one from list_snapchat_ads_profiles, or read it in Snapchat Ads Manager. headline is required too. COPY LIMITS ARE SHORT AND ENFORCED: headline max 34 characters, brandName max 32, and over-long copy is REFUSED rather than truncated, because shipping words nobody wrote is worse than a refusal. To build a SECOND creative on media already uploaded, pass mediaId instead of url — nothing is downloaded or uploaded again. A file over 32MB is refused by name (Snapchat requires a chunked upload flow above that which Hermoso does not implement). Free.',
19191
19372
  inputSchema: {
19192
19373
  adAccountId: z.string().optional(),
19193
19374
  url: z.string().optional().describe('public https url of the render — a Hermoso render URL already is one; for any other file run it through upload_file first. Omit only when reusing mediaId.'),
@@ -19202,7 +19383,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
19202
19383
  leadGenerationFormId: z.string().optional().describe('build a LEAD_GENERATION creative on this lead form (list_snapchat_ads_lead_forms / create_snapchat_ads_lead_form)'),
19203
19384
  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 parameters appended to landingPageUrl. Snapchat URL macros such as {{campaign.name}}, {{adSet.name}}, {{ad.id}} are kept literal so Snapchat fills them in'),
19204
19385
  addUtm: z.boolean().optional().describe('append utm_source=snapchat, utm_medium=paid_social, utm_campaign={{campaign.name}}, utm_content={{ad.id}} (utm overrides any of them). Nothing is added unless asked'),
19205
- profileId: z.string().describe('REQUIRED — the Snapchat Public Profile the ad posts as. Snapchat has rejected every creative without one since 2024-02-26. From list_snapchat_ads_profiles, or Snapchat Ads Manager.'),
19386
+ snapProfileId: z.string().optional().describe('REQUIRED (or profileId): the Snapchat Public Profile the ad posts as. Snapchat has rejected every creative without one since 2024-02-26. From list_snapchat_ads_profiles, or Snapchat Ads Manager.'),
19387
+ profileId: z.string().optional().describe('older name for snapProfileId (MCP only)'),
19206
19388
  creative: z.boolean().optional().describe('set false to upload the MEDIA ONLY and build the creative yourself — the default true is what an ad actually needs'),
19207
19389
  },
19208
19390
  outputSchema: { adAccountId: z.string().optional(), mediaId: z.string().optional(), creativeId: z.string().optional(), kind: z.string().optional(), name: z.string().optional(), bytes: z.number().optional(), reusedMedia: z.boolean().optional(), creativeType: z.string().optional(), landingPageUrl: z.string().optional(), leadGenerationFormId: z.string().optional(), note: z.string().optional() },
@@ -19289,7 +19471,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
19289
19471
  adSquadId: z.string().describe('the ad squad this ad belongs to'),
19290
19472
  name: z.string().describe('max 375 characters'),
19291
19473
  creativeId: z.string().describe('from upload_snapchat_ads_creative — NOT a mediaId'),
19292
- type: z.string().optional().describe('omit it — Hermoso reads the creative and picks the ad type Snapchat pairs with it (WEB_VIEW → REMOTE_WEBPAGE, LEAD_GENERATION → LEAD_GENERATION, SNAP_AD → SNAP_AD). Pass one only to override.'),
19474
+ type: z.string().optional().describe('omit it — Hermoso reads the creative and picks the ad type Snapchat pairs with it (WEB_VIEW -> REMOTE_WEBPAGE, LEAD_GENERATION -> LEAD_GENERATION, SNAP_AD -> SNAP_AD). Pass one only to override.'),
19293
19475
  },
19294
19476
  outputSchema: { id: z.string().optional(), adAccountId: z.string().optional(), adSquadId: z.string().optional(), creativeId: z.string().optional(), name: z.string().optional(), status: z.string().optional(), type: z.string().optional(), reviewStatus: z.string().optional(), verified: z.boolean().optional(), note: z.string().optional() },
19295
19477
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
@@ -19365,7 +19547,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
19365
19547
  }));
19366
19548
  server.registerTool('create_snapchat_ads_lead_form', {
19367
19549
  title: 'Create a Snapchat lead form',
19368
- 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.",
19550
+ 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.",
19369
19551
  inputSchema: {
19370
19552
  adAccountId: z.string().optional(),
19371
19553
  name: z.string().describe('the form name in Ads Manager'),
@@ -19375,7 +19557,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
19375
19557
  privacyPolicyUrl: z.string().optional().describe('linked on the confirmation screen'),
19376
19558
  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'),
19377
19559
  bannerMediaId: z.string().optional().describe('an IMAGE media id already on the ad account; 75:23, 750x230 to 1875x575, 5MB'),
19378
- 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']), url: z.string() }).optional().describe('the screen after submit'),
19560
+ 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'),
19379
19561
  },
19380
19562
  outputSchema: { adAccountId: z.string().optional(), formId: z.string().optional(), form: z.any().optional(), verified: z.boolean().optional(), note: z.string().optional() },
19381
19563
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
@@ -19406,7 +19588,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
19406
19588
  }));
19407
19589
  server.registerTool('update_snapchat_ads_object', {
19408
19590
  title: 'Edit a Snapchat campaign, ad squad, ad or creative',
19409
- description: "Edit a live or paused Snapchat object IN PLACE, only where Snapchat allows it. campaign / ad: name, startTime, endTime. adsquad: name, startTime, endTime, bid, bidStrategy, pixelId, audiences / excludeAudiences (changing targeting sends its ads back to review and briefly pauses a live squad). creative: name, headline (max 34), brandName (max 32), callToAction, landingPageUrl, utm / addUtm — the URL only on WEB_VIEW creatives; a creative edit re-submits every ad using it for review. Refused BY NAME because Snapchat does not allow them: an ad's creative, a squad's optimization goal or billing event, a creative's type. Status and budget are set_snapchat_ads_status and set_snapchat_ads_budget. confirm:true required — without it NOTHING changes and you get each field's before → after to show the user. The answer is the READ-BACK, per field.",
19591
+ description: "Edit a live or paused Snapchat object IN PLACE, only where Snapchat allows it. campaign / ad: name, startTime, endTime. adsquad: name, startTime, endTime, bid, bidStrategy, pixelId, audiences / excludeAudiences (changing targeting sends its ads back to review and briefly pauses a live squad). creative: name, headline (max 34), brandName (max 32), callToAction, landingPageUrl, utm / addUtm — the URL only on WEB_VIEW creatives; a creative edit re-submits every ad using it for review. Refused BY NAME because Snapchat does not allow them: an ad's creative, a squad's optimization goal or billing event, a creative's type. Status and budget are set_snapchat_ads_status and set_snapchat_ads_budget. confirm:true required — without it NOTHING changes and you get each field's before -> after to show the user. The answer is the READ-BACK, per field.",
19410
19592
  inputSchema: {
19411
19593
  adAccountId: z.string().optional(),
19412
19594
  level: z.enum(['campaign', 'adsquad', 'ad', 'creative']),
@@ -19426,7 +19608,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
19426
19608
  landingPageUrl: z.string().optional().describe('https only, max 2048 characters'),
19427
19609
  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 parameters set on the landing page URL; Snapchat macros like {{campaign.name}} stay literal'),
19428
19610
  addUtm: z.boolean().optional().describe('set utm_source=snapchat, utm_medium=paid_social, utm_campaign={{campaign.name}}, utm_content={{ad.id}}'),
19429
- confirm: z.boolean().optional().describe('REQUIRED true — without it nothing changes and you get the before → after'),
19611
+ confirm: z.boolean().optional().describe('REQUIRED true — without it nothing changes and you get the before -> after'),
19430
19612
  },
19431
19613
  outputSchema: { adAccountId: z.string().optional(), level: z.string().optional(), id: z.string().optional(), name: z.string().optional(), changes: z.array(z.any()).optional(), verified: z.boolean().optional(), reviewStatus: z.string().optional(), note: z.string().optional() },
19432
19614
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
@@ -19567,6 +19749,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
19567
19749
  videoThumbnailUrl: z.string().optional().describe('the COVER IMAGE for a videoUrl post — a Hermoso-hosted image (a render, or any picture of the user\u2019s via upload_file). Without it LinkedIn adds a system-generated thumbnail, which on an ad is usually whatever the first frame happens to be. Like captions this can only be set WHILE the video is uploaded, never afterwards. Requires videoUrl. This is NOT linkThumbnailUrl, which is the picture on a link-preview card.'),
19568
19750
  coverAtMs: z.number().optional().describe('THE VIDEO COVER of a videoUrl post, as ONE frame: milliseconds from the start (7000 = the frame at 7s). That frame is uploaded as LinkedIn’s video thumbnail (only possible while the video uploads). videoThumbnailUrl wins.'),
19569
19751
  visibility: z.enum(['PUBLIC', 'CONNECTIONS']).optional().describe('default PUBLIC'),
19752
+ firstComment: z.string().optional().describe('FIRST COMMENT — posted on the new post AS THE PAGE right after it publishes. Needs the LinkedIn comments authorization on this brand (provider linkedin_community). Up to 2,000 characters, refused rather than cut. If the comment fails the post stays live and firstComment in the result says why.'),
19570
19753
  targetAudience: z.object({ geoLocations: z.array(z.string()).optional(), industries: z.array(z.string()).optional(), seniorities: z.array(z.string()).optional(), jobFunctions: z.array(z.string()).optional(), staffCountRanges: z.array(z.enum(['SIZE_1', 'SIZE_2_TO_10', 'SIZE_11_TO_50', 'SIZE_51_TO_200', 'SIZE_201_TO_500', 'SIZE_501_TO_1000', 'SIZE_1001_TO_5000', 'SIZE_5001_TO_10000', 'SIZE_10001_OR_MORE'])).optional(), degrees: z.array(z.string()).optional(), fieldsOfStudy: z.array(z.string()).optional(), organizations: z.array(z.string()).optional() }).optional().describe('LINKEDIN COMPANY PAGE POST ONLY — show the post only to Page followers matching these facets (URNs or bare numeric ids; search_linkedin_ads_targeting finds them). LinkedIn requires the matching audience to be over 300 followers and refuses a smaller one. Personal-profile posts cannot be targeted.'),
19571
19754
  platformCover: z.boolean().optional().describe('VIDEO COVER. Omit it (the default) and Hermoso sets the video\u2019s best frame \u2014 the same frame as its Library thumbnail \u2014 as the cover (LinkedIn\u2019s video thumbnail upload). true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins.'),
19572
19755
  },
@@ -20440,6 +20623,92 @@ function buildTools(rawServer, opts = {}, sink = null) {
20440
20623
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
20441
20624
  }, wrap(async (a) => { const d = await apiPost('/api/notion/schedule', a); return ok(d.note, d); }));
20442
20625
 
20626
+ // DATABASE ROWS (2026-10-27): read a Notion database the team keeps (a content calendar) and change one row's
20627
+ // properties (mark it Posted, put the live link in). Rules + citations: lib/notion.mjs. Free.
20628
+ server.group('files');
20629
+ server.registerTool('query_notion_database', {
20630
+ title: 'Read a Notion database',
20631
+ 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).",
20632
+ 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') },
20633
+ 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() },
20634
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
20635
+ }, wrap(async (a) => {
20636
+ const d = await apiPost('/api/notion/query', a);
20637
+ const cols = Object.entries(d.schema || {}).map(([k, v]) => `${k} (${v.type}${v.options ? `: ${v.options.join(' / ')}` : ''})`).join(', ');
20638
+ const show = (v) => (Array.isArray(v) ? v.join(', ') : v);
20639
+ 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);
20640
+ }));
20641
+ server.registerTool('update_notion_row', {
20642
+ title: 'Update a Notion database row',
20643
+ 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.",
20644
+ 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() },
20645
+ 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() },
20646
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
20647
+ }, wrap(async (a) => { const d = await apiPost('/api/notion/row', { ...a, confirm: a.confirm === true }); return ok(d.note, d); }));
20648
+
20649
+ // ---------- Canva (2026-10-27): the user's OWN Canva, the same /api/canva routes the Studio tools call ----------
20650
+ // Renders go in (an upload, and for an image a new editable design); finished designs come back out into the Library.
20651
+ // Free (no model call). Nothing here deletes or overwrites anything in Canva.
20652
+ server.group('files');
20653
+ server.registerTool('save_to_canva', {
20654
+ title: 'Save to Canva',
20655
+ 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).",
20656
+ 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') },
20657
+ outputSchema: { ok: z.boolean().optional(), sent: z.number().optional(), failed: z.number().optional(), results: z.array(z.any()).optional(), note: z.string().optional() },
20658
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
20659
+ }, wrap(async (a) => { const d = await apiPost('/api/canva/send', a); return ok(d.note, d); }));
20660
+ server.registerTool('list_canva_designs', {
20661
+ title: 'List Canva designs',
20662
+ 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.",
20663
+ 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() },
20664
+ outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), designs: z.array(z.any()).optional(), continuation: z.string().nullable().optional(), note: z.string().optional() },
20665
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
20666
+ }, wrap(async (a) => {
20667
+ const d = await apiGet('/api/canva/designs', a);
20668
+ 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);
20669
+ }));
20670
+ server.registerTool('export_canva_design', {
20671
+ title: 'Export a Canva design',
20672
+ 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, mp4 or gif (pdf and pptx return Canva's 24-hour download link instead of a Library file); 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.",
20673
+ 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() },
20674
+ 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() },
20675
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
20676
+ }, wrap(async (a) => { const d = await apiPost('/api/canva/export', a); return ok(d.note, d); }));
20677
+ server.registerTool('list_canva_brand_templates', {
20678
+ title: 'List Canva brand templates',
20679
+ 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.",
20680
+ inputSchema: { query: z.string().optional(), brandTemplateId: z.string().optional(), autofillOnly: z.boolean().optional(), limit: z.number().optional(), continuation: z.string().optional() },
20681
+ 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() },
20682
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
20683
+ }, wrap(async (a) => {
20684
+ const d = await apiGet('/api/canva/brand-templates', a);
20685
+ return ok(d.templates ? `${d.note}\n${d.templates.map((t) => `• ${t.title} (id ${t.id})`).join('\n')}` : d.note, d);
20686
+ }));
20687
+ server.registerTool('autofill_canva_template', {
20688
+ title: 'Autofill a Canva template',
20689
+ 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.",
20690
+ inputSchema: { brandTemplateId: z.string(), data: z.record(z.any()).describe('{ field: value }'), title: z.string().optional() },
20691
+ outputSchema: { ok: z.boolean().optional(), pending: z.boolean().optional(), design: z.any().optional(), note: z.string().optional() },
20692
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
20693
+ }, wrap(async (a) => { const d = await apiPost('/api/canva/autofill', a); return ok(d.note, d); }));
20694
+ server.registerTool('list_canva_folder', {
20695
+ title: 'List a Canva folder',
20696
+ 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.",
20697
+ inputSchema: { folderId: z.string().optional(), itemTypes: z.array(z.enum(['design', 'folder', 'image', 'brand_template'])).optional(), limit: z.number().optional(), continuation: z.string().optional() },
20698
+ 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() },
20699
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
20700
+ }, wrap(async (a) => {
20701
+ const d = await apiGet('/api/canva/folder', { ...a, ...(Array.isArray(a.itemTypes) ? { itemTypes: a.itemTypes.join(',') } : {}) });
20702
+ 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);
20703
+ }));
20704
+ server.registerTool('create_canva_folder', {
20705
+ title: 'Create a Canva folder',
20706
+ description: "Create a folder in the user's Canva (in their Projects by default, or inside parentFolderId). save_to_canva's folderId then files renders into it. Free.",
20707
+ inputSchema: { name: z.string(), parentFolderId: z.string().optional().describe('root (default), uploads or a folder id') },
20708
+ outputSchema: { ok: z.boolean().optional(), folder: z.any().optional(), note: z.string().optional() },
20709
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
20710
+ }, wrap(async (a) => { const d = await apiPost('/api/canva/folders', a); return ok(d.note, d); }));
20711
+
20443
20712
  // ---------- Google Docs: export copy / brief / report as a doc the app creates (drive.file scope) ----------
20444
20713
  server.group('files');
20445
20714
  server.registerTool('create_doc', {
@@ -20678,8 +20947,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
20678
20947
  // ONEDRIVE SHARING (2026-10-27): hand a render to a client or teammate as a link. Rules: server.js onedriveShare.
20679
20948
  server.registerTool('share_onedrive_file', {
20680
20949
  title: 'Share a OneDrive file as a link',
20681
- 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 (personal OneDrive only). 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.",
20682
- 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 only'), confirm: z.boolean().optional().describe('required for an anonymous edit link') },
20950
+ 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.",
20951
+ 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') },
20683
20952
  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() },
20684
20953
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
20685
20954
  }, wrap(async (a) => { const d = await apiPost('/api/onedrive/share', a); return ok(d.note, d); }));
@@ -20905,7 +21174,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
20905
21174
  // A product page link → the product read from its own page (saved to the brand's catalog), its real packshot checked and
20906
21175
  // locked as the product photo, and a UGC-style video plan. Stops at the plan like clone_video: render_ad is the spend.
20907
21176
  server.registerTool('click_to_ad', {
20908
- title: 'Make a video ad from a product link',
21177
+ title: 'Plan a video ad from a product link',
20909
21178
  description: 'Paste a product page link (Shopify, Amazon or any store page) and get a creator-style video ad planned for that product. Hermoso reads the page (name, photos, what it says about the product, price), saves the product to the brand\'s catalog, checks the page\'s real product photo and pins it to this ad (the brand\'s saved default photo is not changed), then plans the ad. A store that hides its page from servers is read in a real browser; failing that the name comes from the link and the photo from a search, and the reply says so. Nothing renders here: show the user the concept, then call render_ad with the returned creative (dryRun:true first for the exact price). Works with or without a saved brand.',
20910
21179
  inputSchema: {
20911
21180
  url: z.string().describe('the product page link, https://…'),
@@ -22747,7 +23016,7 @@ function memoryNoteVerdict(text) {
22747
23016
  // answer about the app. It is the OPPOSITE instruction to the permission gap above: nothing the user does fixes
22748
23017
  // it, so it is never phrased as a reconnect. The sentence is the SERVER'S (`awaitingApprovalNote`).
22749
23018
  const waitingNote = ''; // a feature not offered yet is not listed on the connection (owner decision, 2026-10-02)
22750
- 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` : ''}`);
23019
+ 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}` : ''}`);
22751
23020
  const postingNote = posting.length
22752
23021
  ? '\n\n' + posting.map(c => `ℹ ${c.grantModeNote}`).join('\n')
22753
23022
  + '\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') + '.'
@@ -22760,6 +23029,48 @@ function memoryNoteVerdict(text) {
22760
23029
  const safe = { ...d, connectors: (d.connectors || []).map(({ accountLabel, ...c }) => c) };
22761
23030
  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);
22762
23031
  }));
23032
+ // ── ALERTS (2026-10-27): Slack / Discord / a signed generic webhook, for renders, scheduled posts, drafts waiting for
23033
+ // approval, competitor-watch findings, new leads and low credits. Rules: lib/alert-sinks.mjs; routes /api/alerts*.
23034
+ const alertSinkEnum = z.enum(['slack', 'discord', 'webhook']);
23035
+ // CHANNEL_ADMIN, NOT WORKSPACE (2026-10-27): these configure an already-connected Slack / Discord / webhook sink, the
23036
+ // same job as the webhook and template tools in channel_admin. In `workspace` they were on every default roster —
23037
+ // including a brand with nothing connected, since three possible sinks cannot be gated on one provider.
23038
+ server.group('channel_admin');
23039
+ server.registerTool('list_alert_settings', {
23040
+ title: 'Alert settings and recent deliveries',
23041
+ 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.",
23042
+ inputSchema: {},
23043
+ outputSchema: { sinks: z.array(z.any()).optional(), groups: z.any().optional(), log: z.array(z.any()).optional(), note: z.string().optional() },
23044
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
23045
+ }, wrap(async () => {
23046
+ const d = await apiGet('/api/alerts');
23047
+ 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', ' ')}` : ''}`);
23048
+ 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)'}` : ''}`);
23049
+ return ok(`${d.note || ''}\n${sinks.join('\n')}${rows.length ? `\nRecent deliveries:\n${rows.join('\n')}` : ''}`, d);
23050
+ }));
23051
+ server.registerTool('set_alert_events', {
23052
+ title: 'Choose which alerts a channel receives',
23053
+ 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), connections (an account connected to or disconnected from the brand: account.connected / account.disconnected), inbox (a new Facebook or Instagram comment or direct message: comment.received / message.received; opt-in, never on unless named) — or \"all\". An empty list keeps the channel connected and sends it nothing. Unknown names are refused by name. Read back from the stored setting. Free.",
23054
+ inputSchema: { provider: alertSinkEnum, events: z.union([z.array(z.enum(['renders', 'posts', 'approvals', 'watch', 'credits', 'leads', 'connections', 'inbox', 'all'])), z.literal('all')]).describe('event groups, or "all"') },
23055
+ outputSchema: { provider: z.string().optional(), events: z.array(z.string()).nullable().optional(), note: z.string().optional() },
23056
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
23057
+ }, wrap(async (a) => { const d = await apiPost('/api/alerts/events', a); return ok(d.note, d); }));
23058
+ server.registerTool('send_test_alert', {
23059
+ title: 'Send a test alert',
23060
+ 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.",
23061
+ inputSchema: { provider: alertSinkEnum.optional().describe('omit to test every connected alert channel') },
23062
+ outputSchema: { results: z.array(z.any()).optional(), note: z.string().optional() },
23063
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
23064
+ }, wrap(async (a) => { const d = await apiPost('/api/alerts/test', a); return ok(d.note, d); }));
23065
+ server.registerTool('rotate_webhook_signing_secret', {
23066
+ title: 'Rotate the webhook signing secret',
23067
+ 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), then stops: a receiver still holding only the old secret rejects events after that, so rotate only when the user means to replace it. Free.",
23068
+ inputSchema: {},
23069
+ outputSchema: { signingSecret: z.string().optional(), previousStillSignsUntil: z.string().nullable().optional(), note: z.string().optional() },
23070
+ // destructiveHint TRUE (OpenAI scan 2026-10-27): a rotation REPLACES the secret, and 24 hours later the old one stops
23071
+ // signing, so a receiver that was not updated starts rejecting every event — a hard-to-reverse change.
23072
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
23073
+ }, wrap(async () => { const d = await apiPost('/api/alerts/webhook-secret', {}); return ok(`${d.note}\nSigning secret: ${d.signingSecret}`, d); }));
22763
23074
  // ── CONNECTOR WRITES. Connecting needs a browser (OAuth consent) and is correctly NOT headless — but the other two
22764
23075
  // halves of connector management are, and were web-only: DISCONNECTING, and choosing WHICH accounts a brand may
22765
23076
  // act as. The second is a permission decision that fails closed: a Page/ad account not in the list is one nothing
@@ -22933,6 +23244,7 @@ function memoryNoteVerdict(text) {
22933
23244
  const CONN_PICKER_IDS = Object.keys(CONN_ACCOUNT_PICKERS);
22934
23245
  const connIdentityLines = (rows) => rows.map(r => ` ${r.selected ? '[x]' : '[ ]'} ${r.name} (id: ${r.id}, ${r.type})${r.detail ? ` — ${r.detail}` : ''}`).join('\n');
22935
23246
 
23247
+ server.group('workspace');
22936
23248
  server.registerTool('list_connector_accounts', {
22937
23249
  title: 'List a connector’s accounts',
22938
23250
  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.`,
@@ -23895,6 +24207,26 @@ function memoryNoteVerdict(text) {
23895
24207
  return adsOut('posts', all.length, all.slice(0, nAds(limit)), '', 'threads');
23896
24208
  }));
23897
24209
 
24210
+ // ── FIND A PLACE TO TAG ON INSTAGRAM / FACEBOOK (2026-10-27, gap sweep I-23). Meta's own Pages search is closed to
24211
+ // this app, so the server searches the web for facebook.com Pages and reads each one through our data provider; it
24212
+ // needs no connected account, which is why it sits in research, and it is billed in credits like its siblings.
24213
+ server.registerTool('search_meta_places', {
24214
+ title: 'Find a place to tag on Instagram or Facebook',
24215
+ description: 'Find a PLACE to tag on an Instagram or Facebook post: a shop, cafe, restaurant, venue or landmark. Give its name with the city ("Blue Bottle Coffee Oakland"), or paste its Facebook Page link, an Instagram place link (instagram.com/explore/locations/...) or its numeric id. Returns a handful of matching Facebook Pages that have an address: pageId, name, address, category, link. Pass the chosen pageId as locationId (Instagram) or place (Facebook) on post_to_meta, or instagramLocationId on schedule_post. These are matches to CONFIRM with the user (check the name and address; a place tag is public), and Instagram checks the place when the post is made. Needs no connected account. Spends about 1 credit per Page it checks (a name search checks up to 5 Pages, so about 5 to 7 credits); a pasted Facebook Page link is about 1 credit; an Instagram place link or a numeric id costs nothing.',
24216
+ inputSchema: {
24217
+ query: z.string().describe('the place name with its city (e.g. "Blue Bottle Coffee Oakland"), or a Facebook Page link, an Instagram place link, or a numeric id'),
24218
+ near: z.string().optional().describe('optional city or area to narrow a name search, e.g. "Paris"'),
24219
+ limit: z.number().int().min(1).max(8).optional().describe('how many Pages to check on a name search, 1 to 8 (default 5); each is about 1 credit'),
24220
+ },
24221
+ outputSchema: { query: z.string().optional(), near: z.string().optional(), count: z.number().optional(), places: z.array(z.any()).optional(), skipped: z.array(z.any()).optional(), warning: z.string().optional(), note: z.string().optional() },
24222
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
24223
+ }, wrap(async (a) => {
24224
+ const d = await apiGet('/api/meta/places', { query: a.query, ...(a.near ? { near: a.near } : {}), ...(a.limit ? { limit: a.limit } : {}) });
24225
+ const lines = (d.places || []).map((p) => `• ${p.name || '(name not read)'}${p.address ? `, ${p.address}` : ''}${p.category ? ` (${p.category})` : ''}: pageId ${p.pageId}${p.link ? ` · ${p.link}` : ''}`);
24226
+ const sk = (d.skipped || []).map((x) => `• ${x.name || x.link}: ${x.why}`);
24227
+ return ok([d.warning ? `⚠ ${d.warning}` : '', d.count ? `${d.count} place(s) for “${d.query}”${d.near ? ` near ${d.near}` : ''}:` : '', ...lines, sk.length ? 'Not offered as a tag:' : '', ...sk, d.note].filter(Boolean).join('\n'), d);
24228
+ }));
24229
+
23898
24230
  server.registerTool('fetch_social_data', {
23899
24231
  title: 'Fetch social data',
23900
24232
  description: "Generic escape hatch for any ALLOWLISTED long-tail social/web endpoint the dedicated search_* tools don't cover — e.g. {path:'/v1/instagram/profile', params:{handle:'nike'}}. Allowlisted platform families: TikTok (+ TikTok Shop), Instagram, YouTube, Facebook (organic profiles/posts/events/marketplace), LinkedIn (organic posts/companies), Twitter/X, Reddit, Threads, Snapchat, Pinterest, Twitch, Bluesky, Truth Social, Rumble, Spotify, SoundCloud, GitHub, Google search, link-in-bio pages (Linktree etc.). Param names vary per endpoint (profiles use `handle`, keyword searches use `query`, Reddit uses `subreddit`). WARNING: returns RAW provider JSON — large and messy; prefer the dedicated search_* tools. Spends credits.",