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/README.md +2 -2
- package/mcp/openai-extensions.mjs +1 -1
- package/mcp/roster-scope.mjs +3 -0
- package/mcp/tools.mjs +512 -180
- package/package.json +2 -2
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
|
|
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:
|
|
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
|
|
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. @
|
|
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
|
|
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
|
-
|
|
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
|
|
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 ·
|
|
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}
|
|
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}
|
|
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
|
|
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 (@
|
|
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(
|
|
6154
|
-
parseMode: z.enum(['HTML']).optional().describe("TELEGRAM ONLY — 'HTML'
|
|
6155
|
-
silent: z.boolean().optional().describe('TELEGRAM ONLY —
|
|
6156
|
-
disablePreview: z.boolean().optional().describe('TELEGRAM ONLY —
|
|
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
|
|
6334
|
-
parseMode: z.enum(['HTML']).optional().describe("TELEGRAM ONLY — 'HTML'
|
|
6335
|
-
silent: z.boolean().optional().describe('TELEGRAM ONLY —
|
|
6336
|
-
disablePreview: z.boolean().optional().describe('TELEGRAM ONLY —
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:'
|
|
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:'
|
|
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:'
|
|
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:'
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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', {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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', {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
13526
|
-
inputSchema: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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: {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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']),
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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: '
|
|
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.",
|