hermoso 0.1.457 → 0.1.461

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mcp/tools.mjs CHANGED
@@ -258,7 +258,7 @@ export const CAPABILITY_MAP = [
258
258
  'B) CREATE: on-brand image & video ads (real product composited in, copy + CTA baked). draft_brand / get_brand / update_brand (patch single fields without re-onboarding) / use_brand · list_brands / create_brand / delete_brand (one account holds MANY profiles: brands, clients or creators, so an agency runs every client through here; each has its own brand details, memory, swipefile, Library and connectors, and create_brand → draft_brand onboards a new one end to end) · plan_ad (concept + copy) → render_ad (the Studio quality pipeline) or generate_image / generate_video / generate_avatar (UGC creators + lip-sync) · list_creators / save_creator / delete_creator (the workspace’s REUSABLE CAST — saved creators with their portrait urls, so the SAME person stars in every ad; list them before ever generating a new one, then cast one into the ad with render_ad’s `creator`, which also skips the character-portrait render and so costs LESS than casting a stranger) · make_template_ad (native HTML ad formats) · make_motion_film (motion graphics: kinetic typography, logo intros, launch films; no video model) · clone_static / recast_motion / reframe_video / insert_scene / upscale_video / dub_video / change_voice / finish_video / fix_beat / hook_variants / stitch_video · plan_variations + score_ad (fan out + rank).',
259
259
  'C) RAW MODEL PLAYGROUND — direct access to the full catalog (30+ image / video / voice / writing models, each with the exact per-render credit cost shown above), no ad framing: generate_image / generate_video (useBrand:false) for plain prompt-only renders, generate_voice for raw text-to-speech against any voice engine, and generate_text for the writing models (Claude / Gemini / GPT / Llama / DeepSeek…) — all against ANY catalog id.',
260
260
  'D) ACCOUNT — hermoso_credits (balance) · billing_status (plan + your billing role) · buy_credits (one-click top-up on the saved card, or a first-purchase checkout link) · upgrade_plan / set_auto_reload (admin) · list_jobs / get_job (track async renders) · get_settings / update_settings (the LANGUAGE every ad, script and plan is written in (chat replies follow the language the user writes in) — set it once and every render obeys it, over MCP as well as in the app — plus app appearance and the weekly competitor-watch email) · list_team / invite_member / remove_member / set_role (who else can work in this profile).',
261
- 'E) PUBLISH & MANAGE YOUR CHANNELS — post, run ads, and organize files on the user’s OWN connected accounts (Settings ▸ Connectors), all driven over this MCP. Bring ANY file in with upload_file (desktop/external media, not just Hermoso renders). MANAGING THE CONNECTIONS THEMSELVES: list_connectors (what is linked, and what could be) · list_connector_accounts then set_connector_accounts (WHICH Facebook Pages / Instagram / Meta ad accounts / Google Ads customers / LinkedIn company Pages and ad accounts / Pinterest ad accounts / Microsoft Advertising accounts / Reddit ad accounts / Google Business listings / Google Analytics properties this profile may post to, spend from and read — one person often administers or has access to several belonging to different clients, only the chosen ones are usable anywhere, and an empty choice shares nothing) · connect_connector (connect a PASTE-A-KEY account from here: ' + Object.values(KEY_CONNECTORS).map((s) => s.label).join(', ') + '; offer it beside the Connectors page in the app and let the user choose, because a key pasted into a chat stays in its history) · disconnect_connector (drop a connection from this brand, without revoking access at the platform; confirm-gated because reconnecting a sign-in account needs a browser) · leave_connector (on a connector several teammates can each contribute their OWN account to, remove just YOURS — teammates’ accounts keep working and nothing is revoked at the provider). LINKING an account that connects through a provider sign-in screen (OAuth) is the one step that is not headless: hand the user its connect link, https://app.hermoso.ai/?connect=<provider>, or send them to Workspace ▸ Connectors in the app. META: list_meta_pages · instagram_insights (ACCOUNT-level Instagram performance — views, reach, accounts engaged, interactions, saves, profile link taps — plus the audience DEMOGRAPHICS by age / city / country / gender) · list_instagram_media (the brand’s own recent Instagram posts, and where the media id every other Instagram tool needs comes from) · list_instagram_dm_automations / save_instagram_dm_automation / test_instagram_dm_automation / delete_instagram_dm_automation (INSTAGRAM DM AUTOMATIONS — a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply; test shows what a sample would send without sending) · list_dm_automations / save_dm_automation / test_dm_automation / delete_dm_automation (THE SAME ON EVERY CHANNEL — Instagram, Facebook Page comments and ads + Messenger, and X: a keyword mention or reply gets a public reply, a DM keyword a DM; X is billed in credits per delivered event and per send) · search_instagram_audio (licensed music and original sounds an Instagram Reel may use, by keyword or trending) · list_instagram_collab_invites then respond_instagram_collab_invite (collab-post invitations waiting on the account; accept or decline one, read back from Instagram) · list_instagram_collab_media (posts this account co-authors) · like_instagram (like a post or comment as the connected account) · post_to_meta (Facebook / Instagram / Threads) · list_meta_posts (the Page’s / Instagram account’s OWN existing posts with their ids — THIS is where the postId every other Meta read needs comes from; without it an agent that did not itself just publish has no way to name a post) · list_meta_ads + meta_insights (read existing campaigns/ad sets/ads + spend/CTR/CPC, with breakdowns by age / gender / placement / country) · preview_meta_ad (Meta renders the REAL ad per placement — a link the user can look at, valid 24h) · list_meta_pixels + create_meta_pixel (the pixel a conversion-optimised campaign REQUIRES — Meta will not let a build optimise for conversions without one, and until these existed a caller had no way to discover the id they had to pass) · estimate_meta_reach (how many people a targeting spec reaches, BEFORE a budget is committed) · list_meta_audiences / create_meta_audience (website-pixel retargeting, Page + Instagram engagement audiences, and lookalikes — creating one spends nothing) · list_meta_conversations / read_meta_conversation / reply_to_meta_message (MESSENGER AND INSTAGRAM DMs — the brand’s direct-message threads and a reply to someone who wrote first. Meta only permits a reply within 24 HOURS of the person acting, and read_meta_conversation says whether that window is open BEFORE anything is drafted; Hermoso sends replies only, never a proactive message or a message tag) · subscribe_meta_webhooks / meta_webhook_status / unsubscribe_meta_webhooks / list_meta_webhook_events (REAL-TIME EVENTS — have Meta PUSH new comments, mentions, lead-form submissions and inbound DMs to Hermoso instead of polling for them. Every other inbox read asks an edge “anything new?”; this is the only way to be TOLD, and it is how a lead arrives the moment it is submitted rather than when somebody thinks to look. An empty feed is ambiguous — check meta_webhook_status first, because an unsubscribed Page is silent and looks exactly like a quiet one) · instagram_collaborators (who ACCEPTED a Collab invite on an Instagram post — publishing only SENDS the invite, so this is the only way to know whether the post is actually live on the other account too) · list_instagram_shopping_catalogs / search_instagram_shopping_products / manage_instagram_product_tags (INSTAGRAM SHOPPING — make a post SHOPPABLE. Check eligibility and the account’s taggable catalogs, find the product ids, then pass productTags to post_to_meta so tapping the picture opens the product’s price sheet inside Instagram. Tagging needs an APPROVED Instagram Shop, so check FIRST — otherwise it fails after the media is already uploaded — and note that a tag whose product is not “approved” is stored and shown to nobody. Meta publishes no way to REMOVE a tag) · create_meta_catalog / update_meta_catalog / meta_catalog_blast_radius / delete_meta_catalog (BUILD AND RETIRE A CATALOG — create one on a named business portfolio, rename or re-point it, and, before ever proposing a delete, read meta_catalog_blast_radius: a catalog delete is PERMANENT with no archive and no undo, its product sets go with it, and any ad set still bound to one keeps spending with nothing to show) · list_meta_partnership_creators / manage_meta_partnership_creator (PARTNERSHIP ADS — the creators whose content this profile may run as an advert, and who may tag this brand as a paid partner. Two separate lists, neither implying the other, and neither defaults on; adding is a REQUEST the creator must accept, and an ad naming a creator who is only PENDING fails for a reason nothing in the error says) · list_meta_catalogs / list_meta_product_sets / list_meta_catalog_products (PRODUCT CATALOGS — the merchant’s own Meta catalogs, the product SETS inside each and the products themselves with Meta’s review status. A catalog is the input to Advantage+ catalog ads, the highest-performing ecommerce format on Meta: pass productCatalogId to create_meta_campaign and productSetId to create_meta_adset / create_meta_ad, and Meta builds every impression from the product’s own image, name and price — no render needed. An empty list is a fact about which business portfolio this login administers, NEVER about whether the merchant has a catalog) · create_meta_campaign / create_meta_ad / upload_meta_asset (build) · upload_meta_playable / list_meta_playables (PLAYABLE ads: the advertiser’s own interactive HTML game, run through create_meta_ad’s playableAssetId in an App Installs ad set) · list_meta_lead_forms / create_meta_lead_form (INSTANT LEAD FORMS — the form a lead ad opens INSIDE Facebook/Instagram instead of sending the click to a website; pass the id as create_meta_ad(objective:\"OUTCOME_LEADS\", leadFormId:…) and read the submissions with read_meta_leads) · update_meta_object / delete_meta_object / set_meta_campaign_status (edit, delete, activate — every spend + delete is confirm-gated) · delete_meta_audience (remove a custom audience or lookalike — its blast radius is the PEOPLE in it and the lookalikes built from it, which Meta refuses to delete around) · manage_meta_post (edit or delete a published post). THREADS (a separate connection from Meta, on its own API): post_to_meta(target:"threads") publishes · list_threads_posts · threads_insights · list_threads_replies / reply_to_thread / hide_thread_reply · list_threads_mentions · search_threads_keyword · repost_thread (amplify a customer’s post or one of your own to the brand’s profile — the Threads retweet, and there is NO documented un-repost) · delete_thread (confirm-gated; Threads has no EDIT at all, so delete-and-repost is the only correction) · threads_publishing_limit (how much of the rolling-24h quota is left — 250 posts, 1,000 replies, 100 DELETIONS, 500 location searches; check it before a bulk clean-up, because a quota refusal otherwise reads as a broken connection). SCHEDULING (one content calendar across every channel): schedule_post (queue a post for a future time to one or MORE channels at once — Facebook / Instagram / Threads / TikTok / YouTube / LinkedIn / X / Pinterest / Bluesky / Telegram (ten; Google Business Profile is accepted but held back on Google API access) — with per-channel captions; Hermoso publishes it at that time, nothing has to stay open — it goes LIVE PUBLICLY by default, and only stages as draft/unlisted/private if the user asks, and an impossible channel+visibility pair, an over-length caption or media the channel cannot carry is REFUSED while you are still there rather than failing hours later) · list_scheduled (what is queued and what already fired, with PER-CHANNEL outcomes) · reschedule_post (move a queued post to a new time, or change its caption, media, channels or target Page/board — send only what changes) · cancel_scheduled (pull a queued post before it goes out). POST PERFORMANCE (the loop that closes research → publish → learn — Hermoso records the HOOK and SUBJECT of everything it publishes, because those exist only at the moment of publishing and can never be recovered from a post id afterwards): list_published_posts (everything this brand has published across every channel, with the hook it was written to and its measured engagement) · post_performance (which HOOKS and SUBJECTS are getting traction — engagement rates compared WITHIN a channel and NEVER summed across them, with a verdict suppressed below 5 measured posts and the reason stated) · collect_post_metrics (pull fresh numbers ~24h and ~7d after each publish; a metric a channel cannot report is recorded ABSENT with its reason and never as zero, and X is skipped unless asked because it bills per call) · backfill_posts (import a channel’s past posts so the analysis has history — dry-run and cost-quoted first, and an imported post never votes on a hook unless it matched a Hermoso creation). YOUTUBE (publish, measure AND manage): post_to_youtube (publish a finished video to the brand’s channel — PUBLIC by default; unlisted (link-only, the ad-ready setting) or private only when the user asks) · list_youtube_videos (the channel’s OWN uploads with their video ids — call this to resolve “my latest video” yourself instead of asking the user for a link; it is where the videoId every other YouTube tool needs comes from, and it sees unlisted/private uploads a public search cannot) · update_youtube_video (retitle/re-describe/re-tag, and FLIP AN UNLISTED UPLOAD PUBLIC; confirm before going public) · delete_youtube_video (take one down for good — irreversible, so the unconfirmed call reports the video’s real title, privacy, views and comments first; use update_youtube_video(privacy:"private") when they only want it out of sight) · set_youtube_thumbnail (put a Hermoso thumbnail on an uploaded video — the biggest single lever on click-through, and YouTube otherwise picks a frame at random; needs a phone-verified channel) · update_youtube_channel (brand the CHANNEL ITSELF — banner art, description, keywords, country, the trailer non-subscribers see; everything else here brands the videos, this brands the page they sit on. It MERGES with the current settings, and it reports any field YouTube accepted but silently ignored, channel title above all) · set_youtube_watermark (the subscribe badge overlaid on EVERY video on the channel, including ones uploaded later — one square image brands the whole channel at once; the API publishes no way to read it back, so it reports accepted rather than confirmed) · list_youtube_video_stats (views, likes and comments for up to 50 videos IN ONE CALL, which is how to answer "how are my last twenty uploads doing" without one youtube_video_insights per video. It carries NO titles, because VideoStatsSnippet publishes only publishTime, so join on videoId with list_youtube_videos for names. YouTube calls this endpoint "intentionally not atomic", so a short answer is normal: the missing ids are named, and a missing id is never zero views) · youtube_video_insights (per-VIDEO views, watch time, average view PERCENTAGE/retention, likes, comments, shares, subscribers gained — the numbers that say whether a hook held; youtube_channel only gives channel-wide totals) · youtube_channel_report (the same numbers BROKEN DOWN — traffic source (search vs browse vs suggested vs shorts feed), the actual search terms, country/city, device, age+gender, subscribed vs not, and the audience-RETENTION curve showing exactly where viewers left) · list_youtube_comments + list_youtube_comment_replies + reply_to_youtube_comment (read viewer questions and objections in their own words, and answer as the channel) · moderate_youtube_comment (hide, reject, spam-report or delete an abusive comment — reject is reversible, delete is not) · list_youtube_playlists + manage_youtube_playlist + manage_youtube_playlist_items (organise the channel: create playlists, add/remove/re-order videos in them) · manage_youtube_playlist_image (a custom cover on a playlist — make_thumbnail renders the artwork, this is the call that puts it on. YouTube answers every failure here as an HTTP 500 whose real reason is buried inside it, and the tool unpacks that; if it comes back refused, check channel verification first) · manage_youtube_channel_section (the SHELVES ON THE CHANNEL HOMEPAGE — put a chosen playlist or a featured channel above YouTube’s own default layout, and re-order them. Every write is PUBLIC IMMEDIATELY, a delete has no undo, and YouTube’s own section list LAGS a write by a few seconds in both directions, so never treat a list taken straight afterwards as proof either way) · list_youtube_captions + manage_youtube_caption (real subtitle TRACKS — what YouTube indexes the video by and what a viewer toggles on, which is NOT the same as captions burned into the picture; downloading one is also the quickest way to get an existing video’s script back) · list_youtube_categories (which categoryId post_to_youtube will accept in a given country) · youtube_bulk_report (THE ONLY PLACE YOUTUBE PUBLISHES THUMBNAIL IMPRESSIONS AND THUMBNAIL CTR — a different, SCHEDULED API: the first call starts a job and returns nothing, then YouTube writes one file per day, the first within 48 hours, plus a 30-day backfill. It also carries per-card and per-end-screen metrics and an uncapped list of the search terms people arrived on) · list_youtube_report_jobs (whether that thumbnail history is already accumulating, and since when — check before promising a number) · delete_youtube_report_job (stop one; the job IS the history, so deleting it throws the accumulated files away) · youtube_channel (read title + subscriber/view/video counts for reporting). TIKTOK: post_to_tiktok (post a finished video — or a PHOTO POST, TikTok’s photo/slideshow format of 1 to 35 images where a single image is just a one-slide post —LIVE to the profile, or into TikTok drafts to review in the app) · tiktok_creator_info (the creator’s REAL privacy options — read them and let the user choose before any direct post) · tiktok_post_status (follow a post_to_tiktok that came back pending: done, failed or still processing) · tiktok_account (bio, verified status, follower/following/likes/video counts) · list_tiktok_videos (their own posts with views/likes/comments/shares — either the most recent, or specific videoIds read directly however old they are). ⚠️ TIKTOK HAS NO DELETE AND NO EDIT: its API publishes no way to remove a posted video or change its caption, privacy, cover or comment/duet/stitch settings — every one of those is fixed at publish time and there is no delete scope in TikTok’s scope catalogue at all. If the user wants a TikTok taken down or changed, say plainly that it has to be done in the TikTok app rather than hunting for a tool. TIKTOK ACCOUNT AUTHORIZATION (a SECOND, separate consent on the SAME TikTok app the TikTok Ads connection uses — holding one does NOT give you the other, so a brand fully connected for ads can still be unauthorized here, and that is a real third state rather than a broken session): tiktok_account_status (which state this profile is in, the TikTok business id, the scopes the grant carries and any MISSING from it — TikTok binds scopes at authorize time and never retroactively, so only a re-authorization picks up a new one — plus the exact URL to send the user to, because authorizing is the one step that needs a browser) · list_tiktok_comments + list_tiktok_comment_replies (the comments on the brand’s OWN posts, hidden ones included — TikTok’s answer to list_meta_comments and list_youtube_comments) · comment_on_tiktok_video · reply_to_tiktok_comment · moderate_tiktok_comment (LIKE / UNLIKE / HIDE / UNHIDE / DELETE — you can only DELETE a comment this account wrote, so HIDE is the tool for a stranger’s, and TikTok warns UNHIDE may not take effect when its own moderation is what hid it) · upload_tiktok_comment_image (a new comment will not take a raw image URL; a reply will) · set_tiktok_post_ad_authorization (THIS IS WHERE A SPARK ADS AUTHORIZATION CODE COMES FROM for the brand’s OWN post — previously a human had to copy one out of the TikTok app; hand the code to authorize_tiktok_ads_spark_post) · get_tiktok_post_ad_authorization · extend_tiktok_post_ad_authorization (the days are ADDED to what is left, not set as an absolute) · delete_tiktok_post_ad_authorization. BRAND MONITORING AND AUDIENCE, on that same account authorization (these need permissions added on 2026-08-20, so a brand that authorized before then holds a grant that predates them and has to authorize once more; tiktok_account_status names exactly which are missing, and the remedy is always to authorize the TikTok ACCOUNT again rather than to touch the advertiser connection, which is a separate grant and is unaffected): list_tiktok_mentions (public posts whose caption @-mentions the brand, TikTok’s answer to x_mentions and list_threads_mentions) · list_tiktok_mention_comments (comments whose text mentions it) · get_tiktok_mention (one mention in full, for the mentions webhook, and TikTok only keeps that data 48 hours) · tiktok_mention_top_terms (the top 20 keywords and top 20 hashtags inside those mentions) · list_tiktok_brand_hashtags + manage_tiktok_brand_hashtags + list_tiktok_brand_hashtag_posts (the hashtags TikTok counts as this brand’s, up to 50, and the posts carrying them; a new one is not counted for 24 hours and cannot be removed for 7 days) · tiktok_account_insights (follower demographics by age, gender, country and city plus the daily performance series, needing a BUSINESS account with 100+ followers, and capped at 60 days rather than the 90 the mention tools cover) · tiktok_category_benchmark (the same numbers averaged across an industry, so ‘are we ahead of our category’ is answerable). ALL OF THIS IS ORGANIC LISTENING ON THE BRAND’S OWN ACCOUNT, not ad research: for competitors’ ads use the ad-library research tools instead. TIKTOK ADS (a SEPARATE connection from the TikTok posting connector above — Settings ▸ Connectors ▸ TikTok Ads; a brand that posts to TikTok every day may still have no ad account here, so never read one as the other): list_tiktok_ads_accounts (the ADVERTISER accounts this profile can act on — every other TikTok Ads tool needs an advertiserId and this is where it comes from) · list_tiktok_ads_pixels + create_tiktok_ads_pixel + list_tiktok_ads_custom_conversions + tiktok_ads_pixel_stats (CONVERSION TRACKING — a conversion-optimised ad group dies at creation with "Please select a pixel" without one, so discover the pixel and its events BEFORE building the tree; note TikTok publishes no way to DELETE a pixel, so one you create is permanent) · list_tiktok_ads_campaigns (the whole tree — campaigns, ad groups and ads with their statuses) · tiktok_ads_report (impressions, clicks, spend, CTR, CPC, conversions and video views at any level) · list_tiktok_ads_identities (the TikTok accounts an ad may post AS — MANDATORY, with NO default: call it and let the USER pick, because the ad runs publicly under whichever account is named) · search_tiktok_ads_targeting (resolve location / interest / hashtag / language ids — an ad group cannot be created without location ids, and a guessed id targets the wrong people) · list_tiktok_ads_identity_posts (the ORGANIC posts an identity has already published — where a Spark Ad’s post id comes from) · list_tiktok_ads_spark_posts (the posts authorised for Spark Ads, i.e. promoting an organic post instead of uploading a new video) · authorize_tiktok_ads_spark_post + unbind_tiktok_ads_spark_post (add a creator’s post to that authorised set with the code they generated in the TikTok app, or release it again) · upload_tiktok_ads_playable / list_tiktok_ads_playables / delete_tiktok_ads_playable (PLAYABLE ads: the advertiser’s own interactive HTML zip, run through create_tiktok_smart_ad’s playableUrls) · upload_tiktok_ads_creative (THE STEP THAT TURNS A RENDER INTO AN AD — put a finished Hermoso video on the ad account and it hands back the videoId AND the coverImageId create_tiktok_ads_ad needs; there is no other source for either) · create_tiktok_ads_campaign → create_tiktok_ads_ad_group → create_tiktok_ads_ad (the tree) · set_tiktok_ads_budget · set_tiktok_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_tiktok_ads_object (removal on TikTok is a STATUS, not a verb — the same route as set_tiktok_ads_status) · create_tiktok_smart_campaign → create_tiktok_smart_ad_group → create_tiktok_smart_ad (Smart+, TikTok’s Performance Max — born PAUSED) · list_tiktok_smart_campaigns · set_tiktok_smart_status (the Smart+ money switch, confirm-gated) · tiktok_bid_protection (the ad-credit compensation TikTok pays when a Smart+ object misses its bid) · tiktok_lead_ads_terms (read and accept TikTok’s Lead Ads terms, which a lead-generation ad needs first) · list_tiktok_ads_lead_forms + list_tiktok_ads_lead_fields + download_tiktok_ads_leads + manage_tiktok_ads_test_lead (LEAD ADS — an Instant Form is built in TikTok Ads Manager and NO API creates one, so list them to find the id a LEAD_GENERATION ad group needs. The lead REGION is required with no default: it selects which of three separate lead stores you read, and leaving it out is a THIRD value rather than “all”, so an advertiser who omits it downloads an empty file and wrongly concludes there are no leads) · list_tiktok_ads_audiences + create_tiktok_ads_audience + create_tiktok_ads_lookalike_audience + apply_tiktok_ads_audience + update_tiktok_ads_audience + delete_tiktok_ads_audience + tiktok_ads_audience_overlap (CUSTOM AUDIENCES and lookalikes — TikTok targeting is otherwise interests-and-geo only. A freshly created audience reports itself invalid for up to 48 hours BY DESIGN, so that is not a failure to retry) · list_tiktok_ads_business_centers + list_tiktok_ads_catalogs + create_tiktok_ads_catalog + list_tiktok_ads_catalog_products + list_tiktok_ads_catalog_sets + manage_tiktok_ads_catalog_feed + tiktok_ads_catalog_diagnostics (DPA / PRODUCT CATALOGS, the Shopify lane — a catalog is keyed on a BUSINESS CENTER id, NOT an advertiser id, so list the Business Centers first or every call refuses) · list_tiktok_ads_apps + list_tiktok_ads_app_events (the registered apps an APP_INSTALL campaign needs — nothing else can produce an app id) · tiktok_ads_rf_inventory_estimate + create_tiktok_ads_rf_ad_group (REACH & FREQUENCY — a RESERVATION, so it is confirm-gated like a status change rather than born paused, and it needs a per-ad-account allowlist plus a signed branding contract that no endpoint reports. Always price it with the estimate first: TikTok silently books its own maximum rather than refusing an out-of-range value) · send_tiktok_ads_events (SERVER-SIDE conversion events — there is a vendor-sanctioned test code for exercising it without entering the advertiser’s real reporting), and its offline/crm sources take the event-set ids the two tools below mint) · list_tiktok_ads_offline_event_sets + manage_tiktok_ads_offline_event_set + send_tiktok_ads_offline_events (REAL-WORLD CONVERSIONS — an in-store purchase, a phone booking, a signed contract, reported so TikTok can attribute them to the ads that caused them. The timestamp is an ISO-8601 STRING here and a Unix NUMBER on send_tiktok_ads_events; a wrong-shaped one is accepted by TikTok and attributed to nothing. There is NO test code on this pair, so everything sent is a real permanent conversion — rehearse through send_tiktok_ads_events with eventSource “offline” and a testEventCode instead. Reporting also needs the connected user to be an ADMIN or OPERATOR of the advertiser, which managing the event SETS does not) · list_tiktok_ads_crm_event_sets + create_tiktok_ads_crm_event_set (LEAD-LIFECYCLE events — sending “this lead qualified / closed” back is what makes a LEAD_GENERATION campaign optimise toward leads that convert rather than form fills. TikTok publishes create and list and nothing else, so one of these is PERMANENT) · list_tiktok_tto_accounts + list_tiktok_creator_labels + discover_tiktok_creators + tiktok_creator_leaderboard + check_tiktok_creator_status + list_tiktok_tto_brand_profiles + create_tiktok_tto_brand_profile + list_tiktok_tto_campaigns + create_tiktok_tto_campaign + update_tiktok_tto_campaign + link_tiktok_tto_video + list_tiktok_tto_link_requests + tiktok_tto_campaign_report + request_tiktok_tto_spark_authorization + get_tiktok_tto_spark_authorization + manage_tiktok_tto_anchor (TIKTOK ONE / CREATOR MARKETPLACE: INFLUENCER MARKETING, and the only place in Hermoso that does it: find creators by audience size, engagement, price and who their followers actually are, check whether they have joined TikTok One, invite them to a campaign with an invite link, ask them to tag a video to it, and read every metric SPLIT ORGANIC VERSUS PAID. Its account id is a THIRD id space; not an advertiser id and not a Business Center id; so start at list_tiktok_tto_accounts. It rides this same connection with nothing extra to apply for. IT ALSO CLOSES THE SPARK ADS LOOP: request_tiktok_tto_spark_authorization asks a creator directly and get_tiktok_tto_spark_authorization returns the code authorize_tiktok_ads_spark_post takes, which is otherwise obtainable only by the creator pasting one out of the TikTok app. Two things put a notification in a real person’s inbox; a campaign invitation and a video-linking request; and a repeated linking request is a REMINDER that TikTok caps at two, so read list_tiktok_tto_link_requests before re-sending anything) · list_tiktok_ads_stores + list_tiktok_ads_store_products (TIKTOK SHOPS: what a Shopping Ads or GMV Max campaign sells from; the store list is keyed on an ad account and the product list on a BUSINESS CENTER, which each store row names) · tiktok_ads_verification_status + list_tiktok_ads_verification_documents + submit_tiktok_ads_verification (BUSINESS VERIFICATION: an unverified account hits limits that get diagnosed as something else, so it is worth reading during onboarding. Hermoso never handles a verification DOCUMENT: submitting sends account details plus the ids of images the user uploaded in TikTok Ads Manager, and the legal name and document number can never be changed afterwards, so it is confirm-gated) · list_tiktok_ads_payment_portfolios + list_tiktok_ads_payment_portfolio_links (HOW THE AD ACCOUNTS ARE FUNDED: read-only, because "why did delivery stop" is often a funding answer, and because deciding where a customer’s money sits is not ours to do) · create_tiktok_ads_rule + list_tiktok_ads_rules + update_tiktok_ads_rule + bind_tiktok_ads_rule + set_tiktok_ads_rule_status + tiktok_ads_rule_results (AUTOMATED RULES — standing instructions TikTok runs on the account unattended. THE SECOND SPEND SWITCH ON THIS PLATFORM and gated in TWO CLASSES: a rule that can only pause, decrease or email needs confirm:true, while one that can TURN_ON an object or RAISE a budget or bid needs confirm:true AND confirmScope echoing the token list_tiktok_ads_rules prints, computed from the rule as TikTok STORES it. Every rule is created TURNED OFF and read back to prove it, because TikTok publishes no way to create one in the off position. TikTok emails rule notifications to the DEVELOPER address on the app rather than to the advertiser, so tiktok_ads_rule_results is the only place a customer sees what a rule did — and TikTok itself says this endpoint is for direct advertisers and may refuse a platform-managed account entirely). · list_tiktok_ads_comments + tiktok_ads_comment_thread + moderate_tiktok_ads_comment + reply_to_tiktok_ads_comment + delete_tiktok_ads_comment (COMMENT MODERATION on your own TikTok ads — the platform where the comment section IS the ad. HIDE is the moderation verb and works on anyone’s comment and is reversible; DELETE only ever removes a comment your OWN identity posted, which TikTok reports per comment as canDelete. Comments are scoped to an AD GROUP and to nothing else, and the time window may span at most 30 DAYS, so an empty answer means “none in these 30 days” rather than “none ever”) · list_tiktok_ads_blocked_words + manage_tiktok_ads_blocked_words (a standing 500-word filter that auto-hides any comment containing one of these across EVERY ad on the account — nothing else in Hermoso does this, and removing a word republishes every comment it had hidden) · tiktok_ads_diagnosis (TikTok’s own issues-and-suggestions verdict on your ad groups — creative, bid/budget with its full estimated-delivery tables, and a pixel that has gone quiet. It covers ACTIVE ad groups only and omits any it has nothing to say about, so an empty answer is not a clean bill of health) · get_tiktok_ads_brand_safety + set_tiktok_ads_brand_safety (what content the ads may appear next to. Two things to say out loud: TikTok applies this to Smart+ campaigns and explicitly NOT to the regular campaigns create_tiktok_ads_campaign builds, and coverAllObjectives is a ONE-WAY DOOR TikTok cannot set back). TWO THINGS HERE ARE UNLIKE EVERY OTHER AD PLATFORM: TikTok creates objects ENABLED by default, so Hermoso forces every campaign, ad group and ad PAUSED with no override and nothing serves until set_tiktok_ads_status(confirm:true); and TikTok’s QPS is 1, so every call is serialized and a tree build or a bulk read is SLOW BY DESIGN — a throttle is not a broken connection. SNAPCHAT ADS (the tenth ad platform — Settings ▸ Connectors ▸ Snapchat Ads; a SEPARATE connection from Snapchat posting): list_snapchat_ads_accounts (the organizations and AD ACCOUNTS this profile can act on — every other Snapchat tool needs an adAccountId and this is where it comes from) · list_snapchat_ads_campaigns (the whole tree — campaigns, ad squads and ads) · snapchat_ads_report (impressions, spend, swipes and video quartiles at any level) · search_snapchat_ads_targeting (resolve country / region / interest / language ids — an ad squad cannot be created without at least one country) · upload_snapchat_ads_creative (put a finished render on the ad account as MEDIA and then as the CREATIVE an ad points at — Snapchat has no upload-from-URL, so Hermoso streams the bytes) · create_snapchat_ads_campaign → create_snapchat_ads_ad_squad → create_snapchat_ads_ad (the tree, every tier born PAUSED) · set_snapchat_ads_budget · set_snapchat_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_snapchat_ads_object (a REAL delete verb here, unlike TikTok — irreversible, so offer PAUSED first). THREE THINGS TO SAY OUT LOUD ON THIS PLATFORM: money is MICRO-CURRENCY (1,000,000 = one unit), so quote plain amounts and let Hermoso convert, and never pass both units — under-converting fails loudly while double-converting asks for a budget a million times too large; the creative HEADLINE is capped at 34 characters and brandName at 32, far shorter than Meta or Google, and over-long copy is refused rather than truncated; and a Snapchat ad points at a CREATIVE, never at a media id. SNAPCHAT POSTING (Stories / Spotlights on a Public Profile) IS BUILT BUT NOT YET REACHABLE — Snap’s Public Profile API is allowlist-only and Hermoso has not been allowlisted, so the connector is deliberately not offered; say that plainly rather than looking for a tool. LINKEDIN: post_to_linkedin (publish a finished post to the connected LinkedIn PROFILE) · list_linkedin_pages (the company Pages this connection administers — call this first and let the USER pick, never guess a Page) · post_to_linkedin_page (publish as a company PAGE rather than a person — this is the one most brands actually want) · manage_linkedin_post (edit the copy of a published post, or delete it) \u00b7 list_linkedin_comments / reply_to_linkedin_comment / delete_linkedin_comment (moderate the comments on your Page\u2019s posts \u2014 a SEPARATE LinkedIn authorization grants these, see Connectors) · linkedin_page_analytics (ORGANIC Page performance — followers, follower gains, Page views, and post impressions/clicks/engagement, for the Page total or per post; this is the free organic read, NOT linkedin_ads_report). LINKEDIN ADS (full three-tier management): list_linkedin_ads_campaigns (ad accounts, then a chosen account’s campaign groups, campaigns and — with campaignId — the CREATIVES under them) · linkedin_ads_report (impressions, clicks, cost, conversions, leads) · search_linkedin_ads_targeting (resolve locations / titles / industries / seniorities / company sizes to the URNs LinkedIn demands — never invent one) · linkedin_audience_count (HOW MANY members that targeting actually reaches, before a budget is committed — and a returned 0 means fewer than 300 people, LinkedIn’s privacy floor and also its campaign minimum, never an empty audience) · linkedin_bid_pricing (LinkedIn’s own suggested bid and daily-budget range for that audience — quote it instead of guessing what LinkedIn costs) · create_linkedin_ads_campaign_group → create_linkedin_ads_campaign → create_linkedin_ads_creative (the tree, every tier born DRAFT) · set_linkedin_ads_budget / set_linkedin_ads_status / delete_linkedin_ads_object (budgets, activate/pause at any tier, delete — every spend change confirm-gated). LINKEDIN LEAD SYNC: list_linkedin_lead_forms · list_linkedin_leads / get_linkedin_lead (the LEADS its forms collected, answers named by field — PERSONAL DATA: show, never republish) · subscribe_linkedin_leads / list_linkedin_lead_events / list_linkedin_lead_subscriptions / delete_linkedin_lead_subscription (real-time push to Hermoso, optional forwardTo relay to a CRM; LinkedIn validates ONLY Hermoso’s own webhook). LinkedIn is a THREE-tier platform and the third tier is the one people forget: a campaign with no creative shows nothing, and all three tiers must be ACTIVE before a single impression is served. REDDIT ADS: list_reddit_ads_campaigns / reddit_ads_report (read the account tree + performance) · list_reddit_ads_profiles + list_reddit_ads_posts / create_reddit_ads_post / update_reddit_ads_post (the CREATIVE — a Reddit ad promotes a post) · create_reddit_ads_campaign / update_reddit_ads_campaign · create_reddit_ads_ad_group / update_reddit_ads_ad_group · create_reddit_ads_ad / update_reddit_ads_ad · create_reddit_ads_max_campaign / get_reddit_ads_max_template / update_reddit_ads_max_template (a Reddit MAX campaign: automated campaign, ad group and a template ad Reddit generates ads from, built from creative-library assets, all PAUSED) · set_reddit_ads_status (the ONLY switch that arms real spend, confirm-gated) · delete_reddit_ads_object (remove a campaign, ad group or ad — Reddit has no delete verb, removal is a status, and it refuses to delete anything touched in the last 3 hours) · delete_reddit_ads_saved_audience · search_reddit_ads_targeting / reddit_ads_forecast / reddit_ads_bid_suggestion (free planning) · list_reddit_ads_pixels + send_reddit_ads_conversions (conversion tracking — Reddit now requires a pixel on every ad group) · list_reddit_ads_audiences / create_reddit_ads_audience / update_reddit_ads_audience_users / delete_reddit_ads_audience (retargeting lists) · list_reddit_ads_saved_audiences / create_reddit_ads_saved_audience / update_reddit_ads_saved_audience · list_reddit_ads_lead_forms / create_reddit_ads_lead_form · reddit_ads_history (who changed what, when). TELEGRAM: post_to_telegram (publish to a channel, group or chat as the brand’s own bot — text up to 4096 characters, but only 1024 once any photo or video is attached; one image, one video, or an album of 2–10 in which photos and videos may be mixed. chatId IS ALWAYS REQUIRED and is never guessed: the Bot API publishes NO method that lists the chats a bot belongs to, so pass the public channel’s @username or the numeric id) · list_telegram_chats (chats that MESSAGED the bot in the last 24 hours — a shortcut for finding an id, NOT a roster, and a chat missing from it can still be posted to) · list_telegram_dms (what those chats actually SAID, newest per chat — free, and a rolling 24-hour window rather than an inbox: the Bot API has no history endpoint at all) · delete_telegram_message (confirm-gated; Telegram refuses once a message is more than 48 hours old). BLUESKY: post_to_bluesky (publish as the connected account — text up to 300 characters AND, separately, 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; either up to 10 images OR one MP4 video, never both, because a Bluesky post record carries exactly one embed; links are made clickable automatically) · delete_bluesky_post (PERMANENTLY remove one of the account’s own posts — no trash and no undelete. Call it WITHOUT confirm first: it deletes nothing and reports the post’s real text and live like/repost/reply/quote counts, and once the post has any engagement it also wants confirmText echoing its text. Takes the AT-URI or just the record key from the bsky.app link) · list_bluesky_convos / read_bluesky_dm / send_bluesky_dm / mark_bluesky_convo_read (the account’s DIRECT MESSAGES — free, 1000 characters each, text only, and they need a PRIVILEGED app password: an ordinary one posts fine and cannot chat). Replies, mentions AND direct messages all arrive in list_inbox and are answered with reply_to_inbox_item. X / TWITTER: post_to_x (publish a post — text, an image or a video render WITH alt text, a POLL, a reply, or a whole thread, and optionally restrict who may reply; X is the ONE channel that bills per API request, a post carrying a LINK costs roughly 13× one without, and each profile has a rolling 24-hour ceiling on X spend that refuses a request whole rather than publishing half of it) · delete_x_post (remove one) · x_post_metrics (the PUBLIC counts — impressions, likes, reposts, replies, quotes, bookmarks) · x_post_insights (the ADVERTISER numbers for your own posts — link clicks, profile visits, video views and completion quartiles, up to 25 posts at once; this is what says whether a creative worked, and x_post_metrics cannot tell you, but it only sees the LAST 28 HOURS) · x_post_insights_historical (the same advertiser numbers over ANY date range — the one to use for anything older than yesterday) · x_mentions (who is talking to the brand, in their own words — the read half of the reply loop, and a source of real customer language for ad copy) · list_x_dms (the brand’s X DIRECT MESSAGES, grouped into conversations, saying which are waiting on a reply — billed per message returned, and X keeps only 30 days) · send_x_dm (reply privately to one named person; never a broadcast) · x_chat_settings / update_x_chat_settings (the X account’s DM privacy: who may message the brand, the request filter, read receipts and calls). X IS THE ONE CONNECTOR THAT COSTS CREDITS PER CALL — X charges us per API request, so posting, deleting, reading metrics, reading insights and pulling mentions each bill the user, a post CONTAINING A LINK costs 13× one without, and insights and mentions are billed PER POST RETURNED. Say so before posting a thread or pulling a big page of mentions, and prefer one post over five when the content allows. X ADS (the PAID half — a SEPARATE connection from the organic tools above: its own product on its own host with OAuth 1.0a signing, and X grants API access PER AD ACCOUNT rather than per app, so the customer adds Hermoso’s X user at business.x.com → Account access before anything here resolves): list_x_ads_accounts (the ad accounts this profile can act on, WITH the permission level held on each — read it before attempting a write) · list_x_ads_funding_instruments (a campaign cannot be created without one) · list_x_ads_campaigns / list_x_ads_line_items / list_x_ads_promoted_tweets / list_x_ads_targeting (the whole tree as it stands) · x_ads_report (impressions, clicks, spend and engagements at any level) · x_ads_geo_search / x_ads_targeting_search (resolve places and targeting values to the ids X demands — never invent one) · create_x_ads_campaign → create_x_ads_line_item → create_x_ads_promoted_tweet (the tree, every tier born PAUSED with no override; A CAMPAIGN ALONE CANNOT SERVE ON X — it needs a line item and a promoted post underneath it, and the read-back says so rather than letting you call it a finished ad) · add_x_ads_targeting · update_x_ads_campaign / update_x_ads_line_item (throttle or raise spend on a running campaign without rebuilding it) · set_x_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_x_ads_object. PINTEREST — POSTING AND ADS ARE TWO SEPARATE CONNECTIONS on the same Pinterest login (Pinterest keeps ads access behind different permissions), so a brand can hold either without the other and connecting one does not connect the other; if an ads call says Pinterest Ads is not connected, that is the card to send them to, NOT the Pinterest posting one. ADS: pinterest_ads_async_report (the DEEP paid report — 914 days back where the quick one stops at 90, and three times the metric columns; generated asynchronously, so pass the returned token back rather than re-submitting) · pinterest_targeting_analytics (WHICH audience segment delivered — by keyword, interest, age, gender, location, placement) · pinterest_audience_insights (WHO the audience is: interest affinities plus demographics, the input to a creative brief rather than a performance report) · pinterest_analytics (ORGANIC performance — impressions, saves, Pin clicks, outbound clicks, for the account, the TOP PINS, the top video Pins, or one Pin; Pinterest keeps 90 days and publishes no board-level analytics at all) · create_pinterest_board (make a board — a NEW Pinterest account has none and a Pin needs one) · list_pinterest_boards (the user must pick a board — never choose one for them) · post_to_pinterest (create an image or video Pin on a chosen board, with a title, description and destination link) · list_pinterest_pins (the Pins on a board with their ids — where the pinId every Pin tool needs comes from, and it flags any Pin an ad is promoting) · update_pinterest_pin (retitle, re-describe, fix a dead link, move it — Pinterest keeps this endpoint in a limited BETA, so it may be refused outright and save_pinterest_pin is the generally-available way onto another board; a Pin’s picture can never be swapped by anyone) · save_pinterest_pin (copy a Pin onto another board) · delete_pinterest_pin (confirm-gated, and it says whether an ad is promoting the Pin first) · update_pinterest_board (rename, re-describe, or hide it — SECRET hides every Pin on the board, reversibly) · delete_pinterest_board (the heaviest one here: the board AND every Pin on it, confirm-gated with the Pin count echoed back — offer hiding it instead). GOOGLE ADS (full management): list_google_ads_campaigns (list accounts, then a customer’s campaigns + spend/CTR/CPC/conversions) · google_ads_report (any GAQL breakdown — ad groups, keywords, search terms, geo) · create_google_ads_campaign (paused) · set_google_ads_budget / set_google_ads_status (change budget, enable/pause — every spend change confirm-gated) · delete_google_ads_object (remove a campaign, ad group, ad, KEYWORD, asset LINK or conversion action — Google has no delete verb, `remove` is the terminal state and it cannot be undone; call it unconfirmed first to see the spend and the tree that go with it) · upload_google_ads_asset (add an image render or a YouTube video to the ad account’s asset library) · create_google_ads_performance_max_campaign (Google’s cross-surface campaign type, RETAIL INCLUDED — pass merchantCenterId to make it a Shopping-feed Performance Max advertising the WHOLE Merchant Center feed under one root listing group, and feedLabel to narrow it to a single feed; only PARTITIONING that feed by brand/category/custom label is refused by name) · add_google_ads_assets (sitelinks, callouts and structured snippets, CREATED AND ATTACHED — an asset that is not attached shows nothing) · 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). MICROSOFT ADVERTISING / BING ADS (full management, mirroring Google): list_microsoft_ads_campaigns (list the shared ad accounts, then a chosen account’s campaigns + budgets) · microsoft_ads_geo_search (resolve country / region / city names to the Microsoft location ids a campaign needs — call it when an ask is ambiguous and let the USER pick) · microsoft_ads_report (impressions, clicks, CTR, average CPC, spend, conversions — generated asynchronously, so it may come back pending and must be called again) · create_microsoft_ads_campaign (campaign → ad group → responsive search ad → keywords, always Paused; with no locations[] it is created serving WORLDWIDE, Microsoft’s own default, and the read-back warns loudly — relay that before anyone activates it) · create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (fill in an existing account) · set_microsoft_ads_budget / set_microsoft_ads_status (change budget, activate/pause — every spend change confirm-gated; Microsoft statuses are Active/Paused, never Deleted) · search_microsoft_ads_profiles / list_microsoft_ads_profile_targeting / set_microsoft_ads_profile_targeting (LinkedIn profile targeting: company, industry, job function, seniority and job title bid adjustments on a Search, Shopping or DSA campaign; confirm-gated on an ACTIVE campaign) · delete_microsoft_ads_object (a REAL delete — campaign, ad group, ad or keyword — permanent, with no undelete; call it unconfirmed first to see what goes with it) · microsoft_ads_keyword_ideas (Microsoft’s Keyword Planner — real search volume, competition and suggested bids, with NO planning-tier gate, unlike Google’s) · microsoft_ads_traffic_estimates (what those keywords would deliver at a named bid — a range, never one number) · microsoft_ads_budget_opportunities (where Microsoft says a budget is capping delivery, and what raising it is forecast to buy) · microsoft_ads_auction_insights (who ELSE is bidding on the same auctions — rival domains with their impression share, overlap and outranking share; shares of YOUR auctions, never a measure of a competitor’s whole account) · microsoft_ads_bulk_download (export the account as ONE bulk file — the only way to read ~185 Microsoft record types Hermoso cannot otherwise touch: sitelinks, callouts, structured snippets, labels, shared negative keyword lists, bid strategies, audiences, experiments, seasonality adjustments, conversion goals, asset groups, feeds) · microsoft_ads_bulk_upload (apply an edited bulk file — hundreds of objects in one request. IT IS GATED HARDER THAN ANYTHING ELSE ON THIS CONNECTOR, because a bulk file carries a Status column and can turn campaigns ON without ever touching set_microsoft_ads_status: confirm:true alone is refused, and you must first call it unconfirmed to get the row-by-row list of what it would ACTIVATE and DELETE, show that to the user, then echo both counts back as confirmActivations/confirmDeletions — or pass pauseInstead:true to land the file with every activation written as Paused) · list_microsoft_ads_conversion_goals (what the account counts as a conversion, and which goals are OFFLINE ones) · send_microsoft_ads_offline_conversions (close the loop: phone sales, in-store purchases and late-closing leads fed back so smart bidding stops optimising against website conversions alone — pass PLAIN emails and E.164 phones, hashing happens server-side to Microsoft’s own published spec) · microsoft_ads_editorial_reasons (WHY an ad or keyword was disapproved: the code, the word Microsoft objected to, where, and the appeal status) · list_microsoft_ads_audiences (the account’s Customer Match lists with their current sizes; a fresh list reads 0 for up to 48 hours and Microsoft will not use one under 300 people, so never call that a failed upload) · create_microsoft_ads_customer_list then apply_microsoft_ads_customer_list (build a Customer Match audience from PLAIN email addresses, normalized and SHA-256 hashed server-side to Microsoft’s own published spec so no plaintext ever leaves us; the user must be shown Microsoft’s Customer Match terms and agree first) · microsoft_ads_recommendations (what Microsoft ITSELF suggests changing, each one priced by Microsoft: budget raises carrying the current and recommended daily amount, new and broadened keywords, negative keywords it wants removed, and ads it has written. Every one INCREASES what the account buys, which is what they are for, so none is a free win and an empty list means Microsoft has no advice rather than that the account is optimal) · apply_microsoft_ads_recommendations (act on them, gated exactly like the bulk upload: confirm:true alone is REFUSED, so call it unconfirmed first to get every recommendation named with what it changes and Microsoft’s own cost estimate, show that to the user, then echo confirmCount and confirmCostIncrease back. Both are recomputed from a fresh read, and there is no undo) · dismiss_microsoft_ads_recommendations (take advice off the list. It cannot spend, so it needs no confirmation at all, and it is the right answer to “make it stop suggesting that” rather than applying something to clear it) · microsoft_ads_auto_apply (THE READ THAT ANSWERS “is Microsoft changing this account while nobody is looking?”, per type. An inherited account can already be opted in with nobody at the brand having done it) · set_microsoft_ads_auto_apply (turn that standing permission on or off. Switching any type ON is the strongest consent anywhere in Hermoso: Microsoft then writes and publishes its own ads under the brand’s name, deletes negative keywords so the account buys more searches, and changes conversion goals, unattended and indefinitely, with NOTHING to preview beforehand. So confirm:true is not enough and every type must be named in confirmTypes. Switching it OFF is never gated). GOOGLE BUSINESS PROFILE (the local-SEO channel — the listing panel on Google Search and Maps, which for a local business is where the demand actually is, and there is no delete): list_business_locations (the listings the connected Google account manages — call this first and let the USER pick when there is more than one; a Post on the wrong storefront is a public mistake) · post_to_google_business (publish a Post to the listing — text, ONE PHOTO and a call-to-action button; Google’s Posts API takes no video, so pass a still. EVENT and OFFER posts both require a title and a start date, and on an OFFER Google ignores the button link) · list_google_business_posts (what is showing right now, with each Post’s state) · delete_google_business_post (take one down — immediate and public, so confirm first) · list_google_business_reviews (the reviews on the listing, and which ones have NO reply yet — for a local business the highest-leverage surface there is) · reply_to_google_business_review (answer one publicly as the business; it is an UPSERT, so it replaces any existing reply) · list_google_business_questions + answer_google_business_question (the public Q&A on the listing) · google_business_search_keywords (the actual search terms people typed to find the listing — free local keyword data; low-volume terms are SUPPRESSED and come back as "fewer than N", never as zero) · google_business_insights (Search + Maps impressions, calls, website clicks, direction requests, messages, bookings — listing-level; Google discontinued per-Post insights in 2023 with no replacement, so never promise per-Post numbers) · get_business_location (everything the listing actually says — name, address, phone, website, categories, description, hours, service area — as the merchant set it; the answer to “what does our Google listing say?”) · update_business_location (change any of that — hours, phone, website, description, categories, even the name or address. It edits the live panel on Search and Maps with no draft and no undo, so call it WITHOUT confirm first: nothing is written, Google validates the payload, and you get the current value of every field you are about to change to show the user) · google_business_account (whose Business Profile account the listing is on, and whether the connected Google account’s role can edit it at all). Google gates this API behind a per-project access request and the default quota is zero, so the connection can be live and calls still refused — the error says so. CHATGPT ADS (ads under ChatGPT answers, via OpenAI’s Advertiser API — full management): list_openai_ads_campaigns (the ad account, then its campaigns, ad groups and ads with each ad’s review state) · openai_ads_report (impressions, clicks, spend, CTR, CPC, CPM at account / campaign / ad group / ad scope — run this first, it validates the key with zero spend risk) · openai_ads_geo_search (location ids) · list_openai_ads_audiences + create_openai_ads_audience (custom audiences — geo and these are the only list-based targeting this platform has; target them with customAudienceIds / excludedCustomAudienceIds on a campaign) · create_openai_ads_campaign (campaign → ad group → ad in one call, always PAUSED) · create_openai_ads_ad_group / create_openai_ads_ad (fill in an existing campaign) · update_openai_ads_object (rename, re-budget, rewrite context hints or the ad copy) · set_openai_ads_budget / set_openai_ads_status (change budget, activate, pause) · delete_openai_ads_object (ARCHIVE — this API has no delete and OpenAI say archiving is not reversible, so offer pausing first). TWO RULES THIS CHANNEL DOES NOT SHARE WITH THE OTHERS: it is connected by PASTING an Advertiser API key (no OAuth, no manager account, one key = one ad account), and it has exactly ONE creative format — a text plus image card, title 50 characters, body 100. There is NO VIDEO on ChatGPT Ads, so never offer a video ad here. GOOGLE DRIVE — ONE connection covering Drive, Sheets and Docs (full CRUD over the files Hermoso created there, plus any file the user hands over with the Google file picker in the app): save_to_drive · list_drive_files / get_drive_file · update_drive_file (rename/move/trash) · delete_drive_file · create_drive_folder. GOOGLE SHEETS (part of the Google Drive connection — export data to a spreadsheet the app creates, or read one the user picked; drive.file, no verification): create_sheet · append_to_sheet · read_sheet. GOOGLE DOCS (part of the Google Drive connection — export copy/brief/report as a doc, or read one the user picked; drive.file, no verification): create_doc · append_to_doc. GOOGLE SLIDES (part of the Google Drive connection — turn a swipefile collection into a real presentation, one slide per saved ad with the creative, brand, copy, run dates and platform; drive.file, no verification, no new scope): export_swipefile_deck — it CREATES a deck each time and cannot append to one the user already has, and a creative whose ad-library link has expired is reported rather than silently dropped. ONEDRIVE (full CRUD over the user’s Microsoft OneDrive): convert_onedrive_file (Microsoft converts a file server-side to PDF or JPG — ~130 formats including PowerPoint and Word decks, PSD, Illustrator, Sketch, 3D, video, iPhone HEIC and raw camera files; JPG needs both width and height) · save_to_onedrive · list_onedrive_files / get_onedrive_file · update_onedrive_file (rename/move) · delete_onedrive_file · create_onedrive_folder. INTERCOM (the brand’s own support inbox, nothing billed): list_intercom_conversations (filter by state, assignee, team, tag, channel, customer, dates, priority, unread, or app = the product the customer wrote from) · read_intercom_conversation · reply_to_intercom_conversation / add_intercom_note / manage_intercom_conversation (each a preview until confirm:true) · tag_intercom_conversation · list_intercom_tags · search_intercom_contacts · list_intercom_teammates · intercom_customer_language (customers’ own words for ad copy). VERCEL (the brand’s own sites, nothing billed): list_vercel_projects · list_vercel_deployments · list_vercel_domains · vercel_web_analytics (page views, visitors, top pages, referrers, countries) · deploy_vercel_page (a static landing page, confirm-gated; preview by default) · delete_vercel_deployment. Use these standalone — Hermoso is a full posting/ads/file-storage control surface, not only an ad generator.',
261
+ 'E) PUBLISH & MANAGE YOUR CHANNELS — post, run ads, and organize files on the user’s OWN connected accounts (Settings ▸ Connectors), all driven over this MCP. Bring ANY file in with upload_file (desktop/external media, not just Hermoso renders). MANAGING THE CONNECTIONS THEMSELVES: list_connectors (what is linked, and what could be) · list_connector_accounts then set_connector_accounts (WHICH Facebook Pages / Instagram / Meta ad accounts / Google Ads customers / LinkedIn company Pages and ad accounts / Pinterest ad accounts / Microsoft Advertising accounts / Reddit ad accounts / Google Business listings / Google Analytics properties this profile may post to, spend from and read — one person often administers or has access to several belonging to different clients, only the chosen ones are usable anywhere, and an empty choice shares nothing) · connect_connector (connect a PASTE-A-KEY account from here: ' + Object.values(KEY_CONNECTORS).map((s) => s.label).join(', ') + '; offer it beside the Connectors page in the app and let the user choose, because a key pasted into a chat stays in its history) · disconnect_connector (drop a connection from this brand, without revoking access at the platform; confirm-gated because reconnecting a sign-in account needs a browser) · leave_connector (on a connector several teammates can each contribute their OWN account to, remove just YOURS — teammates’ accounts keep working and nothing is revoked at the provider). LINKING an account that connects through a provider sign-in screen (OAuth) is the one step that is not headless: hand the user its connect link, https://app.hermoso.ai/?connect=<provider>, or send them to Workspace ▸ Connectors in the app. META: list_meta_pages · instagram_insights (ACCOUNT-level Instagram performance — views, reach, accounts engaged, interactions, saves, profile link taps — plus the audience DEMOGRAPHICS by age / city / country / gender) · list_instagram_media (the brand’s own recent Instagram posts, and where the media id every other Instagram tool needs comes from) · list_instagram_dm_automations / save_instagram_dm_automation / test_instagram_dm_automation / delete_instagram_dm_automation (INSTAGRAM DM AUTOMATIONS — a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply; test shows what a sample would send without sending) · list_dm_automations / save_dm_automation / test_dm_automation / delete_dm_automation (THE SAME ON EVERY CHANNEL — Instagram, Facebook Page comments and ads + Messenger, and X: a keyword mention or reply gets a public reply, a DM keyword a DM; X is billed in credits per delivered event and per send) · search_instagram_audio (licensed music and original sounds an Instagram Reel may use, by keyword or trending) · list_instagram_collab_invites then respond_instagram_collab_invite (collab-post invitations waiting on the account; accept or decline one, read back from Instagram) · list_instagram_collab_media (posts this account co-authors) · like_instagram (like a post or comment as the connected account) · post_to_meta (Facebook / Instagram / Threads) · list_meta_posts (the Page’s / Instagram account’s OWN existing posts with their ids — THIS is where the postId every other Meta read needs comes from; without it an agent that did not itself just publish has no way to name a post) · list_meta_ads + meta_insights (read existing campaigns/ad sets/ads + spend/CTR/CPC, with breakdowns by age / gender / placement / country) · preview_meta_ad (Meta renders the REAL ad per placement — a link the user can look at, valid 24h) · list_meta_pixels + create_meta_pixel (the pixel a conversion-optimised campaign REQUIRES — Meta will not let a build optimise for conversions without one, and until these existed a caller had no way to discover the id they had to pass) · estimate_meta_reach (how many people a targeting spec reaches, BEFORE a budget is committed) · list_meta_audiences / create_meta_audience (website-pixel retargeting, Page + Instagram engagement audiences, and lookalikes — creating one spends nothing) · list_meta_conversations / read_meta_conversation / reply_to_meta_message (MESSENGER AND INSTAGRAM DMs — the brand’s direct-message threads and a reply to someone who wrote first. Meta only permits a reply within 24 HOURS of the person acting, and read_meta_conversation says whether that window is open BEFORE anything is drafted; Hermoso sends replies only, never a proactive message or a message tag) · subscribe_meta_webhooks / meta_webhook_status / unsubscribe_meta_webhooks / list_meta_webhook_events (REAL-TIME EVENTS — have Meta PUSH new comments, mentions, lead-form submissions and inbound DMs to Hermoso instead of polling for them. Every other inbox read asks an edge “anything new?”; this is the only way to be TOLD, and it is how a lead arrives the moment it is submitted rather than when somebody thinks to look. An empty feed is ambiguous — check meta_webhook_status first, because an unsubscribed Page is silent and looks exactly like a quiet one) · instagram_collaborators (who ACCEPTED a Collab invite on an Instagram post — publishing only SENDS the invite, so this is the only way to know whether the post is actually live on the other account too) · list_instagram_shopping_catalogs / search_instagram_shopping_products / manage_instagram_product_tags (INSTAGRAM SHOPPING — make a post SHOPPABLE. Check eligibility and the account’s taggable catalogs, find the product ids, then pass productTags to post_to_meta so tapping the picture opens the product’s price sheet inside Instagram. Tagging needs an APPROVED Instagram Shop, so check FIRST — otherwise it fails after the media is already uploaded — and note that a tag whose product is not “approved” is stored and shown to nobody. Meta publishes no way to REMOVE a tag) · create_meta_catalog / update_meta_catalog / meta_catalog_blast_radius / delete_meta_catalog (BUILD AND RETIRE A CATALOG — create one on a named business portfolio, rename or re-point it, and, before ever proposing a delete, read meta_catalog_blast_radius: a catalog delete is PERMANENT with no archive and no undo, its product sets go with it, and any ad set still bound to one keeps spending with nothing to show) · list_meta_partnership_creators / manage_meta_partnership_creator (PARTNERSHIP ADS — the creators whose content this profile may run as an advert, and who may tag this brand as a paid partner. Two separate lists, neither implying the other, and neither defaults on; adding is a REQUEST the creator must accept, and an ad naming a creator who is only PENDING fails for a reason nothing in the error says) · list_meta_catalogs / list_meta_product_sets / list_meta_catalog_products (PRODUCT CATALOGS — the merchant’s own Meta catalogs, the product SETS inside each and the products themselves with Meta’s review status. A catalog is the input to Advantage+ catalog ads, the highest-performing ecommerce format on Meta: pass productCatalogId to create_meta_campaign and productSetId to create_meta_adset / create_meta_ad, and Meta builds every impression from the product’s own image, name and price — no render needed. An empty list is a fact about which business portfolio this login administers, NEVER about whether the merchant has a catalog) · create_meta_campaign / create_meta_ad / upload_meta_asset (build) · upload_meta_playable / list_meta_playables (PLAYABLE ads: the advertiser’s own interactive HTML game, run through create_meta_ad’s playableAssetId in an App Installs ad set) · list_meta_lead_forms / create_meta_lead_form (INSTANT LEAD FORMS — the form a lead ad opens INSIDE Facebook/Instagram instead of sending the click to a website; pass the id as create_meta_ad(objective:\"OUTCOME_LEADS\", leadFormId:…) and read the submissions with read_meta_leads) · update_meta_object / delete_meta_object / set_meta_campaign_status (edit, delete, activate — every spend + delete is confirm-gated) · delete_meta_audience (remove a custom audience or lookalike — its blast radius is the PEOPLE in it and the lookalikes built from it, which Meta refuses to delete around) · manage_meta_post (edit or delete a published post). THREADS (a separate connection from Meta, on its own API): post_to_meta(target:"threads") publishes · list_threads_posts · threads_insights · list_threads_replies / reply_to_thread / hide_thread_reply · list_threads_mentions · search_threads_keyword · repost_thread (amplify a customer’s post or one of your own to the brand’s profile — the Threads retweet, and there is NO documented un-repost) · delete_thread (confirm-gated; Threads has no EDIT at all, so delete-and-repost is the only correction) · threads_publishing_limit (how much of the rolling-24h quota is left — 250 posts, 1,000 replies, 100 DELETIONS, 500 location searches; check it before a bulk clean-up, because a quota refusal otherwise reads as a broken connection). SCHEDULING (one content calendar across every channel): schedule_post (queue a post for a future time to one or MORE channels at once — Facebook / Instagram / Threads / TikTok / YouTube / LinkedIn / X / Pinterest / Bluesky / Telegram (ten; Google Business Profile is accepted but held back on Google API access) — with per-channel captions; Hermoso publishes it at that time, nothing has to stay open — it goes LIVE PUBLICLY by default, and only stages as draft/unlisted/private if the user asks, and an impossible channel+visibility pair, an over-length caption or media the channel cannot carry is REFUSED while you are still there rather than failing hours later) · list_scheduled (what is queued and what already fired, with PER-CHANNEL outcomes) · reschedule_post (move a queued post to a new time, or change its caption, media, channels or target Page/board — send only what changes) · cancel_scheduled (pull a queued post before it goes out). POST PERFORMANCE (the loop that closes research → publish → learn — Hermoso records the HOOK and SUBJECT of everything it publishes, because those exist only at the moment of publishing and can never be recovered from a post id afterwards): list_published_posts (everything this brand has published across every channel, with the hook it was written to and its measured engagement) · post_performance (which HOOKS and SUBJECTS are getting traction — engagement rates compared WITHIN a channel and NEVER summed across them, with a verdict suppressed below 5 measured posts and the reason stated) · collect_post_metrics (pull fresh numbers ~24h and ~7d after each publish; a metric a channel cannot report is recorded ABSENT with its reason and never as zero, and X is skipped unless asked because it bills per call) · backfill_posts (import a channel’s past posts so the analysis has history — dry-run and cost-quoted first, and an imported post never votes on a hook unless it matched a Hermoso creation). YOUTUBE (publish, measure AND manage): post_to_youtube (publish a finished video to the brand’s channel — PUBLIC by default; unlisted (link-only, the ad-ready setting) or private only when the user asks) · list_youtube_videos (the channel’s OWN uploads with their video ids — call this to resolve “my latest video” yourself instead of asking the user for a link; it is where the videoId every other YouTube tool needs comes from, and it sees unlisted/private uploads a public search cannot) · update_youtube_video (retitle/re-describe/re-tag, and FLIP AN UNLISTED UPLOAD PUBLIC; confirm before going public) · delete_youtube_video (take one down for good — irreversible, so the unconfirmed call reports the video’s real title, privacy, views and comments first; use update_youtube_video(privacy:"private") when they only want it out of sight) · set_youtube_thumbnail (put a Hermoso thumbnail on an uploaded video — the biggest single lever on click-through, and YouTube otherwise picks a frame at random; needs a phone-verified channel) · update_youtube_channel (brand the CHANNEL ITSELF — banner art, description, keywords, country, the trailer non-subscribers see; everything else here brands the videos, this brands the page they sit on. It MERGES with the current settings, and it reports any field YouTube accepted but silently ignored, channel title above all) · set_youtube_watermark (the subscribe badge overlaid on EVERY video on the channel, including ones uploaded later — one square image brands the whole channel at once; the API publishes no way to read it back, so it reports accepted rather than confirmed) · list_youtube_video_stats (views, likes and comments for up to 50 videos IN ONE CALL, which is how to answer "how are my last twenty uploads doing" without one youtube_video_insights per video. It carries NO titles, because VideoStatsSnippet publishes only publishTime, so join on videoId with list_youtube_videos for names. YouTube calls this endpoint "intentionally not atomic", so a short answer is normal: the missing ids are named, and a missing id is never zero views) · youtube_video_insights (per-VIDEO views, watch time, average view PERCENTAGE/retention, likes, comments, shares, subscribers gained — the numbers that say whether a hook held; youtube_channel only gives channel-wide totals) · youtube_channel_report (the same numbers BROKEN DOWN — traffic source (search vs browse vs suggested vs shorts feed), the actual search terms, country/city, device, age+gender, subscribed vs not, and the audience-RETENTION curve showing exactly where viewers left) · list_youtube_comments + list_youtube_comment_replies + reply_to_youtube_comment (read viewer questions and objections in their own words, and answer as the channel) · moderate_youtube_comment (hide, reject, spam-report or delete an abusive comment — reject is reversible, delete is not) · list_youtube_playlists + manage_youtube_playlist + manage_youtube_playlist_items (organise the channel: create playlists, add/remove/re-order videos in them) · manage_youtube_playlist_image (a custom cover on a playlist — make_thumbnail renders the artwork, this is the call that puts it on. YouTube answers every failure here as an HTTP 500 whose real reason is buried inside it, and the tool unpacks that; if it comes back refused, check channel verification first) · manage_youtube_channel_section (the SHELVES ON THE CHANNEL HOMEPAGE — put a chosen playlist or a featured channel above YouTube’s own default layout, and re-order them. Every write is PUBLIC IMMEDIATELY, a delete has no undo, and YouTube’s own section list LAGS a write by a few seconds in both directions, so never treat a list taken straight afterwards as proof either way) · list_youtube_captions + manage_youtube_caption (real subtitle TRACKS — what YouTube indexes the video by and what a viewer toggles on, which is NOT the same as captions burned into the picture; downloading one is also the quickest way to get an existing video’s script back) · list_youtube_categories (which categoryId post_to_youtube will accept in a given country) · youtube_bulk_report (THE ONLY PLACE YOUTUBE PUBLISHES THUMBNAIL IMPRESSIONS AND THUMBNAIL CTR — a different, SCHEDULED API: the first call starts a job and returns nothing, then YouTube writes one file per day, the first within 48 hours, plus a 30-day backfill. It also carries per-card and per-end-screen metrics and an uncapped list of the search terms people arrived on) · list_youtube_report_jobs (whether that thumbnail history is already accumulating, and since when — check before promising a number) · delete_youtube_report_job (stop one; the job IS the history, so deleting it throws the accumulated files away) · youtube_channel (read title + subscriber/view/video counts for reporting). TIKTOK: post_to_tiktok (post a finished video — or a PHOTO POST, TikTok’s photo/slideshow format of 1 to 35 images where a single image is just a one-slide post —LIVE to the profile, or into TikTok drafts to review in the app) · tiktok_creator_info (the creator’s REAL privacy options — read them and let the user choose before any direct post) · tiktok_post_status (follow a post_to_tiktok that came back pending: done, failed or still processing) · tiktok_account (bio, verified status, follower/following/likes/video counts) · list_tiktok_videos (their own posts with views/likes/comments/shares — either the most recent, or specific videoIds read directly however old they are). ⚠️ TIKTOK HAS NO DELETE AND NO EDIT: its API publishes no way to remove a posted video or change its caption, privacy, cover or comment/duet/stitch settings — every one of those is fixed at publish time and there is no delete scope in TikTok’s scope catalogue at all. If the user wants a TikTok taken down or changed, say plainly that it has to be done in the TikTok app rather than hunting for a tool. TIKTOK ACCOUNT AUTHORIZATION (a SECOND, separate consent on the SAME TikTok app the TikTok Ads connection uses — holding one does NOT give you the other, so a brand fully connected for ads can still be unauthorized here, and that is a real third state rather than a broken session): tiktok_account_status (which state this profile is in, the TikTok business id, the scopes the grant carries and any MISSING from it — TikTok binds scopes at authorize time and never retroactively, so only a re-authorization picks up a new one — plus the exact URL to send the user to, because authorizing is the one step that needs a browser) · list_tiktok_comments + list_tiktok_comment_replies (the comments on the brand’s OWN posts, hidden ones included — TikTok’s answer to list_meta_comments and list_youtube_comments) · comment_on_tiktok_video · reply_to_tiktok_comment · moderate_tiktok_comment (LIKE / UNLIKE / HIDE / UNHIDE / DELETE — you can only DELETE a comment this account wrote, so HIDE is the tool for a stranger’s, and TikTok warns UNHIDE may not take effect when its own moderation is what hid it) · upload_tiktok_comment_image (a new comment will not take a raw image URL; a reply will) · set_tiktok_post_ad_authorization (THIS IS WHERE A SPARK ADS AUTHORIZATION CODE COMES FROM for the brand’s OWN post — previously a human had to copy one out of the TikTok app; hand the code to authorize_tiktok_ads_spark_post) · get_tiktok_post_ad_authorization · extend_tiktok_post_ad_authorization (the days are ADDED to what is left, not set as an absolute) · delete_tiktok_post_ad_authorization. BRAND MONITORING AND AUDIENCE, on that same account authorization (these need permissions added on 2026-08-20, so a brand that authorized before then holds a grant that predates them and has to authorize once more; tiktok_account_status names exactly which are missing, and the remedy is always to authorize the TikTok ACCOUNT again rather than to touch the advertiser connection, which is a separate grant and is unaffected): list_tiktok_mentions (public posts whose caption @-mentions the brand, TikTok’s answer to x_mentions and list_threads_mentions) · list_tiktok_mention_comments (comments whose text mentions it) · get_tiktok_mention (one mention in full, for the mentions webhook, and TikTok only keeps that data 48 hours) · tiktok_mention_top_terms (the top 20 keywords and top 20 hashtags inside those mentions) · list_tiktok_brand_hashtags + manage_tiktok_brand_hashtags + list_tiktok_brand_hashtag_posts (the hashtags TikTok counts as this brand’s, up to 50, and the posts carrying them; a new one is not counted for 24 hours and cannot be removed for 7 days) · tiktok_account_insights (follower demographics by age, gender, country and city plus the daily performance series, needing a BUSINESS account with 100+ followers, and capped at 60 days rather than the 90 the mention tools cover) · tiktok_category_benchmark (the same numbers averaged across an industry, so ‘are we ahead of our category’ is answerable). ALL OF THIS IS ORGANIC LISTENING ON THE BRAND’S OWN ACCOUNT, not ad research: for competitors’ ads use the ad-library research tools instead. TIKTOK ADS (a SEPARATE connection from the TikTok posting connector above — Settings ▸ Connectors ▸ TikTok Ads; a brand that posts to TikTok every day may still have no ad account here, so never read one as the other): list_tiktok_ads_accounts (the ADVERTISER accounts this profile can act on — every other TikTok Ads tool needs an advertiserId and this is where it comes from) · list_tiktok_ads_pixels + create_tiktok_ads_pixel + list_tiktok_ads_custom_conversions + tiktok_ads_pixel_stats (CONVERSION TRACKING — a conversion-optimised ad group dies at creation with "Please select a pixel" without one, so discover the pixel and its events BEFORE building the tree; note TikTok publishes no way to DELETE a pixel, so one you create is permanent) · list_tiktok_ads_campaigns (the whole tree — campaigns, ad groups and ads with their statuses) · tiktok_ads_report (impressions, clicks, spend, CTR, CPC, conversions and video views at any level) · list_tiktok_ads_identities (the TikTok accounts an ad may post AS — MANDATORY, with NO default: call it and let the USER pick, because the ad runs publicly under whichever account is named) · search_tiktok_ads_targeting (resolve location / interest / hashtag / language ids — an ad group cannot be created without location ids, and a guessed id targets the wrong people) · list_tiktok_ads_identity_posts (the ORGANIC posts an identity has already published — where a Spark Ad’s post id comes from) · list_tiktok_ads_spark_posts (the posts authorised for Spark Ads, i.e. promoting an organic post instead of uploading a new video) · authorize_tiktok_ads_spark_post + unbind_tiktok_ads_spark_post (add a creator’s post to that authorised set with the code they generated in the TikTok app, or release it again) · upload_tiktok_ads_playable / list_tiktok_ads_playables / delete_tiktok_ads_playable (PLAYABLE ads: the advertiser’s own interactive HTML zip, run through create_tiktok_smart_ad’s playableUrls) · upload_tiktok_ads_creative (THE STEP THAT TURNS A RENDER INTO AN AD — put a finished Hermoso video on the ad account and it hands back the videoId AND the coverImageId create_tiktok_ads_ad needs; there is no other source for either) · create_tiktok_ads_campaign → create_tiktok_ads_ad_group → create_tiktok_ads_ad (the tree) · set_tiktok_ads_budget · set_tiktok_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_tiktok_ads_object (removal on TikTok is a STATUS, not a verb — the same route as set_tiktok_ads_status) · create_tiktok_smart_campaign → create_tiktok_smart_ad_group → create_tiktok_smart_ad (Smart+, TikTok’s Performance Max — born PAUSED) · list_tiktok_smart_campaigns · set_tiktok_smart_status (the Smart+ money switch, confirm-gated) · tiktok_bid_protection (the ad-credit compensation TikTok pays when a Smart+ object misses its bid) · tiktok_lead_ads_terms (read and accept TikTok’s Lead Ads terms, which a lead-generation ad needs first) · list_tiktok_ads_lead_forms + list_tiktok_ads_lead_fields + download_tiktok_ads_leads + manage_tiktok_ads_test_lead (LEAD ADS — an Instant Form is built in TikTok Ads Manager and NO API creates one, so list them to find the id a LEAD_GENERATION ad group needs. The lead REGION is required with no default: it selects which of three separate lead stores you read, and leaving it out is a THIRD value rather than “all”, so an advertiser who omits it downloads an empty file and wrongly concludes there are no leads) · list_tiktok_ads_audiences + create_tiktok_ads_audience + create_tiktok_ads_lookalike_audience + apply_tiktok_ads_audience + update_tiktok_ads_audience + delete_tiktok_ads_audience + tiktok_ads_audience_overlap (CUSTOM AUDIENCES and lookalikes — TikTok targeting is otherwise interests-and-geo only. A freshly created audience reports itself invalid for up to 48 hours BY DESIGN, so that is not a failure to retry) · list_tiktok_ads_business_centers + list_tiktok_ads_catalogs + create_tiktok_ads_catalog + list_tiktok_ads_catalog_products + list_tiktok_ads_catalog_sets + manage_tiktok_ads_catalog_feed + tiktok_ads_catalog_diagnostics (DPA / PRODUCT CATALOGS, the Shopify lane — a catalog is keyed on a BUSINESS CENTER id, NOT an advertiser id, so list the Business Centers first or every call refuses) · list_tiktok_ads_apps + list_tiktok_ads_app_events (the registered apps an APP_INSTALL campaign needs — nothing else can produce an app id) · tiktok_ads_rf_inventory_estimate + create_tiktok_ads_rf_ad_group (REACH & FREQUENCY — a RESERVATION, so it is confirm-gated like a status change rather than born paused, and it needs a per-ad-account allowlist plus a signed branding contract that no endpoint reports. Always price it with the estimate first: TikTok silently books its own maximum rather than refusing an out-of-range value) · send_tiktok_ads_events (SERVER-SIDE conversion events — there is a vendor-sanctioned test code for exercising it without entering the advertiser’s real reporting), and its offline/crm sources take the event-set ids the two tools below mint) · list_tiktok_ads_offline_event_sets + manage_tiktok_ads_offline_event_set + send_tiktok_ads_offline_events (REAL-WORLD CONVERSIONS — an in-store purchase, a phone booking, a signed contract, reported so TikTok can attribute them to the ads that caused them. The timestamp is an ISO-8601 STRING here and a Unix NUMBER on send_tiktok_ads_events; a wrong-shaped one is accepted by TikTok and attributed to nothing. There is NO test code on this pair, so everything sent is a real permanent conversion — rehearse through send_tiktok_ads_events with eventSource “offline” and a testEventCode instead. Reporting also needs the connected user to be an ADMIN or OPERATOR of the advertiser, which managing the event SETS does not) · list_tiktok_ads_crm_event_sets + create_tiktok_ads_crm_event_set (LEAD-LIFECYCLE events — sending “this lead qualified / closed” back is what makes a LEAD_GENERATION campaign optimise toward leads that convert rather than form fills. TikTok publishes create and list and nothing else, so one of these is PERMANENT) · list_tiktok_tto_accounts + list_tiktok_creator_labels + discover_tiktok_creators + tiktok_creator_leaderboard + check_tiktok_creator_status + list_tiktok_tto_brand_profiles + create_tiktok_tto_brand_profile + list_tiktok_tto_campaigns + create_tiktok_tto_campaign + update_tiktok_tto_campaign + link_tiktok_tto_video + list_tiktok_tto_link_requests + tiktok_tto_campaign_report + request_tiktok_tto_spark_authorization + get_tiktok_tto_spark_authorization + manage_tiktok_tto_anchor (TIKTOK ONE / CREATOR MARKETPLACE: INFLUENCER MARKETING, and the only place in Hermoso that does it: find creators by audience size, engagement, price and who their followers actually are, check whether they have joined TikTok One, invite them to a campaign with an invite link, ask them to tag a video to it, and read every metric SPLIT ORGANIC VERSUS PAID. Its account id is a THIRD id space; not an advertiser id and not a Business Center id; so start at list_tiktok_tto_accounts. It rides this same connection with nothing extra to apply for. IT ALSO CLOSES THE SPARK ADS LOOP: request_tiktok_tto_spark_authorization asks a creator directly and get_tiktok_tto_spark_authorization returns the code authorize_tiktok_ads_spark_post takes, which is otherwise obtainable only by the creator pasting one out of the TikTok app. Two things put a notification in a real person’s inbox; a campaign invitation and a video-linking request; and a repeated linking request is a REMINDER that TikTok caps at two, so read list_tiktok_tto_link_requests before re-sending anything) · list_tiktok_ads_stores + list_tiktok_ads_store_products (TIKTOK SHOPS: what a Shopping Ads or GMV Max campaign sells from; the store list is keyed on an ad account and the product list on a BUSINESS CENTER, which each store row names) · tiktok_ads_verification_status + list_tiktok_ads_verification_documents + submit_tiktok_ads_verification (BUSINESS VERIFICATION: an unverified account hits limits that get diagnosed as something else, so it is worth reading during onboarding. Hermoso never handles a verification DOCUMENT: submitting sends account details plus the ids of images the user uploaded in TikTok Ads Manager, and the legal name and document number can never be changed afterwards, so it is confirm-gated) · list_tiktok_ads_payment_portfolios + list_tiktok_ads_payment_portfolio_links (HOW THE AD ACCOUNTS ARE FUNDED: read-only, because "why did delivery stop" is often a funding answer, and because deciding where a customer’s money sits is not ours to do) · create_tiktok_ads_rule + list_tiktok_ads_rules + update_tiktok_ads_rule + bind_tiktok_ads_rule + set_tiktok_ads_rule_status + tiktok_ads_rule_results (AUTOMATED RULES — standing instructions TikTok runs on the account unattended. THE SECOND SPEND SWITCH ON THIS PLATFORM and gated in TWO CLASSES: a rule that can only pause, decrease or email needs confirm:true, while one that can TURN_ON an object or RAISE a budget or bid needs confirm:true AND confirmScope echoing the token list_tiktok_ads_rules prints, computed from the rule as TikTok STORES it. Every rule is created TURNED OFF and read back to prove it, because TikTok publishes no way to create one in the off position. TikTok emails rule notifications to the DEVELOPER address on the app rather than to the advertiser, so tiktok_ads_rule_results is the only place a customer sees what a rule did — and TikTok itself says this endpoint is for direct advertisers and may refuse a platform-managed account entirely). · list_tiktok_ads_comments + tiktok_ads_comment_thread + moderate_tiktok_ads_comment + reply_to_tiktok_ads_comment + delete_tiktok_ads_comment (COMMENT MODERATION on your own TikTok ads — the platform where the comment section IS the ad. HIDE is the moderation verb and works on anyone’s comment and is reversible; DELETE only ever removes a comment your OWN identity posted, which TikTok reports per comment as canDelete. Comments are scoped to an AD GROUP and to nothing else, and the time window may span at most 30 DAYS, so an empty answer means “none in these 30 days” rather than “none ever”) · list_tiktok_ads_blocked_words + manage_tiktok_ads_blocked_words (a standing 500-word filter that auto-hides any comment containing one of these across EVERY ad on the account — nothing else in Hermoso does this, and removing a word republishes every comment it had hidden) · tiktok_ads_diagnosis (TikTok’s own issues-and-suggestions verdict on your ad groups — creative, bid/budget with its full estimated-delivery tables, and a pixel that has gone quiet. It covers ACTIVE ad groups only and omits any it has nothing to say about, so an empty answer is not a clean bill of health) · get_tiktok_ads_brand_safety + set_tiktok_ads_brand_safety (what content the ads may appear next to. Two things to say out loud: TikTok applies this to Smart+ campaigns and explicitly NOT to the regular campaigns create_tiktok_ads_campaign builds, and coverAllObjectives is a ONE-WAY DOOR TikTok cannot set back). TWO THINGS HERE ARE UNLIKE EVERY OTHER AD PLATFORM: TikTok creates objects ENABLED by default, so Hermoso forces every campaign, ad group and ad PAUSED with no override and nothing serves until set_tiktok_ads_status(confirm:true); and TikTok’s QPS is 1, so every call is serialized and a tree build or a bulk read is SLOW BY DESIGN — a throttle is not a broken connection. SNAPCHAT ADS (the tenth ad platform — Settings ▸ Connectors ▸ Snapchat Ads; a SEPARATE connection from Snapchat posting): list_snapchat_ads_accounts (the organizations and AD ACCOUNTS this profile can act on — every other Snapchat tool needs an adAccountId and this is where it comes from) · list_snapchat_ads_campaigns (the whole tree — campaigns, ad squads and ads) · snapchat_ads_report (impressions, spend, swipes and video quartiles at any level) · search_snapchat_ads_targeting (resolve country / region / interest / language ids — an ad squad cannot be created without at least one country) · upload_snapchat_ads_creative (put a finished render on the ad account as MEDIA and then as the CREATIVE an ad points at — Snapchat has no upload-from-URL, so Hermoso streams the bytes) · create_snapchat_ads_campaign → create_snapchat_ads_ad_squad → create_snapchat_ads_ad (the tree, every tier born PAUSED) · set_snapchat_ads_budget · set_snapchat_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_snapchat_ads_object (a REAL delete verb here, unlike TikTok — irreversible, so offer PAUSED first). THREE THINGS TO SAY OUT LOUD ON THIS PLATFORM: money is MICRO-CURRENCY (1,000,000 = one unit), so quote plain amounts and let Hermoso convert, and never pass both units — under-converting fails loudly while double-converting asks for a budget a million times too large; the creative HEADLINE is capped at 34 characters and brandName at 32, far shorter than Meta or Google, and over-long copy is refused rather than truncated; and a Snapchat ad points at a CREATIVE, never at a media id. SNAPCHAT POSTING (Stories / Spotlights on a Public Profile) IS BUILT BUT NOT YET REACHABLE — Snap’s Public Profile API is allowlist-only and Hermoso has not been allowlisted, so the connector is deliberately not offered; say that plainly rather than looking for a tool. LINKEDIN: post_to_linkedin (publish a finished post to the connected LinkedIn PROFILE) · list_linkedin_pages (the company Pages this connection administers — call this first and let the USER pick, never guess a Page) · post_to_linkedin_page (publish as a company PAGE rather than a person — this is the one most brands actually want) · manage_linkedin_post (edit the copy of a published post, or delete it) \u00b7 list_linkedin_comments / reply_to_linkedin_comment / delete_linkedin_comment (moderate the comments on your Page\u2019s posts \u2014 a SEPARATE LinkedIn authorization grants these, see Connectors) · linkedin_page_analytics (ORGANIC Page performance — followers, follower gains, Page views, and post impressions/clicks/engagement, for the Page total or per post; this is the free organic read, NOT linkedin_ads_report). LINKEDIN ADS (full three-tier management): list_linkedin_ads_campaigns (ad accounts, then a chosen account’s campaign groups, campaigns and — with campaignId — the CREATIVES under them) · linkedin_ads_report (impressions, clicks, cost, conversions, leads) · search_linkedin_ads_targeting (resolve locations / titles / industries / seniorities / company sizes to the URNs LinkedIn demands — never invent one) · linkedin_audience_count (HOW MANY members that targeting actually reaches, before a budget is committed — and a returned 0 means fewer than 300 people, LinkedIn’s privacy floor and also its campaign minimum, never an empty audience) · linkedin_bid_pricing (LinkedIn’s own suggested bid and daily-budget range for that audience — quote it instead of guessing what LinkedIn costs) · create_linkedin_ads_campaign_group → create_linkedin_ads_campaign → create_linkedin_ads_creative (the tree, every tier born DRAFT) · set_linkedin_ads_budget / set_linkedin_ads_status / delete_linkedin_ads_object (budgets, activate/pause at any tier, delete — every spend change confirm-gated). LINKEDIN LEAD SYNC: list_linkedin_lead_forms · list_linkedin_leads / get_linkedin_lead (the LEADS its forms collected, answers named by field — PERSONAL DATA: show, never republish) · subscribe_linkedin_leads / list_linkedin_lead_events / list_linkedin_lead_subscriptions / delete_linkedin_lead_subscription (real-time push to Hermoso, optional forwardTo relay to a CRM; LinkedIn validates ONLY Hermoso’s own webhook). LinkedIn is a THREE-tier platform and the third tier is the one people forget: a campaign with no creative shows nothing, and all three tiers must be ACTIVE before a single impression is served. REDDIT ADS: list_reddit_ads_campaigns / reddit_ads_report (read the account tree + performance) · list_reddit_ads_profiles + list_reddit_ads_posts / create_reddit_ads_post / update_reddit_ads_post (the CREATIVE — a Reddit ad promotes a post) · create_reddit_ads_campaign / update_reddit_ads_campaign · create_reddit_ads_ad_group / update_reddit_ads_ad_group · create_reddit_ads_ad / update_reddit_ads_ad · create_reddit_ads_max_campaign / get_reddit_ads_max_template / update_reddit_ads_max_template (a Reddit MAX campaign: automated campaign, ad group and a template ad Reddit generates ads from, built from creative-library assets, all PAUSED) · set_reddit_ads_status (the ONLY switch that arms real spend, confirm-gated) · delete_reddit_ads_object (remove a campaign, ad group or ad — Reddit has no delete verb, removal is a status, and it refuses to delete anything touched in the last 3 hours) · delete_reddit_ads_saved_audience · search_reddit_ads_targeting / reddit_ads_forecast / reddit_ads_bid_suggestion (free planning) · list_reddit_ads_pixels + send_reddit_ads_conversions (conversion tracking — Reddit now requires a pixel on every ad group) · list_reddit_ads_audiences / create_reddit_ads_audience / update_reddit_ads_audience_users / delete_reddit_ads_audience (retargeting lists) · list_reddit_ads_saved_audiences / create_reddit_ads_saved_audience / update_reddit_ads_saved_audience · list_reddit_ads_lead_forms / create_reddit_ads_lead_form · reddit_ads_history (who changed what, when). TELEGRAM: post_to_telegram (publish to a channel, group or chat as the brand’s own bot — text up to 4096 characters, but only 1024 once any photo or video is attached; one image, one video, or an album of 2–10 in which photos and videos may be mixed. chatId IS ALWAYS REQUIRED and is never guessed: the Bot API publishes NO method that lists the chats a bot belongs to, so pass the public channel’s @username or the numeric id) · list_telegram_chats (chats that MESSAGED the bot in the last 24 hours — a shortcut for finding an id, NOT a roster, and a chat missing from it can still be posted to) · list_telegram_dms (what those chats actually SAID, newest per chat — free, and a rolling 24-hour window rather than an inbox: the Bot API has no history endpoint at all) · delete_telegram_message (confirm-gated; Telegram refuses once a message is more than 48 hours old). BLUESKY: post_to_bluesky (publish as the connected account — text up to 300 characters AND, separately, 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; either up to 10 images OR one MP4 video, never both, because a Bluesky post record carries exactly one embed; links are made clickable automatically) · delete_bluesky_post (PERMANENTLY remove one of the account’s own posts — no trash and no undelete. Call it WITHOUT confirm first: it deletes nothing and reports the post’s real text and live like/repost/reply/quote counts, and once the post has any engagement it also wants confirmText echoing its text. Takes the AT-URI or just the record key from the bsky.app link) · list_bluesky_convos / read_bluesky_dm / send_bluesky_dm / mark_bluesky_convo_read (the account’s DIRECT MESSAGES — free, 1000 characters each, text only, and they need a PRIVILEGED app password: an ordinary one posts fine and cannot chat). Replies, mentions AND direct messages all arrive in list_inbox and are answered with reply_to_inbox_item. X / TWITTER: post_to_x (publish a post — text, an image or a video render WITH alt text, a POLL, a reply, or a whole thread, and optionally restrict who may reply; X is the ONE channel that bills per API request, a post carrying a LINK costs roughly 13× one without, and each profile has a rolling 24-hour ceiling on X spend that refuses a request whole rather than publishing half of it) · delete_x_post (remove one) · x_post_metrics (the PUBLIC counts — impressions, likes, reposts, replies, quotes, bookmarks) · x_post_insights (the ADVERTISER numbers for your own posts — link clicks, profile visits, video views and completion quartiles, up to 25 posts at once; this is what says whether a creative worked, and x_post_metrics cannot tell you, but it only sees the LAST 28 HOURS) · x_post_insights_historical (the same advertiser numbers over ANY date range — the one to use for anything older than yesterday) · x_mentions (who is talking to the brand, in their own words — the read half of the reply loop, and a source of real customer language for ad copy) · list_x_dms (the brand’s X DIRECT MESSAGES, grouped into conversations, saying which are waiting on a reply — billed per message returned, and X keeps only 30 days) · send_x_dm (reply privately to one named person; never a broadcast) · x_chat_settings / update_x_chat_settings (the X account’s DM privacy: who may message the brand, the request filter, read receipts and calls). X IS THE ONE CONNECTOR THAT COSTS CREDITS PER CALL — X charges us per API request, so posting, deleting, reading metrics, reading insights and pulling mentions each bill the user, a post CONTAINING A LINK costs 13× one without, and insights and mentions are billed PER POST RETURNED. Say so before posting a thread or pulling a big page of mentions, and prefer one post over five when the content allows. X ADS (the PAID half — a SEPARATE connection from the organic tools above: its own product on its own host with OAuth 1.0a signing, and X grants API access PER AD ACCOUNT rather than per app, so the customer adds Hermoso’s X user at business.x.com → Account access before anything here resolves): list_x_ads_accounts (the ad accounts this profile can act on, WITH the permission level held on each — read it before attempting a write) · list_x_ads_funding_instruments (a campaign cannot be created without one) · list_x_ads_campaigns / list_x_ads_line_items / list_x_ads_promoted_tweets / list_x_ads_targeting (the whole tree as it stands) · x_ads_report (impressions, clicks, spend and engagements at any level) · x_ads_geo_search / x_ads_targeting_search (resolve places and targeting values to the ids X demands — never invent one) · create_x_ads_campaign → create_x_ads_line_item → create_x_ads_promoted_tweet (the tree, every tier born PAUSED with no override; A CAMPAIGN ALONE CANNOT SERVE ON X — it needs a line item and a promoted post underneath it, and the read-back says so rather than letting you call it a finished ad) · add_x_ads_targeting · update_x_ads_campaign / update_x_ads_line_item (throttle or raise spend on a running campaign without rebuilding it) · set_x_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_x_ads_object. PINTEREST — POSTING AND ADS ARE TWO SEPARATE CONNECTIONS on the same Pinterest login (Pinterest keeps ads access behind different permissions), so a brand can hold either without the other and connecting one does not connect the other; if an ads call says Pinterest Ads is not connected, that is the card to send them to, NOT the Pinterest posting one. ADS: pinterest_ads_async_report (the DEEP paid report — 914 days back where the quick one stops at 90, and three times the metric columns; generated asynchronously, so pass the returned token back rather than re-submitting) · pinterest_targeting_analytics (WHICH audience segment delivered — by keyword, interest, age, gender, location, placement) · pinterest_audience_insights (WHO the audience is: interest affinities plus demographics, the input to a creative brief rather than a performance report) · pinterest_analytics (ORGANIC performance — impressions, saves, Pin clicks, outbound clicks, for the account, the TOP PINS, the top video Pins, or one Pin; Pinterest keeps 90 days and publishes no board-level analytics at all) · create_pinterest_board (make a board — a NEW Pinterest account has none and a Pin needs one) · list_pinterest_boards (the user must pick a board — never choose one for them) · post_to_pinterest (create an image or video Pin on a chosen board, with a title, description and destination link) · list_pinterest_pins (the Pins on a board with their ids — where the pinId every Pin tool needs comes from, and it flags any Pin an ad is promoting) · update_pinterest_pin (retitle, re-describe, fix a dead link, move it — Pinterest keeps this endpoint in a limited BETA, so it may be refused outright and save_pinterest_pin is the generally-available way onto another board; a Pin’s picture can never be swapped by anyone) · save_pinterest_pin (copy a Pin onto another board) · delete_pinterest_pin (confirm-gated, and it says whether an ad is promoting the Pin first) · update_pinterest_board (rename, re-describe, or hide it — SECRET hides every Pin on the board, reversibly) · delete_pinterest_board (the heaviest one here: the board AND every Pin on it, confirm-gated with the Pin count echoed back — offer hiding it instead). GOOGLE ADS (full management): list_google_ads_campaigns (list accounts, then a customer’s campaigns + spend/CTR/CPC/conversions) · google_ads_report (any GAQL breakdown — ad groups, keywords, search terms, geo) · create_google_ads_campaign (paused) · set_google_ads_budget / set_google_ads_status (change budget, enable/pause — every spend change confirm-gated) · delete_google_ads_object (remove a campaign, ad group, ad, KEYWORD, asset LINK or conversion action — Google has no delete verb, `remove` is the terminal state and it cannot be undone; call it unconfirmed first to see the spend and the tree that go with it) · upload_google_ads_asset (add an image render or a YouTube video to the ad account’s asset library) · create_google_ads_performance_max_campaign (Google’s cross-surface campaign type, RETAIL INCLUDED — pass merchantCenterId to make it a Shopping-feed Performance Max advertising the WHOLE Merchant Center feed under one root listing group, and feedLabel to narrow it to a single feed; only PARTITIONING that feed by brand/category/custom label is refused by name) · add_google_ads_assets (sitelinks, callouts, structured snippets, call, promotion, price, lead-form, business name and logo assets, CREATED AND ATTACHED — an asset that is not attached shows nothing) · manage_google_ads_assets (list, edit, pause or detach them) · set_google_ads_tracking (UTMs / tracking template on a campaign, ad group or ad; new campaigns get standard UTMs by default) · manage_google_ads_audience + set_google_ads_audiences (remarketing lists, custom segments, affinity / in-market interests, demographics, Performance Max signals and search themes) · upload_google_ads_video (a render straight into Google Ads, no YouTube channel needed) · create_google_ads_demand_gen_campaign (YouTube Shorts / in-feed / in-stream video ads, born PAUSED) · list_google_ads_conversion_actions + create_google_ads_conversion_action (what Google counts as a result — MAXIMIZE_CONVERSIONS, TARGET_CPA, TARGET_ROAS and every Performance Max campaign are undeliverable without one, and Hermoso refuses to build them on an account that has none) · google_ads_keyword_ideas (Keyword Planner — real monthly search volume, competition and top-of-page bids; use it before choosing keywords) · google_ads_recommendations + manage_google_ads_recommendations (Google’s own advice and optimization score; apply / dismiss / auto-apply, spend-type ones only after the user’s yes) · preview_google_ads_ad (a shareable no-login preview link for a Performance Max asset group or YouTube video ad, for client approval) · google_ads_policy_issues (WHY Google disapproved or limited an ad, asset or keyword: the policy topic and the evidence) · google_ads_change_history (WHAT CHANGED ON THE ACCOUNT AND WHEN — the answer to “performance fell off a cliff on Tuesday, what happened?”. Its default source is field-level and reaches 30 days; the other source reaches 90 and is the ONLY one that sees Google Ads Editor and criterion edits, so check both before telling anyone nothing changed). GOOGLE MERCHANT CENTER (the product feed behind every Shopping ad and every free listing, on the SAME connection as Google Ads): register_merchant_developer (the ONE-TIME link between Hermoso’s Google Cloud project and the merchant’s account. Google refuses every other Merchant call until it is done, so run this first when calls are being refused) · list_merchant_accounts (which Merchant Centers this login can reach, and where the merchantCenterId every other tool needs comes from) · list_merchant_products (the feed itself, with each product’s disapprovals) · list_merchant_issues (account-level problems, the answer to "why is nothing showing at all") · merchant_issue_help + trigger_merchant_issue_action (Google’s OWN remediation steps for a problem, and the button that fires one. Several of those actions are one-shot in Google’s own words, so firing one is confirm-gated) · list_merchant_data_sources + create_merchant_data_source + delete_merchant_data_source (feeds. A product write only lands in an API-input feed, and most accounts have none until one is made, so check before writing) · upsert_merchant_product + update_merchant_product + delete_merchant_product (write the feed) · list_merchant_inventory + set_merchant_inventory (the per-STORE and per-REGION price, stock level and availability override on one product, which is what stops a Shopping ad advertising something the nearest store has sold out of. The write MERGES, because Google’s insert replaces the whole entry, and Google takes up to 30 minutes to reflect it on the product) · list_merchant_promotions + create_merchant_promotion (sale and discount badges on a listing. Google validates them asynchronously, so created is never the same as approved) · manage_merchant_notifications (Google POSTs to a URL THE MERCHANT RUNS the moment a product is disapproved, instead of someone having to poll) · merchant_account_status (WHY THE ACCOUNT IS OR IS NOT SERVING — the first thing to run when Shopping ads or free listings show nothing, and the one read that does not believe the program state: an account can report both programs ENABLED and serve in ZERO countries, because a region counts as active only where every requirement is met. It names Google’s own unmet requirements, then the settings that explain them: homepage claimed or not, business address, phone and support contact, active shipping services, return policies, terms accepted) · manage_merchant_settings (FIXES what merchant_account_status finds: shipping services merged by name so unmentioned ones are kept, return policies, business address and support contact, homepage claim, programs on or off; overwrites and removals ask first) · manage_merchant_conversion_source (WHERE MERCHANT CENTER GETS ITS CONVERSION DATA FROM, which is what free-listing and Shopping performance reporting is built on — a merchant with no conversion source sees clicks and no outcomes. Either a Google tag destination, whose MC-… id comes back only on the create and is the id the Google tag has to send conversions to, or a link to a GA4 property, which is IMMUTABLE and needs the connected Google account to be an admin there. A delete is an ARCHIVE and undelete restores it until the expiry Google reports) · merchant_quota (whether the account is simply out of daily API quota or out of product slots, which looks identical to a broken integration and is not. Google resets it at MIDDAY UTC) · merchant_report (the reports Google computes for free, including competitive visibility, best sellers and price competitiveness). MICROSOFT MERCHANT CENTER (the same job on Microsoft’s side, on the Microsoft Advertising connection): list_microsoft_merchant_stores · list_microsoft_merchant_products · upsert_microsoft_merchant_product · delete_microsoft_merchant_product · list_microsoft_merchant_issues · list_microsoft_merchant_catalogs + manage_microsoft_merchant_catalog. GOOGLE ANALYTICS (GA4 — the brand’s OWN site data, and a SEPARATE connection from Google Ads: a brand that spends on Ads every day may have no Analytics access at all, so never read one as the other): list_analytics_properties (call this FIRST — every other Analytics tool needs a NUMERIC property id, and what users actually know is the “G-XXXXXXX” Measurement ID from their tracking snippet, which no endpoint accepts; resolve it from this list rather than sending them hunting. It lists the properties SHARED WITH THIS PROFILE, not everything the Google account can see — Analytics access is handed out freely and one login often has Viewer on many clients’ properties, so the user ticks which belong to this profile and any other one is refused by name; an empty list means nothing is ticked yet, which set_connector_accounts or Settings ▸ Connectors ▸ Google Analytics ▸ Manage accounts fixes) · analytics_report (what happened — sessions, users, revenue, conversions and engagement broken down by channel, source/medium, campaign, landing page, country, device or date, i.e. the read that says whether the traffic an ad bought actually did anything) · analytics_realtime (who is on the site right now, ~30 minutes — a DIFFERENT metric set that rejects `sessions` outright, never a shortcut for analytics_report) · list_analytics_definitions (what the property already measures: its key events and its own custom dimensions, and the check to run before creating either) · create_analytics_key_event (mark an event GA4 already collects as a KEY EVENT — the 2024 rename of a conversion, and what makes it importable into Google Ads; marking an event the site never fires creates one that can never fire) · create_analytics_custom_dimension (register an event parameter the site already sends so reports can break down by it — say out loud first that a GA4 custom dimension CANNOT be deleted, only archived, and a property is capped at 50 event-scoped ones, so a typo permanently burns a slot) · list_analytics_data_streams (the streams on a property and the measurement ID (G-...) each one carries, which is what a gtag or GTM install needs and what nobody can find in the GA4 UI when asked) · get_analytics_stream_setup (the finished gtag <script> block to paste into the site — the last mile list_analytics_data_streams stops short of — plus whether enhanced measurement is really collecting scrolls, outbound clicks, site search, video, downloads and form interactions, and whether redaction is stripping campaign parameters out of recorded URLs. Web streams only. Read the master switch before believing a toggle: with enhanced measurement off for the stream, every toggle is inert whatever it says) · list_analytics_metadata (every dimension and metric this property can be asked for, including its own custom ones, which is what stops analytics_report guessing a field name) · check_analytics_compatibility (whether a dimension and metric can appear in the same report before spending a call finding out they cannot) · create_analytics_custom_metric + archive_analytics_custom_metric · archive_analytics_custom_dimension · delete_analytics_key_event (all one-way in the same sense as their create twins: archiving is not deleting and there is no un-archive) · list_analytics_google_ads_links + link_google_ads_to_analytics + unlink_google_ads_from_analytics (the join that makes a GA4 audience usable in Google Ads and a GA4 key event importable as a conversion — without it a perfectly good audience simply never appears in the ads account, with no error anywhere) · list_analytics_audiences + create_analytics_audience + archive_analytics_audience (GA4 remarketing audiences, the input to Google Ads remarketing. Archiving is one-way) · manage_analytics_measurement_protocol_secret (mint the API secret that lets the customer’s OWN SERVER send events straight into GA4, the Google twin of the conversions APIs already here for Reddit, Snapchat and OpenAI Ads. Say out loud that there is NO rotation anywhere in the API, so replacing a secret means create the new one, move every sender across, then delete the old one) · manage_analytics_channel_group (HOW GA4 BUCKETS TRAFFIC — the answer to “why is my campaign showing as Unassigned”, and the one number an ad studio is judged on. Read the Default channel group’s rules before diagnosing anything, then author your own group whose channels catch the campaigns Hermoso publishes. The rule fields are the eachScope… names, NOT the sessionSource / medium dimensions reports use, and GA4 stops at the first rule that matches so order decides everything) · manage_analytics_calculated_metric (the derived number a marketer actually reports — cost per purchase, revenue per session — built from metrics GA4 already collects and then available to analytics_report under its own permanent API name. The id is permanent, and a formula naming a metric the property does not collect is created happily and flagged invalid, so read that flag back) · manage_analytics_annotations (mark a launch or a post on the customer’s own GA4 charts, so the traffic it moved shows where they look) · analytics_data_retention (how long GA4 keeps event data — the 2-month default caps every exploration; lowering it deletes data, so it is confirm-gated). MICROSOFT ADVERTISING / BING ADS (full management, mirroring Google): list_microsoft_ads_campaigns (list the shared ad accounts, then a chosen account’s campaigns + budgets) · microsoft_ads_geo_search (resolve country / region / city names to the Microsoft location ids a campaign needs — call it when an ask is ambiguous and let the USER pick) · microsoft_ads_report (impressions, clicks, CTR, average CPC, spend, conversions — generated asynchronously, so it may come back pending and must be called again) · create_microsoft_ads_campaign (campaign → ad group → responsive search ad → keywords, always Paused; with no locations[] it is created serving WORLDWIDE, Microsoft’s own default, and the read-back warns loudly — relay that before anyone activates it) · create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (fill in an existing account) · set_microsoft_ads_budget / set_microsoft_ads_status (change budget, activate/pause — every spend change confirm-gated; Microsoft statuses are Active/Paused, never Deleted) · search_microsoft_ads_profiles / list_microsoft_ads_profile_targeting / set_microsoft_ads_profile_targeting (LinkedIn profile targeting: company, industry, job function, seniority and job title bid adjustments on a Search, Shopping or DSA campaign; confirm-gated on an ACTIVE campaign) · delete_microsoft_ads_object (a REAL delete — campaign, ad group, ad or keyword — permanent, with no undelete; call it unconfirmed first to see what goes with it) · microsoft_ads_keyword_ideas (Microsoft’s Keyword Planner — real search volume, competition and suggested bids, with NO planning-tier gate, unlike Google’s) · microsoft_ads_traffic_estimates (what those keywords would deliver at a named bid — a range, never one number) · microsoft_ads_budget_opportunities (where Microsoft says a budget is capping delivery, and what raising it is forecast to buy) · microsoft_ads_auction_insights (who ELSE is bidding on the same auctions — rival domains with their impression share, overlap and outranking share; shares of YOUR auctions, never a measure of a competitor’s whole account) · microsoft_ads_bulk_download (export the account as ONE bulk file — the only way to read ~185 Microsoft record types Hermoso cannot otherwise touch: sitelinks, callouts, structured snippets, labels, shared negative keyword lists, bid strategies, audiences, experiments, seasonality adjustments, conversion goals, asset groups, feeds) · microsoft_ads_bulk_upload (apply an edited bulk file — hundreds of objects in one request. IT IS GATED HARDER THAN ANYTHING ELSE ON THIS CONNECTOR, because a bulk file carries a Status column and can turn campaigns ON without ever touching set_microsoft_ads_status: confirm:true alone is refused, and you must first call it unconfirmed to get the row-by-row list of what it would ACTIVATE and DELETE, show that to the user, then echo both counts back as confirmActivations/confirmDeletions — or pass pauseInstead:true to land the file with every activation written as Paused) · list_microsoft_ads_conversion_goals (what the account counts as a conversion, and which goals are OFFLINE ones) · send_microsoft_ads_offline_conversions (close the loop: phone sales, in-store purchases and late-closing leads fed back so smart bidding stops optimising against website conversions alone — pass PLAIN emails and E.164 phones, hashing happens server-side to Microsoft’s own published spec) · microsoft_ads_editorial_reasons (WHY an ad or keyword was disapproved: the code, the word Microsoft objected to, where, and the appeal status) · list_microsoft_ads_audiences (the account’s Customer Match lists with their current sizes; a fresh list reads 0 for up to 48 hours and Microsoft will not use one under 300 people, so never call that a failed upload) · create_microsoft_ads_customer_list then apply_microsoft_ads_customer_list (build a Customer Match audience from PLAIN email addresses, normalized and SHA-256 hashed server-side to Microsoft’s own published spec so no plaintext ever leaves us; the user must be shown Microsoft’s Customer Match terms and agree first) · microsoft_ads_recommendations (what Microsoft ITSELF suggests changing, each one priced by Microsoft: budget raises carrying the current and recommended daily amount, new and broadened keywords, negative keywords it wants removed, and ads it has written. Every one INCREASES what the account buys, which is what they are for, so none is a free win and an empty list means Microsoft has no advice rather than that the account is optimal) · apply_microsoft_ads_recommendations (act on them, gated exactly like the bulk upload: confirm:true alone is REFUSED, so call it unconfirmed first to get every recommendation named with what it changes and Microsoft’s own cost estimate, show that to the user, then echo confirmCount and confirmCostIncrease back. Both are recomputed from a fresh read, and there is no undo) · dismiss_microsoft_ads_recommendations (take advice off the list. It cannot spend, so it needs no confirmation at all, and it is the right answer to “make it stop suggesting that” rather than applying something to clear it) · microsoft_ads_auto_apply (THE READ THAT ANSWERS “is Microsoft changing this account while nobody is looking?”, per type. An inherited account can already be opted in with nobody at the brand having done it) · set_microsoft_ads_auto_apply (turn that standing permission on or off. Switching any type ON is the strongest consent anywhere in Hermoso: Microsoft then writes and publishes its own ads under the brand’s name, deletes negative keywords so the account buys more searches, and changes conversion goals, unattended and indefinitely, with NOTHING to preview beforehand. So confirm:true is not enough and every type must be named in confirmTypes. Switching it OFF is never gated). GOOGLE BUSINESS PROFILE (the local-SEO channel — the listing panel on Google Search and Maps, which for a local business is where the demand actually is, and there is no delete): list_business_locations (the listings the connected Google account manages — call this first and let the USER pick when there is more than one; a Post on the wrong storefront is a public mistake) · post_to_google_business (publish a Post to the listing — text, ONE PHOTO and a call-to-action button; Google’s Posts API takes no video, so pass a still. EVENT and OFFER posts both require a title and a start date, and on an OFFER Google ignores the button link) · list_google_business_posts (what is showing right now, with each Post’s state) · delete_google_business_post (take one down — immediate and public, so confirm first) · list_google_business_reviews (the reviews on the listing, and which ones have NO reply yet — for a local business the highest-leverage surface there is) · reply_to_google_business_review (answer one publicly as the business; it is an UPSERT, so it replaces any existing reply) · list_google_business_questions + answer_google_business_question (the public Q&A on the listing) · google_business_search_keywords (the actual search terms people typed to find the listing — free local keyword data; low-volume terms are SUPPRESSED and come back as "fewer than N", never as zero) · google_business_insights (Search + Maps impressions, calls, website clicks, direction requests, messages, bookings — listing-level; Google discontinued per-Post insights in 2023 with no replacement, so never promise per-Post numbers) · get_business_location (everything the listing actually says — name, address, phone, website, categories, description, hours, service area — as the merchant set it; the answer to “what does our Google listing say?”) · update_business_location (change any of that — hours, phone, website, description, categories, even the name or address. It edits the live panel on Search and Maps with no draft and no undo, so call it WITHOUT confirm first: nothing is written, Google validates the payload, and you get the current value of every field you are about to change to show the user) · google_business_account (whose Business Profile account the listing is on, and whether the connected Google account’s role can edit it at all). Google gates this API behind a per-project access request and the default quota is zero, so the connection can be live and calls still refused — the error says so. CHATGPT ADS (ads under ChatGPT answers, via OpenAI’s Advertiser API — full management): list_openai_ads_campaigns (the ad account, then its campaigns, ad groups and ads with each ad’s review state) · openai_ads_report (impressions, clicks, spend, CTR, CPC, CPM at account / campaign / ad group / ad scope — run this first, it validates the key with zero spend risk) · openai_ads_geo_search (location ids) · list_openai_ads_audiences + create_openai_ads_audience (custom audiences — geo and these are the only list-based targeting this platform has; target them with customAudienceIds / excludedCustomAudienceIds on a campaign) · create_openai_ads_campaign (campaign → ad group → ad in one call, always PAUSED) · create_openai_ads_ad_group / create_openai_ads_ad (fill in an existing campaign) · update_openai_ads_object (rename, re-budget, rewrite context hints or the ad copy) · set_openai_ads_budget / set_openai_ads_status (change budget, activate, pause) · delete_openai_ads_object (ARCHIVE — this API has no delete and OpenAI say archiving is not reversible, so offer pausing first). TWO RULES THIS CHANNEL DOES NOT SHARE WITH THE OTHERS: it is connected by PASTING an Advertiser API key (no OAuth, no manager account, one key = one ad account), and it has exactly ONE creative format — a text plus image card, title 50 characters, body 100. There is NO VIDEO on ChatGPT Ads, so never offer a video ad here. GOOGLE DRIVE — ONE connection covering Drive, Sheets and Docs (full CRUD over the files Hermoso created there, plus any file the user hands over with the Google file picker in the app): save_to_drive · list_drive_files / get_drive_file · update_drive_file (rename/move/trash) · delete_drive_file · create_drive_folder. GOOGLE SHEETS (part of the Google Drive connection — export data to a spreadsheet the app creates, or read one the user picked; drive.file, no verification): create_sheet · append_to_sheet · read_sheet. GOOGLE DOCS (part of the Google Drive connection — export copy/brief/report as a doc, or read one the user picked; drive.file, no verification): create_doc · append_to_doc. GOOGLE SLIDES (part of the Google Drive connection — turn a swipefile collection into a real presentation, one slide per saved ad with the creative, brand, copy, run dates and platform; drive.file, no verification, no new scope): export_swipefile_deck — it CREATES a deck each time and cannot append to one the user already has, and a creative whose ad-library link has expired is reported rather than silently dropped. ONEDRIVE (full CRUD over the user’s Microsoft OneDrive): convert_onedrive_file (Microsoft converts a file server-side to PDF or JPG — ~130 formats including PowerPoint and Word decks, PSD, Illustrator, Sketch, 3D, video, iPhone HEIC and raw camera files; JPG needs both width and height) · save_to_onedrive · list_onedrive_files / get_onedrive_file · update_onedrive_file (rename/move) · delete_onedrive_file · create_onedrive_folder. INTERCOM (the brand’s own support inbox, nothing billed): list_intercom_conversations (filter by state, assignee, team, tag, channel, customer, dates, priority, unread, or app = the product the customer wrote from) · read_intercom_conversation · reply_to_intercom_conversation / add_intercom_note / manage_intercom_conversation (each a preview until confirm:true) · tag_intercom_conversation · list_intercom_tags · search_intercom_contacts · list_intercom_teammates · intercom_customer_language (customers’ own words for ad copy). VERCEL (the brand’s own sites, nothing billed): list_vercel_projects · list_vercel_deployments · list_vercel_domains · vercel_web_analytics (page views, visitors, top pages, referrers, countries) · deploy_vercel_page (a static landing page, confirm-gated; preview by default) · delete_vercel_deployment. Use these standalone — Hermoso is a full posting/ads/file-storage control surface, not only an ad generator.',
262
262
  'F) YOUR TOOL LIST IS A STARTING POINT, NOT THE PRODUCT \u2014 and the whole catalogue is two calls away. The listed roster is deliberately small (the core tools, plus whatever this connection asked for or you have switched on); every other tool in this map is held out of the LIST on SIZE alone and is fully built, fully live and fully callable. THE ROUTE THAT WORKS ON EVERY HOST, including the ones that cannot reload their tool list at all (claude.ai, ChatGPT): find_tools({query}) searches every tool by task or name and returns its parameters, its credit cost and its recent health in one line, then call_tool({name, args}) RUNS it \u2014 same account, same permissions, same result as if it had been listed. A direct tools/call to a name you already know works too. enable_tools({groups:[\u2026]}) additionally LISTS a whole group for hosts that re-list: `ads` (paid-campaign management on Meta, Google Ads, LinkedIn, Reddit, Microsoft, Pinterest, X, TikTok, Snapchat, ChatGPT Ads, Apple Search Ads \u2014 by far the heaviest group), `analytics` (GA4, Search Console), `channel_admin` (reading a channel back: insights, comments, DMs, catalogs, templates, webhooks, editing or deleting a published post), plus research, create, channels, files, workspace, or \'all\'. NEVER tell a user Hermoso cannot build a campaign, read a comment, answer a DM or pull a channel\u2019s numbers because you cannot see the tool \u2014 look it up and call it.',
263
263
  ].join('\n');
264
264
 
@@ -2663,6 +2663,16 @@ export const WIDGET_HOST_SPLITS = Object.freeze({
2663
2663
  splitOp('update_analytics_calculated_metric', { action: 'update' }, ['property!', 'calculatedMetricId!', 'displayName', 'formula', 'metricUnit', 'description'], splitW(true, true), 'Change a GA4 calculated metric', 'Use this when a calculated metric’s formula, name, unit or description should change. Its id cannot be renamed. Reports that use it change from then on. Free.'),
2664
2664
  splitOp('delete_analytics_calculated_metric', { action: 'delete' }, ['property!', 'calculatedMetricId!', 'confirm'], splitW(true, true), 'Delete a GA4 calculated metric', 'Use this when a calculated metric should be removed. Reports that ask for it stop working. Needs confirm:true. Free.', { confirm: 'must be true to delete' }),
2665
2665
  ],
2666
+ manage_analytics_annotations: [
2667
+ splitOp('list_analytics_annotations', { action: 'list' }, ['property!'], SPLIT_RO, 'List GA4 chart annotations', 'Use this when you need the annotations on a GA4 property’s charts: each one’s id, title, date or date range, colour and whether Google generated it. Read-only, free.'),
2668
+ splitOp('create_analytics_annotation', { action: 'create' }, ['property!', 'title!', 'description', 'color', 'date', 'startDate', 'endDate'], splitW(false, false), 'Add a GA4 chart annotation', 'Use this when a launch, post or change should be marked on the GA4 charts. Pass date for one day or startDate and endDate for a range. It is read back. Free.'),
2669
+ splitOp('update_analytics_annotation', { action: 'update' }, ['property!', 'annotationId!', 'title', 'description', 'color', 'date', 'startDate', 'endDate'], splitW(true, true), 'Change a GA4 chart annotation', 'Use this when an annotation’s title, description, colour or date should change. Google’s own annotations cannot be edited. Get annotationId from list_analytics_annotations. Free.', { annotationId: 'from list_analytics_annotations' }),
2670
+ splitOp('delete_analytics_annotation', { action: 'delete' }, ['property!', 'annotationId!', 'confirm'], splitW(true, true), 'Delete a GA4 chart annotation', 'Use this when an annotation should be removed from the GA4 charts. Needs confirm:true. Free.', { annotationId: 'from list_analytics_annotations', confirm: 'must be true to delete' }),
2671
+ ],
2672
+ analytics_data_retention: [
2673
+ splitOp('get_analytics_data_retention', { action: 'get' }, ['property!'], SPLIT_RO, 'Read GA4 data retention', 'Use this when you need how long a GA4 property keeps event-level and user-level data; 2 months caps explorations and funnels. Read-only, free.'),
2674
+ splitOp('set_analytics_data_retention', { action: 'set' }, ['property!', 'eventDataRetention', 'userDataRetention', 'resetUserDataOnNewActivity', 'confirm'], splitW(true, true), 'Change GA4 data retention', 'Use this when a GA4 property should keep data longer (2 or 14 months; 26, 38, 50 for event data on Analytics 360). Shortening it deletes older data at Google’s next monthly run, so it needs confirm:true after the user agrees. Free.', { confirm: 'required when shortening retention' }),
2675
+ ],
2666
2676
  manage_intercom_conversation: [
2667
2677
  splitOp('close_intercom_conversation', { action: 'close' }, ['conversationId!', 'message', 'confirm'], splitW(true, true), 'Close an Intercom conversation', 'Use this when a conversation in the brand’s Intercom inbox is resolved. message is optional and the CUSTOMER sees it. Call without confirm first to preview, then with confirm:true once the user agrees. The new state is read back. Free.'),
2668
2678
  splitOp('snooze_intercom_conversation', { action: 'snooze' }, ['conversationId!', 'snoozeUntil!', 'confirm'], splitW(false, true), 'Snooze an Intercom conversation', 'Use this when a conversation should leave the open inbox until a future time (snoozeUntil, an ISO date-time). Call without confirm first to preview, then with confirm:true. Free.'),
@@ -5387,8 +5397,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
5387
5397
  // marketplace banner above sits between like_instagram and these, so without this banner they were filed as research.
5388
5398
  server.registerTool('reply_to_meta_comment', {
5389
5399
  title: 'Reply to a Facebook/Instagram comment',
5390
- description: 'Post a public reply to a comment on the brand’s Facebook or Instagram post. This is PUBLIC and posted as the brand — show the user the exact wording and get their go-ahead first.',
5400
+ description: 'Post a public reply to a comment on the brand’s Facebook or Instagram post. This is PUBLIC and posted as the brand — show the user the exact wording and get their go-ahead first. With private:true it instead sends ONE private message to the commenter (Messenger / Instagram Direct) — once per comment, within 7 days.',
5391
5401
  inputSchema: {
5402
+ private: z.boolean().optional().describe('true = send ONE PRIVATE message to the person who left the comment (Messenger / Instagram Direct) instead of a public reply — the way to answer "price?" or "DM me". Meta allows one per comment, within 7 days of it.'),
5392
5403
  commentId: z.string().describe('comment id from list_meta_comments'),
5393
5404
  message: z.string().describe('reply text'),
5394
5405
  account: z.string().optional().describe('which Instagram account — an @handle or id from list_connector_accounts("instagram"). Needed when several are linked, and the way an Instagram Login (standalone) account is reached; omit for the one Page-linked account.'),
@@ -5397,8 +5408,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
5397
5408
  outputSchema: { ok: z.boolean().optional(), id: z.string().optional() },
5398
5409
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
5399
5410
  }, wrap(async (a) => {
5400
- const d = await apiPost('/api/meta/comment/reply', { commentId: a.commentId, message: a.message, account: a.account, pageId: a.pageId });
5401
- return ok(`Replied to comment ${a.commentId} (${d.id}).`, d);
5411
+ const d = await apiPost('/api/meta/comment/reply', { commentId: a.commentId, message: a.message, account: a.account, pageId: a.pageId, ...(a.private === true ? { private: true } : {}) });
5412
+ return ok(d.private ? d.note : `Replied to comment ${a.commentId} (${d.id}).`, d);
5402
5413
  }));
5403
5414
 
5404
5415
  server.registerTool('moderate_meta_comment', {
@@ -5564,13 +5575,19 @@ function buildTools(rawServer, opts = {}, sink = null) {
5564
5575
  title: 'Search Threads by keyword',
5565
5576
  description: 'Search PUBLIC Threads posts for a keyword or topic — competitor listening, finding what people say about a product, or sourcing real customer language for ad copy. Distinct from search_threads, which reads a specific profile.',
5566
5577
  inputSchema: {
5578
+ searchMode: z.enum(['KEYWORD', 'TAG']).optional().describe('KEYWORD (default) or TAG — TAG searches a TOPIC TAG (what is being said under #skincare)'),
5579
+ mediaType: z.enum(['TEXT', 'IMAGE', 'VIDEO']).optional().describe('TEXT, IMAGE or VIDEO — only that kind of post'),
5580
+ since: z.string().optional().describe('only posts on/after this date (YYYY-MM-DD, ISO or unix seconds; Threads search reaches back to 2023-07-05)'),
5581
+ until: z.string().optional().describe('only posts on/before this date'),
5582
+ limit: z.number().int().min(1).max(100).optional().describe('how many results (1–100, default 25)'),
5583
+ authorUsername: z.string().optional().describe("only posts by this Threads account (exact username, no @) — 'what has @competitor said about X'"),
5567
5584
  q: z.string().describe('keyword or phrase'),
5568
5585
  searchType: z.enum(['TOP', 'RECENT']).optional().describe('TOP (default) or RECENT'),
5569
5586
  },
5570
5587
  outputSchema: { count: z.number().optional(), posts: z.array(z.any()).optional() },
5571
5588
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
5572
5589
  }, wrap(async (a) => {
5573
- const d = await apiGet('/api/threads/search', { q: a.q, searchType: a.searchType });
5590
+ const d = await apiGet('/api/threads/search', { q: a.q, searchType: a.searchType, ...Object.fromEntries(['searchMode', 'mediaType', 'since', 'until', 'limit', 'authorUsername'].filter(k => a[k] != null && a[k] !== '').map(k => [k, a[k]])) });
5574
5591
  const lines = (d.posts || []).map(p => `• @${p.username}: ${String(p.text || '').replace(/\s+/g, ' ').slice(0, 90)} — ${p.permalink || p.id}`);
5575
5592
  // Same rule as the locations renderer above: a caution that arrives after the results is not a caution. Until
5576
5593
  // Meta approves threads_keyword_search this searches only the connected account's own posts, so presenting the
@@ -5910,6 +5927,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5910
5927
  async: z.boolean().optional().describe('publish in the BACKGROUND and return a job id to poll with get_job, instead of waiting. USE THIS FOR VIDEO: a Facebook or Instagram video publish routinely outlives an agent transport, and a timeout on the synchronous path leaves you unable to tell whether the post is live. With async:true nothing can time out — the job reports the post id and url when it lands.'),
5911
5928
  link: z.string().optional().describe('a URL to attach (FB text post only)'),
5912
5929
  collaborators: z.array(z.string()).optional().describe('INSTAGRAM COLLAB \u2014 up to 3 Instagram usernames invited to CO-AUTHOR this post. Once one accepts, the post appears on THEIR profile too, with both handles in the header and the likes and comments shared. Pass handles only ("hermosoai"), not profile links; a leading @ is fine. INSTAGRAM ONLY \u2014 Facebook and Threads have no collab post at all and are refused BY NAME rather than silently dropping the co-authors \u2014 and never on a Story. AN INVITE IS NOT A CO-POST: publishing SENDS a request the other account must accept in their Instagram notifications, and until they do the post is on this brand\u2019s profile ALONE; they may also decline, and Instagram sends no notification either way. So never report the post as live on both accounts \u2014 read the invite status back out of the reply, and use instagram_collaborators later to find out whether they accepted.'),
5930
+ userTags: z.array(z.union([z.string(), z.object({ username: z.string(), x: z.number().min(0).max(1).optional(), y: z.number().min(0).max(1).optional() })])).optional().describe('INSTAGRAM USER TAGS — tag people (public accounts) on the post; each is notified. Usernames, or { username, x, y } with x/y from 0 to 1 = where the tag sits on the IMAGE (a tag without a position sits at the centre). A Reel tag takes no position; a story position is optional. Not on a carousel (refused by name).'),
5913
5931
  target: z.enum(['facebook', 'instagram', 'threads']).optional().describe('default facebook; instagram -> the Page’s linked IG; threads -> the brand’s connected Threads account'),
5914
5932
  account: z.string().optional().describe('WHICH Instagram account when target is instagram and the brand has several — Page-linked and Instagram Login accounts alike; an @username or id from list_connector_accounts("instagram"). Several and none named is refused by name; omit when there is one.'),
5915
5933
  scheduleAt: z.string().optional().describe('FACEBOOK ONLY — schedule instead of posting now. ISO timestamp (2026-08-01T09:00:00Z) or unix seconds; must be 10 minutes to 30 days ahead. Facebook holds the post and publishes it at that time, so nothing has to stay running on our side. Instagram and Threads have NO scheduling in Meta’s API — passing this for them is refused rather than silently posted immediately.'),
@@ -5945,7 +5963,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5945
5963
  paidPartnership: z.boolean().optional().describe('INSTAGRAM — the PAID PARTNERSHIP label. A COMPLIANCE DECLARATION: set it whenever the post is sponsored, gifted or otherwise paid for. Opt-in and never inferred — it is the poster’s own statement about their commercial relationship.'),
5946
5964
  brandedContentSponsorIds: z.array(z.string()).optional().describe('INSTAGRAM — the numeric Instagram USER IDS of the brands behind that label (at most 2, and ids rather than @handles). Naming sponsors IS asking for the label, so setting these with `paidPartnership:false` is refused instead of publishing brand credits with no disclosure.'),
5947
5965
  trialReel: z.enum(['MANUAL', 'SS_PERFORMANCE']).optional().describe('INSTAGRAM TRIAL REEL \u2014 publish this Reel to NON-FOLLOWERS ONLY at first (Instagram allows trials only on accounts above its follower threshold — about 1,000 followers; an ineligible account is refused by name and nothing is posted), so a hook can be tested on a cold audience without spending it on the people who already follow the brand. Instagram then shows it to followers only if it graduates. MANUAL = the creator graduates it by hand in the Instagram app; SS_PERFORMANCE = Instagram graduates it automatically if it performs well. REELS ONLY and INSTAGRAM ONLY: an image, a carousel, a Facebook post or a Threads post is REFUSED BY NAME rather than quietly published as an ordinary post \u2014 a trial that silently goes to every follower is the exact opposite of what was asked for. Omit it for a normal Reel.'),
5948
- story: z.boolean().optional().describe('INSTAGRAM STORY \u2014 publish this as a 24-hour Story instead of a feed post. One image OR one video: Instagram has no carousel story, so a carousel is REFUSED BY NAME rather than quietly posted to the feed. A Story carries NO CAPTION (there is nowhere to show one), no collaborators, no product tags and no trialReel \u2014 passing any of those is refused by name and nothing is posted, because a story that silently drops the words is worse than one that never went. INSTAGRAM ONLY: a Facebook Page story is a different upload and is not built, so a Facebook channel with `story` set is refused rather than published to the feed.'),
5966
+ story: z.boolean().optional().describe('INSTAGRAM OR FACEBOOK PAGE STORY \u2014 publish this as a 24-hour Story instead of a feed post. One image OR one video: Instagram has no carousel story, so a carousel is REFUSED BY NAME rather than quietly posted to the feed. A Story carries NO CAPTION (there is nowhere to show one), no collaborators, no product tags and no trialReel \u2014 passing any of those is refused by name and nothing is posted, because a story that silently drops the words is worse than one that never went. FACEBOOK PAGE STORIES WORK TOO (target facebook): one image, or one vertical 9:16 video of 3–90 seconds, no caption (a message is refused by name), not schedulable natively — use schedule_post to queue one; the reply reads the story back from the Page.'),
5949
5967
  aiGenerated: z.boolean().optional().describe('INSTAGRAM / FACEBOOK REEL \u2014 Meta\u2019s is_ai_generated self-disclosure. OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, media that came through upload_file or from an external URL (the user\u2019s own photographs or footage) is NOT \u2014 a real photo must never carry Instagram\u2019s \u201cAI info\u201d label. Pass true or false only to override: false strips the label from something Hermoso would otherwise declare, true declares a render the user uploaded themselves.'),
5950
5968
  altText: z.union([z.string(), z.array(z.string())]).optional().describe('ACCESSIBILITY \u2014 the description screen readers announce, and what the platform otherwise auto-generates badly or not at all. Describe what is actually IN the picture, never the caption. ONE STRING describes the picture; on a CAROUSEL it describes EVERY slide. Pass an ARRAY of strings instead to describe each slide separately, aligned to the slide order \u2014 use that on a multi-slide post, because one sentence read out over six different pictures is wrong for five of them. More descriptions than pictures is refused rather than dropped. WHERE IT LANDS, per Meta\u2019s own docs: INSTAGRAM image posts and the IMAGE slides of an Instagram carousel (up to 1000 characters; dropped rather than sent on a Reel or a video slide, which Meta do not support); FACEBOOK Page photos including every photo of an album, via alt_text_custom (Meta publish no length for it, so nothing is truncated); THREADS on a single-image or single-video post ONLY \u2014 Meta document no way to attach alt text to a Threads CAROUSEL slide, so a Threads carousel publishes undescribed and the reply says so rather than risking the whole post on a guess. (Meta\u2019s AI disclosure defaults from PROVENANCE: a Hermoso render is declared is_ai_generated; media that came through upload_file or an external URL, i.e. the user\u2019s own photos or footage, is NOT. Pass `aiGenerated` to force it either way.)'),
5951
5969
  pageId: z.string().optional().describe('target Page id (from list_meta_pages); omit = first Page'),
@@ -5953,6 +5971,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
5953
5971
  // the five worth having on a marketing surface. All Threads-only — Meta ignores them elsewhere.
5954
5972
  linkAttachment: z.string().optional().describe('THREADS TEXT POSTS ONLY — a full http(s) URL rendered as a clickable link card. This is how a Threads post carries a destination at all; without it a link is just text. Meta allows at most 5 links per post and refuses a link attachment on a post that also carries an image or video, so Hermoso refuses that combination up front rather than letting Meta silently drop it.'),
5955
5973
  replyControl: z.enum(['everyone', 'accounts_you_follow', 'mentioned_only', 'parent_post_author_only', 'followers_only']).optional().describe('THREADS ONLY — who may reply. Default is everyone.'),
5974
+ threadsLongText: z.string().optional().describe('THREADS ONLY — LONG-FORM text, up to 10,000 characters, attached under a TEXT-only Threads post (Threads\' text attachment). The way to publish more than Threads\' 500-character post: keep a short lead in the message and put the full text here. Not with an image, video or carousel.'),
5975
+ threadsGhostPost: z.boolean().optional().describe('THREADS ONLY — a GHOST post: text only, Threads archives it automatically after 24 hours (a flash offer, a 24h-only note). No other Threads option can ride it.'),
5976
+ threadsGif: z.string().optional().describe('THREADS ONLY — a GIPHY GIF id (the id in a giphy.com/gifs/… link) attached to a TEXT Threads post. GIPHY is the only provider Threads takes.'),
5956
5977
  quotePostId: z.string().optional().describe('THREADS ONLY — the id of a post to QUOTE. A quote post is a distinct post type, not a formatting option.'),
5957
5978
  topicTag: z.string().optional().describe('THREADS ONLY — one topic tag for discovery, 1–50 characters. A leading # is stripped for you; Threads refuses "." and "&".'),
5958
5979
  countryCodes: z.array(z.string()).optional().describe('THREADS ONLY — restrict the post to these ISO 3166-1 alpha-2 countries. Warning: This is an ALLOWLIST, so the post becomes INVISIBLE in every country not named — it narrows reach, it does not target.'),
@@ -6016,10 +6037,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
6016
6037
  description: z.string().optional().describe('YOUTUBE: the video DESCRIPTION, max 5000 characters, carrying the links, the CTA and what YouTube search reads. Omit it and the caption is used.'),
6017
6038
  tags: z.array(z.string()).optional().describe('YOUTUBE — up to 30 search tags for the video (plain words, no #).'),
6018
6039
  thumbnailUrl: z.string().optional().describe('YOUTUBE: the custom thumbnail, a Hermoso-hosted image (make_thumbnail, or upload_file for the user’s own). Omit it and a frame of the video is used; "auto" keeps YouTube’s pick.'),
6040
+ paidProductPlacement: z.boolean().optional().describe('YOUTUBE: paid product placement (shows the disclosure)'),
6041
+ brandPartner: z.string().optional().describe('YOUTUBE: sponsoring brand channel (UC… or @handle)'),
6019
6042
  // THREADS' OWN POST OPTIONS — schedulable since 2026-08-17. `threadsPublish` has accepted all five since the
6020
6043
  // connector-breadth sweep and `threadsPublishExtras` validates them; only the QUEUE could not carry them, so
6021
6044
  // they worked when you posted now and vanished when you scheduled.
6022
6045
  replyControl: z.enum(['everyone', 'accounts_you_follow', 'mentioned_only', 'parent_post_author_only', 'followers_only']).optional().describe('THREADS ONLY — who may reply. Omit for Threads\' own default (everyone).'),
6046
+ threadsLongText: z.string().optional().describe('THREADS ONLY — LONG-FORM text, up to 10,000 characters, attached under a TEXT-only Threads post (Threads\' text attachment). The way to publish more than Threads\' 500-character post: keep a short lead in the message and put the full text here. Not with an image, video or carousel.'),
6047
+ threadsGhostPost: z.boolean().optional().describe('THREADS ONLY — a GHOST post: text only, Threads archives it automatically after 24 hours (a flash offer, a 24h-only note). No other Threads option can ride it.'),
6048
+ threadsGif: z.string().optional().describe('THREADS ONLY — a GIPHY GIF id (the id in a giphy.com/gifs/… link) attached to a TEXT Threads post. GIPHY is the only provider Threads takes.'),
6023
6049
  quotePostId: z.string().optional().describe('THREADS ONLY — the id of the Threads post this one quotes.'),
6024
6050
  linkAttachment: z.string().optional().describe('THREADS ONLY — a full http(s) URL rendered as a link card. This is the ONLY way a Threads post carries a destination, and Threads attaches it to TEXT-ONLY posts (a post with media cannot also carry a card).'),
6025
6051
  topicTag: z.string().optional().describe('THREADS ONLY — one topic tag for the post, without the leading #.'),
@@ -6064,6 +6090,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
6064
6090
  // and the image is the cover, so the only new fields are the two the Article itself adds.
6065
6091
  xArticle: z.object({ title: z.string(), headings: z.enum(['blocks', 'text']).optional() }).optional().describe('X: publish the X item as a long-form X ARTICLE. title is its headline, the X text (message or captions.x) its markdown body, the image its cover. Refused now if the markdown has formatting X cannot hold. X allows about 5 Articles a day; one that fires into that cap fails with the reset time and can be retried.'),
6066
6092
  collaborators: z.array(z.string()).optional().describe('INSTAGRAM \u2014 a COLLAB post: up to 3 Instagram usernames invited to CO-AUTHOR it, so it appears on their profile too once they accept, with both handles in the header and the engagement shared. Handles only ("hermosoai"); a leading @ is fine. Instagram must be one of the `channels` \u2014 asking for collaborators on a schedule Instagram is not on is REFUSED now rather than discovered when it fires, and the other channels in a mixed schedule simply publish without co-authors. THE INVITE IS SENT WHEN THE POST FIRES, not when you schedule it, and it is PENDING until the other account accepts in their notifications; check with instagram_collaborators afterwards rather than telling the user it is live on both profiles.'),
6093
+ userTags: z.array(z.union([z.string(), z.object({ username: z.string(), x: z.number().min(0).max(1).optional(), y: z.number().min(0).max(1).optional() })])).optional().describe('INSTAGRAM USER TAGS — tag people (public accounts) on the post; each is notified. Usernames, or { username, x, y } with x/y from 0 to 1 = where the tag sits on the IMAGE (a tag without a position sits at the centre). A Reel tag takes no position; a story position is optional. Not on a carousel (refused by name).'),
6067
6094
  // ── THE FACEBOOK PAGE FEED, SAME 2026-09-16 AUDIT ──────────────────────────────────────────────────────────
6068
6095
  // Meta's /{page-id}/feed table lists ~24 parameters and we sent four. `call_to_action` was the instructive
6069
6096
  // one: its value builder already existed in this codebase, inside the ADS creative path, and the organic
@@ -6097,7 +6124,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
6097
6124
  instagramLocationId: z.string().optional().describe('INSTAGRAM — tag a place (called locationId on post_to_meta; locationId here is the Google Business listing). It is the NUMERIC ID OF A FACEBOOK PAGE associated with that location, not a place name and not coordinates; a non-numeric value is refused rather than sent.'),
6098
6125
  brandedContentSponsorIds: z.array(z.string()).optional().describe('INSTAGRAM — the numeric Instagram USER IDS of the brands behind that label (at most 2, and ids rather than @handles). Naming sponsors IS asking for the label, so setting these with `paidPartnership:false` is refused instead of publishing brand credits with no disclosure.'),
6099
6126
  trialReel: z.enum(['MANUAL', 'SS_PERFORMANCE']).optional().describe('INSTAGRAM TRIAL REEL \u2014 publish this Reel to NON-FOLLOWERS ONLY at first (Instagram allows trials only on accounts above its follower threshold — about 1,000 followers; an ineligible account is refused by name and nothing is posted), so a hook can be tested on a cold audience without spending it on the people who already follow the brand; Instagram shows it to followers only if it graduates. MANUAL = the creator graduates it by hand in the Instagram app; SS_PERFORMANCE = Instagram graduates it automatically if it performs. REELS ONLY and INSTAGRAM ONLY: an image, a carousel, or a Facebook/Threads channel is REFUSED BY NAME rather than quietly published as an ordinary post \u2014 a trial that silently goes to every follower is the exact opposite of what was asked for, so Instagram must be one of the `channels` and the item must carry a video. Omit it for a normal Reel.'),
6100
- story: z.boolean().optional().describe('INSTAGRAM STORY \u2014 publish this as a 24-hour Story instead of a feed post. One image OR one video: Instagram has no carousel story, so a carousel is REFUSED BY NAME rather than quietly posted to the feed. A Story carries NO CAPTION (there is nowhere to show one), no collaborators, no product tags and no trialReel \u2014 passing any of those is refused by name and nothing is posted, because a story that silently drops the words is worse than one that never went. INSTAGRAM ONLY: a Facebook Page story is a different upload and is not built, so a Facebook channel with `story` set is refused rather than published to the feed.'),
6127
+ story: z.boolean().optional().describe('INSTAGRAM OR FACEBOOK PAGE STORY \u2014 publish this as a 24-hour Story instead of a feed post. One image OR one video: Instagram has no carousel story, so a carousel is REFUSED BY NAME rather than quietly posted to the feed. A Story carries NO CAPTION (there is nowhere to show one), no collaborators, no product tags and no trialReel \u2014 passing any of those is refused by name and nothing is posted, because a story that silently drops the words is worse than one that never went. FACEBOOK PAGE STORIES WORK TOO (target facebook): one image, or one vertical 9:16 video of 3–90 seconds, no caption (a message is refused by name), not schedulable natively — use schedule_post to queue one; the reply reads the story back from the Page.'),
6101
6128
  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 Hermoso decides from provenance: 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.'),
6102
6129
  // ── WHICH ACCOUNT (server-side SCHED_ID_FIELDS). Every one of these is an answer the publish helper REFUSES
6103
6130
  // to guess, so a schedule that cannot carry it can only fail at fire time with nobody watching.
@@ -6211,6 +6238,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
6211
6238
  // The same six the CREATE path gained — a field you can set at create and not at edit is a one-way door,
6212
6239
  // and reschedule_post is how an agent corrects a queued post it got wrong.
6213
6240
  replyControl: z.enum(['everyone', 'accounts_you_follow', 'mentioned_only', 'parent_post_author_only', 'followers_only']).optional().describe('THREADS ONLY — who may reply.'),
6241
+ threadsLongText: z.string().optional().describe('THREADS ONLY — LONG-FORM text, up to 10,000 characters, attached under a TEXT-only Threads post (Threads\' text attachment). The way to publish more than Threads\' 500-character post: keep a short lead in the message and put the full text here. Not with an image, video or carousel.'),
6242
+ threadsGhostPost: z.boolean().optional().describe('THREADS ONLY — a GHOST post: text only, Threads archives it automatically after 24 hours (a flash offer, a 24h-only note). No other Threads option can ride it.'),
6243
+ threadsGif: z.string().optional().describe('THREADS ONLY — a GIPHY GIF id (the id in a giphy.com/gifs/… link) attached to a TEXT Threads post. GIPHY is the only provider Threads takes.'),
6214
6244
  quotePostId: z.string().optional().describe('THREADS ONLY — the id of the Threads post this one quotes.'),
6215
6245
  linkAttachment: z.string().optional().describe('THREADS ONLY — a full http(s) URL rendered as a link card on a TEXT-ONLY post. The only way a Threads post carries a destination.'),
6216
6246
  topicTag: z.string().optional().describe('THREADS ONLY — one topic tag, without the leading #.'),
@@ -6246,6 +6276,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
6246
6276
  paidPartnership: z.boolean().optional().describe('INSTAGRAM AND X — the paid-partnership label; false turns it off.'),
6247
6277
  xArticle: z.object({ title: z.string().optional(), headings: z.enum(['blocks', 'text']).optional() }).optional().describe('X: replaces the X Article (title, headings); {} makes it an ordinary X post again.'),
6248
6278
  collaborators: z.array(z.string()).optional().describe('INSTAGRAM \u2014 replaces the WHOLE collab list (up to 3 usernames); an explicit [] removes the co-authors and the post goes out as an ordinary single-author post. Only takes effect if the post has not fired yet \u2014 an invite already sent cannot be withdrawn from here.'),
6279
+ userTags: z.array(z.union([z.string(), z.object({ username: z.string(), x: z.number().min(0).max(1).optional(), y: z.number().min(0).max(1).optional() })])).optional().describe('INSTAGRAM USER TAGS — tag people (public accounts) on the post; each is notified. Usernames, or { username, x, y } with x/y from 0 to 1 = where the tag sits on the IMAGE (a tag without a position sits at the centre). A Reel tag takes no position; a story position is optional. Not on a carousel (refused by name).'),
6249
6280
  trialReel: z.enum(['MANUAL', 'SS_PERFORMANCE', '']).optional().describe('INSTAGRAM \u2014 replaces the trial-reel setting on a queued Reel (MANUAL or SS_PERFORMANCE); an explicit "" turns the trial off and it goes out as an ordinary Reel. Only takes effect while the post is still queued \u2014 a Reel already published cannot be converted into a trial.'),
6250
6281
  story: z.boolean().optional().describe('INSTAGRAM — true makes it a 24-hour Story, false an ordinary feed post. One image or one video, no carousel.'),
6251
6282
  coverUrl: z.string().optional().describe('INSTAGRAM REEL — replaces the cover image url; an empty string removes it.'),
@@ -6485,12 +6516,25 @@ function buildTools(rawServer, opts = {}, sink = null) {
6485
6516
  }));
6486
6517
  server.registerTool('post_to_linkedin', {
6487
6518
  title: 'Publish to LinkedIn',
6488
- description: 'Use this when the user wants to post to their personal LinkedIn profile. Publishing costs no credits. Publish a post to the user’s connected LinkedIn profile — text, and optionally an image (pass its served URL as imageUrl). The image does NOT have to be something Hermoso generated: LinkedIn is served the bytes from us, so the URL must be Hermoso-hosted, and upload_file turns ANY file the user already has into exactly that. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Needs a connected LinkedIn account (Settings > Connectors > LinkedIn).',
6519
+ description: 'Use this when the user wants to post to their personal LinkedIn profile. Publishing costs no credits. Publish a post to the user’s connected LinkedIn profile — text, and optionally an image (imageUrl), a VIDEO (videoUrl, with captionsSrt and a videoThumbnailUrl cover), a 2–20 image MULTI-IMAGE post (imageUrls), a LINK post with a real preview card (linkUrl + linkTitle / linkDescription / linkThumbnailUrl — LinkedIn never scrapes the page), a DOCUMENT post (documentUrl: a PDF / PPTX readers swipe through), and @mentions of companies (mentions). The image does NOT have to be something Hermoso generated: LinkedIn is served the bytes from us, so the URL must be Hermoso-hosted, and upload_file turns ANY file the user already has into exactly that. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Needs a connected LinkedIn account (Settings > Connectors > LinkedIn).',
6489
6520
  inputSchema: {
6490
6521
  ...HOOK_ATTR,
6491
6522
  text: z.string().describe('the post text'),
6492
6523
  imageUrl: z.string().optional().describe('a Hermoso-hosted image URL to attach (≤12MB) — a Hermoso render, or ANY file of the user’s own put through upload_file first. An arbitrary external host is refused (we fetch the bytes ourselves).'),
6493
- imageUrls: z.array(z.string()).optional().describe('A CAROUSEL IS NOT AVAILABLE ON A PERSONAL PROFILE — LinkedIn\'s organic multi-image post publishes from a COMPANY PAGE. Passing several here is refused by name rather than posting slide 1; use post_to_linkedin_page instead.'),
6524
+ imageUrls: z.array(z.string()).optional().describe('a MULTI-IMAGE post: 2–20 Hermoso-hosted images in order, one post the reader swipes through (LinkedIn\u2019s MultiImage API takes a member author on w_member_social). Images only.'),
6525
+ videoUrl: z.string().optional().describe('a Hermoso-hosted video (a render, or the user\u2019s footage via upload_file). LinkedIn processes it before publishing, which takes a minute.'),
6526
+ captionsSrt: z.string().optional().describe('SRT caption CONTENT for a videoUrl post (English only, attached during upload)'),
6527
+ videoThumbnailUrl: z.string().optional().describe('cover image for a videoUrl post (Hermoso-hosted)'),
6528
+ title: z.string().optional().describe('video title'),
6529
+ altText: z.union([z.string(), z.array(z.string())]).optional().describe('image alt text — a string for every image, or an array per slide'),
6530
+ linkUrl: z.string().optional().describe('a LINK post with a real preview card. Not with media.'),
6531
+ linkTitle: z.string().optional().describe('the card headline — LinkedIn never scrapes the page, so without it the card is unlabelled'),
6532
+ linkDescription: z.string().optional(),
6533
+ linkThumbnailUrl: z.string().optional().describe('Hermoso-hosted image for the card'),
6534
+ documentUrl: z.string().optional().describe('a DOCUMENT post — a Hermoso-hosted PDF / PPTX / DOCX (upload_file first) that readers swipe through page by page in the feed; LinkedIn takes up to 100MB and 300 pages. One kind of media per post: not with an image, video, slides or linkUrl.'),
6535
+ documentTitle: z.string().optional().describe('the title shown on the document (defaults to the file name)'),
6536
+ mentions: z.array(z.any()).optional().describe('TAG COMPANIES: write "@Name" in the text where each tag goes and list each company here — its vanity name (the part after linkedin.com/company/), its company page URL, or {organizationId, name}. Hermoso resolves it on LinkedIn and LinkedIn renders a real clickable mention; a company with no "@" spot in the text is refused rather than appended.'),
6537
+
6494
6538
  idempotencyKey: z.string().optional().describe('SAFE RETRIES — any stable string: a repeat of the SAME publish within 24h returns the ORIGINAL post id instead of posting again (an identical publish is also auto-recognised for 10 minutes). On a timeout or an ambiguous error, CALL AGAIN WITH THE SAME KEY: it reports the original post or publishes it once. It never posts twice.'),
6495
6539
  allowDuplicate: z.boolean().optional().describe('post it even though an identical post was just made or attempted. Only pass this when the user genuinely wants the same thing posted twice, or when you have LOOKED at the account and confirmed a timed-out attempt did not land.'),
6496
6540
  visibility: z.enum(['PUBLIC', 'CONNECTIONS']).optional().describe('default PUBLIC'),
@@ -6529,6 +6573,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
6529
6573
  quotePostId: z.string().optional().describe('numeric id of a post to QUOTE \u2014 X renders the quoted post inside yours. This is NOT replyToId: a reply sits under the original in its thread, a quote stands alone on your own timeline with the original embedded, which is the one you want for commentary. X makes a quote mutually exclusive with media and with a poll (their own schema), and a quote is billed at the higher LINK rate because X appends the quoted post\u2019s t.co URL whatever your text says. Same X rule as replyToId: a quote of a post that does not mention this account is refused by X.'),
6530
6574
  communityId: z.string().optional().describe('publish into an X COMMUNITY instead of the main timeline \u2014 the number in the community\u2019s own URL (x.com/i/communities/<id>). The connected account must be a MEMBER of it; X answers a non-member and a non-existent id with the same refusal and does not separate them.'),
6531
6575
  paidPartnership: z.boolean().optional().describe('label the post a PAID PARTNERSHIP on X \u2014 the same disclosure Hermoso already ships for TikTok. OPT-IN ONLY: set it when the post is sponsored, gifted or otherwise paid for, and never assume it on the user\u2019s behalf.'),
6576
+ videoTitle: z.string().optional().describe('VIDEO posts only \u2014 a headline under the video, max 70 (measured: X drops it on an image post, so it is refused there)'),
6577
+ videoDescription: z.string().optional().describe('VIDEO posts only \u2014 a line under the title, max 200'),
6578
+ videoCta: z.enum(['visit_site', 'watch_now']).optional().describe('VIDEO posts only \u2014 a button on the video; needs videoCtaUrl (https)'),
6579
+ videoCtaUrl: z.string().optional(),
6580
+ taggedUserIds: z.array(z.string()).optional().describe('tag up to 10 people (numeric X user ids, from x_user) in the post\u2019s media'),
6581
+ dmDeepLink: z.union([z.string(), z.boolean()]).optional().describe('a "Message us" button: true for the connected account, or https://x.com/messages/compose?recipient_id=<id>. TEXT posts only \u2014 X refuses it alongside media.'),
6532
6582
  platformCover: z.boolean().optional().describe('VIDEO COVER. X has no cover setting and shows the video\u2019s FIRST frame, so by default Hermoso checks it and, only when that frame is blank (a template ad\u2019s empty opening card), sends X a copy with the first frame replaced by the video\u2019s best frame. Your Library file is never changed. true = send the file exactly as it is.'),
6533
6583
  },
6534
6584
  outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), url: z.string().optional(), thread: z.boolean().optional(), media: z.boolean().optional(), altText: z.boolean().optional(), poll: z.boolean().optional(), costCredits: z.number().optional(), posts: z.array(z.object({ id: z.string().optional(), text: z.string().optional(), url: z.string().optional() })).optional(), quotedPostId: z.string().optional(), communityId: z.string().optional(), paidPartnership: z.boolean().optional() },
@@ -6814,6 +6864,55 @@ function buildTools(rawServer, opts = {}, sink = null) {
6814
6864
  const d = await apiGet('/api/pinterest/trends', { kind: a.kind, region: a.region, trendType: a.trendType, interests: j(a.interests), interest: a.interest, ages: j(a.ages), genders: j(a.genders), includeKeywords: j(a.includeKeywords), limit: a.limit, includeDemographics: a.includeDemographics, verticals: j(a.verticals), engagementType: a.engagementType, productCategories: j(a.productCategories), lookbackWindow: a.lookbackWindow });
6815
6865
  return ok(`${d.note}\n${JSON.stringify(d.trends || d.topics || d.data || []).slice(0, 5000)}`, d);
6816
6866
  }));
6867
+ // PINTEREST CATALOGS (2026-10-27) — the organic connector's catalogs:read / catalogs:write. What a Shopping
6868
+ // (CATALOG_SALES) campaign serves from: feeds, items, product groups. Rules: lib/pinterest-phase1.mjs.
6869
+ const pinBizAcct = z.string().optional().describe('optional — act as this ad account’s owner via Pinterest Business Access (must be shared with this profile)');
6870
+ server.registerTool('list_pinterest_catalogs', {
6871
+ title: 'Read the Pinterest product catalog',
6872
+ description: 'Read the Pinterest product CATALOG side of the connected account — what a Shopping (CATALOG_SALES) campaign serves from. what: catalogs (default), feeds, feed (feedId), processing_results (feedId — did Pinterest ingest the feed, product counts, errors), item_issues (processingResultId — per-product problems), product_groups (optionally feedId / catalogId), product_counts or products (productGroupId), items_batch (batchId). Pinterest catalogs need a BUSINESS account with a claimed website. Read-only, 0 credits.',
6873
+ inputSchema: { what: z.enum(['catalogs', 'feeds', 'feed', 'processing_results', 'item_issues', 'product_groups', 'product_counts', 'products', 'items_batch']).optional(), catalogId: z.string().optional(), feedId: z.string().optional(), processingResultId: z.string().optional(), productGroupId: z.string().optional(), batchId: z.string().optional(), adAccountId: pinBizAcct },
6874
+ outputSchema: { ok: z.boolean().optional(), what: z.string().optional(), count: z.number().nullable().optional(), catalogs: z.array(z.any()).optional(), feeds: z.array(z.any()).optional(), results: z.array(z.any()).optional(), issues: z.array(z.any()).optional(), productGroups: z.array(z.any()).optional(), data: z.any().optional(), batch: z.any().optional(), note: z.string().optional() },
6875
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
6876
+ }, wrap(async (a) => {
6877
+ const d = await apiGet('/api/pinterest/catalogs', a);
6878
+ return ok(`${d.note}\n${JSON.stringify(d.catalogs || d.feeds || d.results || d.issues || d.productGroups || d.data || d.batch || []).slice(0, 5000)}`, d);
6879
+ }));
6880
+ server.registerTool('create_pinterest_catalog', {
6881
+ title: 'Create a Pinterest catalog',
6882
+ description: 'Create a Pinterest product CATALOG (RETAIL for products, or HOTEL) — the container feeds and items fill. Read back from Pinterest. Most merchants can skip it: a feed created without catalogId lands in a default retail catalog. 0 credits.',
6883
+ inputSchema: { name: z.string(), catalogType: z.enum(['RETAIL', 'HOTEL']).optional(), adAccountId: pinBizAcct },
6884
+ outputSchema: { ok: z.boolean().optional(), verified: z.boolean().optional(), catalog: z.any().optional(), note: z.string().optional() },
6885
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
6886
+ }, wrap(async (a) => { const d = await apiPost('/api/pinterest/catalog', a); return ok(d.note, d); }));
6887
+ server.registerTool('manage_pinterest_catalog_feed', {
6888
+ title: 'Create, edit or delete a Pinterest product feed',
6889
+ description: 'Create, edit or delete a Pinterest PRODUCT FEED — the CSV / TSV / XML file of your products Pinterest downloads on a schedule to fill the catalog Shopping ads serve from. action:"create" needs name, location (the feed URL), defaultCountry and defaultLocale (Pinterest refuses a feed without them), optionally format, defaultCurrency, defaultAvailability, catalogId, schedule {time HH:MM, timezone}, credentials. action:"update" changes those on feedId. action:"delete" removes feedId and needs confirm:true — without it nothing changes and the reply names what would go. Read back from Pinterest every time. 0 credits.',
6890
+ inputSchema: {
6891
+ action: z.enum(['create', 'update', 'delete']).optional().describe('default create'), feedId: z.string().optional(), name: z.string().optional(),
6892
+ location: z.string().optional().describe('the URL Pinterest downloads the feed from'), format: z.enum(['CSV', 'TSV', 'XML', 'INTEGRATION']).optional(),
6893
+ defaultCountry: z.string().optional().describe('two-letter, e.g. US — required on create'), defaultLocale: z.string().optional().describe('e.g. en-US — required on create'),
6894
+ defaultCurrency: z.string().optional(), defaultAvailability: z.enum(['IN_STOCK', 'OUT_OF_STOCK', 'PREORDER']).optional(), catalogId: z.string().optional(),
6895
+ status: z.enum(['ACTIVE', 'INACTIVE']).optional(), schedule: z.object({ time: z.string(), timezone: z.string() }).optional(),
6896
+ credentials: z.object({ username: z.string(), password: z.string() }).optional().describe('only if the feed URL needs a login'),
6897
+ confirm: z.boolean().optional().describe('required true for delete'), adAccountId: pinBizAcct,
6898
+ },
6899
+ outputSchema: { ok: z.boolean().optional(), action: z.string().optional(), verified: z.boolean().optional(), deleted: z.boolean().optional(), feed: z.any().optional(), note: z.string().optional() },
6900
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
6901
+ }, wrap(async (a) => { const d = await apiPost('/api/pinterest/catalog-feed', a); return ok(d.note, d); }));
6902
+ server.registerTool('update_pinterest_catalog_items', {
6903
+ title: 'Add, change or remove Pinterest catalog products',
6904
+ description: 'Add, change or remove PRODUCTS in a Pinterest catalog directly — no feed file needed. items: up to 1,000 of {itemId (your SKU), operation UPSERT (default) / CREATE / UPDATE / DELETE, attributes {title, description, link, image_link, price e.g. "19.99 USD", availability e.g. "in stock", brand, sale_price, …}}. A create or upsert must carry title, link, image_link and price (refused here otherwise). Pinterest processes the batch asynchronously; the batch is read back and its status and every per-item error reported. 0 credits.',
6905
+ inputSchema: { items: z.array(z.object({ itemId: z.string(), operation: z.enum(['UPSERT', 'CREATE', 'UPDATE', 'DELETE']).optional(), attributes: z.record(z.any()).optional(), updateMask: z.array(z.string()).optional() })), country: z.string().optional().describe('default US'), language: z.string().optional().describe('default en-US'), catalogId: z.string().optional(), adAccountId: pinBizAcct },
6906
+ outputSchema: { ok: z.boolean().optional(), batchId: z.string().optional(), status: z.string().optional(), sent: z.number().optional(), failed: z.array(z.any()).optional(), note: z.string().optional() },
6907
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
6908
+ }, wrap(async (a) => { const d = await apiPost('/api/pinterest/catalog-items', a); return ok(d.note, d); }));
6909
+ server.registerTool('manage_pinterest_product_group', {
6910
+ title: 'Create, edit or delete a Pinterest product group',
6911
+ description: 'Create, edit or delete a Pinterest PRODUCT GROUP — the slice of a catalog a Shopping ad group promotes (one brand, a price band, a custom label). action:"create" needs name, feedId and filters; "update" changes name / description / filters on productGroupId; "delete" needs confirm:true. Read back from Pinterest. 0 credits.',
6912
+ inputSchema: { action: z.enum(['create', 'update', 'delete']).optional().describe('default create'), productGroupId: z.string().optional(), name: z.string().optional(), description: z.string().optional(), feedId: z.string().optional(), filters: z.record(z.any()).optional().describe('Pinterest product-group filters: {"all_of":[{"BRAND":{"values":["Acme"]}},{"MIN_PRICE":{"values":10}}]} or {"any_of":[…]}. Keys: AVAILABILITY, BRAND, CONDITION, CURRENCY, CUSTOM_LABEL_0-4, CUSTOM_NUMBER_0-4, GENDER, GOOGLE_PRODUCT_CATEGORY_0-6, ITEM_GROUP_ID, ITEM_ID, MAX_PRICE, MEDIA_TYPE, MIN_PRICE, PINTEREST_PRODUCT_CATEGORIES, PRODUCT_GROUP, PRODUCT_TYPE_0-4, TITLE_KEYWORDS — an unknown key is refused by name'), isFeatured: z.boolean().optional(), confirm: z.boolean().optional(), adAccountId: pinBizAcct },
6913
+ outputSchema: { ok: z.boolean().optional(), action: z.string().optional(), verified: z.boolean().optional(), deleted: z.boolean().optional(), productGroup: z.any().optional(), note: z.string().optional() },
6914
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
6915
+ }, wrap(async (a) => { const d = await apiPost('/api/pinterest/product-group', a); return ok(d.note, d); }));
6817
6916
  server.registerTool('create_pinterest_board', {
6818
6917
  title: 'Create a Pinterest board',
6819
6918
  description: "Create a board on the connected Pinterest account. Needed because a Pin cannot exist without a board, and a NEW Pinterest business account has none — if list_pinterest_boards comes back empty, make one here rather than telling the user you can't pin. Boards are PUBLIC unless you pass privacy 'SECRET'; a Pin on a secret board is invisible to everyone, so only choose that if the user asked for it.",
@@ -7224,6 +7323,19 @@ function buildTools(rawServer, opts = {}, sink = null) {
7224
7323
  return ok(`“${d.location}” (${d.locationId}) sits on Business Profile account ${d.id}${d.accountName ? ` — “${d.accountName}”` : ''}. Type ${d.type || 'unknown'}; the connected Google account’s role on it is ${d.role || 'unknown'} (${d.permissionLevel || 'permission level unknown'}); account verification ${d.verificationState || 'unknown'}.${/OWNER|MANAGER/.test(String(d.role || '')) ? ' That role can edit the listing.' : ' If that role is not an owner or manager, Google will refuse edits whatever we send.'}`, d);
7225
7324
  }));
7226
7325
  server.group('channels');
7326
+ // YOUTUBE METADATA PARITY (2026-10-27, gap sweep G13/G16) — the zod half of lib/youtube-metadata.mjs's
7327
+ // YT_EXTRA_KEYS; post_to_youtube and update_youtube_video take the SAME set. The server validates every one.
7328
+ const YT_EXTRA = {
7329
+ paidProductPlacement: z.boolean().optional().describe('true = contains PAID product placement (sponsored) — YouTube shows the disclosure; legally required for sponsored content'),
7330
+ brandPartner: z.string().optional().describe("sponsoring brand's channel id (UC…) or @handle — gives that brand access to run the video as a partnership ad; \"\" unlinks (update only)"),
7331
+ defaultLanguage: z.string().optional().describe('language of title/description, e.g. "en"'),
7332
+ defaultAudioLanguage: z.string().optional().describe('spoken language, e.g. "es"'),
7333
+ localizations: z.record(z.string(), z.object({ title: z.string().optional(), description: z.string().optional() })).optional().describe('translated title/description per language, e.g. {"es":{"title":"…"}}; needs defaultLanguage'),
7334
+ license: z.enum(['youtube', 'creativeCommon']).optional(),
7335
+ embeddable: z.boolean().optional(),
7336
+ publicStatsViewable: z.boolean().optional(),
7337
+ recordingDate: z.string().optional().describe('YYYY-MM-DD'),
7338
+ };
7227
7339
  server.registerTool('post_to_youtube', {
7228
7340
  title: 'Post a video to YouTube',
7229
7341
  description: 'Use this when the user wants to upload a video or Short to YouTube. Publishing costs no credits. Publish a finished video to the brand’s connected YouTube channel. Pass a Hermoso render URL (or an upload_file url for a local/external file). PUBLISHES PUBLICLY BY DEFAULT: a plain "post this to YouTube" puts it ON the channel (confirm the title with the user, as for any publish) and notifies subscribers as YouTube does. Pass the privacy the user states instead: "unlisted" (link-only) or "private" (eyes-only). A video meant to run as a YouTube/Google AD should go up "unlisted" — private videos CANNOT be used as ads. SCHEDULE it with publishAt, FILE it under the right categoryId (the default 22 "People & Blogs" is wrong for most ads), SUBSCRIBER NOTIFICATIONS FOLLOW PRIVACY — a public publish announces the video to the channel’s subscribers (YouTube’s own default), while unlisted/private uploads stay quiet; pass notifySubscribers explicitly to override either way. THUMBNAIL: pass thumbnailUrl, or a frame of the video is set for free; a thumbnail YouTube refuses never fails the upload, and thumbnailNote says why. YOUTUBE MUSIC: YouTube’s API takes no music track. Only when the user wants a track from YouTube’s own Audio Library: BEFORE uploading, tell them plainly it will go up UNLISTED (not on their channel, nobody sees it) so they can add the track in YouTube Studio on desktop (Content > the video > Editor > Audio), and that THEY must then switch it to Public there themselves; get their yes, then pass privacy:"unlisted". Never choose unlisted for music on your own. Needs a connected YouTube channel (Settings > Connectors > YouTube).',
@@ -7242,6 +7354,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
7242
7354
  aiGenerated: z.boolean().optional().describe('YouTube\u2019s \u201caltered or synthetic content\u201d declaration (containsSyntheticMedia). OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, a video the user uploaded through upload_file or from an external URL is NOT \u2014 real footage must not carry the label. true/false overrides.'),
7243
7355
  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 (YouTube custom thumbnail; the same as thumbnailUrl:"auto" when true). true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins.'),
7244
7356
  coverAtMs: z.number().optional().describe('THE VIDEO COVER (the custom thumbnail), as ONE frame: milliseconds from the start (7000 = the frame at 7s). That frame is cut from the upload and set with thumbnails.set. thumbnailUrl wins; this beats platformCover.'),
7357
+ ...YT_EXTRA,
7245
7358
  },
7246
7359
  outputSchema: { ok: z.boolean().optional(), videoId: z.string().optional(), url: z.string().optional(), privacy: z.string().optional(), requestedPrivacy: z.string().optional(), categoryId: z.string().optional(), categoryName: z.string().optional(), publishAt: z.string().optional(), scheduled: z.boolean().optional(), notifySubscribers: z.boolean().optional(), notifyNote: z.string().optional(), warning: z.string().optional(), scheduleWarning: z.string().optional(), thumbnailSet: z.boolean().optional(), thumbnailSource: z.string().nullable().optional(), thumbnailReadBack: z.string().nullable().optional(), thumbnailNote: z.string().optional(), title: z.string().optional() },
7247
7360
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
@@ -7252,7 +7365,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
7252
7365
  // Printing the request would be a flat lie.
7253
7366
  const bits = [d.privacy, d.categoryName ? `category ${d.categoryName}` : (d.categoryId ? `category ${d.categoryId}` : null), d.publishAt ? `scheduled for ${d.publishAt}` : null].filter(Boolean);
7254
7367
  // The thumbnail line is YouTube's read-back or the plain reason it did not land; the upload stands either way.
7255
- return ok(`Posted to YouTube (${bits.join(', ')})${d.url ? ` — ${d.url}` : ''}.${d.thumbnailNote ? ` ${d.thumbnailNote}` : ''}${d.warning ? `\n\n${d.warning}` : ''}${d.scheduleWarning ? `\n\n${d.scheduleWarning}` : ''}`, d);
7368
+ return ok(`Posted to YouTube (${bits.join(', ')})${d.url ? ` — ${d.url}` : ''}.${d.thumbnailNote ? ` ${d.thumbnailNote}` : ''}${d.metadata ? ` Read back: ${Object.entries(d.metadata).map(([k, v]) => `${k}=${Array.isArray(v) ? v.join('/') : v}`).join(', ')}.` : ''}${d.warning ? `\n\n${d.warning}` : ''}${d.scheduleWarning ? `\n\n${d.scheduleWarning}` : ''}${d.metadataWarning ? `\n\n${d.metadataWarning}` : ''}${d.metadataPendingNote ? `\n\n${d.metadataPendingNote}` : ''}`, d);
7256
7369
  }));
7257
7370
  server.group('channel_admin');
7258
7371
  server.registerTool('youtube_channel', {
@@ -7288,19 +7401,21 @@ function buildTools(rawServer, opts = {}, sink = null) {
7288
7401
  title: 'Performance of one of your YouTube videos',
7289
7402
  description: 'Per-VIDEO performance for a video on the connected channel — views, estimated minutes watched, average view duration, average view PERCENTAGE (the retention number that tells you whether the hook held), likes, comments, shares and subscribers gained. Use it for "how did that video do", "which upload performed best", or to judge an ad before spending more behind it. youtube_channel only returns channel-wide totals and cannot answer this. Defaults to the last 28 days; pass startDate/endDate (YYYY-MM-DD) for another window. Read-only, 0 credits. Needs a connected YouTube channel.',
7290
7403
  inputSchema: { ...MANAGE_BRAND, videoId: z.string().describe('the YouTube video id (the v= part of the watch URL, or the videoId returned by post_to_youtube)'), startDate: z.string().optional().describe('YYYY-MM-DD, default 28 days ago'), endDate: z.string().optional().describe('YYYY-MM-DD, default today') },
7291
- outputSchema: { videoId: z.string().optional(), startDate: z.string().optional(), endDate: z.string().optional(), views: z.number().optional(), estimatedMinutesWatched: z.number().optional(), averageViewDuration: z.number().optional(), averageViewPercentage: z.number().optional(), likes: z.number().optional(), comments: z.number().optional(), shares: z.number().optional(), subscribersGained: z.number().optional(), url: z.string().optional() },
7404
+ outputSchema: { videoId: z.string().optional(), startDate: z.string().optional(), endDate: z.string().optional(), views: z.number().optional(), engagedViews: z.number().optional(), estimatedMinutesWatched: z.number().optional(), averageViewDuration: z.number().optional(), averageViewPercentage: z.number().optional(), likes: z.number().optional(), comments: z.number().optional(), shares: z.number().optional(), subscribersGained: z.number().optional(), url: z.string().optional() },
7292
7405
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
7293
7406
  }, wrap(async (a) => {
7294
7407
  const d = await apiGet('/api/youtube/video-insights', queryBrand(a, { videoId: a.videoId, ...(a.startDate ? { startDate: a.startDate } : {}), ...(a.endDate ? { endDate: a.endDate } : {}) }));
7295
- return ok(`${d.views ?? 0} views, ${d.averageViewPercentage ?? 0}% average retention, ${d.estimatedMinutesWatched ?? 0} minutes watched (${d.startDate} → ${d.endDate}).`, d);
7408
+ return ok(`${d.views ?? 0} views (${d.engagedViews ?? 0} engaged — past the first frame), ${d.averageViewPercentage ?? 0}% average retention, ${d.estimatedMinutesWatched ?? 0} minutes watched (${d.startDate} → ${d.endDate}).`, d);
7296
7409
  }));
7297
7410
  // DIMENSIONED YOUTUBE ANALYTICS (2026-08-04). `yt-analytics.readonly` is already granted by every connected
7298
7411
  // channel; we were using one flat call out of a 44-report surface. No new scope, no reconnect.
7299
7412
  server.registerTool('youtube_channel_report', {
7300
7413
  title: 'YouTube analytics broken down by dimension',
7301
- description: 'The YouTube Analytics reports that say WHERE views came from, WHO watched and WHERE they stopped watching — the questions youtube_channel (totals) and youtube_video_insights (one video, flat) cannot answer. Pick a report: day / month (time series) · country / province (US states) / city / dma (geography) · trafficSource (search vs browse vs suggested vs shorts feed vs external — the single most useful one for judging a thumbnail and title) · trafficSourceDetail (the actual search terms, inside ONE source — pass parent, e.g. "YT_SEARCH") · playbackLocation / playbackLocationDetail (which sites embedded it) · device / operatingSystem · demographics (age + gender) · sharingService · subscribedStatus · audienceRetention (the drop-off CURVE, 100 points across ONE video — the read that tells you whether the hook held and exactly when people left) · topVideos (the channel’s best in the window). Scope it to one or more videoIds, or omit for the whole channel. An unknown report name is refused WITH the list rather than quietly swapped. TWO THINGS TO SAY OUT LOUD WHEN REPORTING: demographics returns viewerPercentage and NOTHING else — YouTube publishes no absolute demographic counts, so never convert it into a number of viewers — and a capped report (city 250, topVideos 200, the *Detail reports 25) is the TOP N, not the whole set. Zero rows means missing data for that window, never zero views. Read-only, 0 credits.',
7414
+ description: 'The YouTube Analytics reports that say WHERE views came from, WHO watched and WHERE they stopped watching — the questions youtube_channel (totals) and youtube_video_insights (one video, flat) cannot answer. Pick a report: day / month (time series) · country / province (US states) / city / dma (geography) · trafficSource (search vs browse vs suggested vs shorts feed vs external — the single most useful one for judging a thumbnail and title) · trafficSourceDetail (the actual search terms, inside ONE source — pass parent, e.g. "YT_SEARCH") · playbackLocation / playbackLocationDetail (which sites embedded it) · device / operatingSystem · demographics (age + gender) · sharingService · subscribedStatus · audienceRetention (the drop-off CURVE, 100 points across ONE video — the read that tells you whether the hook held and exactly when people left) · topVideos (the channel’s best in the window) · contentType (SHORTS vs long-form vs live; contentType:"shorts" narrows most reports to Shorts, and engagedViews — views past the first frame — is the honest Shorts number). Scope it to one or more videoIds, or omit for the whole channel. An unknown report name is refused WITH the list rather than quietly swapped. TWO THINGS TO SAY OUT LOUD WHEN REPORTING: demographics returns viewerPercentage and NOTHING else — YouTube publishes no absolute demographic counts, so never convert it into a number of viewers — and a capped report (city 250, topVideos 200, the *Detail reports 25) is the TOP N, not the whole set. Zero rows means missing data for that window, never zero views. Read-only, 0 credits.',
7302
7415
  inputSchema: {
7303
- report: z.enum(['day', 'month', 'country', 'province', 'city', 'dma', 'trafficSource', 'trafficSourceDetail', 'playbackLocation', 'playbackLocationDetail', 'device', 'operatingSystem', 'demographics', 'sharingService', 'subscribedStatus', 'audienceRetention', 'topVideos']).optional().describe('which report (default day)'),
7416
+ report: z.enum(['day', 'month', 'country', 'province', 'city', 'dma', 'trafficSource', 'trafficSourceDetail', 'playbackLocation', 'playbackLocationDetail', 'device', 'operatingSystem', 'demographics', 'sharingService', 'subscribedStatus', 'audienceRetention', 'topVideos', 'contentType']).optional().describe('which report (default day); contentType = Shorts vs long-form vs live'),
7417
+ contentType: z.enum(['shorts', 'videoOnDemand', 'liveStream', 'story']).optional().describe('keep only this type (videoOnDemand = long-form) — any report except topVideos, the *Detail reports and audienceRetention'),
7418
+ splitByContentType: z.boolean().optional().describe('add a content-type column instead of filtering'),
7304
7419
  videoIds: z.array(z.string()).optional().describe('narrow to these videos — audienceRetention requires exactly ONE, because the curve is per video'),
7305
7420
  parent: z.string().optional().describe('required by the *Detail reports: the ONE parent to drill into, e.g. "YT_SEARCH" / "SUBSCRIBER" / "RELATED_VIDEO" for trafficSourceDetail, "EMBEDDED" for playbackLocationDetail'),
7306
7421
  startDate: z.string().optional().describe('YYYY-MM-DD, default 28 days ago'),
@@ -7310,7 +7425,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
7310
7425
  outputSchema: { report: z.string().optional(), dimensions: z.string().optional(), count: z.number().optional(), columns: z.array(z.string()).optional(), rows: z.array(z.any()).optional(), note: z.string().optional() },
7311
7426
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
7312
7427
  }, wrap(async (a) => {
7313
- const d = await apiGet('/api/youtube/report', { report: a.report, videoIds: (a.videoIds || []).join(','), parent: a.parent, startDate: a.startDate, endDate: a.endDate, limit: a.limit });
7428
+ const d = await apiGet('/api/youtube/report', { report: a.report, videoIds: (a.videoIds || []).join(','), parent: a.parent, startDate: a.startDate, endDate: a.endDate, limit: a.limit, contentType: a.contentType, splitByContentType: a.splitByContentType });
7314
7429
  return ok(`${d.note}\n${rowLines(d.rows)}`, d);
7315
7430
  }));
7316
7431
  // ── YOUTUBE REPORTING API — BULK CSV JOBS (2026-08-05) ─────────────────────────────────────────────────────────
@@ -7354,14 +7469,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
7354
7469
  return ok(d.note, d);
7355
7470
  }));
7356
7471
  server.registerTool('update_youtube_video', {
7357
- title: 'Update a YouTube video’s title, description, tags or privacy',
7358
- description: 'Edit an existing video on the connected channel: title, description, tags, and/or privacy (unlisted | public | private). THIS IS HOW YOU FLIP AN UNLISTED UPLOAD PUBLIC (post_to_youtube is public by default; unlisted only when the user asked for it). Making a video PUBLIC puts it on the channel where anyone can find it, so show the user exactly what will change and get an explicit yes before calling with privacy:"public". Fields you omit are left untouched. Needs a connected YouTube channel.',
7359
- inputSchema: { ...MANAGE_BRAND, videoId: z.string().describe('the YouTube video id'), title: z.string().optional().describe('≤100 chars'), description: z.string().optional().describe('≤5000 chars'), tags: z.array(z.string()).optional(), privacy: z.enum(['unlisted', 'public', 'private']).optional().describe('public = live on the channel; confirm with the user first') },
7360
- outputSchema: { videoId: z.string().optional(), title: z.string().optional(), privacy: z.string().optional(), url: z.string().optional() },
7472
+ title: 'Update a YouTube video’s details',
7473
+ description: 'Edit an existing video on the connected channel: title, description, tags, privacy (unlisted | public | private), category, schedule (publishAt; "" clears it), madeForKids, aiGenerated, licence, embedding, stats visibility, languages + per-language translations, paid-placement disclosure and brand partner. THIS IS HOW YOU FLIP AN UNLISTED UPLOAD PUBLIC. Making a video PUBLIC puts it on the channel where anyone can find it, so show the user exactly what will change and get an explicit yes before calling with privacy:"public". Fields you omit are left untouched; every change is read back from YouTube and any field it did not apply is named. Needs a connected YouTube channel.',
7474
+ inputSchema: { ...MANAGE_BRAND, videoId: z.string().describe('the YouTube video id'), title: z.string().optional().describe('≤100 chars'), description: z.string().optional().describe('≤5000 chars'), tags: z.array(z.string()).optional(), privacy: z.enum(['unlisted', 'public', 'private']).optional().describe('public = live on the channel; confirm with the user first'),
7475
+ categoryId: z.string().optional().describe('numeric YouTube category id'), publishAt: z.string().optional().describe('ISO date-time to go public; keeps it private until then; "" clears a schedule'), madeForKids: z.boolean().optional(), aiGenerated: z.boolean().optional().describe('YouTube’s “altered or synthetic content” label. OMIT IT and the value already on the post stays; a post that never had one is decided from provenance (a Hermoso render is declared, real footage from upload_file or an external URL is not). true/false overrides.'), ...YT_EXTRA },
7476
+ outputSchema: { videoId: z.string().optional(), title: z.string().optional(), privacy: z.string().optional(), url: z.string().optional(), changed: z.array(z.string()).optional(), applied: z.any().optional(), notApplied: z.array(z.string()).optional(), verified: z.boolean().optional(), note: z.string().optional() },
7361
7477
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
7362
7478
  }, wrap(async (a) => {
7363
7479
  const d = await apiPost('/api/youtube/update-video', a);
7364
- return ok(`Updated — “${d.title}” is now ${d.privacy}. ${d.url}`, d);
7480
+ return ok(`${d.note || 'Updated.'} “${d.title}” is ${d.privacy}. ${d.url}`, d);
7365
7481
  }));
7366
7482
  // DELETE. Every other channel we publish to has a removal path (delete_x_post, manage_meta_post,
7367
7483
  // manage_linkedin_post, delete_thread, delete_google_business_post); YouTube had only update_youtube_video, so the
@@ -7680,6 +7796,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
7680
7796
  const top = (d.users || []).slice(0, 10).map((u) => `\u2022 ${u.username}${u.name ? ` (${u.name})` : ''}${u.followers != null ? ` \u2014 ${Number(u.followers).toLocaleString()} followers` : ''}`).join('\n');
7681
7797
  return ok(`${d.count} of ${d.account}\u2019s ${d.direction}${d.total != null ? ` (of ${Number(d.total).toLocaleString()})` : ''}:\n${top}${d.nextToken ? '\n\nMore available \u2014 pass paginationToken.' : ''}`, d);
7682
7798
  }));
7799
+ server.registerTool('hide_x_reply', {
7800
+ title: 'Hide or unhide a reply on X',
7801
+ description: 'Hide a reply under the brand’s own X post (hidden:false to unhide) — spam and abuse moderation. A hidden reply is not deleted; it moves behind "View hidden replies". Only works on replies in a conversation the connected account started. Needs the tweet.moderate.write permission: an X connection made before 2026-10-27 must be reconnected once under Settings > Connectors > X (the tool says so without calling X). Costs credits (X bills per API call).',
7802
+ inputSchema: { replyId: z.string().describe('the REPLY’s id, or its x.com link'), hidden: z.boolean().optional().describe('default true; false unhides') },
7803
+ outputSchema: { replyId: z.string().optional(), hidden: z.boolean().optional(), costCredits: z.number().optional(), note: z.string().optional() },
7804
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
7805
+ }, wrap(async (a = {}) => { const d = await apiPost('/api/x/hide-reply', a); return ok(d.note, d); }));
7683
7806
  server.registerTool('list_x_blocks', {
7684
7807
  title: 'Who the connected X account has blocked',
7685
7808
  description: 'The accounts the connected X account has BLOCKED, with handle, name, bio and follower count. READ-ONLY: X refuses the block and unblock WRITE on the access tier Hermoso\'s app runs on (measured 2026-09-15: X\'s own "attached to a Project" refusal with the scope granted), so blocking itself is done on x.com and there is no block tool here. Needs the block.read permission: an X connection made before 2026-09-15 must be reconnected once under Settings > Connectors > X. Costs credits (X bills per API call).',
@@ -7922,19 +8045,42 @@ function buildTools(rawServer, opts = {}, sink = null) {
7922
8045
  }));
7923
8046
  server.registerTool('create_linkedin_event', {
7924
8047
  title: 'Create a LinkedIn event',
7925
- description: 'Create a LinkedIn LIVE event on a company Page the brand administers. Warning: THIS REQUIRES LINKEDIN LIVE ACCESS on the member\u2019s account: `liveVideo` is the only online format LinkedIn documents for event creation, and a member without Live access is refused with \u201cThe member does not have LINKEDIN_LIVE creation access\u201d (measured live 2026-08-31). Check the account has it before offering this. Warning: CREATED BUT NOT ANNOUNCED: LinkedIn requires a SEPARATE post to make an event publicly visible, so creating one puts nothing in anyone\u2019s feed and the brand decides when to tell people. That separation is LinkedIn\u2019s own and it is why an agent cannot accidentally broadcast to a company\u2019s followers. ONLINE events only for now \u2014 an in-person event needs a full address object and a custom background image needs a three-step upload, and neither is offered rather than failing at LinkedIn. Needs the Events permission (administrator flag) and a reconnect, the same two conditions as list_linkedin_events. 0 credits.',
8048
+ description: 'Create an event on a LinkedIn company Page the brand administers: an EXTERNAL online event (a webinar on Zoom, YouTube or your own site \u2014 pass url), an IN-PERSON event (pass address), or a LinkedIn Live event (format:"live" \u2014 that one needs LinkedIn Live access on the member, and without it LinkedIn refuses with \u201cThe member does not have LINKEDIN_LIVE creation access\u201d, measured 2026-08-31). Optionally attach a LinkedIn REGISTRATION FORM (registrationForm:true + privacyPolicyUrl, or your own approved leadFormId); sign-ups arrive as EVENT leads in list_linkedin_leads(leadType:"EVENT"). Read back from LinkedIn. Warning: ANNOUNCING IS A SEPARATE, PUBLIC STEP (announce:true does it in this call): LinkedIn requires a SEPARATE post to make an event publicly visible, so creating one puts nothing in anyone\u2019s feed and the brand decides when to tell people. That separation is LinkedIn\u2019s own and it is why an agent cannot accidentally broadcast to a company\u2019s followers. Edit it with update_linkedin_event until it starts. A custom background image is not offered yet. Needs the Events permission (administrator flag) and a reconnect, the same two conditions as list_linkedin_events. 0 credits.',
7926
8049
  inputSchema: {
7927
8050
  name: z.string().describe('the event title'),
7928
8051
  startsAt: z.string().describe('ISO 8601, must be in the future \u2014 LinkedIn rejects an event that has already begun'),
7929
8052
  endsAt: z.string().optional().describe('ISO 8601, after startsAt; defaults to one hour after the start'),
7930
8053
  description: z.string().optional(),
7931
8054
  organizationId: z.string().optional().describe('the Page \u2014 omit if only one is shared with this profile'),
8055
+ format: z.enum(['external', 'in_person', 'live']).optional().describe('inferred from url / address when omitted; live needs LinkedIn Live access'),
8056
+ url: z.string().optional().describe('external: where it streams or where people join; in_person: an info page'),
8057
+ address: z.object({ line1: z.string().optional(), city: z.string(), region: z.string().optional(), postalCode: z.string().optional(), country: z.string().describe('2-letter code, e.g. US') }).optional().describe('in_person venue'),
8058
+ venueDetails: z.string().optional().describe('room, floor, parking\u2026'),
8059
+ registrationForm: z.boolean().optional().describe('attach a LinkedIn-built registration form (needs privacyPolicyUrl)'),
8060
+ privacyPolicyUrl: z.string().optional(),
8061
+ leadFormId: z.string().optional().describe('or attach your own APPROVED lead form instead of registrationForm'),
8062
+ discoveryMode: z.enum(['LISTED', 'URL_ONLY']).optional(),
8063
+ announce: z.boolean().optional().describe('ALSO publish the Page post that shares the event. LinkedIn makes an event reachable only once a post shares it (an unannounced one stopped being readable within a minute, measured 2026-10-27). PUBLIC — show the user the copy and get a yes first'),
8064
+ announceText: z.string().optional().describe('the copy of the announcing post'),
7932
8065
  },
7933
8066
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
7934
8067
  }, wrap(async (a) => {
7935
8068
  const d = await apiPost('/api/linkedin/events', a);
7936
8069
  return ok(`Created \u201c${d.name}\u201d on ${d.organization} for ${String(d.startsAt).slice(0, 16).replace('T', ' ')}.\n${d.url}\n${d.note}`, d);
7937
8070
  }));
8071
+ server.registerTool('update_linkedin_event', {
8072
+ title: 'Edit a LinkedIn event',
8073
+ description: 'Edit a LinkedIn event the Page organizes: name, description, start / end time, the join or info url, the in-person address or venue details, or discoveryMode (LISTED / URL_ONLY). LinkedIn\u2019s own rules, quoted back when it refuses: an event can be edited only BEFORE it starts (never once cancelled), and online vs in-person never changes. Read back from LinkedIn. 0 credits.',
8074
+ inputSchema: {
8075
+ eventId: z.string().describe('from list_linkedin_events or create_linkedin_event'),
8076
+ organizationId: z.string().optional(),
8077
+ name: z.string().optional(), description: z.string().optional(),
8078
+ startsAt: z.string().optional().describe('ISO 8601'), endsAt: z.string().optional().describe('ISO 8601'),
8079
+ url: z.string().optional(), address: z.object({ line1: z.string().optional(), city: z.string(), region: z.string().optional(), postalCode: z.string().optional(), country: z.string().describe('2-letter code, e.g. US') }).optional(), venueDetails: z.string().optional(),
8080
+ discoveryMode: z.enum(['LISTED', 'URL_ONLY']).optional(),
8081
+ },
8082
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
8083
+ }, wrap(async (a) => { const d = await apiPost('/api/linkedin/events/update', a); return ok(d.note, d); }));
7938
8084
  server.registerTool('list_linkedin_events', {
7939
8085
  title: 'List a LinkedIn Page\u2019s events',
7940
8086
  description: 'The events a LinkedIn company Page is organizing \u2014 name, link, start and end times, online or in-person, visibility and lifecycle state. LinkedIn is the one channel where an event is a first-class object rather than a post about one, so this is how you see what a B2B brand actually has scheduled (webinars, launches, live sessions) and pair it with the posts promoting it. Only Pages the user has SHARED with this profile are readable. Warning: Needs the Events permission: an administrator switches it on for the deployment, and because LinkedIn grants permissions at authorise time and never retroactively, every existing LinkedIn connection must RECONNECT once before it works. The tool tells you which of those two applies rather than failing generically. Read-only, 0 credits. Needs LinkedIn connected.',
@@ -8745,15 +8891,17 @@ function buildTools(rawServer, opts = {}, sink = null) {
8745
8891
  // 200 {"success":true}. Rules, citations and every refusal sentence in lib/instagram-business-discovery.mjs.
8746
8892
  server.registerTool('instagram_profile', {
8747
8893
  title: 'Look up an Instagram account by handle',
8748
- description: 'Turn an Instagram @handle into the NUMERIC Instagram account id, straight from Meta — with the account’s real name, bio, follower count, post count, website and profile picture. Two jobs: it answers "what is @<handle>’s Instagram id?", and it sizes up any brand or creator on Instagram before you work with them (followers and post count are Meta’s own numbers, not a scraper’s). CALL IT BEFORE manage_meta_partnership_creator: that tool’s `allowTagging` list takes NUMERIC IDS ONLY, and an id that did not come from Meta is rejected there with “An unknown error has occurred.” — an error carrying no cause at all, so a scraped or guessed id looks like an outage instead of a bad argument. PROFESSIONAL ACCOUNTS ONLY: Meta reads Instagram Business and Creator accounts here and nothing else. A personal account, a private account, an age-gated account and a handle that does not exist all come back as the SAME refusal, because Meta returns one error code for all four — so never tell a user which of them it was. Read-only, 0 credits, no new permission, no reconnect.',
8894
+ description: 'Turn an Instagram @handle into the NUMERIC Instagram account id, straight from Meta — with the account’s real name, bio, follower count, post count, website and profile picture. Two jobs: it answers "what is @<handle>’s Instagram id?", and it sizes up any brand or creator on Instagram before you work with them (followers and post count are Meta’s own numbers, not a scraper’s). CALL IT BEFORE manage_meta_partnership_creator: that tool’s `allowTagging` list takes NUMERIC IDS ONLY, and an id that did not come from Meta is rejected there with “An unknown error has occurred.” — an error carrying no cause at all, so a scraped or guessed id looks like an outage instead of a bad argument. PROFESSIONAL ACCOUNTS ONLY: Meta reads Instagram Business and Creator accounts here and nothing else. A personal account, a private account, an age-gated account and a handle that does not exist all come back as the SAME refusal, because Meta returns one error code for all four — so never tell a user which of them it was. Read-only, 0 credits, no new permission, no reconnect. RECENT POSTS: pass posts (1–50) to also get that account\'s latest posts with their like, comment and view counts and links, read first-party from Meta for FREE — use this instead of fetch_social_data for a business or creator account\'s recent Instagram posts (competitor organic research); page with cursor.',
8749
8895
  inputSchema: {
8750
8896
  handle: z.string().describe('the Instagram handle — "fellowproducts", "@fellowproducts" or an instagram.com profile link all work. Meta matches it EXACTLY and does not strip a leading @, so Hermoso normalises it first.'),
8751
8897
  pageId: z.string().optional().describe('Facebook Page id — omit when only one Page is connected. Meta requires the lookup to be made AS a professional account, so the Page must have an Instagram account linked.'),
8898
+ posts: z.number().int().min(0).max(50).optional().describe('how many of the account’s recent posts to read with like / comment / view counts and links (0–50, default 0). First-party and free'),
8899
+ cursor: z.string().optional().describe('next page of posts — the cursor from the previous reply'),
8752
8900
  },
8753
8901
  outputSchema: { handle: z.string().optional(), id: z.string().optional(), username: z.string().optional(), name: z.string().optional(), biography: z.string().optional(), followers: z.number().nullable().optional(), follows: z.number().nullable().optional(), mediaCount: z.number().nullable().optional(), profilePicture: z.string().optional(), website: z.string().optional(), published: z.boolean().nullable().optional(), summary: z.string().optional(), warning: z.string().optional(), idUse: z.string().optional(), limitation: z.string().optional() },
8754
8902
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
8755
8903
  }, wrap(async (a) => {
8756
- const d = await apiGet('/api/instagram/profile', { handle: a.handle, pageId: a.pageId });
8904
+ const d = await apiGet('/api/instagram/profile', { handle: a.handle, pageId: a.pageId, ...(a.posts ? { posts: a.posts } : {}), ...(a.cursor ? { cursor: a.cursor } : {}) });
8757
8905
  // THE ID LEADS. It is what the caller came for, and a fact printed below a follower count is one that gets
8758
8906
  // asked for a second time.
8759
8907
  const bio = d.biography ? `\n\u201c${String(d.biography).replace(/\s+/g, ' ').slice(0, 200)}\u201d` : '';
@@ -9948,6 +10096,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
9948
10096
  startDate: z.string().optional().describe('YYYY-MM-DD'),
9949
10097
  endDate: z.string().optional().describe('YYYY-MM-DD'),
9950
10098
  adGroup: z.object({ name: z.string().optional(), cpcBidUsd: z.number().optional(), ad: z.object(gadsAdShape).optional(), keywords: gadsKeywordShape.optional() }).optional().describe('build the serving tree in the same atomic call — WITHOUT this you get a campaign shell that can never show an ad'),
10099
+ finalUrlSuffix: z.string().optional().describe('UTMs appended to every click, e.g. "utm_source=google&utm_medium=cpc". Omit for our default (utm_source/medium/campaign + {campaignid}/{creative}); "" for none'),
10100
+ trackingUrlTemplate: z.string().optional().describe('click-tracking redirect, e.g. "{lpurl}?src=x" — only if you use a tracker'),
10101
+ urlCustomParameters: z.record(z.string(), z.string()).optional().describe('up to 8 custom parameters, used in URLs as {_key}'),
9951
10102
  containsEuPoliticalAds: z.boolean().optional().describe('EU Political Advertising Regulation declaration. Google REQUIRES one on every campaign. Default false (a normal commercial ad) — set true ONLY for genuine EU political advertising'),
9952
10103
  dryRun: z.boolean().optional().describe('validate the WHOLE tree against Google without creating anything. Nothing is written and no budget is consumed'),
9953
10104
  loginCustomerId: z.string().optional().describe('manager id if operating through an MCC'),
@@ -10334,12 +10485,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
10334
10485
  return ok(d.note || `${d.count || 0} conversion action(s).`, d);
10335
10486
  }));
10336
10487
  server.registerTool('add_google_ads_assets', {
10337
- title: 'Add sitelinks / callouts / structured snippets to a Google Ads campaign',
10338
- description: 'Add SITELINKS, CALLOUTS or STRUCTURED SNIPPETS to a Google Ads campaign or ad group — and ATTACH them, which is the part that makes them do anything (an asset sitting in the account library shows nothing at all). Sitelinks are the highest-CTR free win on Search: extra links under the ad, each with its own landing page. Pass assetType plus assets[]: SITELINK needs {linkText (≤25 chars), finalUrl, and optionally description1 / description2}; CALLOUT needs {calloutText (≤25)}; STRUCTURED_SNIPPET needs {header, values[] — at least 3}. Or link assets that already exist with assetResourceNames[]. Assets and links go up in ONE atomic operation, so a rejected link never strands an orphan asset, and the links are READ BACK from Google before you are told they exist. Attaching a live asset to a LIVE (ENABLED) campaign changes what that ad shows on the very next auction — show the user what would appear, get an explicit yes, then pass confirm:true. On a paused campaign it never needs confirmation.',
10488
+ title: 'Add sitelinks, callouts, call, promotion, price, lead-form and brand assets to Google Ads',
10489
+ description: 'Add ASSETS (extensions) to a Google Ads campaign, ad group or the whole account (level:"customer") — and ATTACH them, which is what makes them show. Pass assetType plus assets[]: SITELINK {linkText ≤25, finalUrl, description1/2}; CALLOUT {calloutText ≤25}; STRUCTURED_SNIPPET {header, values[] ≥3}; CALL {phoneNumber, countryCode}; PROMOTION {promotionTarget ≤20, percentOff OR moneyAmountOff, finalUrl, promotionCode?, occasion?, startDate?, endDate?}; PRICE {priceType, offerings[3–8] of {header ≤25, description ≤25, price, unit?, finalUrl}}; LEAD_FORM {businessName, headline, description, privacyPolicyUrl, callToActionType, callToActionDescription, finalUrl, fields?}; BUSINESS_NAME {businessName ≤25}; BUSINESS_LOGO {image: a square Hermoso image}. Or link existing ones with assetResourceNames[]. Edit, pause or detach later with manage_google_ads_assets. (Google does not let apps attach Search image extensions; images go through Performance Max, Demand Gen and display ads.) Assets and links go up in ONE atomic operation, so a rejected link never strands an orphan asset, and the links are READ BACK from Google before you are told they exist. Attaching a live asset to a LIVE (ENABLED) campaign changes what that ad shows on the very next auction — show the user what would appear, get an explicit yes, then pass confirm:true. On a paused campaign it never needs confirmation.',
10339
10490
  inputSchema: {
10340
10491
  customerId: z.string().optional().describe('10-digit account id (dashes ok) — omit to use the brand’s selected default account'),
10341
- assetType: z.enum(['SITELINK', 'CALLOUT', 'STRUCTURED_SNIPPET']).describe('what kind of asset to create and attach'),
10342
- level: z.enum(['campaign', 'adGroup']).optional().describe('where to attach it — default campaign'),
10492
+ assetType: z.enum(['SITELINK', 'CALLOUT', 'STRUCTURED_SNIPPET', 'CALL', 'PROMOTION', 'PRICE', 'LEAD_FORM', 'BUSINESS_NAME', 'BUSINESS_LOGO']).describe('what kind of asset to create and attach'),
10493
+ level: z.enum(['campaign', 'adGroup', 'customer']).optional().describe('where to attach it — follows the id you pass; customer = every campaign on the account'),
10343
10494
  campaignId: z.string().optional().describe('campaign id (level:"campaign")'),
10344
10495
  adGroupId: z.string().optional().describe('ad group id (level:"adGroup")'),
10345
10496
  assets: z.array(z.object({
@@ -10352,6 +10503,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
10352
10503
  header: z.string().optional().describe('STRUCTURED_SNIPPET — e.g. "Services", "Brands", "Types"'),
10353
10504
  values: z.array(z.string()).optional().describe('STRUCTURED_SNIPPET — at least 3 values'),
10354
10505
  name: z.string().optional().describe('optional asset name in the library'),
10506
+ phoneNumber: z.string().optional(), countryCode: z.string().optional().describe('CALL — two-letter country of the number'),
10507
+ promotionTarget: z.string().optional(), percentOff: z.number().optional(), moneyAmountOff: z.number().optional(), currencyCode: z.string().optional(),
10508
+ promotionCode: z.string().optional(), ordersOverAmount: z.number().optional(), occasion: z.string().optional().describe('PROMOTION — e.g. BLACK_FRIDAY, SUMMER_SALE'),
10509
+ startDate: z.string().optional(), endDate: z.string().optional(), languageCode: z.string().optional(),
10510
+ priceType: z.string().optional().describe('PRICE — SERVICES, PRODUCT_CATEGORIES, PRODUCT_TIERS, BRANDS, EVENTS, LOCATIONS…'), priceQualifier: z.enum(['FROM', 'UP_TO', 'AVERAGE']).optional(),
10511
+ offerings: z.array(z.object({ header: z.string(), description: z.string(), price: z.number(), unit: z.string().optional(), finalUrl: z.string() })).optional(),
10512
+ businessName: z.string().optional(), headline: z.string().optional(), description: z.string().optional(), privacyPolicyUrl: z.string().optional(),
10513
+ callToActionType: z.string().optional(), callToActionDescription: z.string().optional(), fields: z.array(z.string()).optional().describe('LEAD_FORM — default name + email'),
10514
+ image: z.string().optional().describe('BUSINESS_LOGO — render id, Library id, attachment://N or Hermoso image link'),
10355
10515
  })).optional().describe('the assets to CREATE and attach'),
10356
10516
  assetResourceNames: z.array(z.string()).optional().describe('attach assets that ALREADY exist instead of creating new ones'),
10357
10517
  status: z.enum(['ENABLED', 'PAUSED']).optional().describe('the LINK status — default ENABLED'),
@@ -10422,6 +10582,92 @@ function buildTools(rawServer, opts = {}, sink = null) {
10422
10582
  outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), issues: z.array(z.any()).optional(), note: z.string().optional() },
10423
10583
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
10424
10584
  }, wrap(async (a) => { const d = await apiPost('/api/google-ads/policy-issues', a); return ok(d.note, d); }));
10585
+ server.registerTool('manage_google_ads_assets', {
10586
+ title: 'List, edit, pause or detach Google Ads assets',
10587
+ description: 'The assets on a Google Ads campaign, ad group or the whole account (level:"customer"): action "list" (every attached asset with its review status and any policy problem), "update" (edit a sitelink/callout/snippet/call/promotion/price/lead-form asset — pass assetId, assetType and asset{} in add_google_ads_assets\' shape), "pause", "enable" or "unlink" (pass assetId + fieldType from list). Google never deletes an asset; unlink stops it showing. Editing or re-enabling on a LIVE campaign needs confirm:true after the user agrees.',
10588
+ inputSchema: {
10589
+ customerId: z.string().optional(),
10590
+ action: z.enum(['list', 'update', 'pause', 'enable', 'unlink']).optional().describe('default list'),
10591
+ level: z.enum(['campaign', 'adGroup', 'customer']).optional(), campaignId: z.string().optional(), adGroupId: z.string().optional(),
10592
+ assetId: z.string().optional(), fieldType: z.string().optional().describe('the link field type from list, e.g. CALL, SITELINK'),
10593
+ assetType: z.string().optional().describe('update — the asset kind'), asset: z.record(z.string(), z.any()).optional().describe('update — the new content'),
10594
+ confirm: z.boolean().optional(), dryRun: z.boolean().optional(), loginCustomerId: z.string().optional(),
10595
+ },
10596
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
10597
+ }, wrap(async (a) => { const d = await apiPost('/api/google-ads/assets/manage', a); return ok(d.note || 'Done.', d); }));
10598
+ server.registerTool('set_google_ads_tracking', {
10599
+ title: 'Set UTMs / tracking on a Google Ads campaign, ad group or ad',
10600
+ description: 'Set the FINAL URL SUFFIX (the UTMs added to every click), tracking template and custom parameters on a Google Ads campaign, ad group or ad; a lower level overrides a higher one. "" clears a field; utmDefault:true applies our standard UTMs (utm_source=google, utm_medium, utm_campaign=<name>, utm_id={campaignid}, utm_content={creative}). New campaigns already get those unless you say otherwise. Read back from Google.',
10601
+ inputSchema: {
10602
+ customerId: z.string().optional(), campaignId: z.string().optional(), adGroupId: z.string().optional(), adId: z.string().optional(),
10603
+ level: z.enum(['campaign', 'adGroup', 'ad']).optional().describe('follows the id you pass'),
10604
+ finalUrlSuffix: z.string().optional(), trackingUrlTemplate: z.string().optional(), urlCustomParameters: z.record(z.string(), z.string()).optional(),
10605
+ utmDefault: z.boolean().optional(), confirm: z.boolean().optional().describe('needed to change the tracking redirect on a LIVE campaign'),
10606
+ dryRun: z.boolean().optional(), loginCustomerId: z.string().optional(),
10607
+ },
10608
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
10609
+ }, wrap(async (a) => { const d = await apiPost('/api/google-ads/tracking', a); return ok(d.note || 'Done.', d); }));
10610
+ server.registerTool('manage_google_ads_audience', {
10611
+ title: 'Find or build Google Ads audiences',
10612
+ description: 'Audiences for Google Ads. action "search" finds affinity + in-market interests, remarketing lists, custom segments and saved audiences (query by name). "create_remarketing_list": people who visited pages matching urlRules [{operator CONTAINS|EQUALS|STARTS_WITH|ENDS_WITH, value}] in the last membershipDays (needs the Google tag on the site). "create_custom_segment": people searching keywords[] or into sites like urls[] (type AUTO or SEARCH). "create_audience": a reusable audience (userLists, interests, customSegments ids + ages like "25-34", genders) — what Performance Max signals and Demand Gen targeting take. "remove" (userList or customSegment, confirm:true). Attach with set_google_ads_audiences.',
10613
+ inputSchema: {
10614
+ customerId: z.string().optional(),
10615
+ action: z.enum(['search', 'create_remarketing_list', 'create_custom_segment', 'create_audience', 'remove']).optional().describe('default search'),
10616
+ query: z.string().optional(), name: z.string().optional(), description: z.string().optional(),
10617
+ urlRules: z.array(z.object({ operator: z.string().optional(), value: z.string() })).optional(), excludeUrlRules: z.array(z.object({ operator: z.string().optional(), value: z.string() })).optional(),
10618
+ membershipDays: z.number().optional().describe('1–540, default 30'),
10619
+ type: z.enum(['AUTO', 'SEARCH']).optional(), keywords: z.array(z.string()).optional(), urls: z.array(z.string()).optional(), apps: z.array(z.string()).optional(),
10620
+ userLists: z.array(z.string()).optional(), interests: z.array(z.string()).optional(), customSegments: z.array(z.string()).optional(),
10621
+ ages: z.array(z.string()).optional(), genders: z.array(z.enum(['male', 'female', 'unknown'])).optional(),
10622
+ kind: z.enum(['userList', 'customSegment']).optional().describe('remove'), id: z.string().optional().describe('remove'),
10623
+ confirm: z.boolean().optional(), dryRun: z.boolean().optional(), loginCustomerId: z.string().optional(),
10624
+ },
10625
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
10626
+ }, wrap(async (a) => { const d = await apiPost('/api/google-ads/audience', a); return ok(d.note || 'Done.', d); }));
10627
+ server.registerTool('set_google_ads_audiences', {
10628
+ title: 'Target or exclude audiences on Google Ads',
10629
+ description: 'Who a Google Ads campaign reaches. adGroupId (Search/Display): userLists, interests (affinity/in-market ids), customSegments (Display only), mode "observation" (report, do not narrow — default on Search) or "targeting" (narrow to them); Demand Gen ad groups take audiences[] ids. assetGroupId (Performance Max): audiences[] as signals and searchThemes[]. campaignId: exclusions only. exclude{userLists, interests, customSegments}, excludeAges ["18-24"…"65+","unknown"], excludeGenders. remove[] takes criterion resource names from a call with no changes (which lists what is set). Narrowing a LIVE ad group needs confirm:true.',
10630
+ inputSchema: {
10631
+ customerId: z.string().optional(), adGroupId: z.string().optional(), assetGroupId: z.string().optional(), campaignId: z.string().optional(),
10632
+ userLists: z.array(z.string()).optional(), interests: z.array(z.string()).optional(), customSegments: z.array(z.string()).optional(), audiences: z.array(z.string()).optional(),
10633
+ searchThemes: z.array(z.string()).optional(), mode: z.enum(['observation', 'targeting']).optional(),
10634
+ exclude: z.object({ userLists: z.array(z.string()).optional(), interests: z.array(z.string()).optional(), customSegments: z.array(z.string()).optional() }).optional(),
10635
+ excludeAges: z.array(z.string()).optional(), excludeGenders: z.array(z.string()).optional(), remove: z.array(z.string()).optional(),
10636
+ confirm: z.boolean().optional(), dryRun: z.boolean().optional(), loginCustomerId: z.string().optional(),
10637
+ },
10638
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
10639
+ }, wrap(async (a) => { const d = await apiPost('/api/google-ads/audiences/targeting', a); return ok(d.note || 'Done.', d); }));
10640
+ server.registerTool('upload_google_ads_video', {
10641
+ title: 'Upload a video straight into Google Ads',
10642
+ description: 'Upload a Hermoso video (render id, Library id, attachment://N or Hermoso link) straight into Google Ads — no YouTube channel needed: it goes UNLISTED to the Google-managed channel of the ad account (or to your own brand channel with channelId, where PUBLIC is allowed). Waits for YouTube to process it, then returns the video asset ready for create_google_ads_demand_gen_campaign or Performance Max. AI-generated renders are disclosed as such. action "status" finishes a slow one (uploadId), "list" shows uploads, "remove" deletes one (confirm:true). Free.',
10643
+ inputSchema: {
10644
+ customerId: z.string().optional(), action: z.enum(['upload', 'status', 'list', 'remove']).optional().describe('default upload'),
10645
+ video: z.string().optional(), title: z.string().optional().describe('required, ≤100 chars'), description: z.string().optional(),
10646
+ channelId: z.string().optional().describe('your own YouTube channel (UC…), else Google\'s'), privacy: z.enum(['UNLISTED', 'PUBLIC']).optional(),
10647
+ aiGenerated: z.boolean().optional().describe('Google Ads generative-content disclosure. OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, a video that came through upload_file (the user\u2019s own footage) is NOT. true/false overrides'), uploadId: z.string().optional(), waitSeconds: z.number().optional(),
10648
+ confirm: z.boolean().optional(), dryRun: z.boolean().optional(), loginCustomerId: z.string().optional(),
10649
+ },
10650
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
10651
+ }, wrap(async (a) => { const d = await apiPost('/api/google-ads/video', a); return ok(d.note || 'Done.', d); }));
10652
+ server.registerTool('create_google_ads_demand_gen_campaign', {
10653
+ title: 'Create a Google Demand Gen video campaign (YouTube Shorts, in-feed, in-stream)',
10654
+ description: 'Build a Google DEMAND GEN campaign — video ads on YouTube Shorts, in-feed and in-stream (plus Discover, Gmail, Display, Maps if you pick them) — as ONE atomic, PAUSED build: budget, campaign, ad group with channel controls, locations/languages, optional audiences, and a video ad. ad needs videoAssets (from upload_google_ads_video) or youtubeVideoIds, a square logo (logoImage or logoAssets), finalUrl, businessName ≤25, 1–5 headlines, longHeadlines, descriptions. Google sets a minimum daily budget (it names it if you go under). Read back; turn it on with set_google_ads_status(confirm:true) only after the user approves.',
10655
+ inputSchema: {
10656
+ customerId: z.string().optional(), name: z.string(), dailyBudgetUsd: z.number().optional(), budgetResourceName: z.string().optional(),
10657
+ bidding: z.object({ strategy: z.enum(['MAXIMIZE_CONVERSIONS', 'TARGET_CPA', 'MAXIMIZE_CONVERSION_VALUE', 'TARGET_ROAS', 'MAXIMIZE_CLICKS']), targetCpaUsd: z.number().optional(), targetRoas: z.number().optional(), maxCpcUsd: z.number().optional() }).optional(),
10658
+ channels: z.array(z.enum(['youtubeShorts', 'youtubeInFeed', 'youtubeInStream', 'discover', 'gmail', 'display', 'maps'])).optional().describe('omit for all of Google\'s channels'),
10659
+ locations: z.array(z.string()).optional(), excludedLocations: z.array(z.string()).optional(), languages: z.array(z.string()).optional(), countryCode: z.string().optional(),
10660
+ audiences: z.array(z.string()).optional().describe('audience ids from manage_google_ads_audience'),
10661
+ ad: z.object({
10662
+ videoAssets: z.array(z.string()).optional(), youtubeVideoIds: z.array(z.string()).optional(), logoImage: z.string().optional(), logoAssets: z.array(z.string()).optional(),
10663
+ finalUrl: z.string(), businessName: z.string(), headlines: z.array(z.string()), longHeadlines: z.array(z.string()), descriptions: z.array(z.string()), name: z.string().optional(),
10664
+ }),
10665
+ finalUrlSuffix: z.string().optional(), trackingUrlTemplate: z.string().optional(), urlCustomParameters: z.record(z.string(), z.string()).optional(),
10666
+ startDate: z.string().optional(), endDate: z.string().optional(), containsEuPoliticalAds: z.boolean().optional(),
10667
+ dryRun: z.boolean().optional(), loginCustomerId: z.string().optional(),
10668
+ },
10669
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
10670
+ }, wrap(async (a) => { const d = await apiPost('/api/google-ads/demand-gen', a); return ok(d.note || 'Done.', d); }));
10425
10671
  server.registerTool('create_google_ads_performance_max_campaign', {
10426
10672
  title: 'Create a Google Ads Performance Max campaign',
10427
10673
  description: 'Build a PERFORMANCE MAX campaign — Google’s cross-surface campaign type (Search, YouTube, Display, Discover, Gmail, Maps). ALWAYS created PAUSED; it spends NOTHING until you enable it with set_google_ads_status(confirm:true). PMax has NO manual bidding and NO keywords: it bids only on conversions, so the account MUST already have a conversion action — check with list_google_ads_conversion_actions, because this REFUSES rather than build a campaign that cannot optimise. Creative lives in an ASSET GROUP, and Google’s minimums are enforced before anything is sent: 3–15 headlines (≤30 chars), 1–5 longHeadlines (≤90), 2–5 descriptions (≤90), one businessName (≤25), at least one LOGO (1:1), one MARKETING_IMAGE (1.91:1) and one SQUARE_MARKETING_IMAGE (1:1) — upload the images with upload_google_ads_asset first and pass their asset resource names. A YouTube video is optional (Google generates one from the asset group if you omit it). Brand guidelines: since Google Ads API v21 they are ON by default for new PMax campaigns, which means the businessName and LOGO assets are linked to the CAMPAIGN (CampaignAsset), not to the asset group — Hermoso does that for you. Leave brandGuidelinesEnabled alone unless the user wants the older asset-group layout, and pass false for that. Budget, campaign, location/language targeting, the asset group and every asset link go up in ONE ATOMIC operation — if any part is rejected, nothing at all is created — and the whole tree is READ BACK from Google before you are told it exists. Print the returned note verbatim; if it says the campaign cannot serve, say that instead of calling it finished. RETAIL/SHOPPING: pass merchantCenterId (from list_merchant_accounts) to make it a Shopping-feed Performance Max — it then advertises the WHOLE feed (one root listing group); feedLabel narrows it to a single feed. Partitioning the feed by brand/category/custom label is NOT built and is refused by name, so a caller can never believe they narrowed it when they did not.',
@@ -10441,6 +10687,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
10441
10687
  countryCode: z.string().optional().describe('2-letter hint to disambiguate a city name'),
10442
10688
  startDate: z.string().optional().describe('YYYY-MM-DD'),
10443
10689
  endDate: z.string().optional().describe('YYYY-MM-DD'),
10690
+ finalUrlSuffix: z.string().optional().describe('UTMs appended to every click, e.g. "utm_source=google&utm_medium=cpc". Omit for our default (utm_source/medium/campaign + {campaignid}/{creative}); "" for none'),
10691
+ trackingUrlTemplate: z.string().optional().describe('click-tracking redirect, e.g. "{lpurl}?src=x" — only if you use a tracker'),
10692
+ urlCustomParameters: z.record(z.string(), z.string()).optional().describe('up to 8 custom parameters, used in URLs as {_key}'),
10444
10693
  containsEuPoliticalAds: z.boolean().optional().describe('true ONLY for genuine EU political advertising'),
10445
10694
  merchantCenterId: z.string().optional().describe('makes this a RETAIL (Shopping-feed) Performance Max — the Merchant Center account whose products it advertises, from list_merchant_accounts. It advertises the WHOLE feed; partitioning by brand/category is not built and is refused by name'),
10446
10695
  feedLabel: z.string().optional().describe('optional, retail only: narrow to ONE Merchant Center feed by its feed label. Omit to use products from all feeds'),
@@ -10457,6 +10706,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
10457
10706
  marketingImages: z.array(z.string()).describe('at least one 1.91:1 asset resource name'),
10458
10707
  squareMarketingImages: z.array(z.string()).describe('at least one 1:1 asset resource name'),
10459
10708
  youtubeVideos: z.array(z.string()).optional().describe('optional YOUTUBE_VIDEO asset resource names'),
10709
+ searchThemes: z.array(z.string()).optional().describe('signals: up to 50 search themes (keyword-like hints, ≤10 words each)'),
10710
+ audiences: z.array(z.string()).optional().describe('signals: audience ids from manage_google_ads_audience(create_audience)'),
10460
10711
  path1: z.string().optional().describe('display-URL path, ≤15 characters'),
10461
10712
  path2: z.string().optional().describe('second display-URL path, ≤15 characters'),
10462
10713
  }).describe('the creative — Google requires every field above before a PMax campaign can serve'),
@@ -10522,7 +10773,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
10522
10773
  }));
10523
10774
  server.registerTool('analytics_report', {
10524
10775
  title: 'Run a GA4 report',
10525
- description: 'WHAT HAPPENED ON THE SITE — the GA4 Data API report, and the tool that answers "where is our traffic coming from", "which campaign converted", "which landing page is working". Pass metrics by name (activeUsers, sessions, screenPageViews, conversions, totalRevenue, engagementRate, bounceRate, averageSessionDuration …) and dimensions to break them down by (sessionDefaultChannelGroup, sessionSource / sessionMedium / sessionCampaignName, landingPage, pagePath, country, deviceCategory, date, eventName …). GA4 publishes hundreds of both and each property ships its OWN custom dimensions, so names are forwarded as given rather than validated against a copied list — use list_analytics_definitions to see a property\'s custom ones. THE PROPERTY IS A NUMERIC ID (e.g. 123456789), NOT the G-XXXXXXX Measurement ID — run list_analytics_properties to resolve it. Dates default to the last 28 days and accept either YYYY-MM-DD or GA4\'s relative forms ("28daysAgo", "yesterday", "today"). Rows come back as flat named objects, and a sampled result says so. Read-only, 0 credits.',
10776
+ description: 'WHAT HAPPENED ON THE SITE — the GA4 Data API report, and the tool that answers "where is our traffic coming from", "which campaign converted", "which landing page is working". Pass metrics by name (activeUsers, sessions, screenPageViews, conversions, totalRevenue, engagementRate, bounceRate, averageSessionDuration …) and dimensions to break them down by (sessionDefaultChannelGroup, sessionSource / sessionMedium / sessionCampaignName, landingPage, pagePath, country, deviceCategory, date, eventName …). GA4 publishes hundreds of both and each property ships its OWN custom dimensions, so names are forwarded as given rather than validated against a copied list — use list_analytics_definitions to see a property\'s custom ones. THE PROPERTY IS A NUMERIC ID (e.g. 123456789), NOT the G-XXXXXXX Measurement ID — run list_analytics_properties to resolve it. Dates default to the last 28 days and accept either YYYY-MM-DD or GA4\'s relative forms ("28daysAgo", "yesterday", "today"). PERIOD OVER PERIOD IN ONE CALL: compare:"previous_period" (or "previous_year") adds the earlier period and returns per-period totals plus `change` (absolute + %) computed from GA4\'s own totals — quote it, never redo the maths; or pass up to 4 named dateRanges. comparisons put segments side by side; totals:true adds a total row. Rows come back as flat named objects, and a sampled result says so. Read-only, 0 credits.',
10526
10777
  inputSchema: {
10527
10778
  property: z.string().describe('the NUMERIC GA4 property id from list_analytics_properties (e.g. "123456789") — never the G-XXXXXXX Measurement ID, and it must be one SHARED with this profile'),
10528
10779
  metrics: z.array(z.string()).optional().describe('GA4 metric names — default ["activeUsers","sessions","screenPageViews"]'),
@@ -10535,8 +10786,16 @@ function buildTools(rawServer, opts = {}, sink = null) {
10535
10786
  orderDesc: z.boolean().optional().describe('default true (largest first) when orderByMetric is set'),
10536
10787
  dimensionFilter: z.record(z.any()).optional().describe('NARROW THE REPORT — a GA4 FilterExpression, exactly one of andGroup | orGroup | notExpression | filter. Without it a report is the WHOLE property. Example: {"filter":{"fieldName":"sessionSource","stringFilter":{"matchType":"EXACT","value":"google"}}}; combine with {"andGroup":{"expressions":[…]}}. METRICS CANNOT BE USED HERE — use metricFilter.'),
10537
10788
  metricFilter: z.record(z.any()).optional().describe('Filter the AGGREGATED rows, GA4\'s having-clause — same FilterExpression shape. Example: {"filter":{"fieldName":"sessions","numericFilter":{"operation":"GREATER_THAN","value":{"int64Value":"30"}}}}. DIMENSIONS CANNOT BE USED HERE — use dimensionFilter.'),
10789
+ compare: z.enum(['previous_period', 'previous_year']).optional().describe('also read the period before startDate–endDate (or the same dates a year earlier) and return `change`'),
10790
+ dateRanges: z.array(z.object({ startDate: z.string(), endDate: z.string(), name: z.string().optional() })).max(4).optional().describe('up to 4 periods instead of startDate/endDate; rows gain a dateRange column; with two, change = the FIRST relative to the SECOND'),
10791
+ totals: z.boolean().optional().describe('add TOTAL rows'),
10792
+ aggregations: z.array(z.enum(['TOTAL', 'MINIMUM', 'MAXIMUM', 'COUNT'])).optional(),
10793
+ comparisons: z.array(z.object({ name: z.string().optional(), dimensionFilter: z.record(z.any()).optional(), comparison: z.string().optional() })).optional().describe('segments side by side: [{name, dimensionFilter}] or [{comparison:"comparisons/123"}]; adds a comparison column'),
10794
+ orderBy: z.array(z.object({ dimension: z.string().optional(), metric: z.string().optional(), desc: z.boolean().optional(), orderType: z.enum(['ALPHANUMERIC', 'CASE_INSENSITIVE_ALPHANUMERIC', 'NUMERIC']).optional() })).optional().describe('[{dimension:"date"}] or [{metric:"sessions", desc:true}]'),
10795
+ keepEmptyRows: z.boolean().optional(),
10796
+ currencyCode: z.string().optional().describe('ISO 4217, e.g. EUR — default the property currency'),
10538
10797
  },
10539
- outputSchema: { property: z.string().optional(), rows: z.array(z.any()).optional(), rowCount: z.number().optional(), returned: z.number().optional(), offset: z.number().optional(), truncated: z.boolean().optional(), nextOffset: z.number().optional(), dimensions: z.array(z.string()).optional(), metrics: z.array(z.string()).optional(), filtered: z.boolean().optional(), sampled: z.boolean().optional() },
10798
+ outputSchema: { property: z.string().optional(), rows: z.array(z.any()).optional(), dateRanges: z.array(z.any()).optional(), totals: z.array(z.any()).optional(), minimums: z.array(z.any()).optional(), maximums: z.array(z.any()).optional(), change: z.record(z.any()).optional(), rowCount: z.number().optional(), returned: z.number().optional(), offset: z.number().optional(), truncated: z.boolean().optional(), nextOffset: z.number().optional(), dimensions: z.array(z.string()).optional(), metrics: z.array(z.string()).optional(), filtered: z.boolean().optional(), sampled: z.boolean().optional() },
10540
10799
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
10541
10800
  }, wrap(async (a) => {
10542
10801
  const d = await apiPost('/api/analytics/report', a);
@@ -10547,7 +10806,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
10547
10806
  const head = d.truncated
10548
10807
  ? `${d.returned} of ${d.rowCount} row(s) for property ${d.property} — THIS IS A PARTIAL ANSWER. Call again with offset:${d.nextOffset} for the next page, or raise limit (max 250000).`
10549
10808
  : `${d.rowCount || 0} row(s) for property ${d.property}`;
10550
- return ok(`${head}${d.filtered ? ' (FILTERED — this is a SUBSET of the property, not its total)' : ''}${d.sampled ? ' (SAMPLED — GA4 dropped rows to answer this; say so when reporting)' : ''}.\n${rowLines(d.rows)}`, d);
10809
+ // PERIOD CHANGE is computed server-side from GA4's own totals (lib/ga4-report-depth.mjs) — printed so nobody redoes it.
10810
+ const chg = d.change ? `\nCHANGE (${(d.dateRanges || []).map(r => r.name).join(' vs ')}, from GA4's totals — quote these): ${Object.entries(d.change).map(([m, c]) => `${m} ${c.current} vs ${c.previous} (${c.pct == null ? 'previous was 0' : `${c.pct > 0 ? '+' : ''}${c.pct}%`})`).join(' · ')}` : '';
10811
+ const tot = d.totals && !d.change ? `\nTOTALS: ${d.totals.map(t => Object.entries(t).map(([k, v]) => `${k}=${v}`).join(' · ')).join(' | ')}` : '';
10812
+ return ok(`${head}${d.filtered ? ' (FILTERED — this is a SUBSET of the property, not its total)' : ''}${d.sampled ? ' (SAMPLED — GA4 dropped rows to answer this; say so when reporting)' : ''}.${chg}${tot}\n${rowLines(d.rows)}`, d);
10551
10813
  }));
10552
10814
  server.registerTool('analytics_realtime', {
10553
10815
  title: 'Who is on the site right now (GA4 realtime)',
@@ -10901,6 +11163,49 @@ function buildTools(rawServer, opts = {}, sink = null) {
10901
11163
  const rows = (d.metrics || []).map(m => `${m.displayName} (${m.apiName}) = ${m.formula} · ${m.metricUnit}${m.invalidMetricReference ? ' · ⚠ INVALID: references a metric this property does not have' : ''}`);
10902
11164
  return ok([d.note, ...rows].filter(Boolean).join('\n'), d);
10903
11165
  }));
11166
+ // ── GA4 REPORTING ANNOTATIONS + DATA RETENTION (2026-10-27, gap sweep G19/G20) ─────────────────────────────────
11167
+ // Both on the held `analytics.edit`. Annotations are v1alpha only; lowering retention DELETES data at Google's next
11168
+ // monthly run, so it is confirm-gated server-side (lib/ga4-admin-extras.mjs carries the citations).
11169
+ server.registerTool('manage_analytics_annotations', {
11170
+ title: 'GA4 chart annotations',
11171
+ description: "MARK WHAT YOU DID ON THE CUSTOMER'S OWN GA4 CHARTS — \"ad launched\", \"post went out\", \"price change\" — so the traffic it moved is visible where they already look. action:'list' reads them (Google's own system annotations included, which cannot be edited or deleted), 'create' adds one on a single date or a startDate–endDate range, 'update' changes one, 'delete' removes one (confirm-gated). color: PURPLE BROWN BLUE GREEN RED CYAN ORANGE (default BLUE). Every write is read back. Free.",
11172
+ inputSchema: {
11173
+ property: z.string().describe('NUMERIC GA4 property id, from list_analytics_properties'),
11174
+ action: z.enum(['list', 'create', 'update', 'delete']).optional().describe("defaults to 'list'"),
11175
+ annotationId: z.string().optional().describe("for update/delete — from action:'list'"),
11176
+ title: z.string().optional().describe('the label on the chart. Required to create'),
11177
+ description: z.string().optional(),
11178
+ color: z.enum(['PURPLE', 'BROWN', 'BLUE', 'GREEN', 'RED', 'CYAN', 'ORANGE']).optional(),
11179
+ date: z.string().optional().describe('YYYY-MM-DD — one day'),
11180
+ startDate: z.string().optional().describe('YYYY-MM-DD — a range, with endDate'),
11181
+ endDate: z.string().optional(),
11182
+ confirm: z.boolean().optional().describe('must be true to actually delete'),
11183
+ },
11184
+ outputSchema: { property: z.string().optional(), action: z.string().optional(), count: z.number().optional(), annotations: z.array(z.any()).optional(), annotation: z.any().optional(), deleted: z.boolean().optional(), verified: z.boolean().nullable().optional(), needsConfirm: z.boolean().optional(), message: z.string().optional(), note: z.string().optional() },
11185
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
11186
+ }, wrap(async (a) => {
11187
+ const d = await apiPost('/api/analytics/annotations', a);
11188
+ if (d.needsConfirm) return ok(d.message, d);
11189
+ const rows = (d.annotations || []).map(r => `${r.date || `${r.startDate} → ${r.endDate}`} · ${r.title} (${r.annotationId}, ${r.color})${r.systemGenerated ? ' · Google’s own' : ''}`);
11190
+ return ok([d.note, ...rows].filter(Boolean).join('\n'), d);
11191
+ }));
11192
+ server.registerTool('analytics_data_retention', {
11193
+ title: 'GA4 data retention',
11194
+ description: "HOW LONG GA4 KEEPS EVENT- AND USER-LEVEL DATA. The default is 2 months, which silently caps every exploration and funnel at the last two months; standard reports are unaffected. action:'get' reads it; 'set' changes eventDataRetention / userDataRetention (2 or 14 months on a standard property; 26, 38, 50 for event data on Analytics 360 only) and resetUserDataOnNewActivity. RAISING applies to data already collected. LOWERING DELETES the older data at Google's next monthly run and cannot be undone, so it answers needsConfirm first — show the user, get a yes, then confirm:true. Read back. Free.",
11195
+ inputSchema: {
11196
+ property: z.string().describe('NUMERIC GA4 property id, from list_analytics_properties'),
11197
+ action: z.enum(['get', 'set']).optional().describe("defaults to 'get'"),
11198
+ eventDataRetention: z.string().optional().describe('months: 2 or 14 (26/38/50 Analytics 360 only), or the enum e.g. FOURTEEN_MONTHS'),
11199
+ userDataRetention: z.string().optional().describe('months: 2 or 14'),
11200
+ resetUserDataOnNewActivity: z.boolean().optional(),
11201
+ confirm: z.boolean().optional().describe('required when the change SHORTENS retention, which deletes data'),
11202
+ },
11203
+ outputSchema: { property: z.string().optional(), action: z.string().optional(), eventDataRetention: z.string().optional(), eventMonths: z.number().nullable().optional(), userDataRetention: z.string().optional(), userMonths: z.number().nullable().optional(), resetUserDataOnNewActivity: z.boolean().optional(), changed: z.array(z.string()).optional(), notApplied: z.array(z.string()).optional(), verified: z.boolean().optional(), needsConfirm: z.boolean().optional(), lowers: z.array(z.string()).optional(), message: z.string().optional(), note: z.string().optional() },
11204
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
11205
+ }, wrap(async (a) => {
11206
+ const d = await apiPost('/api/analytics/data-retention', a);
11207
+ return ok(d.needsConfirm ? d.message : d.note, d);
11208
+ }));
10904
11209
  // ── GOOGLE TAG MANAGER (2026-08-19) ─────────────────────────────────────────────────────────────────────────
10905
11210
  //
10906
11211
  // THE MEASUREMENT HALF OF THE ADS SURFACE, and it closes a gap that was offerable-and-undeliverable in one
@@ -13929,10 +14234,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
13929
14234
  }));
13930
14235
  server.registerTool('delete_x_ads_object', {
13931
14236
  title: 'Delete an X ads object (permanent, cascades)',
13932
- description: "PERMANENTLY delete an X campaign, line item, promoted post or targeting criterion. 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.",
14237
+ 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.",
13933
14238
  inputSchema: {
13934
14239
  accountId: z.string(),
13935
- type: z.enum(['campaign', 'line_item', 'promoted_tweet', 'targeting_criterion', 'promoted_account']),
14240
+ type: z.enum(['campaign', 'line_item', 'promoted_tweet', 'targeting_criterion', 'promoted_account', 'lead_form', 'card', 'media', 'custom_audience']),
13936
14241
  id: z.string().describe('campaign/line-item id, or the id from list_x_ads_promoted_tweets / list_x_ads_targeting'),
13937
14242
  lineItemId: z.string().optional().describe('REQUIRED for type "targeting_criterion" — X cannot look one up without its line item'),
13938
14243
  confirm: z.boolean().optional().describe('omit on the first call to see the blast radius'),
@@ -13963,7 +14268,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13963
14268
  accountId: z.string().describe('from list_x_ads_accounts'),
13964
14269
  campaignId: z.string().describe('from create_x_ads_campaign or list_x_ads_campaigns'),
13965
14270
  name: z.string().optional(),
13966
- objective: z.enum(['APP_ENGAGEMENTS', 'APP_INSTALLS', 'ENGAGEMENTS', 'FOLLOWERS', 'LEAD_GENERATION', 'PREROLL_VIEWS', 'REACH', 'VIDEO_VIEWS', 'WEBSITE_CLICKS']),
14271
+ 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'),
13967
14272
  productType: z.enum(['MEDIA', 'PROMOTED_ACCOUNT', 'PROMOTED_TWEETS']).optional().describe('default PROMOTED_TWEETS \u2014 PROMOTED_ACCOUNT when objective is FOLLOWERS, and that line item serves the ACCOUNT attached with create_x_ads_promoted_account'),
13968
14273
  placements: z.array(z.enum(['ALL_ON_TWITTER', 'PUBLISHER_NETWORK', 'TAP_BANNER', 'TAP_FULL', 'TAP_FULL_LANDSCAPE', 'TAP_MRECT', 'TAP_NATIVE', 'TWITTER_MEDIA_VIEWER', 'TWITTER_PROFILE', 'TWITTER_REPLIES', 'TWITTER_SEARCH', 'TWITTER_TIMELINE'])).optional().describe('default ALL_ON_TWITTER'),
13969
14274
  goal: z.enum(['APP_CLICKS', 'APP_INSTALLS', 'APP_PURCHASES', 'ENGAGEMENT', 'FOLLOWERS', 'LINK_CLICKS', 'MAX_REACH', 'PREROLL', 'PREROLL_STARTS', 'REACH_WITH_ENGAGEMENT', 'SITE_VISITS', 'SOCIAL_ENGAGEMENT', 'VIDEO_VIEW', 'VIEW_15S', 'VIEW_3S_100PCT', 'VIEW_6S', 'WEBSITE_CONVERSIONS', 'WEBSITE_CONVERSIONS_V2']).optional(),
@@ -13993,6 +14298,153 @@ function buildTools(rawServer, opts = {}, sink = null) {
13993
14298
  outputSchema: { accountId: z.string().optional(), users: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
13994
14299
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
13995
14300
  }, 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); }));
14301
+ // ── X ADS LEAD GEN, CARDS, MEDIA, PROMOTED-ONLY POSTS (2026-10-27) — lib/x-ads-creative.mjs on the server ─────────
14302
+ // LEAD_GENERATION was offered and could not collect a lead, and every X ad had to be a public post first.
14303
+ const X_LEAD_FIELD_KEYS_MCP = ['EMAIL', 'PHONE_NUMBER', 'STREET_ADDRESS', 'CITY', 'STATE', 'COUNTRY', 'ZIP_CODE', 'FULL_NAME', 'FIRST_NAME', 'LAST_NAME', 'DATE_OF_BIRTH', 'COMPANY_NAME', 'JOB_TITLE', 'WORK_EMAIL', 'WORK_PHONE_NUMBER', 'GENDER', 'HANDLE'];
14304
+ const xLeadField = z.object({ key: z.enum(X_LEAD_FIELD_KEYS_MCP), optional: z.boolean().optional() });
14305
+ const xUtm = z.object({ source: z.string().optional(), medium: z.string().optional(), campaign: z.string().optional(), content: z.string().optional(), term: z.string().optional() });
14306
+ server.registerTool('list_x_ads_lead_forms', {
14307
+ title: 'List X lead forms',
14308
+ 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.",
14309
+ inputSchema: { accountId: z.string(), leadFormId: z.string().optional(), withDeleted: z.boolean().optional().describe('include archived forms'), limit: z.number().optional() },
14310
+ outputSchema: { accountId: z.string().optional(), leadForms: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
14311
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
14312
+ }, 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); }));
14313
+ server.registerTool('create_x_ads_lead_form', {
14314
+ title: 'Create an X lead form',
14315
+ 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.",
14316
+ inputSchema: {
14317
+ accountId: z.string(), name: z.string(), purpose: z.string().describe('what the leads are used for (shown to the person)'),
14318
+ fields: z.array(xLeadField).describe('at least one must not be optional'), privacyPolicyUrl: z.string().describe('https'), companyName: z.string(),
14319
+ headline: z.string().optional().describe('intro card headline'), description: z.string().optional().describe('intro card text'),
14320
+ thankYouHeadline: z.string().optional(), thankYouDescription: z.string().optional(), thankYouUrl: z.string().optional().describe('https link on the thank-you button'), thankYouButton: z.string().optional(),
14321
+ conversionEventId: z.string().optional().describe('Event ID from X Events Manager — required when the ad account has no X Pixel'),
14322
+ },
14323
+ outputSchema: { accountId: z.string().optional(), leadFormId: z.string().optional(), leadForm: z.any().optional(), note: z.string().optional() },
14324
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
14325
+ }, wrap(async (a) => { const d = await apiPost('/api/x-ads/lead-form', a); return ok(d.note, d); }));
14326
+ server.registerTool('update_x_ads_lead_form', {
14327
+ title: 'Change an X lead form',
14328
+ 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.",
14329
+ inputSchema: {
14330
+ accountId: z.string(), leadFormId: z.string(), name: z.string().optional(), purpose: z.string().optional(), fields: z.array(xLeadField).optional(),
14331
+ privacyPolicyUrl: z.string().optional(), companyName: z.string().optional(), headline: z.string().optional(), description: z.string().optional(),
14332
+ thankYouHeadline: z.string().optional(), thankYouDescription: z.string().optional(), thankYouUrl: z.string().optional(), thankYouButton: z.string().optional(), conversionEventId: z.string().optional(),
14333
+ },
14334
+ outputSchema: { accountId: z.string().optional(), leadFormId: z.string().optional(), leadForm: z.any().optional(), updated: z.array(z.string()).optional(), note: z.string().optional() },
14335
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
14336
+ }, wrap(async (a) => { const d = await apiPost('/api/x-ads/lead-form-update', a); return ok(d.note, d); }));
14337
+ server.registerTool('get_x_ads_leads', {
14338
+ title: 'Read the leads from an X lead form',
14339
+ 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.",
14340
+ inputSchema: { accountId: z.string(), leadFormId: z.string().optional(), startTime: z.string().optional().describe('ISO 8601'), endTime: z.string().optional().describe('ISO 8601; default now'), days: z.number().optional().describe('window length when startTime is omitted; default 30'), format: z.enum(['json', 'csv', 'xlsx']).optional(), exportId: z.string().optional().describe('collect an export that was still running') },
14341
+ 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() },
14342
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
14343
+ }, 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); }));
14344
+ server.registerTool('upload_x_ads_media', {
14345
+ title: 'Upload media to an X ad account',
14346
+ 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.",
14347
+ inputSchema: { accountId: z.string(), url: z.string(), name: z.string().optional().describe('label in the Media Library') },
14348
+ 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() },
14349
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
14350
+ }, wrap(async (a) => { const d = await apiPost('/api/x-ads/media', a); return ok(d.note, d); }));
14351
+ server.registerTool('list_x_ads_media', {
14352
+ title: 'List an X ad account’s Media Library',
14353
+ description: "List the X ad account's Media Library — media keys usable in cards and promoted-only posts. Read-only, free.",
14354
+ inputSchema: { accountId: z.string(), mediaType: z.enum(['IMAGE', 'VIDEO', 'GIF']).optional(), limit: z.number().optional() },
14355
+ outputSchema: { accountId: z.string().optional(), media: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
14356
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
14357
+ }, 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); }));
14358
+ server.registerTool('create_x_ads_card', {
14359
+ title: 'Build an X ad card',
14360
+ 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.",
14361
+ inputSchema: {
14362
+ accountId: z.string(), name: z.string(), mediaKey: z.string().optional(), mediaKeys: z.array(z.string()).optional(),
14363
+ title: z.string().optional().describe('headline, for website and lead-form cards'), websiteUrl: z.string().optional(), utm: xUtm.optional(),
14364
+ leadFormId: z.string().optional(), buttonLabel: z.enum(['BOOK', 'CONNECT', 'INSTALL', 'OPEN', 'ORDER', 'PLAY', 'SHOP']).optional(),
14365
+ iosAppId: z.string().optional(), googlePlayAppId: z.string().optional(), countryCode: z.string().optional(), iosDeepLink: z.string().optional(), googlePlayDeepLink: z.string().optional(),
14366
+ },
14367
+ outputSchema: { accountId: z.string().optional(), cardId: z.string().optional(), cardUri: z.string().optional(), cardType: z.string().optional(), destination: z.any().optional(), note: z.string().optional() },
14368
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
14369
+ }, wrap(async (a) => { const d = await apiPost('/api/x-ads/card', a); return ok(d.note, d); }));
14370
+ server.registerTool('list_x_ads_cards', {
14371
+ title: 'List X ad cards',
14372
+ description: 'List the cards on an X ad account with their card_uri and destination. Read-only, free.',
14373
+ inputSchema: { accountId: z.string(), query: z.string().optional().describe('name prefix'), limit: z.number().optional() },
14374
+ outputSchema: { accountId: z.string().optional(), cards: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
14375
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
14376
+ }, 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); }));
14377
+ server.registerTool('create_x_ads_post', {
14378
+ title: 'Write a promoted-only X post for ads',
14379
+ 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.",
14380
+ inputSchema: {
14381
+ accountId: z.string(), text: z.string().optional(), cardUri: z.string().optional(), mediaKeys: z.array(z.string()).optional(),
14382
+ name: z.string().optional().describe('internal name in ads.x.com'), promotedOnly: z.boolean().optional().describe('default true'), confirmPublic: z.boolean().optional(),
14383
+ asUserId: z.string().optional(), conversationSettings: z.enum(['EVERYONE', 'FOLLOWING', 'MENTIONED_USERS']).optional(),
14384
+ videoTitle: z.string().optional(), videoDescription: z.string().optional(), videoCta: z.enum(['VISIT_SITE', 'WATCH_NOW']).optional(), videoCtaUrl: z.string().optional(), utm: xUtm.optional(),
14385
+ lineItemId: z.string().optional().describe('attach to this line item right away'),
14386
+ },
14387
+ outputSchema: { accountId: z.string().optional(), tweetId: z.string().optional(), promotedOnly: z.boolean().nullable().optional(), cardUri: z.string().nullable().optional(), asUserId: z.string().optional(), verified: z.boolean().optional(), promotedTweetIds: z.array(z.string()).nullable().optional(), note: z.string().optional() },
14388
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
14389
+ }, wrap(async (a) => { const d = await apiPost('/api/x-ads/post', a); return ok(d.note, d); }));
14390
+ server.registerTool('list_x_ads_posts', {
14391
+ title: 'List the posts an X ad account can promote',
14392
+ description: 'List the posts an X ad account can promote — promoted-only ones by default (timeline NULLCAST), or ORGANIC / ALL. Read-only, free.',
14393
+ inputSchema: { accountId: z.string(), timeline: z.enum(['ALL', 'NULLCAST', 'ORGANIC']).optional(), tweetType: z.enum(['DRAFT', 'PUBLISHED', 'SCHEDULED']).optional(), tweetIds: z.array(z.string()).optional(), asUserId: z.string().optional(), limit: z.number().optional() },
14394
+ outputSchema: { accountId: z.string().optional(), posts: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
14395
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
14396
+ }, 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); }));
14397
+ server.registerTool('preview_x_ads_post', {
14398
+ title: 'Preview an X ad post',
14399
+ 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.',
14400
+ inputSchema: { accountId: z.string(), tweetIds: z.array(z.string()), tweetType: z.enum(['DRAFT', 'PUBLISHED', 'SCHEDULED']).optional() },
14401
+ outputSchema: { accountId: z.string().optional(), previews: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
14402
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
14403
+ }, wrap(async (a) => { const d = await apiGet('/api/x-ads/preview', a); return ok(d.note, d); }));
14404
+ // ── X ADS CUSTOM AUDIENCES + CONVERSION API (2026-10-27) — lib/x-ads-audiences.mjs on the server ────────────────
14405
+ const xPeople = {
14406
+ emails: z.array(z.string()).optional().describe('plain addresses or 64-hex SHA-256 hashes'),
14407
+ phones: z.array(z.string()).optional().describe('E.164, e.g. +14155550100, or hashes'),
14408
+ handles: z.array(z.string()).optional().describe('X handles, with or without @'),
14409
+ twitterIds: z.array(z.string()).optional(), deviceIds: z.array(z.string()).optional().describe('IDFA / AdID / Android id'),
14410
+ users: z.array(z.record(z.any())).optional().describe('one person per entry with several identifiers: {email, phone, handle, twitterId, deviceId}'),
14411
+ };
14412
+ server.registerTool('list_x_ads_audiences', {
14413
+ title: 'List X custom audiences',
14414
+ 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.',
14415
+ inputSchema: { accountId: z.string(), audienceIds: z.array(z.string()).optional(), query: z.string().optional().describe('name prefix'), shared: z.boolean().optional().describe('lists shared with this account instead of owned'), limit: z.number().optional() },
14416
+ outputSchema: { accountId: z.string().optional(), audiences: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
14417
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
14418
+ }, 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); }));
14419
+ server.registerTool('create_x_ads_audience', {
14420
+ title: 'Create an X custom audience',
14421
+ 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.",
14422
+ inputSchema: { accountId: z.string(), name: z.string().describe('unique per account'), description: z.string().optional(), ...xPeople, expiresAt: z.string().optional().describe('ISO 8601; X defaults to 13 months') },
14423
+ outputSchema: { accountId: z.string().optional(), audienceId: z.string().optional(), audience: z.any().optional(), users: z.any().optional(), note: z.string().optional() },
14424
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
14425
+ }, wrap(async (a) => { const d = await apiPost('/api/x-ads/audience', a); return ok(d.note, d); }));
14426
+ server.registerTool('update_x_ads_audience', {
14427
+ title: 'Change an X custom audience',
14428
+ 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.',
14429
+ inputSchema: { accountId: z.string(), audienceId: z.string(), name: z.string().optional(), description: z.string().optional(), operation: z.enum(['add', 'remove']).optional(), ...xPeople, effectiveAt: z.string().optional(), expiresAt: z.string().optional() },
14430
+ outputSchema: { accountId: z.string().optional(), audienceId: z.string().optional(), audience: z.any().optional(), users: z.any().optional(), note: z.string().optional() },
14431
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
14432
+ }, wrap(async (a) => { const d = await apiPost('/api/x-ads/audience-update', a); return ok(d.note, d); }));
14433
+ server.registerTool('send_x_ads_conversions', {
14434
+ title: 'Send conversions to X (Conversion API)',
14435
+ 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.",
14436
+ inputSchema: {
14437
+ accountId: z.string(), pixelId: z.string().describe('the X Pixel (event source) id from Events Manager'), eventId: z.string().optional().describe('default Event ID for every event'),
14438
+ events: z.array(z.object({
14439
+ eventId: z.string().optional(), conversionTime: z.string().optional().describe('ISO 8601; default now'), twclid: z.string().optional(),
14440
+ email: z.string().optional(), phone: z.string().optional(), ipAddress: z.string().optional(), userAgent: z.string().optional(),
14441
+ value: z.number().optional(), currency: z.string().optional(), numberItems: z.number().optional(),
14442
+ conversionId: z.string().optional().describe('dedup key shared with the X Pixel'), description: z.string().optional(), eventSourceUrl: z.string().optional(),
14443
+ })),
14444
+ },
14445
+ outputSchema: { pixelId: z.string().optional(), sent: z.number().optional(), processed: z.number().nullable().optional(), debugId: z.string().nullable().optional(), note: z.string().optional() },
14446
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
14447
+ }, wrap(async (a) => { const d = await apiPost('/api/x-ads/conversions', a); return ok(d.note, d); }));
13996
14448
  server.registerTool('create_x_ads_promoted_tweet', {
13997
14449
  title: 'Attach a post to an X line item (paused)',
13998
14450
  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.",
@@ -14936,6 +15388,28 @@ function buildTools(rawServer, opts = {}, sink = null) {
14936
15388
  const d = await apiGet('/api/pinterest/keyword-ideas', { adAccountId: a.adAccountId, mode: a.mode, terms: (a.terms || []).join(','), limit: a.limit, countryCode: a.countryCode });
14937
15389
  return ok(`${d.note}\n${JSON.stringify(d.groups || d.terms || d.keywords || []).slice(0, 5000)}`, d);
14938
15390
  }));
15391
+ // Product-group promotions: the ADS half of Shopping — what makes a CATALOG_SALES ad group serve products.
15392
+ server.registerTool('list_pinterest_ads_product_group_promotions', {
15393
+ title: 'List Pinterest product-group promotions',
15394
+ description: 'List the PRODUCT GROUPS being promoted in one Pinterest Shopping ad group — Pinterest lists them per ad group, so adGroupId is required — with status and bid. A CATALOG_SALES ad group with none has no products to serve. Read-only, 0 credits.',
15395
+ inputSchema: { adAccountId: pinAcct, adGroupId: z.string() },
15396
+ outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), promotions: z.array(z.any()).optional(), note: z.string().optional() },
15397
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
15398
+ }, wrap(async (a) => { const d = await apiGet('/api/pinterest/product-group-promotions', { adAccountId: a.adAccountId, adGroupId: a.adGroupId }); return ok(`${d.note}\n${JSON.stringify(d.promotions || []).slice(0, 4000)}`, d); }));
15399
+ server.registerTool('create_pinterest_ads_product_group_promotion', {
15400
+ title: 'Promote catalog product groups in a Pinterest ad group',
15401
+ description: 'Attach catalog PRODUCT GROUPS to a Pinterest Shopping (CATALOG_SALES) ad group — this is what makes a Shopping ad group serve products. Created PAUSED by default; creating them ACTIVE under a LIVE campaign and ad group starts real spend, so that needs an explicit yes and confirm:true. Optional bid (ad account currency), included:false to exclude a group, creativeType, cta, trackingUrl. Read back from Pinterest. 0 credits.',
15402
+ inputSchema: { adAccountId: pinAcct, adGroupId: z.string(), productGroupIds: z.array(z.string()), status: z.enum(['ACTIVE', 'PAUSED']).optional().describe('default PAUSED'), bid: z.number().optional(), included: z.boolean().optional(), creativeType: z.enum(['SHOPPING', 'COLLECTION', 'CAROUSEL', 'MAX_VIDEO']).optional(), cta: z.string().optional().describe('catalog sales CTAs: SHOP_NOW, BOOK_NOW, ON_SALE, GET_DEAL'), trackingUrl: z.string().optional(), confirm: z.boolean().optional() },
15403
+ outputSchema: { ok: z.boolean().optional(), verified: z.boolean().optional(), promotions: z.array(z.any()).optional(), note: z.string().optional() },
15404
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
15405
+ }, wrap(async (a) => { const d = await apiPost('/api/pinterest/product-group-promotion', a); return ok(d.note, d); }));
15406
+ server.registerTool('update_pinterest_ads_product_group_promotion', {
15407
+ title: 'Change a Pinterest product-group promotion',
15408
+ description: 'Change one Pinterest product-group promotion — status (ACTIVE / PAUSED / ARCHIVED), bid, included, trackingUrl. Activating it, or moving its bid, under a LIVE campaign and ad group changes real spend and needs confirm:true. Read back from Pinterest. 0 credits.',
15409
+ inputSchema: { adAccountId: pinAcct, promotionId: z.string(), status: z.enum(['ACTIVE', 'PAUSED', 'ARCHIVED']).optional(), bid: z.number().optional(), included: z.boolean().optional(), trackingUrl: z.string().optional(), confirm: z.boolean().optional() },
15410
+ outputSchema: { ok: z.boolean().optional(), verified: z.boolean().optional(), promotion: z.any().optional(), note: z.string().optional() },
15411
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
15412
+ }, wrap(async (a) => { const d = await apiPost('/api/pinterest/product-group-promotion/update', a); return ok(d.note, d); }));
14939
15413
  server.registerTool('preview_pinterest_ads_ad', {
14940
15414
  title: 'Preview a Pinterest ad',
14941
15415
  description: 'See a Pinterest AD before spending — Pinterest renders a preview link (valid 7 days, iframe-able) from an existing promotable Pin (pinId), from an image not yet pinned (imageUrl + title), or from a catalog product group (productGroupId, for a shopping ad). Creates no ad, spends nothing, 0 credits.',
@@ -14967,12 +15441,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
14967
15441
  const d = await apiGet('/api/reddit-ads/campaigns', { adAccountId: a.adAccountId });
14968
15442
  const c = (d.campaigns || []).map(x => `• campaign ${x.name} (${x.id}) — ${x.configured_status}/${x.effective_status}, ${x.objective}`);
14969
15443
  const g = (d.adGroups || []).map(x => `• ad group ${x.name} (${x.id}) — ${x.configured_status}/${x.effective_status}`);
14970
- const s = (d.ads || []).map(x => `• ad ${x.name} (${x.id}) — ${x.configured_status}/${x.effective_status}`);
15444
+ // A REJECTED ad carries Reddit's own reason code (rejection_reason, ~130 policy values); print it beside the status
15445
+ // so "why isn't my ad running?" is answered by the list itself.
15446
+ const s = (d.ads || []).map(x => `• ad ${x.name} (${x.id}) — ${x.configured_status}/${x.effective_status}${x.rejection_reason ? ` — REJECTED by Reddit: ${x.rejection_reason}` : ''}${x.preview_url ? ` — preview ${x.preview_url}` : ''}`);
14971
15447
  return ok(`Reddit ad account ${d.adAccountId}${d.name ? ` (${d.name})` : ''}: ${(d.campaigns || []).length} campaign(s), ${(d.adGroups || []).length} ad group(s), ${(d.ads || []).length} ad(s).\n${[...c, ...g, ...s].join('\n') || '(empty)'}`, d);
14972
15448
  }));
14973
15449
  server.registerTool('reddit_ads_report', {
14974
15450
  title: 'Reddit ads performance report',
14975
- description: 'Performance for a Reddit ad account — impressions, clicks, spend, CTR, CPC, eCPM, reach and any of Reddit’s ~450 metric fields, optionally broken down by campaign, ad group, ad, date, hour, community, country, gender, interest, keyword, placement and more. Money comes back in WHOLE UNITS of the ad account’s currency (Reddit reports micro-currency; the conversion is done for you) — and that currency is not necessarily dollars, so check the account. Reddit only accepts HOURLY window boundaries; plain YYYY-MM-DD dates are accepted here and snapped for you. A report with ZERO rows genuinely means nothing delivered in that window — say exactly that and never present zeros as measured performance. ONE DATED LIMIT WORTH PLANNING AROUND: from 2026-10-30 Reddit caps the HOUR breakdown at windows of 7 DAYS OR FEWER and answers 400 beyond that. Hermoso warns before that date and refuses after it rather than relaying a bare vendor 400 — for a longer stretch, run one report per <=7-day window, or use the DATE breakdown for a daily series over the whole period. Read-only, free.',
15451
+ description: 'Performance for a Reddit ad account — impressions, clicks, spend, CTR, CPC, eCPM, reach and any of Reddit’s ~450 metric fields, optionally broken down by campaign, ad group, ad, date, hour, community, country, gender, interest, keyword, placement and more. Money comes back in WHOLE UNITS of the ad account’s currency (Reddit reports micro-currency; the conversion is done for you) — and that currency is not necessarily dollars, so check the account. Reddit only accepts HOURLY window boundaries; plain YYYY-MM-DD dates are accepted here and snapped for you. A report with ZERO rows genuinely means nothing delivered in that window — say exactly that and never present zeros as measured performance. ONE DATED LIMIT WORTH PLANNING AROUND: from 2026-12-08 Reddit caps the HOUR breakdown at windows of 7 DAYS OR FEWER and answers 400 beyond that. Hermoso warns before that date and refuses after it rather than relaying a bare vendor 400 — for a longer stretch, run one report per <=7-day window, or use the DATE breakdown for a daily series over the whole period. Read-only, free.',
14976
15452
  inputSchema: {
14977
15453
  adAccountId: z.string().optional(),
14978
15454
  since: z.string().optional().describe('YYYY-MM-DD or full ISO timestamp, default 30 days ago'),
@@ -14999,20 +15475,22 @@ function buildTools(rawServer, opts = {}, sink = null) {
14999
15475
  return ok(`${d.note}\n${(d.profiles || []).map(p => `• ${p.name} (${p.id})`).join('\n')}`, d);
15000
15476
  }));
15001
15477
  server.registerTool('search_reddit_ads_targeting', {
15002
- title: 'Resolve Reddit communities / geolocations / interests for targeting',
15003
- description: 'Look up the exact values Reddit ad-group targeting expects, so none of them has to be guessed. kind:"communities" searches subreddits by keyword and returns each one’s NAME plus its subscriber count — targeting wants the bare name ("running"), NOT the t5_ id and NOT "r/running". kind:"geolocations" lists targetable places (pass country like US, or a city search) — targeting accepts a country code or one of the returned ids. kind:"interests" lists Reddit’s interest taxonomy — targeting wants the id ("pets_v3"). Read-only, free. Use this before create_reddit_ads_ad_group rather than inventing a community name.',
15478
+ title: 'Resolve Reddit targeting: communities, keywords, geos, interests, devices, carriers, languages',
15479
+ description: 'Look up the exact values Reddit ad-group targeting expects, so none of them has to be guessed. kind:"communities" searches subreddits by keyword and returns each one’s NAME plus its subscriber count — targeting wants the bare name ("running"), NOT the t5_ id and NOT "r/running". kind:"community_suggestions" asks Reddit which communities fit some topics (query, comma-separated) and/or a websiteUrl — the best way to widen reach. kind:"keywords" turns seed terms (keywords, or query comma-separated) into Reddit’s keyword suggestions WITH MONTHLY VIEWS, sorted biggest first — also a quick read on what Reddit is talking about. kind:"keyword_check" asks Reddit whether keywords are targetable under its brand-safety rules; leave the unsafe ones out of targeting.keywords. kind:"geolocations" lists targetable places (pass country like US, or a city search) — targeting accepts a country code or one of the returned ids. kind:"interests" lists Reddit’s interest taxonomy — targeting wants the id ("pets_v3"). kind:"devices" / "carriers" / "languages" list what targeting.devices / carriers (e.g. VERIZON_US) / languages (e.g. EN) take; "third_party_audiences" lists partner data segments with their size and CPM data cost. Read-only, free. Use this before create_reddit_ads_ad_group rather than inventing a value.',
15004
15480
  inputSchema: {
15005
15481
  adAccountId: z.string().optional(),
15006
- kind: z.enum(['communities', 'geolocations', 'interests']).optional().describe('default communities'),
15007
- query: z.string().optional().describe('keyword — required for communities, filters interests, searches cities for geolocations'),
15482
+ kind: z.enum(['communities', 'community_suggestions', 'keywords', 'keyword_check', 'geolocations', 'interests', 'devices', 'carriers', 'languages', 'third_party_audiences']).optional().describe('default communities'),
15483
+ query: z.string().optional().describe('keyword — required for communities; topics for community_suggestions; seeds for keywords; filters interests / devices / carriers / languages; searches cities for geolocations'),
15484
+ keywords: z.array(z.string()).optional().describe('keywords / keyword_check: the terms (up to 1,000, each ≤100 characters)'),
15485
+ websiteUrl: z.string().optional().describe('community_suggestions: suggest communities related to this site'),
15008
15486
  country: z.string().optional().describe('2-letter country code, geolocations only'),
15009
15487
  postalCode: z.string().optional(),
15010
15488
  limit: z.number().optional().describe('max results, default 15'),
15011
15489
  },
15012
- outputSchema: { kind: z.string().optional(), count: z.number().optional(), results: z.array(z.any()).optional(), note: z.string().optional() },
15490
+ outputSchema: { kind: z.string().optional(), count: z.number().optional(), results: z.array(z.any()).optional(), unsafe: z.array(z.string()).optional(), note: z.string().optional() },
15013
15491
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
15014
15492
  }, wrap(async (a) => {
15015
- const d = await apiGet('/api/reddit-ads/targeting', a);
15493
+ const d = Array.isArray(a.keywords) && a.keywords.length ? await apiPost('/api/reddit-ads/targeting', a) : await apiGet('/api/reddit-ads/targeting', a);
15016
15494
  return ok(`${d.note}\n${JSON.stringify((d.results || []).slice(0, 30))}`, d);
15017
15495
  }));
15018
15496
  server.registerTool('reddit_ads_forecast', {
@@ -15124,13 +15602,29 @@ function buildTools(rawServer, opts = {}, sink = null) {
15124
15602
  name: z.string(),
15125
15603
  objective: z.enum(['APP_INSTALLS', 'BRAND_AWARENESS', 'CATALOG_SALES', 'CLICKS', 'CONVERSIONS', 'IMPRESSIONS', 'LEAD_GENERATION', 'SALES', 'VIDEO_VIEWABLE_IMPRESSIONS']).optional().describe('default CLICKS \u2014 which is what Reddit calls website traffic'),
15126
15604
  spendCapCents: z.number().optional().describe('lifetime spend ceiling for the whole campaign, in minor units of the ad account\u2019s currency'),
15127
- useCatalog: z.boolean().optional().describe('SALES only: true makes it a product-catalog campaign (the new spelling of CATALOG_SALES) \u2014 every ad group under it must then use a catalog (create_reddit_ads_ad_group with productSetId + shoppingType). Frozen once created'),
15128
- },
15129
- outputSchema: { id: z.string().optional(), name: z.string().optional(), objective: z.string().optional(), status: z.string().optional(), useCatalog: z.boolean().optional(), adAccountId: z.string().optional() },
15605
+ useCatalog: z.boolean().optional().describe('SALES only: true makes it a product-catalog campaign (the new spelling of CATALOG_SALES) \u2014 every ad group under it must then use a catalog (create_reddit_ads_ad_group with productSetId + shoppingType; list_reddit_ads_catalogs gives the product set ids, create_reddit_ads_catalog / create_reddit_ads_product_set make them). Frozen once created'),
15606
+ isCampaignBudgetOptimization: z.boolean().optional().describe('CAMPAIGN BUDGET OPTIMIZATION (Reddit\u2019s recommended structure): the budget and bidding live on the campaign and Reddit spreads the budget across its ad groups, which then inherit goal type, bid type and bid strategy. Needs budget, startTime, bidStrategy, bidType (and optimizationGoal on a CONVERSIONS / SALES / VIDEO_VIEWABLE_IMPRESSIONS campaign); a conversion pixel is attached automatically when the account has one. Cannot be changed after the campaign is created'),
15607
+ budget: z.number().optional().describe('CBO only: the campaign budget in the ad account\u2019s currency, paired with goalType'),
15608
+ goalType: z.enum(['DAILY_SPEND', 'LIFETIME_SPEND']).optional().describe('CBO only, default DAILY_SPEND; LIFETIME_SPEND needs endTime'),
15609
+ startTime: z.string().optional().describe('CBO only: ISO 8601'),
15610
+ endTime: z.string().optional().describe('CBO only: ISO 8601, required for LIFETIME_SPEND'),
15611
+ bidStrategy: z.enum(['BIDLESS', 'MAXIMIZE_VOLUME', 'TARGET_CPX']).optional().describe('CBO only (MANUAL_BIDDING is ad-group only)'),
15612
+ bidType: z.enum(['CPC', 'CPM', 'CPV6', 'CPV15']).optional().describe('CBO only, must fit the objective \u2014 CLICKS takes CPC'),
15613
+ bidAmount: z.number().optional().describe('CBO only: target cost in the ad account\u2019s currency \u2014 required for TARGET_CPX; when set here no ad group may set its own'),
15614
+ optimizationGoal: z.string().optional().describe('CBO only: e.g. PURCHASE, SIGN_UP, LEAD, ADD_TO_CART, PAGE_VISIT, CLICKS. Cannot be changed later'),
15615
+ viewThroughConversionType: z.enum(['SEVEN_DAY_CLICKS', 'SEVEN_DAY_CLICKS_ONE_DAY_VIEW']).optional().describe('CBO only: the conversion attribution window. Cannot be changed later'),
15616
+ conversionPixelId: z.string().optional().describe('CBO only: needed when the ad account has more than one pixel'),
15617
+ schedule: z.array(z.object({ startDay: z.number(), endDay: z.number(), startHour: z.number(), endHour: z.number() })).optional().describe('CBO only: weekly delivery windows (day 0 = Sunday, hour 0-23) \u2014 replaces every ad group\u2019s schedule'),
15618
+ specialAdCategories: z.array(z.enum(['HOUSING_EMPLOYMENT_CREDIT', 'NONE'])).optional().describe('HOUSING_EMPLOYMENT_CREDIT for housing, employment or credit ads \u2014 Reddit then restricts their targeting. Cannot be changed after creation'),
15619
+ appId: z.string().optional().describe('APP_INSTALLS only: the App Store id (e.g. 1064216828) or Google Play package (e.g. com.reddit.frontpage)'),
15620
+ fundingInstrumentId: z.string().optional().describe('bill this campaign to a specific Reddit funding instrument'),
15621
+ invoiceLabel: z.string().optional().describe('agency reconciliation label on the invoice, \u226480 characters \u2014 Reddit ignores it on credit-card-funded campaigns, and the reply says so'),
15622
+ },
15623
+ outputSchema: { id: z.string().optional(), name: z.string().optional(), objective: z.string().optional(), status: z.string().optional(), useCatalog: z.boolean().optional(), isCampaignBudgetOptimization: z.boolean().optional(), budget: z.number().nullable().optional(), specialAdCategories: z.array(z.string()).nullable().optional(), appId: z.string().nullable().optional(), adAccountId: z.string().optional(), note: z.string().optional() },
15130
15624
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
15131
15625
  }, wrap(async (a) => {
15132
15626
  const d = await apiPost('/api/reddit-ads/campaigns', a);
15133
- return ok(`Created Reddit campaign "${d.name}" (${d.id}) with objective ${d.objective}, status ${d.status} \u2014 PAUSED and spending nothing. Next: create_reddit_ads_post for the creative, then create_reddit_ads_ad_group under this campaign, then create_reddit_ads_ad.`, d);
15627
+ return ok(`${d.note || `Created Reddit campaign "${d.name}" (${d.id}) with objective ${d.objective}, status ${d.status} \u2014 PAUSED and spending nothing.`} Next: create_reddit_ads_post for the creative, then create_reddit_ads_ad_group under this campaign, then create_reddit_ads_ad.`, d);
15134
15628
  }));
15135
15629
  server.registerTool('create_reddit_ads_ad_group', {
15136
15630
  title: 'Create a Reddit ad group (targeting, budget, bidding, schedule)',
@@ -15139,12 +15633,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
15139
15633
  adAccountId: z.string().optional(),
15140
15634
  campaignId: z.string().describe('the campaign this ad group belongs to'),
15141
15635
  name: z.string(),
15142
- budget: z.number().describe('budget in the ad account’s currency, paired with goalType'),
15143
- goalType: z.enum(['DAILY_SPEND', 'LIFETIME_SPEND']).optional().describe('default DAILY_SPEND'),
15144
- bidType: z.enum(['CPC', 'CPM', 'CPV', 'CPV6', 'CPV15']).describe('must fit the campaign objective — CLICKS campaigns take CPC'),
15145
- bidStrategy: z.enum(['BIDLESS', 'MANUAL_BIDDING', 'MAXIMIZE_VOLUME', 'TARGET_CPX']).describe('MANUAL_BIDDING needs bidAmount'),
15636
+ budget: z.number().optional().describe('budget in the ad account’s currency, paired with goalType — required, EXCEPT under a campaign-budget-optimization campaign, where the budget lives on the campaign and must be left off'),
15637
+ goalType: z.enum(['DAILY_SPEND', 'LIFETIME_SPEND']).optional().describe('default DAILY_SPEND; under a CBO campaign it is taken from the campaign'),
15638
+ bidType: z.enum(['CPC', 'CPM', 'CPV', 'CPV6', 'CPV15']).optional().describe('required — must fit the campaign objective, CLICKS campaigns take CPC; under a CBO campaign it is taken from the campaign'),
15639
+ bidStrategy: z.enum(['BIDLESS', 'MANUAL_BIDDING', 'MAXIMIZE_VOLUME', 'TARGET_CPX']).optional().describe('required — MANUAL_BIDDING needs bidAmount; under a CBO campaign it is taken from the campaign'),
15146
15640
  bidAmount: z.number().optional().describe('bid in the ad account’s currency — required for paid bid types; ask reddit_ads_bid_suggestion'),
15147
- startTime: z.string().describe('ISO 8601, e.g. 2026-08-15T00:00:00Z — Reddit rejects the create without one'),
15641
+ startTime: z.string().optional().describe('ISO 8601, e.g. 2026-08-15T00:00:00Z — required (Reddit rejects the create without one), except under a CBO campaign, where it defaults to the campaign’s start'),
15148
15642
  endTime: z.string().optional(),
15149
15643
  conversionPixelId: z.string().optional().describe('only needed when the ad account has more than one pixel'),
15150
15644
  optimizationGoal: z.string().optional().describe('the event this ad group optimizes toward, e.g. PURCHASE, SIGN_UP, LEAD, ADD_TO_CART, PAGE_VISIT, CLICKS, VIDEO_VIEW_6S; leave it unset on a CONVERSIONS or SALES campaign to take the campaign\u2019s. CUSTOM_EVENT (new) optimizes toward a custom pixel event: Reddit switches it on per ad account and answers "not available for this ad account" where it has not. Cannot be changed later'),
@@ -15214,17 +15708,29 @@ function buildTools(rawServer, opts = {}, sink = null) {
15214
15708
  }));
15215
15709
  server.registerTool('create_reddit_ads_ad', {
15216
15710
  title: 'Create a Reddit ad',
15217
- description: 'Create the ad itself — the object that binds a POST (the creative) to an AD GROUP (the targeting and budget). Create the post first with create_reddit_ads_post and pass its id as postId; without a post there is nothing to show. ALWAYS created PAUSED, and Reddit additionally has to APPROVE it, so a fresh ad reports effective status PENDING_APPROVAL — report that rather than calling it live. GOTCHA: a TEXT ("free form") post’s ad may not carry clickUrl at all — Reddit refuses it. The click destination for image and link ads lives on the POST’s media destinationUrl, not here.',
15711
+ description: 'Create the ad itself — the object that binds a POST (the creative) to an AD GROUP (the targeting and budget). Create the post first with create_reddit_ads_post and pass its id as postId; without a post there is nothing to show. A CATALOG ad (an ad group with a productSetId under a SALES useCatalog campaign) has no post: pass shoppingCreative (headline, callToAction, optional destinationUrl, secondLineCta PRICE/DESCRIPTION, dpaCarouselMode, heroCard for a collection ad) plus redditProfileId, and optionally up to 6 productIds to feature. ALWAYS created PAUSED, and Reddit additionally has to APPROVE it, so a fresh ad reports effective status PENDING_APPROVAL — report that rather than calling it live. UTMs: clickUrlQueryParameters ({"utm_source":"reddit","utm_campaign":"{{CAMPAIGN_ID}}"} — Reddit macros allowed, ≤14) are appended to the click URL by Reddit — Reddit allows that list only on some campaigns and otherwise refuses “URL Parameters cannot be added to this campaign”; then write the parameters into clickUrl itself. PREVIEW: previewDays (1-30) asks Reddit for a preview link, read back as previewUrl (the very first time, Reddit wants the test URL generated once in Ads Manager — the reply says so). GOTCHA: a TEXT ("free form") post’s ad may not carry clickUrl at all — Reddit refuses it. The click destination for image and link ads lives on the POST’s media destinationUrl, not here.',
15218
15712
  inputSchema: {
15219
15713
  adAccountId: z.string().optional(),
15220
15714
  adGroupId: z.string().describe('the ad group whose targeting and budget this ad runs under'),
15221
15715
  name: z.string(),
15222
- postId: z.string().describe('the post to promote (t3_…) from create_reddit_ads_post or list_reddit_ads_posts'),
15716
+ postId: z.string().optional().describe('the post to promote (t3_…) from create_reddit_ads_post or list_reddit_ads_posts — required except for a catalog ad built from shoppingCreative'),
15223
15717
  clickUrl: z.string().optional().describe('leave unset for TEXT-post ads — Reddit rejects "Free form ads cannot have a click url"'),
15718
+ clickUrlQueryParameters: z.record(z.string()).optional().describe('UTM / tracking parameters Reddit appends to the click URL, e.g. {"utm_source":"reddit","utm_medium":"paid","utm_content":"{{AD_ID}}"} — up to 14'),
15719
+ previewDays: z.number().optional().describe('1-30: switch on a preview link that stays valid this many days'),
15224
15720
  redditProfileId: z.string().optional().describe('the post author profile — required for catalog sales campaigns'),
15225
15721
  eventTrackers: z.array(z.object({ type: z.enum(['CLICK', 'VIEW']), url: z.string() })).optional().describe('third-party measurement URLs; only Reddit-approved providers are accepted'),
15226
- },
15227
- outputSchema: { id: z.string().optional(), name: z.string().optional(), status: z.string().optional(), effectiveStatus: z.string().optional(), postId: z.string().nullable().optional(), note: z.string().optional() },
15722
+ productIds: z.array(z.string()).optional().describe('catalog ads: up to 6 product ids to feature'),
15723
+ shoppingCreative: z.object({
15724
+ headline: z.string().optional(),
15725
+ callToAction: z.string().optional().describe('Reddit’s exact CTA text, e.g. "Shop Now"'),
15726
+ destinationUrl: z.string().optional().describe('empty = each product’s own page'),
15727
+ secondLineCta: z.enum(['DESCRIPTION', 'PRICE']).optional(),
15728
+ dpaCarouselMode: z.enum(['AUTO', 'CAROUSEL_ONLY', 'CAROUSEL_DISABLED']).optional(),
15729
+ allowComments: z.boolean().optional(),
15730
+ heroCard: z.object({ destinationUrl: z.string(), imageUrl: z.string().optional() }).optional().describe('makes it a collection ad with an intro card'),
15731
+ }).optional().describe('catalog (shopping) ads only'),
15732
+ },
15733
+ outputSchema: { id: z.string().optional(), name: z.string().optional(), status: z.string().optional(), effectiveStatus: z.string().optional(), postId: z.string().nullable().optional(), previewUrl: z.string().nullable().optional(), clickUrlQueryParameters: z.array(z.any()).nullable().optional(), rejectionReason: z.string().nullable().optional(), note: z.string().optional() },
15228
15734
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
15229
15735
  }, wrap(async (a) => {
15230
15736
  const d = await apiPost('/api/reddit-ads/ads', a);
@@ -15304,15 +15810,22 @@ function buildTools(rawServer, opts = {}, sink = null) {
15304
15810
  }, wrap(async (a) => { const d = await apiPost('/api/reddit-ads/max-template/update', a); return ok(d.note, d); }));
15305
15811
  server.registerTool('update_reddit_ads_ad', {
15306
15812
  title: 'Edit a Reddit ad',
15307
- description: 'Rename a Reddit ad, point it at a different post, or change its click url. Does not activate or pause it — use set_reddit_ads_status. Swapping the post changes what people see, so confirm the new creative with the user first. The result is read back from Reddit.',
15813
+ description: 'Edit a Reddit ad, including a LIVE one: rename it, point it at a different post, change its click url, set or clear its UTM / tracking parameters (clickUrlQueryParameters; pass null to clear), switch on or refresh its PREVIEW link (previewDays 1-30, read back as previewUrl; previewOff:true switches it off), replace its third-party event trackers, or for a catalog ad change its shoppingCreative / featured productIds. Does not activate or pause it — use set_reddit_ads_status. Swapping the post changes what people see, so confirm the new creative with the user first — and Reddit does NOT move the click url with the post, so the reply says where clicks still go. The reply also carries Reddit’s rejection reason when the ad was rejected. The result is read back from Reddit.',
15308
15814
  inputSchema: {
15309
15815
  adAccountId: z.string().optional(),
15310
15816
  adId: z.string(),
15311
15817
  name: z.string().optional(),
15312
15818
  postId: z.string().optional().describe('promote a different post'),
15313
15819
  clickUrl: z.string().optional().describe('pass an empty string to clear it'),
15314
- },
15315
- outputSchema: { id: z.string().optional(), name: z.string().optional(), status: z.string().optional(), note: z.string().optional() },
15820
+ clickUrlQueryParameters: z.record(z.string()).nullable().optional().describe('UTM / tracking parameters, e.g. {"utm_source":"reddit","utm_content":"{{AD_ID}}"} (≤14); null clears them'),
15821
+ previewDays: z.number().optional().describe('1-30: (re)enable the preview link for this many days'),
15822
+ previewOff: z.boolean().optional().describe('true switches the preview link off'),
15823
+ eventTrackers: z.array(z.object({ type: z.enum(['CLICK', 'VIEW']), url: z.string() })).optional().describe('replaces the third-party trackers (Reddit-approved providers only)'),
15824
+ redditProfileId: z.string().optional(),
15825
+ productIds: z.array(z.string()).optional().describe('catalog ads: up to 6 featured product ids'),
15826
+ shoppingCreative: z.record(z.any()).optional().describe('catalog ads: same fields as create_reddit_ads_ad'),
15827
+ },
15828
+ outputSchema: { id: z.string().optional(), name: z.string().optional(), status: z.string().optional(), effectiveStatus: z.string().optional(), previewUrl: z.string().nullable().optional(), rejectionReason: z.string().nullable().optional(), note: z.string().optional() },
15316
15829
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
15317
15830
  }, wrap(async (a) => {
15318
15831
  const d = await apiPost('/api/reddit-ads/ads/update', a);
@@ -15389,6 +15902,211 @@ function buildTools(rawServer, opts = {}, sink = null) {
15389
15902
  const d = await apiGet('/api/reddit-ads/pixels', a);
15390
15903
  return ok(d.note, d);
15391
15904
  }));
15905
+ // REDDIT PRODUCT CATALOGS (2026-10-27) — Reddit's "Product Catalogs" tag, read off its OpenAPI. A catalog hangs off
15906
+ // the BUSINESS behind the shared ad account; every call proves the catalog is that business's before touching it.
15907
+ server.registerTool('list_reddit_ads_catalogs', {
15908
+ title: 'List Reddit product catalogs, feeds and product sets',
15909
+ description: 'Read the Reddit PRODUCT CATALOGS behind a shared ad account — what a catalog SALES campaign (create_reddit_ads_campaign objective SALES + useCatalog:true) advertises. With no catalogId: every catalog on the ad account’s business, with its product counts (total / approved / rejected / invalid), currency, language and attached pixels. With catalogId: that catalog’s linked FEEDS (url, REPLACE/UPDATE mode, schedule, next import), its PRODUCT SETS (id, name, filter, how many products match) and its recent IMPORTS — the product set id is the productSetId create_reddit_ads_ad_group needs. Read-only, free.',
15910
+ inputSchema: {
15911
+ adAccountId: z.string().optional().describe('Reddit ad account id (a2_…) — omit when only one is shared'),
15912
+ catalogId: z.string().optional().describe('a catalog id from the list — returns its feeds, product sets and recent imports'),
15913
+ },
15914
+ outputSchema: { adAccountId: z.string().optional(), businessId: z.string().optional(), count: z.number().optional(), catalogs: z.array(z.any()).optional(), catalog: z.any().optional(), feeds: z.array(z.any()).optional(), productSets: z.array(z.any()).optional(), recentImports: z.array(z.any()).optional(), note: z.string().optional() },
15915
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
15916
+ }, wrap(async (a) => {
15917
+ const d = await apiGet('/api/reddit-ads/catalogs', a);
15918
+ const lines = d.catalogs ? d.catalogs.map((c) => `• ${c.name} (${c.id}) — ${c.products ?? '?'} products, ${c.approved ?? '?'} approved, ${c.defaultCurrency}`)
15919
+ : (d.productSets || []).map((s) => `• product set ${s.name} (${s.id}) — ${s.products ?? '?'} products`);
15920
+ return ok(`${d.note}${lines.length ? '\n' + lines.join('\n') : ''}`, d);
15921
+ }));
15922
+ server.registerTool('create_reddit_ads_catalog', {
15923
+ title: 'Create a Reddit product catalog',
15924
+ description: 'Create an empty Reddit PRODUCT CATALOG on the business behind the shared ad account — the product inventory a catalog SALES campaign advertises (dynamic product ads, collection ads). Spends nothing. Name it, give the CURRENCY your prices are in (USD, GBP, CAD, EUR, AUD, JPY, CHF, NZD, SEK or NOK — Reddit’s list) and optionally the language and the Reddit pixel(s) to match products against (list_reddit_ads_pixels; catalog ads optimize on pixel events such as PURCHASE). The currency and language cannot be changed later. Then fill it — upsert_reddit_ads_products for a handful, or create_reddit_ads_product_feed to have Reddit fetch your product file on a schedule — and define which products to advertise with create_reddit_ads_product_set. Reddit gates catalog writes on the connected login’s ROLE in the Reddit Ads business (a login that can run campaigns can still be refused “no permissions to Create Catalog action”) — that refusal is about the role, not the connection, and the reply says how to fix it. Read back from Reddit.',
15925
+ inputSchema: {
15926
+ adAccountId: z.string().optional(),
15927
+ name: z.string(),
15928
+ defaultCurrency: z.enum(['USD', 'GBP', 'CAD', 'EUR', 'AUD', 'JPY', 'CHF', 'NZD', 'SEK', 'NOK']).describe('the currency your product prices are in — fixed once created'),
15929
+ defaultLanguage: z.enum(['en', 'de', 'es', 'fr', 'it', 'pt']).optional().describe('default en — fixed once created'),
15930
+ pixelIds: z.array(z.string()).optional().describe('Reddit pixel ids from list_reddit_ads_pixels to match products against; each must be on the shared ad account'),
15931
+ },
15932
+ outputSchema: { id: z.string().optional(), name: z.string().optional(), defaultCurrency: z.string().optional(), pixelIds: z.array(z.string()).optional(), note: z.string().optional() },
15933
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
15934
+ }, wrap(async (a) => {
15935
+ const d = await apiPost('/api/reddit-ads/catalogs', a);
15936
+ return ok(d.note, d);
15937
+ }));
15938
+ server.registerTool('create_reddit_ads_product_feed', {
15939
+ title: 'Link a product feed to a Reddit catalog',
15940
+ description: 'Have Reddit FETCH your product file (CSV/TSV/XML at a public https:// or FTP/SFTP address — a Shopify, Google Merchant or WooCommerce product feed works) into a catalog, once or on a schedule. mode REPLACE (default) treats the file as the whole catalog, so products missing from it are removed; UPDATE only adds and changes. A catalog takes at most TWO feeds and they must be of different types. Feed credentials (username/password) go to Reddit and are never echoed back. Spends nothing. Check how the import went with list_reddit_ads_catalogs(catalogId) then get_reddit_ads_catalog_import. Read back from Reddit.',
15941
+ inputSchema: {
15942
+ adAccountId: z.string().optional(),
15943
+ catalogId: z.string(),
15944
+ url: z.string().describe('the product file Reddit fetches'),
15945
+ mode: z.enum(['REPLACE', 'UPDATE']).optional().describe('default REPLACE — the file is the whole catalog'),
15946
+ name: z.string().optional(),
15947
+ username: z.string().optional().describe('only if the url needs a login'),
15948
+ password: z.string().optional(),
15949
+ schedule: z.object({
15950
+ interval: z.enum(['HOURLY', 'DAILY', 'WEEKLY', 'MONTHLY']).optional(),
15951
+ intervalCount: z.number().optional().describe('every N intervals, default 1'),
15952
+ dayOfWeek: z.enum(['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY']).optional().describe('required for WEEKLY'),
15953
+ dayOfMonth: z.number().optional().describe('1-31, required for MONTHLY'),
15954
+ hour: z.number().optional().describe('0-23'),
15955
+ minute: z.number().optional().describe('0-59'),
15956
+ timezone: z.string().optional().describe('IANA zone, e.g. America/New_York'),
15957
+ isPaused: z.boolean().optional(),
15958
+ }).optional().describe('how often Reddit re-fetches the file; omit for a one-off'),
15959
+ },
15960
+ outputSchema: { id: z.string().optional(), catalogId: z.string().optional(), url: z.string().optional(), mode: z.string().optional(), schedule: z.any().optional(), nextImportAt: z.string().nullable().optional(), note: z.string().optional() },
15961
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
15962
+ }, wrap(async (a) => {
15963
+ const d = await apiPost('/api/reddit-ads/catalogs/feeds', a);
15964
+ return ok(d.note, d);
15965
+ }));
15966
+ server.registerTool('create_reddit_ads_product_set', {
15967
+ title: 'Create a Reddit product set (which catalog products to advertise)',
15968
+ description: 'Define a PRODUCT SET in a Reddit catalog — the rule that picks which products a catalog ad group advertises; its id is the productSetId create_reddit_ads_ad_group takes. The filter is Reddit’s JSON rule: one rule {"attribute":{"operator":value}} or several combined with {"and":[…]} / {"or":[…]}. Attributes: id, title, description, price, currency, sale_price, availability, brand, color, gender, product_type, item_group_id, condition, age_group, size, pattern, material, custom_label_0…4. Operators: contains, not_contains, i_contains, i_not_contains, starts_with, i_starts_with, is_any, is_not_any, i_is_any, i_is_not_any (the *_any ones take a list), eq, neq, lt, lte, gt, gte — case-sensitive unless prefixed i_. Examples: everything in stock {"availability":{"eq":"IN_STOCK"}}; a brand under 50 {"and":[{"brand":{"eq":"Acme"}},{"price":{"lt":"50"}}]}. Validated before Reddit is asked; spends nothing; read back with how many products match.',
15969
+ inputSchema: {
15970
+ adAccountId: z.string().optional(),
15971
+ catalogId: z.string(),
15972
+ name: z.string(),
15973
+ filter: z.any().describe('the rule as an object (or a JSON string), e.g. {"availability":{"eq":"IN_STOCK"}}'),
15974
+ },
15975
+ outputSchema: { id: z.string().optional(), name: z.string().optional(), filter: z.any().optional(), products: z.number().nullable().optional(), note: z.string().optional() },
15976
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
15977
+ }, wrap(async (a) => {
15978
+ const d = await apiPost('/api/reddit-ads/catalogs/product-sets', a);
15979
+ return ok(d.note, d);
15980
+ }));
15981
+ server.registerTool('update_reddit_ads_catalog_object', {
15982
+ title: 'Edit a Reddit catalog, feed or product set',
15983
+ description: 'Change a Reddit catalog (kind "catalog": its name or attached pixelIds — currency and language are fixed), a product FEED (kind "feed": url, mode, name, credentials or its schedule, including pausing it with schedule.isPaused) or a PRODUCT SET (kind "product_set": name or filter, same rule format as create_reddit_ads_product_set). A changed product-set filter changes what every catalog ad group using that set advertises, immediately — say so before doing it. Read back from Reddit.',
15984
+ inputSchema: {
15985
+ adAccountId: z.string().optional(),
15986
+ kind: z.enum(['catalog', 'feed', 'product_set']),
15987
+ id: z.string().describe('the catalog, feed or product set id'),
15988
+ name: z.string().optional(),
15989
+ pixelIds: z.array(z.string()).optional().describe('catalog only — replaces the attached pixels'),
15990
+ url: z.string().optional().describe('feed only'),
15991
+ mode: z.enum(['REPLACE', 'UPDATE']).optional().describe('feed only'),
15992
+ username: z.string().optional().describe('feed only'),
15993
+ password: z.string().optional().describe('feed only'),
15994
+ schedule: z.record(z.any()).optional().describe('feed only — same shape as create_reddit_ads_product_feed'),
15995
+ filter: z.any().optional().describe('product_set only — the new rule'),
15996
+ },
15997
+ outputSchema: { kind: z.string().optional(), id: z.string().optional(), name: z.string().nullable().optional(), note: z.string().optional() },
15998
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
15999
+ }, wrap(async (a) => {
16000
+ const d = await apiPost('/api/reddit-ads/catalogs/update', a);
16001
+ return ok(d.note, d);
16002
+ }));
16003
+ server.registerTool('delete_reddit_ads_catalog_object', {
16004
+ title: 'Delete a Reddit catalog, feed or product set',
16005
+ description: 'PERMANENTLY delete a Reddit catalog (with all its products, feeds and product sets), a product feed, or a product set. Unlike campaigns these really are deleted, and it cannot be undone. Call WITHOUT confirm first: nothing is deleted, and the answer lists what would go with it — including any ad group on the shared ad account still advertising an affected product set. Show that to the user, get an explicit yes, then call again with confirm:true. The result is checked by reading the object back (Reddit then answers 404).',
16006
+ inputSchema: {
16007
+ adAccountId: z.string().optional(),
16008
+ kind: z.enum(['catalog', 'feed', 'product_set']),
16009
+ id: z.string(),
16010
+ confirm: z.boolean().optional().describe('true only after the user approved the listed impact'),
16011
+ },
16012
+ outputSchema: { deleted: z.boolean().optional(), needsConfirm: z.boolean().optional(), impact: z.array(z.string()).optional(), note: z.string().optional() },
16013
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
16014
+ }, wrap(async (a) => {
16015
+ const d = await apiPost('/api/reddit-ads/catalogs/delete', a);
16016
+ return ok(d.note, d);
16017
+ }));
16018
+ server.registerTool('list_reddit_ads_products', {
16019
+ title: 'List products in a Reddit catalog or product set',
16020
+ description: 'Read the products in a Reddit catalog (catalogId) or in one product set (productSetId), each with its title, price, availability, link, image and Reddit’s own review state and issues (the "reddit" block). Filter by approvalDecision (PENDING / APPROVED / REJECTED — REJECTED is how to find out why products are not serving), a search term, or specific ids. Paginated: pass the returned nextPageToken as pageToken. Read-only, free.',
16021
+ inputSchema: {
16022
+ adAccountId: z.string().optional(),
16023
+ catalogId: z.string().optional().describe('one of catalogId or productSetId'),
16024
+ productSetId: z.string().optional(),
16025
+ approvalDecision: z.enum(['PENDING', 'APPROVED', 'REJECTED']).optional(),
16026
+ search: z.string().optional(),
16027
+ ids: z.array(z.string()).optional(),
16028
+ limit: z.number().optional().describe('page size, default 50, max 1000'),
16029
+ pageToken: z.string().optional(),
16030
+ },
16031
+ outputSchema: { count: z.number().optional(), products: z.array(z.any()).optional(), nextPageToken: z.string().nullable().optional(), note: z.string().optional() },
16032
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
16033
+ }, wrap(async (a) => {
16034
+ const d = await apiGet('/api/reddit-ads/catalogs/products', a);
16035
+ return ok(`${d.note}\n${(d.products || []).slice(0, 25).map((p) => `• ${p.title} (${p.id}) — ${p.price ?? ''} ${p.availability ?? ''}`).join('\n')}`, d);
16036
+ }));
16037
+ server.registerTool('upsert_reddit_ads_products', {
16038
+ title: 'Add or update products in a Reddit catalog',
16039
+ description: 'Add products to a Reddit catalog, or update them — re-sending an id you already sent UPDATES that product, it never duplicates it. Up to 1,000 per call. Fields are Reddit’s own snake_case product attributes: id (your SKU, required), title, description, price as "<amount> <ISO currency>" e.g. "19.99 USD", link, image_link, availability (IN_STOCK, OUT_OF_STOCK, PREORDER, BACKORDER), brand, sale_price, additional_image_links, product_type, google_product_category, gtin, mpn, condition, color, size, gender, age_group, item_group_id, custom_label_0…4 and the rest of Reddit’s list. Reddit answers per product — CREATED, UPDATED, UNCHANGED or INVALID with the exact field and issue code — and that answer is what is reported. New products go through Reddit’s review before they can serve. Spends nothing.',
16040
+ inputSchema: {
16041
+ adAccountId: z.string().optional(),
16042
+ catalogId: z.string(),
16043
+ products: z.array(z.record(z.any())).describe('each {id, title, price:"19.99 USD", link, image_link, availability, …}'),
16044
+ },
16045
+ outputSchema: { counts: z.any().optional(), invalid: z.array(z.any()).optional(), total: z.number().optional(), note: z.string().optional() },
16046
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
16047
+ }, wrap(async (a) => {
16048
+ const d = await apiPost('/api/reddit-ads/catalogs/products', a);
16049
+ return ok(d.note, d);
16050
+ }));
16051
+ server.registerTool('delete_reddit_ads_products', {
16052
+ title: 'Remove products from a Reddit catalog',
16053
+ description: 'PERMANENTLY remove products (by id) from a Reddit catalog — Reddit: "This action can\'t be undone". Up to 1,000 ids per call. Call WITHOUT confirm first: nothing is deleted and the answer says how many would go; get the user\'s yes, then call again with confirm:true. Reddit answers DELETED or NOT_FOUND per id, and that is what is reported. A catalog fed by a REPLACE feed re-imports whatever the file still contains, so remove the product from the file too.',
16054
+ inputSchema: {
16055
+ adAccountId: z.string().optional(),
16056
+ catalogId: z.string(),
16057
+ productIds: z.array(z.string()),
16058
+ confirm: z.boolean().optional(),
16059
+ },
16060
+ outputSchema: { deleted: z.boolean().optional(), needsConfirm: z.boolean().optional(), deletedIds: z.array(z.string()).optional(), notFound: z.array(z.string()).optional(), note: z.string().optional() },
16061
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
16062
+ }, wrap(async (a) => {
16063
+ const d = await apiPost('/api/reddit-ads/catalogs/products/delete', a);
16064
+ return ok(d.note, d);
16065
+ }));
16066
+ server.registerTool('get_reddit_ads_catalog_import', {
16067
+ title: 'Check a Reddit catalog feed import',
16068
+ description: 'See how Reddit’s fetches of a catalog’s product feed went. Without importId: the catalog’s recent imports with their status (PENDING, PROCESSING, COMPLETED, FAILED, SKIPPED) and added / modified / deleted / invalid counts. With importId: that import’s issue rows (which products failed and why) plus a time-limited link to Reddit’s full import report. An import whose file did not change is SKIPPED and has no report. Read-only, free.',
16069
+ inputSchema: {
16070
+ adAccountId: z.string().optional(),
16071
+ catalogId: z.string(),
16072
+ importId: z.string().optional(),
16073
+ },
16074
+ outputSchema: { imports: z.array(z.any()).optional(), import: z.any().optional(), issues: z.array(z.any()).optional(), reportUrl: z.string().nullable().optional(), note: z.string().optional() },
16075
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
16076
+ }, wrap(async (a) => {
16077
+ const d = await apiGet('/api/reddit-ads/catalogs/import', a);
16078
+ return ok(d.note, d);
16079
+ }));
16080
+ // REDDIT DATA DELETION (2026-10-27) — the adsdatadeletion scope the connector already holds.
16081
+ server.registerTool('reddit_ads_data_deletion', {
16082
+ title: 'Request (or check) deletion of a person’s Reddit ads data',
16083
+ description: 'Honour a person’s data-deletion request on Reddit Ads: asks Reddit to PERMANENTLY delete the ads data it holds for their identifiers — EMAIL, PHONE_NUMBER, IDFA, AAID or EXTERNAL_ID, 1–200 per job — across the shared ad account, or only under one pixel (pixelId; Reddit requires business-admin access for that). Emails and phone numbers are canonicalised and SHA-256 hashed here exactly as the audience upload and Conversions API hash them, so the deletion matches what was sent; a 64-hex hash you made yourself passes through. It removes ads data (custom-audience matches, conversion records) and never anyone’s Reddit account. Call WITHOUT confirm first — nothing is sent and the answer states the scope — then again with confirm:true. Reddit processes jobs DAILY and finishes within 30 days: pass jobId alone to check one (QUEUED, COMPLETED or FAILED), at most once a day.',
16084
+ inputSchema: {
16085
+ adAccountId: z.string().optional(),
16086
+ identifiers: z.array(z.object({ type: z.enum(['EMAIL', 'PHONE_NUMBER', 'IDFA', 'AAID', 'EXTERNAL_ID']), value: z.string() })).optional().describe('who to delete; omit when checking a jobId'),
16087
+ pixelId: z.string().optional().describe('limit the deletion to one pixel’s data (from list_reddit_ads_pixels)'),
16088
+ confirm: z.boolean().optional().describe('true only after the user approved — deletion is irreversible'),
16089
+ jobId: z.string().optional().describe('check an existing job instead of creating one'),
16090
+ },
16091
+ outputSchema: { submitted: z.boolean().optional(), needsConfirm: z.boolean().optional(), jobId: z.string().optional(), status: z.string().optional(), count: z.number().optional(), note: z.string().optional() },
16092
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
16093
+ }, wrap(async (a) => {
16094
+ const d = await apiPost('/api/reddit-ads/data-deletion', a);
16095
+ return ok(d.note, d);
16096
+ }));
16097
+ server.registerTool('list_reddit_ads_funding_instruments', {
16098
+ title: 'List Reddit funding instruments (can this account pay?)',
16099
+ description: 'List the funding instruments (cards, credit lines, insertion orders) on a Reddit ad account and whether each can pay for delivery — Reddit’s own is_servable flag plus its reasons when not (outside its dates, credit line used up, card not approved, not active, test). This is the answer to "why isn’t my Reddit campaign spending?" when everything is ACTIVE, and the source of the fundingInstrumentId create_reddit_ads_campaign accepts. mode: ALL (default), ACTIVE, INACTIVE, UPCOMING or SELECTABLE. Read-only, free.',
16100
+ inputSchema: {
16101
+ adAccountId: z.string().optional(),
16102
+ mode: z.enum(['ALL', 'ACTIVE', 'INACTIVE', 'UPCOMING', 'SELECTABLE']).optional(),
16103
+ },
16104
+ outputSchema: { adAccountId: z.string().optional(), count: z.number().optional(), fundingInstruments: z.array(z.any()).optional(), note: z.string().optional() },
16105
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
16106
+ }, wrap(async (a) => {
16107
+ const d = await apiGet('/api/reddit-ads/funding-instruments', a);
16108
+ return ok(d.note, d);
16109
+ }));
15392
16110
  // REDDIT CREATIVE ASSET LIBRARY (2026-09-07) — read off Reddit's own OpenAPI (spec v3). Reddit DOWNLOADS the
15393
16111
  // media from a url you hand it; there is no byte upload. A Reddit MAX template ad builds its ads from these ids
15394
16112
  // (create_reddit_ads_max_campaign, 2026-09-13); a standard post-based ad still takes no asset id.
@@ -15727,14 +16445,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
15727
16445
  server.registerTool('list_tiktok_ads_campaigns', {
15728
16446
  title: 'List TikTok campaigns, ad groups and ads',
15729
16447
  description: 'Read a TikTok advertiser account’s whole tree in one call — campaigns, ad groups and ads, each with the operation status that says whether it is enabled at all. Omit advertiserId when the brand reaches exactly one account; with several, the call names the choices rather than picking for you. THE THREE TIERS ARE READ SEPARATELY AND A TIER THAT FAILED IS REPORTED AS FAILED, never as empty — if the result carries a `partial` note, say which tier could not be read instead of telling the user they have no ads. Read-only, free. TikTok’s QPS is 1, so a big account reads back slowly: that is the throttle working, not a fault.',
15730
- inputSchema: { advertiserId: z.string().optional().describe('from list_tiktok_ads_accounts — omit only when exactly one is reachable') },
15731
- outputSchema: { advertiserId: z.string().optional(), name: z.string().optional(), campaigns: z.array(z.any()).optional(), adGroups: z.array(z.any()).optional(), ads: z.array(z.any()).optional(), partial: z.record(z.any()).optional() },
16448
+ inputSchema: { advertiserId: z.string().optional().describe('from list_tiktok_ads_accounts — omit only when exactly one is reachable'), includeReview: z.boolean().optional().describe('attach TikTok’s review verdict to every ad: approved / restricted / rejected, the reasons and TikTok’s suggested fix') },
16449
+ outputSchema: { advertiserId: z.string().optional(), name: z.string().optional(), campaigns: z.array(z.any()).optional(), adGroups: z.array(z.any()).optional(), ads: z.array(z.any()).optional(), partial: z.record(z.any()).optional(), reviewError: z.string().optional() },
15732
16450
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
15733
16451
  }, wrap(async (a) => {
15734
16452
  const d = await apiGet('/api/tiktok-ads/campaigns', a);
15735
16453
  const c = (d.campaigns || []).map(x => `• campaign ${x.campaign_name} (${x.campaign_id}) — ${x.operation_status}${x.objective_type ? `, ${x.objective_type}` : ''}`);
15736
16454
  const g = (d.adGroups || []).map(x => `• ad group ${x.adgroup_name} (${x.adgroup_id}) — ${x.operation_status}`);
15737
- const s = (d.ads || []).map(x => `• ad ${x.ad_name} (${x.ad_id}) — ${x.operation_status}`);
16455
+ const s = (d.ads || []).map(x => `• ad ${x.ad_name} (${x.ad_id}) — ${x.operation_status}${x.review ? ` · review ${x.review.reviewStatus || '?'}${x.review.reasons?.length ? `: ${x.review.reasons.join(' | ')}` : ''}` : ''}`);
15738
16456
  // A TIER THAT THREW IS NOT A TIER THAT IS EMPTY ([[failed-read-is-not-empty]]) — the route reports the two
15739
16457
  // apart and the sentence has to keep them apart, or "0 campaigns" reads as a fact about the account.
15740
16458
  const bad = Object.entries(d.partial || {}).filter(([, v]) => v);
@@ -17191,6 +17909,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
17191
17909
  adText: z.string().optional().describe('the ad copy people read'),
17192
17910
  callToAction: z.string().optional().describe('TikTok’s CTA button value — default LEARN_MORE'),
17193
17911
  landingPageUrl: z.string().optional().describe('where the ad sends people — not needed on an Instant-Form lead ad'),
17912
+ utmParams: z.record(z.string()).optional().describe('URL parameters (UTMs) TikTok appends to the landing URL, e.g. {"utm_source":"tiktok","utm_campaign":"__CAMPAIGN_NAME__"}; up to 14; values may be TikTok macros (__CAMPAIGN_NAME__, __AID__, __CID__, __PLACEMENT__ …).'),
17913
+ musicId: z.string().optional().describe('CAROUSEL ADS — the sound, from list_tiktok_ads_music. Required with adFormat CAROUSEL_ADS, which takes the slide images (1-35) as imageIds and no videoId; refused here for free otherwise.'),
17194
17914
  pageId: z.string().optional().describe('LEAD ADS \u2014 the Instant Form the ad opens, from list_tiktok_ads_lead_forms (a PUBLISHED one). Use it under a LEAD_GENERATION campaign whose ad group has promotionTargetType INSTANT_PAGE. Checked first, for free: a draft form, a form on another account, or an ad account that has not accepted TikTok\u2019s Lead Ads terms is refused by name. The reply reads the form back off TikTok\u2019s stored ad.'),
17195
17915
  },
17196
17916
  outputSchema: { id: z.string().optional(), advertiserId: z.string().optional(), adgroupId: z.string().optional(), name: z.string().optional(), status: z.string().nullable().optional(), identityId: z.string().optional(), videoId: z.string().nullable().optional(), tiktokItemId: z.string().nullable().optional(), spark: z.boolean().optional(), pageId: z.string().nullable().optional(), leadFormAttached: z.boolean().optional(), imageIds: z.array(z.string()).optional(), verified: z.boolean().optional(), note: z.string().optional() },
@@ -17199,6 +17919,194 @@ function buildTools(rawServer, opts = {}, sink = null) {
17199
17919
  const d = await apiPost('/api/tiktok-ads/ad', a);
17200
17920
  return ok(`Created TikTok ${d.spark ? 'SPARK ad promoting organic post ' + d.tiktokItemId : 'ad'} "${d.name}" (${d.id}) in ad group ${d.adgroupId}, posting as identity ${d.identityId} — TikTok stored it as ${d.status || 'an unreported status'}. ${d.note} TikTok still has to review it before it can show.`, d);
17201
17921
  }));
17922
+ // ══ THE 2026-10-26 SOCIAL GAP SWEEP — TIKTOK PHASE 1 (2026-10-27) ═════════════════════════════════════════════
17923
+ // Edit a live ad, preview it, read why it was rejected, read creative fatigue and per-second retention, find
17924
+ // carousel music, TikTok's own trends, per-post organic insights, which post to boost, and caption hashtags.
17925
+ // Every rule is in lib/tiktok-phase1.mjs (each constant cited to the TikTok doc_id it was read from).
17926
+ server.registerTool('update_tiktok_ads_ad', {
17927
+ title: 'Edit a TikTok ad',
17928
+ description: 'EDIT an existing TikTok ad in place — its ad text, call-to-action, landing URL, URL parameters (UTMs), name, video and cover, carousel sound or Instant Form — instead of deleting and rebuilding it, which throws away its review history and delivery learning. Only the fields you pass change. TikTok blanks any field a full update leaves out, so Hermoso rebuilds the edit from TikTok’s own stored copy of the ad and lays your changes over it; a text/name/media-only edit uses TikTok’s incremental mode. An ad that is SWITCHED ON needs confirm:true, because the change reaches real viewers once TikTok accepts it (and TikTok reviews it again); a paused ad edits freely. A CTA change also clears a dynamic-CTA portfolio, which TikTok would otherwise show instead, and the reply says so. The result is READ BACK from TikTok field by field — relay `verified` and the note as given. Free.',
17929
+ inputSchema: {
17930
+ advertiserId: z.string().optional(),
17931
+ adId: z.string().describe('the ad to edit, from list_tiktok_ads_campaigns'),
17932
+ name: z.string().optional().describe('the ad name in TikTok Ads Manager'),
17933
+ adText: z.string().optional().describe('the copy people read, 1-100 characters, no emoji'),
17934
+ callToAction: z.string().optional().describe('TikTok’s CTA value, e.g. LEARN_MORE, SHOP_NOW, SIGN_UP, DOWNLOAD'),
17935
+ landingPageUrl: z.string().optional().describe('full http(s) URL'),
17936
+ utmParams: z.record(z.string()).optional().describe('URL parameters TikTok appends to the landing URL, e.g. {"utm_source":"tiktok","utm_campaign":"__CAMPAIGN_NAME__"}; up to 14. Values may be TikTok macros: __CAMPAIGN_NAME__, __CAMPAIGN_ID__, __AID_NAME__, __AID__, __CID_NAME__, __CID__, __PLACEMENT__. Replaces the whole set.'),
17937
+ videoId: z.string().optional().describe('swap the video (from upload_tiktok_ads_creative); pass its coverImageId as imageIds too'),
17938
+ imageIds: z.array(z.string()).optional(),
17939
+ musicId: z.string().optional().describe('carousel sound, from list_tiktok_ads_music'),
17940
+ tiktokItemId: z.string().optional().describe('swap the Spark post'),
17941
+ pageId: z.string().optional().describe('Instant Form id for a lead ad (checked first)'),
17942
+ confirm: z.boolean().optional().describe('required when the ad is switched on'),
17943
+ },
17944
+ outputSchema: { advertiserId: z.string().optional(), adId: z.string().optional(), adgroupId: z.string().optional(), mode: z.string().optional(), requested: z.record(z.any()).optional(), read: z.record(z.any()).nullable().optional(), verified: z.boolean().optional(), dropped: z.array(z.string()).optional(), status: z.string().nullable().optional(), note: z.string().optional() },
17945
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
17946
+ }, wrap(async (a) => {
17947
+ const d = await apiPost('/api/tiktok-ads/ad/update', a);
17948
+ return ok(`TikTok ad ${d.adId}: ${d.note}${d.verified ? '' : ' (not yet confirmed by the read-back)'}`, d);
17949
+ }));
17950
+ server.registerTool('preview_tiktok_ads_ad', {
17951
+ title: 'Preview a TikTok ad',
17952
+ description: 'See a TikTok ad the way it renders on TikTok: pass adId for an EXISTING ad (the link works for 30 days), or a videoId with displayName and adText to preview a video BEFORE any ad is built (24 hours). TikTok also returns its own fix-it tips per placement (for example a resolution that is too low) — relay them. Read-only, free, spends nothing.',
17953
+ inputSchema: {
17954
+ advertiserId: z.string().optional(),
17955
+ adId: z.string().optional().describe('preview an existing ad'),
17956
+ videoId: z.string().optional().describe('preview a video before building the ad (from upload_tiktok_ads_creative)'),
17957
+ displayName: z.string().optional().describe('the brand name shown on the ad, 1-40 characters (video preview)'),
17958
+ adText: z.string().optional().describe('1-100 characters (video preview)'),
17959
+ callToAction: z.string().optional().describe('default LEARN_MORE (video preview)'),
17960
+ placement: z.enum(['PLACEMENT_TIKTOK', 'PLACEMENT_PANGLE', 'ALL']).optional().describe('existing ad only; default TikTok'),
17961
+ language: z.enum(['ENGLISH', 'CHINESE', 'JAPANESE']).optional(),
17962
+ },
17963
+ outputSchema: { advertiserId: z.string().optional(), previewType: z.string().optional(), adId: z.string().optional(), videoId: z.string().optional(), previewLink: z.string().optional(), validFor: z.string().optional(), tips: z.array(z.any()).optional(), note: z.string().optional() },
17964
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
17965
+ }, wrap(async (a) => {
17966
+ const d = await apiPost('/api/tiktok-ads/preview', a);
17967
+ return ok(`${d.previewLink}\n${d.note}`, d);
17968
+ }));
17969
+ server.registerTool('tiktok_ads_review_info', {
17970
+ title: 'Why a TikTok ad was rejected',
17971
+ description: 'TikTok’s own review verdict for up to 100 ads: approved everywhere (ALL_AVAILABLE), approved for only some of its targeting (PART_AVAILABLE — with the placements, locations, ages or operating systems it is barred from), or rejected (UNAVAILABLE) — with TikTok’s REASONS and its SUGGESTED FIX per piece of content. Use it whenever an ad is not running or the user asks why it was rejected; fix it with update_tiktok_ads_ad. list_tiktok_ads_campaigns(includeReview:true) attaches the same verdict to every ad. Read-only, free.',
17972
+ inputSchema: { advertiserId: z.string().optional(), adIds: z.array(z.string()).describe('1-100 ad ids from list_tiktok_ads_campaigns') },
17973
+ outputSchema: { advertiserId: z.string().optional(), ads: z.array(z.any()).optional(), note: z.string().optional() },
17974
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
17975
+ }, wrap(async (a) => {
17976
+ const d = await apiGet('/api/tiktok-ads/review', { ...a, adIds: (a.adIds || []).join(',') });
17977
+ const lines = (d.ads || []).map(x => `• ad ${x.adId}: ${x.reviewStatus || 'no status'}${x.means ? ` — ${x.means}` : ''}${x.reasons.length ? `\n reasons: ${x.reasons.join(' | ')}` : ''}${x.suggestions.length ? `\n TikTok suggests: ${x.suggestions.join(' | ')}` : ''}`);
17978
+ return ok(`${d.note}\n${lines.join('\n')}`, d);
17979
+ }));
17980
+ server.registerTool('tiktok_ads_creative_fatigue', {
17981
+ title: 'TikTok creative fatigue',
17982
+ description: 'Is a creative WEARING OUT? TikTok’s own creative-fatigue verdict per creative per day over the last 14 days, with spend and cost per conversion — the signal to make a fresh variation and swap it in. TikTok computes it for Upgraded Smart+ ad groups: pass the adgroupId and Hermoso reads every creative (ad material) in it, or name adMaterialIds. Read-only, free.',
17983
+ inputSchema: {
17984
+ advertiserId: z.string().optional(),
17985
+ adgroupId: z.string().describe('a Smart+ ad group (list_tiktok_smart_campaigns)'),
17986
+ adMaterialIds: z.array(z.string()).optional().describe('optional; default every creative in the ad group'),
17987
+ startDate: z.string().optional().describe('YYYY-MM-DD, within the last 14 days (default 7 days ago)'),
17988
+ endDate: z.string().optional().describe('YYYY-MM-DD (default today)'),
17989
+ },
17990
+ outputSchema: { advertiserId: z.string().optional(), adgroupId: z.string().optional(), startDate: z.string().optional(), endDate: z.string().optional(), materials: z.array(z.any()).optional(), note: z.string().optional() },
17991
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
17992
+ }, wrap(async (a) => {
17993
+ const d = await apiGet('/api/tiktok-ads/creative-fatigue', { ...a, adMaterialIds: a.adMaterialIds ? a.adMaterialIds.join(',') : undefined });
17994
+ return ok(`${d.note}\n${(d.materials || []).map(m => `• ${m.adMaterialId}: ${m.latestFatigue ? 'FATIGUED' : 'fresh'} as of ${m.latestDate}${m.fatiguedDays.length ? ` (fatigued on ${m.fatiguedDays.join(', ')})` : ''}, spend ${m.spend}`).join('\n')}`, d);
17995
+ }));
17996
+ server.registerTool('tiktok_ads_video_retention', {
17997
+ title: 'TikTok per-second video retention',
17998
+ description: 'Per-SECOND performance for TikTok ad videos — which second viewers swipe away on, the share still watching at 2s and 3s, and the biggest single drop: the hook read for the next variation. reportType AD (default) reads ads by adIds, adgroupIds or campaignIds with per-second views, clicks, drop-offs, likes and shares; reportType VIDEO reads ONE video’s lifetime retain/drop-off curve by videoId. Covers the first 60 seconds; data lags about an hour; an ad that never served has none. Read-only, free.',
17999
+ inputSchema: {
18000
+ advertiserId: z.string().optional(),
18001
+ reportType: z.enum(['AD', 'VIDEO']).optional(),
18002
+ adIds: z.array(z.string()).optional(), adgroupIds: z.array(z.string()).optional(), campaignIds: z.array(z.string()).optional(),
18003
+ videoId: z.string().optional().describe('VIDEO report: one video id from upload_tiktok_ads_creative'),
18004
+ materialId: z.string().optional(),
18005
+ },
18006
+ outputSchema: { advertiserId: z.string().optional(), reportType: z.string().optional(), rows: z.array(z.any()).optional(), totalRows: z.number().optional(), hasMore: z.boolean().optional(), note: z.string().optional() },
18007
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
18008
+ }, wrap(async (a) => {
18009
+ const j = (v) => (v ? v.join(',') : undefined);
18010
+ const d = await apiGet('/api/tiktok-ads/video-performance', { ...a, adIds: j(a.adIds), adgroupIds: j(a.adgroupIds), campaignIds: j(a.campaignIds) });
18011
+ return ok(`${d.note}\n${(d.rows || []).map(r => `• ${r.adId ? `ad ${r.adId}` : `video ${r.videoId}`}: ${r.retention ? `${r.retention.at2s ?? '?'}% still watching at 2s, ${r.retention.at3s ?? '?'}% at 3s, biggest drop at second ${r.retention.biggestDropSecond ?? '?'} (−${r.retention.biggestDropPct ?? '?'} pts)` : 'no curve'}`).join('\n')}`, d);
18012
+ }));
18013
+ server.registerTool('list_tiktok_ads_music', {
18014
+ title: 'Find TikTok ad music',
18015
+ description: 'TikTok’s ad-cleared music for the ad account — the sound a CAROUSEL ad needs (musicId on create_tiktok_ads_ad with adFormat CAROUSEL_ADS). Search by keyword, get recommendations for 2-35 already-uploaded carousel images (imageUrls), look up musicIds, or read the liked / history / your-uploads lists (searchType); scene CREATIVE_ASSET lists the creative-tools library instead. Preview URLs last 12 hours. Read-only, free.',
18016
+ inputSchema: {
18017
+ advertiserId: z.string().optional(),
18018
+ scene: z.enum(['CAROUSEL_ADS', 'CREATIVE_ASSET']).optional().describe('CAROUSEL_ADS when searching; CREATIVE_ASSET for the creative-tools library'),
18019
+ keyword: z.string().optional(),
18020
+ imageUrls: z.array(z.string()).optional().describe('2-35 URLs of images already uploaded to the ad account'),
18021
+ musicIds: z.array(z.string()).optional(),
18022
+ searchType: z.enum(['SEARCH_BY_KEYWORD', 'SEARCH_BY_RECOMMEND', 'SEARCH_BY_LIKED', 'SEARCH_BY_HISTORY', 'SEARCH_BY_MUSIC_ID', 'SEARCH_BY_SOURCE']).optional(),
18023
+ pageSize: z.number().optional(),
18024
+ },
18025
+ outputSchema: { advertiserId: z.string().optional(), scene: z.string().optional(), music: z.array(z.any()).optional(), total: z.number().optional(), note: z.string().optional() },
18026
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
18027
+ }, wrap(async (a) => {
18028
+ const d = await apiGet('/api/tiktok-ads/music', { ...a, imageUrls: a.imageUrls ? a.imageUrls.join(',') : undefined, musicIds: a.musicIds ? a.musicIds.join(',') : undefined });
18029
+ return ok(`${d.note}\n${(d.music || []).slice(0, 30).map(m => `• ${m.musicId} — ${m.name || '(untitled)'}${m.author ? ` · ${m.author}` : ''}${m.durationSeconds ? ` · ${m.durationSeconds}s` : ''}`).join('\n')}`, d);
18030
+ }));
18031
+ server.registerTool('tiktok_ads_trending_hashtags', {
18032
+ title: 'TikTok trending hashtags',
18033
+ description: 'TikTok’s OWN first-party trending hashtags, through the TikTok Ads connection: kind "hashtags" lists the top hashtags by country, industry category (e.g. FOOD_AND_BEVERAGE, BEAUTY_AND_PERSONAL_CARE, TECHNOLOGY, or ALL) and period (1DAY/7DAY/30DAY/120DAY) with rank, rank change, views, posts and top countries; "hashtag_detail" gives one hashtag’s 30-day trend and audience ages; "hashtag_videos" gives the top videos using up to 10 hashtags. A detail or video read must use the SAME countryCode and dateRange the hashtag was listed with. Read-only, free.',
18034
+ inputSchema: {
18035
+ advertiserId: z.string().optional(),
18036
+ kind: z.enum(['hashtags', 'hashtag_detail', 'hashtag_videos']).optional().describe('default hashtags'),
18037
+ countryCode: z.string().optional().describe('2-letter, default US'),
18038
+ category: z.string().optional().describe('industry category, default ALL'),
18039
+ dateRange: z.enum(['1DAY', '7DAY', '30DAY', '120DAY']).optional(),
18040
+ hashtagIds: z.array(z.string()).optional().describe('for hashtag_detail (first id) and hashtag_videos (up to 10)'),
18041
+ },
18042
+ outputSchema: { kind: z.string().optional(), hashtags: z.array(z.any()).optional(), hashtag: z.any().optional(), filter: z.any().optional(), note: z.string().optional() },
18043
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
18044
+ }, wrap(async (a) => {
18045
+ const d = await apiGet('/api/tiktok-ads/trends', { ...a, kind: a.kind || 'hashtags', hashtagIds: a.hashtagIds ? a.hashtagIds.join(',') : undefined });
18046
+ const body = d.hashtag ? JSON.stringify(d.hashtag) : (d.hashtags || []).slice(0, 30).map(h => `• #${h.name} (${h.hashtagId})${h.rank != null ? ` rank ${h.rank}` : ''}${h.rankChange != null ? ` (${h.rankChange > 0 ? '+' : ''}${h.rankChange})` : ''}${h.views != null ? ` · ${h.views} views` : ''}${h.videos ? ` · ${h.videos.length} video(s): ${h.videos.slice(0, 5).map(v => v.shareUrl).join(' ')}` : ''}`).join('\n');
18047
+ return ok(`${d.note}\n${body}`, d);
18048
+ }));
18049
+ server.registerTool('tiktok_trends', {
18050
+ title: 'TikTok trending sounds and searches',
18051
+ description: 'TikTok’s own trend data through the connected TikTok ACCOUNT: kind "sounds" lists trending Commercial Music Library tracks — pre-cleared for organic posts AND ads — by genre (POP, HIP_HOP/RAP, ELECTRONIC … or ALL), country and period (1DAY/7DAY/30DAY/90DAY); "sound_videos" the top videos using one sound; "search_terms" the top 20 trending TikTok search terms; "search_suggestions" TikTok search keywords for a product, service or brand (query). Read-only, free.',
18052
+ inputSchema: {
18053
+ kind: z.enum(['sounds', 'sound_videos', 'search_terms', 'search_suggestions']),
18054
+ countryCode: z.string().optional(),
18055
+ genre: z.string().optional(),
18056
+ dateRange: z.enum(['1DAY', '7DAY', '30DAY', '90DAY']).optional(),
18057
+ soundId: z.string().optional().describe('for sound_videos — soundId from kind sounds'),
18058
+ query: z.string().optional().describe('for search_suggestions'),
18059
+ personalized: z.boolean().optional().describe('search terms personalized for this account'),
18060
+ },
18061
+ outputSchema: { kind: z.string().optional(), sounds: z.array(z.any()).optional(), sound: z.any().optional(), videos: z.array(z.any()).optional(), keywords: z.array(z.string()).optional(), note: z.string().optional() },
18062
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
18063
+ }, wrap(async (a) => {
18064
+ const d = await apiGet('/api/tiktok-ads/trends', a);
18065
+ const body = d.sounds ? d.sounds.slice(0, 30).map(m => `• ${m.name}${m.artist ? ` — ${m.artist}` : ''} (${m.soundId})${m.rank != null ? ` rank ${m.rank}` : ''}`).join('\n')
18066
+ : d.videos ? d.videos.map(v => `• ${v.shareUrl || v.videoId}`).join('\n') : (d.keywords || []).join(' · ');
18067
+ return ok(`${d.note}\n${body}`, d);
18068
+ }));
18069
+ server.registerTool('tiktok_video_insights', {
18070
+ title: 'TikTok post insights',
18071
+ 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.',
18072
+ inputSchema: {
18073
+ videoIds: z.array(z.string()).optional(),
18074
+ maxCount: z.number().optional().describe('1-20, default 20'),
18075
+ cursor: z.string().optional(),
18076
+ insights: z.boolean().optional().describe('false = counters only (no retention/traffic/audience)'),
18077
+ },
18078
+ outputSchema: { videos: z.array(z.any()).optional(), cursor: z.any().optional(), hasMore: z.boolean().optional(), note: z.string().optional() },
18079
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
18080
+ }, wrap(async (a) => {
18081
+ const d = await apiGet('/api/tiktok-account/videos', { ...a, videoIds: a.videoIds ? a.videoIds.join(',') : undefined });
18082
+ return ok(`${d.note}\n${(d.videos || []).map(v => `• ${v.itemId}: ${v.views ?? '?'} views, reach ${v.reach ?? '?'}, avg watch ${v.averageWatchSeconds ?? '?'}s, ${v.retentionAt3s ?? '?'}% still watching at 3s — ${(v.caption || '').slice(0, 60)}`).join('\n')}`, d);
18083
+ }));
18084
+ server.registerTool('tiktok_spark_recommendations', {
18085
+ title: 'Which TikTok posts to boost',
18086
+ description: 'Which organic TikTok posts are worth running as Spark Ads: TikTok rates the connected account’s posts HIGH / MEDIUM / LOW, optionally for an objective (REACH, TRAFFIC, VIDEO_VIEWS, ENGAGEMENT, APP_PROMOTION, LEAD_GENERATION, WEB_CONVERSIONS, PRODUCT_SALES) and period (PAST_ONE_MONTH / THREE / SIX). Pass ttoAccountId to rate TikTok One creator-marketplace posts instead. Run a HIGH one with create_tiktok_ads_ad(tiktokItemId). Read-only, free.',
18087
+ inputSchema: {
18088
+ objective: z.string().optional(),
18089
+ time: z.enum(['PAST_ONE_MONTH', 'PAST_THREE_MONTHS', 'PAST_SIX_MONTHS']).optional(),
18090
+ videoIds: z.array(z.string()).optional().describe('rate only these (up to 20)'),
18091
+ excludeVideoIds: z.array(z.string()).optional(),
18092
+ ttoAccountId: z.string().optional().describe('rate TikTok One creator posts instead of your own'),
18093
+ },
18094
+ outputSchema: { source: z.string().optional(), videos: z.array(z.any()).optional(), note: z.string().optional() },
18095
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
18096
+ }, wrap(async (a) => {
18097
+ const d = await apiGet('/api/tiktok-account/spark-recommendations', { ...a, videoIds: a.videoIds ? a.videoIds.join(',') : undefined, excludeVideoIds: a.excludeVideoIds ? a.excludeVideoIds.join(',') : undefined });
18098
+ return ok(`${d.note}\n${(d.videos || []).map(v => `• ${v.level || '?'} — ${v.itemId}${v.views != null ? ` (${v.views} views)` : ''}${v.creatorHandle ? ` @${v.creatorHandle}` : ''}`).join('\n')}`, d);
18099
+ }));
18100
+ server.registerTool('tiktok_hashtag_suggestions', {
18101
+ title: 'TikTok hashtag ideas',
18102
+ description: 'Hashtag ideas for a TikTok caption: 10 suggestions for a keyword, exact matches first and then by how many posts use them, with each tag’s post count. Read-only, free.',
18103
+ inputSchema: { keyword: z.string(), language: z.string().optional().describe('TikTok language code, default en (e.g. es, fr, de-DE, pt-BR)') },
18104
+ outputSchema: { keyword: z.string().optional(), suggestions: z.array(z.any()).optional(), note: z.string().optional() },
18105
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
18106
+ }, wrap(async (a) => {
18107
+ const d = await apiGet('/api/tiktok-account/hashtag-suggestions', a);
18108
+ return ok(`${d.note}\n${(d.suggestions || []).map(h => `#${h.hashtag}${h.posts != null ? ` (${h.posts} posts)` : ''}`).join(' ')}`, d);
18109
+ }));
17202
18110
  // ══ AUTOMATED RULES (2026-08-19) ══════════════════════════════════════════════════════════════════════════════
17203
18111
  // Held since the 2026-08-19 approval and recorded as DELIBERATELY unbuilt — "a rule is standing permission to
17204
18112
  // move money with no human in the loop, and every spend switch in this product is confirm-gated for exactly
@@ -17957,12 +18865,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
17957
18865
  title: 'Read the Snapchat ad tree',
17958
18866
  description: 'Read the whole Snapchat ad tree for an ad account — campaigns, ad squads and ads with their statuses. The three tiers are fetched separately so one failure cannot take the tree down, and a tier that FAILED to read is reported in `partial` rather than as an empty list: an empty list here means an empty account, never a failed read. Read-only, free.',
17959
18867
  inputSchema: { adAccountId: z.string().optional().describe('from list_snapchat_ads_accounts — omit only when exactly one is reachable') },
17960
- outputSchema: { adAccountId: z.string().optional(), name: z.string().optional(), currency: z.string().optional(), campaigns: z.array(z.any()).optional(), adSquads: z.array(z.any()).optional(), ads: z.array(z.any()).optional(), partial: z.record(z.any()).optional() },
18868
+ outputSchema: { adAccountId: z.string().optional(), name: z.string().optional(), currency: z.string().optional(), campaigns: z.array(z.any()).optional(), adSquads: z.array(z.any()).optional(), ads: z.array(z.any()).optional(), review: z.any().optional(), partial: z.record(z.any()).optional() },
17961
18869
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
17962
18870
  }, wrap(async (a) => {
17963
18871
  const d = await apiGet('/api/snapchat-ads/campaigns', a);
17964
18872
  const part = d.partial ? `\n⚠ Some tiers could not be READ: ${Object.entries(d.partial).filter(([, v]) => v).map(([k, v]) => `${k}: ${v}`).join('; ')} — that is "could not tell", not "nothing there".` : '';
17965
- return ok(`Snapchat ad account ${d.name || d.adAccountId}: ${(d.campaigns || []).length} campaign(s), ${(d.adSquads || []).length} ad squad(s), ${(d.ads || []).length} ad(s).${part}\n${JSON.stringify({ campaigns: (d.campaigns || []).slice(0, 25), adSquads: (d.adSquads || []).slice(0, 25), ads: (d.ads || []).slice(0, 25) })}`, d);
18873
+ return ok(`Snapchat ad account ${d.name || d.adAccountId}: ${(d.campaigns || []).length} campaign(s), ${(d.adSquads || []).length} ad squad(s), ${(d.ads || []).length} ad(s).${part}${d.review?.line ? `\n${d.review.line}` : ''}\n${JSON.stringify({ campaigns: (d.campaigns || []).slice(0, 25), adSquads: (d.adSquads || []).slice(0, 25), ads: (d.ads || []).slice(0, 25) })}`, d);
17966
18874
  }));
17967
18875
  server.registerTool('snapchat_ads_report', {
17968
18876
  title: 'Snapchat ad performance',
@@ -17978,11 +18886,20 @@ function buildTools(rawServer, opts = {}, sink = null) {
17978
18886
  breakdown: z.string().optional().describe('object-level breakdown: ad, adsquad (campaign stats only) or campaign (ad-account stats only)'),
17979
18887
  swipeUpAttributionWindow: z.enum(['1_DAY', '7_DAY', '28_DAY']).optional().describe('how long after a SWIPE a conversion still counts. Omit to use the ad account default. Changing it changes the numbers, not just the report.'),
17980
18888
  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."),
18889
+ 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)'),
18890
+ conversionSourceTypes: z.string().optional().describe('comma-separated: web, app, offline, total, total_off_platform, total_on_platform — splits conversions by where they happened'),
18891
+ 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)'),
18892
+ omitEmpty: z.boolean().optional().describe('drop days/entities with zero on every metric'),
18893
+ async: z.boolean().optional().describe('for long ranges or wide breakdowns: Snapchat builds a CSV/Excel file instead and this returns a reportRunId'),
18894
+ asyncFormat: z.enum(['csv', 'excel']).optional(),
18895
+ reportRunId: z.string().optional().describe('fetch an async report started earlier — same level and id'),
17981
18896
  },
17982
- 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(), note: z.string().optional() },
18897
+ 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() },
17983
18898
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
17984
18899
  }, wrap(async (a) => {
17985
18900
  const d = await apiGet('/api/snapchat-ads/report', a);
18901
+ if (d.reportRunId || d.downloadUrl) return ok(d.note, d);
18902
+ if (d.read !== false && d.reportDimension) return ok(`${(d.rows || []).length} row(s) from Snapchat broken down by ${d.reportDimension} (${d.level} level, ${d.granularity}). SPEND IS IN MICRO-CURRENCY — divide by 1,000,000:\n${JSON.stringify((d.rows || []).slice(0, 60))}`, d);
17986
18903
  if (d.read === false) return ok(`Could not READ Snapchat stats for ${d.level} ${d.id} — ${d.note} That is "could not tell", not "no performance".`, d);
17987
18904
  const rows = d.rows || [];
17988
18905
  if (!rows.length) return ok(`NO ROWS for ${d.level} ${d.id} in that window. That means nothing DELIVERED — it is not a measurement of zero performance, and on a freshly built account it usually means nothing has been activated yet.`, d);
@@ -18034,11 +18951,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
18034
18951
  brandName: z.string().optional().describe('MAX 32 CHARACTERS'),
18035
18952
  callToAction: z.string().optional().describe('the swipe-up button label; must fit the creative type'),
18036
18953
  landingPageUrl: z.string().optional().describe('where a swipe-up goes'),
18037
- creativeType: z.string().optional().describe('default SNAP_AD'),
18954
+ creativeType: z.string().optional().describe('omit it: LEAD_GENERATION when leadGenerationFormId is given, WEB_VIEW when landingPageUrl is given, otherwise SNAP_AD (a top snap only)'),
18955
+ leadGenerationFormId: z.string().optional().describe('build a LEAD_GENERATION creative on this lead form (list_snapchat_ads_lead_forms / create_snapchat_ads_lead_form)'),
18956
+ 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'),
18957
+ 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'),
18038
18958
  profileId: z.string().describe('REQUIRED — the Snapchat Public Profile the ad posts as. Snapchat has rejected every creative without one since 2024-02-26. From list_snapchat_ads_profiles, or Snapchat Ads Manager.'),
18039
18959
  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'),
18040
18960
  },
18041
- 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(), note: z.string().optional() },
18961
+ 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() },
18042
18962
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
18043
18963
  }, wrap(async (a) => {
18044
18964
  const d = await apiPost('/api/snapchat-ads/upload', a);
@@ -18100,6 +19020,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
18100
19020
  regions: z.array(z.string()).optional().describe('region/state ids INSIDE the one country named in countries — resolve with search_snapchat_ads_targeting(kind:"region", countryCode:"ca"). Refused if more than one country is given, because a region id belongs to a country.'),
18101
19021
  metros: z.array(z.string()).optional().describe('metro/DMA ids inside the one country named in countries — resolve with kind:"metro"'),
18102
19022
  postalCodes: z.array(z.string()).optional().describe('postal codes inside the one country named in countries'),
19023
+ audiences: z.array(z.string()).optional().describe('audience ids to TARGET — customer lists, lookalikes, pixel audiences (list_snapchat_ads_audiences); only READY ones deliver'),
19024
+ excludeAudiences: z.array(z.string()).optional().describe('audience ids to EXCLUDE, e.g. existing customers'),
18103
19025
  pixelId: z.string().optional().describe('the Snap Pixel a PIXEL_* optimizationGoal optimises toward \u2014 from list_snapchat_ads_pixels. Omit it and the ad account\u2019s ONLY active pixel is used and named in the reply; an account with none or several is refused by name, never guessed. The pixel Snapchat stored is read back.'),
18104
19026
  regulatedContent: z.boolean().optional().describe('declare regulated content (alcohol, gambling and the like)'),
18105
19027
  startTime: z.string().optional().describe('ISO 8601'),
@@ -18120,7 +19042,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
18120
19042
  adSquadId: z.string().describe('the ad squad this ad belongs to'),
18121
19043
  name: z.string().describe('max 375 characters'),
18122
19044
  creativeId: z.string().describe('from upload_snapchat_ads_creative — NOT a mediaId'),
18123
- type: z.string().optional().describe('default SNAP_AD. Others include REMOTE_WEBPAGE, APP_INSTALL, STORY, COLLECTION, LEAD_GENERATION.'),
19045
+ 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.'),
18124
19046
  },
18125
19047
  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() },
18126
19048
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
@@ -18181,6 +19103,170 @@ function buildTools(rawServer, opts = {}, sink = null) {
18181
19103
  return ok(`Snapchat ${d.level} delete. Read back: ${gone}.${d.verified ? '' : ` ⚠ ${d.note}`}`, d);
18182
19104
  }));
18183
19105
 
19106
+ // ══ SNAPCHAT ADS BREADTH (2026-10-27): lead forms + delivery, edits, previews, audiences, Conversions API ══════════
19107
+ // Each tool calls the app's /api/snapchat-ads route, whose worker the Studio handler also calls — one implementation
19108
+ // of every refusal, confirm gate and read-back on every surface. Vendor facts: lib/snapchat-ads-breadth.mjs.
19109
+ server.registerTool('list_snapchat_ads_lead_forms', {
19110
+ title: 'List Snapchat lead forms',
19111
+ description: 'The LEAD FORMS on a Snapchat ad account — id, name, title and questions — or one form with formId. A Snapchat lead ad opens one of these: build the LEAD_GENERATION creative with upload_snapchat_ads_creative(leadGenerationFormId), then create_snapchat_ads_ad. Read-only, free.',
19112
+ inputSchema: { adAccountId: z.string().optional(), formId: z.string().optional().describe('read one form') },
19113
+ outputSchema: { adAccountId: z.string().optional(), forms: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
19114
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
19115
+ }, wrap(async (a) => {
19116
+ const d = await apiGet('/api/snapchat-ads/lead-forms', a);
19117
+ return ok(`${d.note}${(d.forms || []).length > 1 ? '\n' + d.forms.map(f => `· ${f.name} — ${f.formId} (${(f.fields || []).length} question(s))`).join('\n') : ''}`, d);
19118
+ }));
19119
+ server.registerTool('create_snapchat_ads_lead_form', {
19120
+ title: 'Create a Snapchat lead form',
19121
+ description: "Create the in-app form a Snapchat lead ad opens. Snapchat's rules are refused BY NAME, never repaired: FIRST_NAME and LAST_NAME are required, plus at least one of EMAIL or PHONE_NUMBER; ADDRESS and POSTAL_CODE cannot both be on one form; title max 25 characters, description max 180, legal title 35 / description 80, at most 2 consent checkboxes, only one question may use preferred options. A form spends nothing; it is read back from Snapchat. Snapchat has NO endpoint that lists leads afterwards — to receive them, turn on delivery with manage_snapchat_ads_lead_webhook(action:'subscribe'). Free.",
19122
+ inputSchema: {
19123
+ adAccountId: z.string().optional(),
19124
+ name: z.string().describe('the form name in Ads Manager'),
19125
+ title: z.string().describe('max 25 characters, shown at the top of the form'),
19126
+ description: z.string().describe('max 180 characters, shown above the questions'),
19127
+ fields: z.array(z.union([z.string(), z.object({ type: z.string().optional(), question: z.string().optional(), kind: z.enum(['TEXT', 'DATE', 'MULTIPLE_CHOICE_SINGLE_SELECTION', 'MULTIPLE_CHOICE_MULTI_SELECTION']).optional(), options: z.array(z.union([z.string(), z.object({ text: z.string(), preferred: z.boolean().optional() })])).optional() })])).describe('the questions, in order: standard types as strings (FIRST_NAME, LAST_NAME, EMAIL, PHONE_NUMBER, ADDRESS, POSTAL_CODE, BIRTHDAY_DATE, JOB_TITLE, COMPANY_NAME) and custom questions as {question, kind, options}'),
19128
+ privacyPolicyUrl: z.string().optional().describe('linked on the confirmation screen'),
19129
+ legal: z.object({ title: z.string(), description: z.string(), consents: z.array(z.union([z.string(), z.object({ text: z.string(), required: z.boolean().optional() })])).optional() }).optional().describe('legal disclosure: title max 35, description max 80, up to 2 consent checkboxes'),
19130
+ bannerMediaId: z.string().optional().describe('an IMAGE media id already on the ad account; 75:23, 750x230 to 1875x575, 5MB'),
19131
+ endPage: z.object({ callToAction: z.enum(['VIEW_WEBSITE', 'BOOK_NOW', 'LEARN_MORE', 'DONATE', 'SPECIAL_OFFER', 'SCHEDULE_NOW', 'BUY_TICKETS', 'TEST_DRIVE', 'APPLY_NOW', 'GET_COUPON', 'CLAIM_SAMPLE', 'FREE_TRIAL']), url: z.string() }).optional().describe('the screen after submit'),
19132
+ },
19133
+ outputSchema: { adAccountId: z.string().optional(), formId: z.string().optional(), form: z.any().optional(), verified: z.boolean().optional(), note: z.string().optional() },
19134
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
19135
+ }, wrap(async (a) => { const d = await apiPost('/api/snapchat-ads/lead-form', a); return ok(d.note, d); }));
19136
+ server.registerTool('manage_snapchat_ads_lead_webhook', {
19137
+ title: 'Turn on real-time Snapchat lead delivery',
19138
+ description: "Have Snapchat PUSH every new lead from a lead form to Hermoso the moment it is submitted — the ONLY way Snapchat hands leads to an API (it has no endpoint that lists leads), so leads submitted before this was on are never delivered. action 'subscribe' (default) turns it on, read back from Snapchat; forwardTo (public https) also relays each lead to a CRM, Zapier or Make. 'list' shows the form's webhooks; 'test' makes Snapchat send its sample lead and waits for it to ARRIVE at Hermoso, which proves delivery end to end; 'delete' removes a webhook (confirm:true; one Hermoso did not make may feed the advertiser's own CRM, and the refusal says so). Snapchat allows ONE webhook per form and requires Organization Admin on the Snapchat organization. Every delivery is verified against Snapchat's HMAC signature before it is stored. Read leads with list_snapchat_ads_leads. Free.",
19139
+ inputSchema: {
19140
+ adAccountId: z.string().optional(),
19141
+ action: z.enum(['subscribe', 'list', 'test', 'delete']).optional().describe('default subscribe'),
19142
+ formId: z.string().optional().describe('the lead form (list_snapchat_ads_lead_forms)'),
19143
+ integrationId: z.string().optional().describe('for delete, or test of one integration'),
19144
+ forwardTo: z.string().optional().describe('optional public https URL each lead is also forwarded to'),
19145
+ confirm: z.boolean().optional().describe('REQUIRED true for delete'),
19146
+ },
19147
+ outputSchema: { adAccountId: z.string().optional(), action: z.string().optional(), formId: z.string().optional(), integrationId: z.string().optional(), integrations: z.array(z.any()).optional(), verified: z.boolean().optional(), received: z.boolean().optional(), forwardTo: z.string().nullable().optional(), note: z.string().optional() },
19148
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
19149
+ }, wrap(async (a) => { const d = await apiPost('/api/snapchat-ads/lead-webhook', a); return ok(d.note, d); }));
19150
+ server.registerTool('list_snapchat_ads_leads', {
19151
+ title: 'Snapchat leads received',
19152
+ description: "The Snapchat leads Snapchat has delivered to Hermoso for this brand, newest first: leadId, createdAt, email, firstName, lastName, phone, postalCode, address, birthday, jobTitle, companyName, the custom answers and consents, and the form / campaign / ad squad / ad they came from. Only leads that arrived after manage_snapchat_ads_lead_webhook subscribed their form exist — an empty list means none ARRIVED, not that nobody signed up before delivery was on. Snapchat's sample leads (action:'test') are hidden unless includeTest. Read-only, free.",
19153
+ inputSchema: { formId: z.string().optional(), since: z.string().optional().describe('ISO date'), limit: z.number().optional().describe('default 100, max 1000'), includeTest: z.boolean().optional() },
19154
+ outputSchema: { count: z.number().optional(), leads: z.array(z.any()).optional(), subscribedForms: z.array(z.any()).optional(), note: z.string().optional() },
19155
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
19156
+ }, wrap(async (a) => {
19157
+ const d = await apiGet('/api/snapchat-ads/leads', a);
19158
+ return ok(`${d.note}${(d.leads || []).length ? '\n' + d.leads.slice(0, 40).map(l => `· ${[l.firstName, l.lastName].filter(Boolean).join(' ') || '(no name)'}${l.email ? ` <${l.email}>` : ''}${l.phone ? ` ${l.phone}` : ''} — form ${l.formName || l.formId}, ${l.createdAt || l.receivedAt}${l.test ? ' [sample]' : ''}`).join('\n') : ''}`, d);
19159
+ }));
19160
+ server.registerTool('update_snapchat_ads_object', {
19161
+ title: 'Edit a Snapchat campaign, ad squad, ad or creative',
19162
+ 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.",
19163
+ inputSchema: {
19164
+ adAccountId: z.string().optional(),
19165
+ level: z.enum(['campaign', 'adsquad', 'ad', 'creative']),
19166
+ id: z.string().describe('the object id (a creative id comes from the ad row’s creative_id or upload_snapchat_ads_creative)'),
19167
+ name: z.string().optional(),
19168
+ startTime: z.string().optional().describe('ISO 8601; on an ad squad it must be now or later'),
19169
+ endTime: z.string().optional().describe('ISO 8601, after startTime'),
19170
+ bid: z.number().optional().describe("ad squad bid in the ad account's currency (converted to micro here)"),
19171
+ bidMicro: z.number().optional().describe('ONLY if already multiplied by 1,000,000; never with bid'),
19172
+ bidStrategy: z.enum(['AUTO_BID', 'LOWEST_COST_WITH_MAX_BID', 'TARGET_COST']).optional(),
19173
+ pixelId: z.string().optional(),
19174
+ audiences: z.array(z.string()).optional().describe('audience ids the squad TARGETS — replaces its audience targeting; [] clears it'),
19175
+ excludeAudiences: z.array(z.string()).optional(),
19176
+ headline: z.string().optional(),
19177
+ brandName: z.string().optional(),
19178
+ callToAction: z.string().optional(),
19179
+ landingPageUrl: z.string().optional().describe('https only, max 2048 characters'),
19180
+ 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'),
19181
+ addUtm: z.boolean().optional().describe('set utm_source=snapchat, utm_medium=paid_social, utm_campaign={{campaign.name}}, utm_content={{ad.id}}'),
19182
+ confirm: z.boolean().optional().describe('REQUIRED true — without it nothing changes and you get the before → after'),
19183
+ },
19184
+ 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() },
19185
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
19186
+ }, wrap(async (a) => {
19187
+ const d = await apiPost('/api/snapchat-ads/update', a);
19188
+ return ok(`${d.note}\n${(d.changes || []).map(c => `· ${c.field}: ${JSON.stringify(c.stored)} ${c.verified ? '✓' : c.verified === false ? '⚠ not what was requested' : '(unread)'}`).join('\n')}`, d);
19189
+ }));
19190
+ server.registerTool('preview_snapchat_ads_creative', {
19191
+ title: 'Preview a Snapchat ad',
19192
+ description: 'See a Snapchat ad before it spends: a Snap-hosted preview page of the creative (no login, live for 90 days), its type and its review status. snapcode:true adds a Snapcode image to scan with the Snapchat app (its expiry is printed). Pass creativeId, or adId to preview the creative that ad uses. Read-only, free.',
19193
+ inputSchema: { adAccountId: z.string().optional(), creativeId: z.string().optional(), adId: z.string().optional(), snapcode: z.boolean().optional() },
19194
+ outputSchema: { adAccountId: z.string().optional(), creativeId: z.string().optional(), name: z.string().optional(), type: z.string().nullable().optional(), reviewStatus: z.string().nullable().optional(), previewUrl: z.string().nullable().optional(), expiresAt: z.string().nullable().optional(), snapcode: z.any().optional(), note: z.string().optional() },
19195
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
19196
+ }, wrap(async (a) => { const d = await apiGet('/api/snapchat-ads/preview', a); return ok(d.note, d); }));
19197
+ server.registerTool('list_snapchat_ads_audiences', {
19198
+ title: 'List Snapchat audiences',
19199
+ description: 'The custom AUDIENCES on a Snapchat ad account — customer lists, lookalikes, website-event (pixel) and engagement audiences — with approximate size, upload status and whether each can be targeted yet (READY, TOO_FEW_USERS, NOT_READY). Pass audienceId for one. Target them with create_snapchat_ads_ad_squad(audiences / excludeAudiences). Read-only, free.',
19200
+ inputSchema: { adAccountId: z.string().optional(), audienceId: z.string().optional() },
19201
+ outputSchema: { adAccountId: z.string().optional(), audiences: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
19202
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
19203
+ }, wrap(async (a) => {
19204
+ const d = await apiGet('/api/snapchat-ads/audiences', a);
19205
+ return ok(`${d.note}${(d.audiences || []).length > 1 ? '\n' + d.audiences.map(x => `· ${x.name} — ${x.audienceId} (${x.sourceType}, ${x.targetableStatus || '?'}, ~${x.approximateUsers ?? '?'} users)`).join('\n') : ''}`, d);
19206
+ }));
19207
+ server.registerTool('create_snapchat_ads_audience', {
19208
+ title: 'Create a Snapchat audience',
19209
+ description: "Create a Snapchat custom audience. sourceType FIRST_PARTY: a customer list, EMPTY until update_snapchat_ads_audience_users adds people (retention defaults to lifetime). LOOKALIKE: people like a customer-list seed — seedSegmentId + countries, lookalikeType BALANCE (default) / SIMILARITY / REACH, retention max 180. PIXEL: people who fired Snap Pixel events — pixelId + events (e.g. PURCHASE, ADD_CART) + retentionInDays up to 395. ENGAGEMENT is read-only on Snapchat and refused. An audience spends nothing; it is read back from Snapchat. Free.",
19210
+ inputSchema: {
19211
+ adAccountId: z.string().optional(),
19212
+ sourceType: z.enum(['FIRST_PARTY', 'LOOKALIKE', 'PIXEL']).optional().describe('default FIRST_PARTY'),
19213
+ name: z.string(),
19214
+ description: z.string().optional(),
19215
+ retentionInDays: z.number().optional().describe('FIRST_PARTY default lifetime (9999); LOOKALIKE max 180 (default 180); PIXEL required, max 395'),
19216
+ seedSegmentId: z.string().optional().describe('LOOKALIKE: the customer-list audience to model'),
19217
+ countries: z.array(z.string()).optional().describe('LOOKALIKE: two-letter codes, e.g. ["US"]'),
19218
+ lookalikeType: z.enum(['BALANCE', 'SIMILARITY', 'REACH']).optional(),
19219
+ pixelId: z.string().optional().describe('PIXEL: from list_snapchat_ads_pixels'),
19220
+ events: z.array(z.string()).optional().describe('PIXEL: the events that put someone in it, e.g. ["PURCHASE","START_CHECKOUT"]'),
19221
+ },
19222
+ outputSchema: { adAccountId: z.string().optional(), audienceId: z.string().optional(), audience: z.any().optional(), verified: z.boolean().optional(), note: z.string().optional() },
19223
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
19224
+ }, wrap(async (a) => { const d = await apiPost('/api/snapchat-ads/audience', a); return ok(d.note, d); }));
19225
+ server.registerTool('update_snapchat_ads_audience_users', {
19226
+ title: 'Add or remove people in a Snapchat customer list',
19227
+ description: "Add people to, or remove them from, a Snapchat FIRST_PARTY customer-list audience. Pass plain emails, phone numbers WITH their country code (+1 555 …), or mobile advertising ids — Hermoso normalises them the way Snapchat specifies and SHA-256 hashes them before anything leaves (a 64-hex value is taken as already hashed). ONE schema per call (Snapchat's rule). Values that are not valid are refused and counted, never sent. action 'remove' and 'remove_all' (empties the list) need confirm:true. Matching is Snapchat's: the reply reports what it accepted and the audience's size and targetable status read back. Free.",
19228
+ inputSchema: {
19229
+ adAccountId: z.string().optional(),
19230
+ audienceId: z.string(),
19231
+ action: z.enum(['add', 'remove', 'remove_all']).optional().describe('default add'),
19232
+ schema: z.enum(['EMAIL', 'PHONE', 'MOBILE_AD_ID']).optional().describe('default EMAIL'),
19233
+ identifiers: z.array(z.string()).optional().describe('plain values or 64-hex SHA-256 hashes; up to 100,000 per request are batched'),
19234
+ confirm: z.boolean().optional().describe('REQUIRED true for remove and remove_all'),
19235
+ },
19236
+ outputSchema: { adAccountId: z.string().optional(), audienceId: z.string().optional(), action: z.string().optional(), sent: z.number().optional(), accepted: z.number().optional(), rejected: z.number().optional(), audience: z.any().optional(), approximateUsers: z.number().nullable().optional(), note: z.string().optional() },
19237
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
19238
+ }, wrap(async (a) => { const d = await apiPost('/api/snapchat-ads/audience-users', a); return ok(d.note, d); }));
19239
+ server.registerTool('delete_snapchat_ads_audience', {
19240
+ title: 'Delete a Snapchat audience',
19241
+ description: "Permanently delete a Snapchat audience — Snapchat: deleted audiences \"cannot be retrieved\". confirm:true required; without it nothing is deleted and you get the audience's name, type and size to show the user. Ad squads targeting it lose that targeting. Read back. Free.",
19242
+ inputSchema: { adAccountId: z.string().optional(), audienceId: z.string(), confirm: z.boolean().optional().describe('REQUIRED true') },
19243
+ outputSchema: { adAccountId: z.string().optional(), audienceId: z.string().optional(), name: z.string().optional(), verified: z.boolean().optional(), note: z.string().optional() },
19244
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
19245
+ }, wrap(async (a) => { const d = await apiPost('/api/snapchat-ads/audience-delete', a); return ok(d.note, d); }));
19246
+ server.registerTool('send_snapchat_ads_conversions', {
19247
+ title: 'Send conversions to Snapchat (Conversions API)',
19248
+ description: "Report conversions to Snapchat server-side (Conversions API v3) so it can attribute them to ads and optimise delivery toward them — purchases, sign-ups, leads, page views and the rest. pixelId for website and offline events (list_snapchat_ads_pixels), or snapAppId for app events. Pass plain emails and phone numbers: Hermoso normalises and SHA-256 hashes them before they leave. Snapchat's rules, refused for free: each event needs something to match on (email, phone, madid, ipAddress + userAgent, clickId from the ScCid URL parameter, or leadId); WEB events need eventSourceUrl; PURCHASE needs customData.currency and value; nothing older than 7 days; at most 2,000 events per call. validateOnly:true has Snapchat CHECK the events and records nothing — use it first. eventId deduplicates against the browser pixel. Free.",
19249
+ inputSchema: {
19250
+ adAccountId: z.string().optional(),
19251
+ pixelId: z.string().optional().describe('website / offline events'),
19252
+ snapAppId: z.string().optional().describe('app events (Snap App ID)'),
19253
+ validateOnly: z.boolean().optional().describe('Snapchat validates and records nothing'),
19254
+ testEventCode: z.string().optional().describe('label for Events Manager testing'),
19255
+ events: z.array(z.object({
19256
+ eventName: z.string().describe('PURCHASE, SIGN_UP, PAGE_VIEW, ADD_CART, START_CHECKOUT, VIEW_CONTENT, SUBSCRIBE, START_TRIAL, … or CUSTOM_EVENT_1..5'),
19257
+ eventTime: z.union([z.number(), z.string()]).optional().describe('ISO or epoch; default now; within the last 7 days'),
19258
+ eventId: z.string().optional().describe('dedup key shared with the pixel (order id, transaction id)'),
19259
+ actionSource: z.enum(['WEB', 'OFFLINE', 'MOBILE_APP']).optional().describe('default WEB'),
19260
+ eventSourceUrl: z.string().optional().describe('REQUIRED for WEB: the page it happened on (a URL carrying ScCid also attributes the click)'),
19261
+ user: z.object({ email: z.string().optional(), phone: z.string().optional(), madid: z.string().optional(), firstName: z.string().optional(), lastName: z.string().optional(), ipAddress: z.string().optional(), userAgent: z.string().optional(), clickId: z.string().optional().describe('the ScCid value'), cookie1: z.string().optional().describe('the _scid cookie'), externalId: z.string().optional(), leadId: z.string().optional().describe('a Snapchat lead id from list_snapchat_ads_leads') }).optional(),
19262
+ customData: z.object({ currency: z.string().optional(), value: z.union([z.number(), z.string()]).optional(), contents: z.array(z.any()).optional(), orderId: z.string().optional() }).optional(),
19263
+ limitedDataUse: z.boolean().optional().describe('the person opted out (LMU)'),
19264
+ })),
19265
+ },
19266
+ outputSchema: { adAccountId: z.string().optional(), pixelId: z.string().optional(), snapAppId: z.string().optional(), validateOnly: z.boolean().optional(), sent: z.number().optional(), status: z.string().nullable().optional(), reason: z.string().nullable().optional(), note: z.string().optional() },
19267
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
19268
+ }, wrap(async (a) => { const d = await apiPost('/api/snapchat-ads/conversions', a); return ok(d.note, d); }));
19269
+
18184
19270
  // ══ LINKEDIN COMPANY PAGES + ADS (2026-07-30) ══════════════════════════════════════════════════════════════
18185
19271
  // → `channels` (2026-08-20). Organic LinkedIn: list the Pages, post to one, edit/delete that post, read the
18186
19272
  // Page's own analytics. post_to_linkedin (the member profile) has always been `channels`, so a default roster
@@ -18211,7 +19297,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
18211
19297
  }));
18212
19298
  server.registerTool('post_to_linkedin_page', {
18213
19299
  title: 'Publish to a LinkedIn company Page',
18214
- description: 'Use this when the user wants to post to a LinkedIn company Page. Publishing costs no credits. Publish a post to one of the user’s LinkedIn COMPANY PAGES — text, plus optionally an image, a video, a 2–20 image CAROUSEL (LinkedIn calls it a MultiImage post; pass the slides in order as imageUrls[]), or a LINK POST with a real preview card (linkUrl). USE linkUrl WHENEVER THE POINT OF THE POST IS A LINK: LinkedIn disables URL scraping for API partners, so a url sitting in the text renders as plain text with no card, and the card’s title, description and image only exist if you pass linkTitle / linkDescription / linkThumbnailUrl — read them off the page and supply them. The media need not be a Hermoso render — it must be Hermoso-HOSTED because we upload the bytes to LinkedIn ourselves, and upload_file turns ANY file the user already has into such a URL. ORGANIC CAROUSELS ARE COMPANY-PAGE ONLY — a personal profile cannot publish one and is refused by name, so send a deck here rather than to post_to_linkedin. This is a DIFFERENT thing from post_to_linkedin, which publishes to the person’s own profile: pick the one the user actually asked for and never substitute. organizationId comes from list_linkedin_pages; omit it only when the account administers exactly one Page. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. A VIDEO POST CAN CARRY CAPTIONS AND ITS OWN COVER, and both are attached only during the upload: pass captionsSrt (SubRip content — LinkedIn is watched with the sound off) and videoThumbnailUrl (otherwise LinkedIn picks a frame for you). LinkedIn does NOT allow the image, video, captions or thumbnail of a published post to be swapped afterwards, so get all of that right first (the copy can still be edited with manage_linkedin_post).',
19300
+ description: 'Use this when the user wants to post to a LinkedIn company Page. Publishing costs no credits. Publish a post to one of the user’s LinkedIn COMPANY PAGES — text, plus optionally an image, a video, a 2–20 image CAROUSEL (LinkedIn calls it a MultiImage post; pass the slides in order as imageUrls[]), or a LINK POST with a real preview card (linkUrl). USE linkUrl WHENEVER THE POINT OF THE POST IS A LINK: LinkedIn disables URL scraping for API partners, so a url sitting in the text renders as plain text with no card, and the card’s title, description and image only exist if you pass linkTitle / linkDescription / linkThumbnailUrl — read them off the page and supply them. The media need not be a Hermoso render — it must be Hermoso-HOSTED because we upload the bytes to LinkedIn ourselves, and upload_file turns ANY file the user already has into such a URL. A DOCUMENT post (documentUrl — a PDF/PPTX deck readers swipe through) is LinkedIn\u2019s strongest organic B2B format. Tag companies with @Name in the text plus mentions. This is a DIFFERENT thing from post_to_linkedin, which publishes to the person’s own profile: pick the one the user actually asked for and never substitute. organizationId comes from list_linkedin_pages; omit it only when the account administers exactly one Page. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. A VIDEO POST CAN CARRY CAPTIONS AND ITS OWN COVER, and both are attached only during the upload: pass captionsSrt (SubRip content — LinkedIn is watched with the sound off) and videoThumbnailUrl (otherwise LinkedIn picks a frame for you). LinkedIn does NOT allow the image, video, captions or thumbnail of a published post to be swapped afterwards, so get all of that right first (the copy can still be edited with manage_linkedin_post).',
18215
19301
  inputSchema: {
18216
19302
  ...HOOK_ATTR,
18217
19303
  organizationId: z.string().optional().describe('numeric Page id from list_linkedin_pages'),
@@ -18220,12 +19306,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
18220
19306
  linkTitle: z.string().optional().describe('the headline ON the preview card. LINKEDIN NEVER SCRAPES THE PAGE — their Posts API disables URL scraping for API partners outright — so if you do not pass this the card renders UNLABELLED. Fetch the page\u2019s own title and pass it.'),
18221
19307
  linkDescription: z.string().optional().describe('the sub-line on the preview card. Same rule as linkTitle: absent means blank, because LinkedIn will not fetch it.'),
18222
19308
  linkThumbnailUrl: z.string().optional().describe('a Hermoso-hosted image used as the card\u2019s picture (uploaded to LinkedIn for you). Without it the card has no image.'),
19309
+ documentUrl: z.string().optional().describe('a DOCUMENT post — a Hermoso-hosted PDF / PPTX / DOCX (upload_file first) that readers swipe through page by page in the feed; LinkedIn takes up to 100MB and 300 pages. One kind of media per post: not with an image, video, slides or linkUrl.'),
19310
+ documentTitle: z.string().optional().describe('the title shown on the document (defaults to the file name)'),
19311
+ mentions: z.array(z.any()).optional().describe('TAG COMPANIES: write "@Name" in the text where each tag goes and list each company here — its vanity name (the part after linkedin.com/company/), its company page URL, or {organizationId, name}. Hermoso resolves it on LinkedIn and LinkedIn renders a real clickable mention; a company with no "@" spot in the text is refused rather than appended.'),
18223
19312
  imageUrl: z.string().optional().describe('a Hermoso-hosted image URL — a render (list_library), or ANY image of the user’s own passed through upload_file first. An arbitrary external host is refused.'),
18224
19313
  videoUrl: z.string().optional().describe('a Hermoso-hosted video URL — a render, or the user’s own footage via upload_file. LinkedIn processes it before publishing, which takes a minute.'),
18225
19314
  imageUrls: z.array(z.string()).optional().describe('CAROUSEL — an ORDERED list of image (and, where the channel allows, video) URLs published as ONE post the viewer swipes through. THIS IS NOT “post several” — it is a single post with several slides, which is what a multi-slide creative (a listicle, a “1/6 · SWIPE” deck) actually needs; publishing only its first slide tells the viewer to swipe at something that cannot. The ORDER is the product. Limits per channel: Instagram 2–20 (images, videos or a mix), Threads 2–20 (mix allowed), Facebook 2+ (Meta publishes no documented maximum; Hermoso caps the upload fan-out at 30 and says so), LinkedIn company Pages 2–20 (images only), Pinterest 2–5 (images only), TikTok up to 35. One url here is simply an ordinary single post. Anything a channel cannot do is REFUSED with the real reason — nothing is ever quietly downgraded to one slide.'),
18226
19315
  idempotencyKey: z.string().optional().describe('SAFE RETRIES — any stable string: a repeat of the SAME publish within 24h returns the ORIGINAL post id instead of posting again (an identical publish is also auto-recognised for 10 minutes). On a timeout or an ambiguous error, CALL AGAIN WITH THE SAME KEY: it reports the original post or publishes it once. It never posts twice.'),
18227
19316
  allowDuplicate: z.boolean().optional().describe('post it even though an identical post was just made or attempted. Only pass this when the user genuinely wants the same thing posted twice, or when you have LOOKED at the account and confirmed a timed-out attempt did not land.'),
18228
- altText: z.union([z.string(), z.array(z.string())]).optional().describe('accessibility alt text (max 4086 characters, ~120 recommended). A STRING describes every image; an ARRAY describes each slide of a multi-image post separately, in slide order \u2014 LinkedIn stores altText per image, and their own sample request carries a different one on each. Not available on a PERSONAL-profile post: LinkedIn\u2019s member posting API has no alt-text field at all.'),
19317
+ altText: z.union([z.string(), z.array(z.string())]).optional().describe('accessibility alt text (max 4086 characters, ~120 recommended). A STRING describes every image; an ARRAY describes each slide of a multi-image post separately, in slide order \u2014 LinkedIn stores altText per image, and their own sample request carries a different one on each. Also works on a personal-profile post (post_to_linkedin).'),
18229
19318
  title: z.string().optional().describe('video title'),
18230
19319
  captionsSrt: z.string().optional().describe('CLOSED CAPTIONS for a videoUrl post — the SubRip (.srt) CONTENT itself, cue numbers and `00:00:00,000 --> 00:00:02,000` timing lines included, NOT a URL and NOT the plain script (a file with no timings is refused, because LinkedIn would accept it and then silently never show it). Most of LinkedIn is watched with the sound off, so an uncaptioned video is one most of the feed never hears. LinkedIn allows ONE caption file per video and ENGLISH ONLY; it can be attached only WHILE the video is uploaded, never added to a published post; and it is processed asynchronously, so the reply confirms it was UPLOADED and never that it is visible yet. Requires videoUrl — passing it on an image, carousel or link post is refused by name.'),
18231
19320
  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.'),
@@ -18302,6 +19391,16 @@ function buildTools(rawServer, opts = {}, sink = null) {
18302
19391
  // Three vendor facts are put in the description rather than left to the model to discover the hard way: the
18303
19392
  // 12-month retention, the ~2-day follower reporting lag, and the fact that LinkedIn OMITS zero-activity rows —
18304
19393
  // which is how "we have no data on that post" becomes a confident "it got no engagement".
19394
+ server.registerTool('linkedin_profile_followers', {
19395
+ title: 'Followers of a personal LinkedIn profile',
19396
+ description: 'Follower count of the PERSONAL LinkedIn profile shared with this brand — the lifetime total and, with startDate / endDate, the new followers per day (LinkedIn\u2019s memberFollowersCount). This is the creator / founder growth number; Company Page followers are in linkedin_page_analytics. It needs a personal profile ticked in Manage accounts (list_connector_accounts provider linkedin) and the LinkedIn analytics authorization on the LinkedIn card — if the reply says re-authorize, say exactly that. Read-only, free.',
19397
+ inputSchema: {
19398
+ startDate: z.string().optional().describe('YYYY-MM-DD — include for a daily series'),
19399
+ endDate: z.string().optional().describe('YYYY-MM-DD, exclusive, default today'),
19400
+ },
19401
+ outputSchema: { ok: z.boolean().optional(), followers: z.number().nullable().optional(), gained: z.number().nullable().optional(), daily: z.array(z.any()).optional(), note: z.string().optional() },
19402
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
19403
+ }, wrap(async (a) => { const d = await apiGet('/api/linkedin/member-followers', a); return ok(d.note, d); }));
18305
19404
  server.registerTool('linkedin_page_analytics', {
18306
19405
  title: 'Organic performance of a LinkedIn company Page',
18307
19406
  description: 'ORGANIC performance for one of the brand’s LinkedIn COMPANY PAGES: total followers, followers gained (organic vs paid) across the window, Page views (all / unique / desktop / mobile), and the impressions, unique impressions, clicks, likes, comments, shares and engagement rate of the Page’s posts. This is what answers “is our LinkedIn actually working” and “did that post land”. It is NOT linkedin_ads_report — that covers PAID campaigns; LinkedIn excludes sponsored activity from these figures entirely. Pass postUrns (the urn:li:share:… / urn:li:ugcPost:… that post_to_linkedin_page returned) for PER-POST numbers; LinkedIn forbids a date range together with named posts, so that switches to lifetime-per-post. Only Pages the user ticked in Manage accounts (or call list_connector_accounts with provider linkedin, then set_connector_accounts) are readable — a Page the account merely administers is refused, by design. LinkedIn keeps 12 months, follower figures run about 2 days behind, and it OMITS posts with no recorded activity rather than returning zeros: report an absent post or an unavailable section as MISSING data, never as zero. Read-only, 0 credits. Needs LinkedIn connected with the organization scopes.',
@@ -18310,6 +19409,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
18310
19409
  startDate: z.string().optional().describe('YYYY-MM-DD, default 28 days ago (LinkedIn keeps 12 months)'),
18311
19410
  endDate: z.string().optional().describe('YYYY-MM-DD, default today'),
18312
19411
  postUrns: z.array(z.string()).optional().describe('urn:li:share:… / urn:li:ugcPost:… — switches to per-post lifetime numbers instead of the Page total'),
19412
+ demographics: z.boolean().optional().describe('also return WHO follows the Page and who visits it (all-time, LinkedIn\u2019s lifetime statistics): the top seniorities, industries, job functions, countries, regions and company sizes, each named — the core B2B "who is our audience" answer'),
18313
19413
  },
18314
19414
  outputSchema: {
18315
19415
  organizationId: z.string().optional(), organizationName: z.string().optional(), url: z.string().optional(),
@@ -18318,7 +19418,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
18318
19418
  followerGains: z.object({ organic: z.number().optional(), paid: z.number().optional(), total: z.number().optional(), through: z.string().optional() }).nullable().optional(),
18319
19419
  pageViews: z.object({ all: z.number().optional(), unique: z.number().nullable().optional(), desktop: z.number().optional(), mobile: z.number().optional() }).nullable().optional(),
18320
19420
  posts: z.any().nullable().optional(), perPost: z.array(z.any()).nullable().optional(),
18321
- noActivity: z.array(z.string()).optional(), unavailable: z.array(z.any()).optional(), note: z.string().optional(),
19421
+ noActivity: z.array(z.string()).optional(), unavailable: z.array(z.any()).optional(), note: z.string().optional(), demographics: z.any().optional(),
18322
19422
  },
18323
19423
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
18324
19424
  }, wrap(async (a) => {
@@ -18506,7 +19606,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
18506
19606
  }, wrap(async (a) => { const d = await apiGet('/api/linkedin/ads-targeting', a); return ok(d.note, d); }));
18507
19607
  server.registerTool('create_linkedin_ads_creative', {
18508
19608
  title: 'Create a LinkedIn ad (creative, draft)',
18509
- description: 'Create the AD ITSELF on LinkedIn — a CREATIVE inside an existing campaign. A LinkedIn campaign holds no copy and no visual, so until this runs the campaign CANNOT show an impression no matter what its status says; say that rather than calling a campaign "live". Two ways in: pass postUrn to sponsor a post that already exists (LinkedIn’s "boost this post"), or pass text and/or imageUrl / videoUrl to author a DIRECT SPONSORED CONTENT post — a real post by a company Page the user administers that is NEVER shown on the Page’s feed and exists only as an ad. Created DRAFT: it spends nothing until you activate it with set_linkedin_ads_status(level:"creative", status:"ACTIVE", confirm:true). Creating it ACTIVE under an already-live campaign starts REAL AD SPEND on the very next auction and therefore requires confirm:true. The whole tree — group, campaign and every creative — is read back from LinkedIn before you are told anything exists.',
19609
+ description: 'Create the AD ITSELF on LinkedIn — a CREATIVE inside an existing campaign. A LinkedIn campaign holds no copy and no visual, so until this runs the campaign CANNOT show an impression no matter what its status says; say that rather than calling a campaign "live". Two ways in: pass postUrn to sponsor a post that already exists (LinkedIn’s "boost this post"), or pass text and/or imageUrl / videoUrl / documentUrl (a PDF/PPTX/DOCX document ad) to author a DIRECT SPONSORED CONTENT post — a real post by a company Page the user administers that is NEVER shown on the Page’s feed and exists only as an ad. Created DRAFT: it spends nothing until you activate it with set_linkedin_ads_status(level:"creative", status:"ACTIVE", confirm:true). Creating it ACTIVE under an already-live campaign starts REAL AD SPEND on the very next auction and therefore requires confirm:true. LEAD GEN ADS: under a LEAD_GENERATION campaign pass leadFormId (create_linkedin_lead_form makes one) — LinkedIn requires it there. The whole tree — group, campaign and every creative — is read back from LinkedIn before you are told anything exists.',
18510
19610
  inputSchema: {
18511
19611
  adAccountId: z.string().optional(),
18512
19612
  campaignId: z.string().describe('the campaign this ad belongs to'),
@@ -18521,6 +19621,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
18521
19621
  allowReshare: z.boolean().optional(),
18522
19622
  landingPage: z.string().optional().describe('WHERE THE AD SENDS THE CLICK — a full https URL, sent as LinkedIn\u2019s contentLandingPage. REQUIRED when the campaign objective is WEBSITE_VISIT (refused here, for free, without it). Only for a NEW sponsored post: an existing postUrn keeps its own link.'),
18523
19623
  callToAction: z.enum(['APPLY', 'DOWNLOAD', 'VIEW_QUOTE', 'LEARN_MORE', 'SIGN_UP', 'SUBSCRIBE', 'REGISTER', 'JOIN', 'ATTEND', 'REQUEST_DEMO', 'SEE_MORE', 'BUY_NOW', 'SHOP_NOW']).optional().describe('the BUTTON on the ad (contentCallToActionLabel) \u2014 needs landingPage. Omit for no button.'),
19624
+ leadFormId: z.string().optional().describe('LEAD GEN ADS: the lead gen form this ad opens (create_linkedin_lead_form / list_linkedin_lead_forms). REQUIRED under a LEAD_GENERATION campaign and refused under any other objective'),
19625
+ leadButton: z.enum(['APPLY', 'DOWNLOAD', 'VIEW_QUOTE', 'LEARN_MORE', 'SIGN_UP', 'SUBSCRIBE', 'REGISTER', 'REQUEST_DEMO', 'JOIN', 'ATTEND', 'UNLOCK_FULL_DOCUMENT', 'SHOP_NOW', 'BUY_NOW', 'SEE_MORE']).optional().describe('the lead gen button label, default LEARN_MORE (UNLOCK_FULL_DOCUMENT for a gated document)'),
19626
+ documentUrl: z.string().optional().describe('DOCUMENT AD: a PDF / PPTX / DOCX shown as a swipeable document in the feed — Hermoso-hosted, so upload_file it first'),
19627
+ gatedPreviewPages: z.number().optional().describe('document ad under a LEAD_GENERATION campaign only: pages shown free before the lead form unlocks the rest'),
18524
19628
  intendedStatus: z.enum(['DRAFT', 'ACTIVE', 'PAUSED', 'ARCHIVED']).optional().describe('default DRAFT'),
18525
19629
  confirm: z.boolean().optional().describe('REQUIRED true to create it ACTIVE under a LIVE campaign (real spend)'),
18526
19630
  },
@@ -18530,6 +19634,85 @@ function buildTools(rawServer, opts = {}, sink = null) {
18530
19634
  const d = await apiPost('/api/linkedin/ads-creative', a);
18531
19635
  return ok(`${d.note} To make it spend, use set_linkedin_ads_status(level:"creative", status:"ACTIVE", confirm:true) after the user approves.`, d);
18532
19636
  }));
19637
+ // ── LINKEDIN GAP SWEEP PHASE 1 (2026-10-27): lead gen forms, ad edits, LinkedIn's own previews, dynamic UTMs. Each
19638
+ // calls the SAME server function the Studio agent calls (lib/linkedin-phase1.mjs holds the rules).
19639
+ server.registerTool('create_linkedin_lead_form', {
19640
+ title: 'Create a LinkedIn lead gen form',
19641
+ description: 'Create a LinkedIn LEAD GEN FORM — the form a lead gen ad opens, prefilled from the member\u2019s LinkedIn profile so they submit in two taps. Owned by an ad account (adAccountId, the usual choice for ads) or a company Page (pageId). Give it a name (only shown in Campaign Manager), the headline people see, the questions (predefined fields LinkedIn prefills, plus custom text or multiple-choice questions), a privacy policy URL, optional consent checkboxes, a thank-you message and a thank-you button with its landing page. Created PUBLISHED by default — LinkedIn still lets you edit every field until a running ad uses it. Read back from LinkedIn before you are told it exists. Then attach it with create_linkedin_ads_creative(leadFormId) under a LEAD_GENERATION campaign; the leads it collects are read with list_linkedin_leads. A form cannot spend money. Free.',
19642
+ inputSchema: {
19643
+ adAccountId: z.string().optional().describe('own the form by this ad account'),
19644
+ pageId: z.string().optional().describe('or own it by a company Page (from list_linkedin_pages)'),
19645
+ name: z.string().describe('internal name, shown in Campaign Manager'),
19646
+ headline: z.string().describe('the title of the form people see'),
19647
+ description: z.string().optional(),
19648
+ questions: z.array(z.any()).describe('the fields to ask, in order: predefined names LinkedIn prefills from the member profile (FIRST_NAME, LAST_NAME, EMAIL, WORK_EMAIL, PHONE_NUMBER, COMPANY_NAME, JOB_TITLE, SENIORITY, COMPANY_SIZE, INDUSTRY, CITY, COUNTRY, ZIP_CODE, LINKEDIN_PROFILE_LINK …; friendly spellings like "work email" work) and/or custom questions {question, type:"text", maxLength?} or {question, options:[2–15 choices]}'),
19649
+ privacyPolicyUrl: z.string().optional().describe('your privacy policy URL — LinkedIn shows it on the form'),
19650
+ legalDisclaimer: z.string().optional().describe('1–2000 characters'),
19651
+ consents: z.array(z.any()).optional().describe('consent checkbox texts, or {text, required:true}'),
19652
+ thankYouMessage: z.string().optional(),
19653
+ thankYouLandingPage: z.string().optional().describe('where the thank-you button sends people'),
19654
+ thankYouCta: z.enum(['VISIT_COMPANY_WEBSITE', 'LEARN_MORE', 'VIEW_NOW', 'DOWNLOAD_NOW', 'TRY_NOW']).optional(),
19655
+ hiddenFields: z.record(z.string()).optional().describe('tracking values carried on every lead, {name: value}, max 20'),
19656
+ locale: z.string().optional().describe('default en_US'),
19657
+ state: z.enum(['PUBLISHED', 'DRAFT']).optional().describe('default PUBLISHED'),
19658
+ },
19659
+ outputSchema: { ok: z.boolean().optional(), formId: z.string().optional(), adFormUrn: z.string().optional(), form: z.any().optional(), note: z.string().optional() },
19660
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
19661
+ }, wrap(async (a) => { const d = await apiPost('/api/linkedin/lead-form', a); return ok(d.note, d); }));
19662
+ server.registerTool('update_linkedin_lead_form', {
19663
+ title: 'Edit or archive a LinkedIn lead gen form',
19664
+ description: 'Edit a LinkedIn lead gen form — name, headline, description, questions, privacy policy, disclaimer, consents, thank-you message / button / landing page — or retire it with state:"ARCHIVED". LinkedIn allows every field while no running ad uses the form; once a live ad does, only headline, description, disclaimer, privacy policy, the thank-you fields and state can change, and anything else is refused here by name. Changing the QUESTIONS can break a CRM that already mapped the form, so tell the user. Read back from LinkedIn. Free.',
19665
+ inputSchema: {
19666
+ formId: z.string().describe('from list_linkedin_lead_forms or create_linkedin_lead_form'),
19667
+ adAccountId: z.string().optional(), pageId: z.string().optional(),
19668
+ name: z.string().optional(), headline: z.string().optional(), description: z.string().optional(),
19669
+ questions: z.array(z.any()).describe('the fields to ask, in order: predefined names LinkedIn prefills from the member profile (FIRST_NAME, LAST_NAME, EMAIL, WORK_EMAIL, PHONE_NUMBER, COMPANY_NAME, JOB_TITLE, SENIORITY, COMPANY_SIZE, INDUSTRY, CITY, COUNTRY, ZIP_CODE, LINKEDIN_PROFILE_LINK …; friendly spellings like "work email" work) and/or custom questions {question, type:"text", maxLength?} or {question, options:[2–15 choices]}').optional(),
19670
+ privacyPolicyUrl: z.string().optional(), legalDisclaimer: z.string().optional(), consents: z.array(z.any()).optional(),
19671
+ thankYouMessage: z.string().optional(), thankYouLandingPage: z.string().optional(), thankYouCta: z.enum(['VISIT_COMPANY_WEBSITE', 'LEARN_MORE', 'VIEW_NOW', 'DOWNLOAD_NOW', 'TRY_NOW']).optional(),
19672
+ state: z.enum(['PUBLISHED', 'ARCHIVED']).optional(),
19673
+ },
19674
+ outputSchema: { ok: z.boolean().optional(), formId: z.string().optional(), changed: z.array(z.string()).optional(), form: z.any().optional(), note: z.string().optional() },
19675
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
19676
+ }, wrap(async (a) => { const d = await apiPost('/api/linkedin/lead-form-update', a); return ok(d.note, d); }));
19677
+ server.registerTool('update_linkedin_ads_creative', {
19678
+ title: 'Edit a LinkedIn ad (copy, landing page, button, lead form)',
19679
+ description: 'Edit a LinkedIn ad (creative) in place: its copy (text), landing page, button (callToAction), its name, or — while it is still DRAFT — the lead gen form it opens (leadFormId) and that button\u2019s label (leadButton). Copy, landing page and button live on the ad\u2019s sponsored post and are edited there. An ACTIVE ad needs confirm:true because real people see the change on the next impression, and LinkedIn may review it again. Status is NOT changed here — use set_linkedin_ads_status. Everything is read back from LinkedIn. Free.',
19680
+ inputSchema: {
19681
+ adAccountId: z.string().optional(),
19682
+ creativeId: z.string().describe('from list_linkedin_ads_campaigns(campaignId) or create_linkedin_ads_creative'),
19683
+ text: z.string().optional().describe('new ad copy'),
19684
+ landingPage: z.string().optional().describe('new click destination, full https URL'),
19685
+ callToAction: z.enum(['APPLY', 'DOWNLOAD', 'VIEW_QUOTE', 'LEARN_MORE', 'SIGN_UP', 'SUBSCRIBE', 'REGISTER', 'JOIN', 'ATTEND', 'REQUEST_DEMO', 'SEE_MORE', 'BUY_NOW', 'SHOP_NOW']).optional(),
19686
+ name: z.string().optional(),
19687
+ leadFormId: z.string().optional().describe('DRAFT creatives only — the lead gen form the ad opens'),
19688
+ leadButton: z.enum(['APPLY', 'DOWNLOAD', 'VIEW_QUOTE', 'LEARN_MORE', 'SIGN_UP', 'SUBSCRIBE', 'REGISTER', 'REQUEST_DEMO', 'JOIN', 'ATTEND', 'UNLOCK_FULL_DOCUMENT', 'SHOP_NOW', 'BUY_NOW', 'SEE_MORE']).optional(),
19689
+ confirm: z.boolean().optional().describe('REQUIRED true to edit an ACTIVE ad'),
19690
+ },
19691
+ outputSchema: { ok: z.boolean().optional(), creativeId: z.string().optional(), intendedStatus: z.string().optional(), text: z.string().optional(), landingPage: z.string().nullable().optional(), callToAction: z.string().nullable().optional(), reviewStatus: z.string().nullable().optional(), note: z.string().optional() },
19692
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
19693
+ }, wrap(async (a) => { const d = await apiPost('/api/linkedin/ads-creative-update', a); return ok(d.note, d); }));
19694
+ server.registerTool('preview_linkedin_ad', {
19695
+ title: 'Preview a LinkedIn ad + its review status',
19696
+ description: 'See a LinkedIn ad exactly as LinkedIn renders it: desktop and mobile feed preview links straight from LinkedIn (they expire after ~3 hours — ask again for fresh ones), plus the ad\u2019s REVIEW status and, if LinkedIn rejected it, its rejection reasons and serving holds. Works for single-image, carousel, video, job and event ads; LinkedIn\u2019s Ad Preview API renders none for text-only or document ads, and the reply says so. Read-only, free.',
19697
+ inputSchema: { adAccountId: z.string().optional(), creativeId: z.string() },
19698
+ outputSchema: { ok: z.boolean().optional(), previews: z.array(z.any()).optional(), reviewStatus: z.string().nullable().optional(), rejectionReasons: z.array(z.string()).optional(), servingHoldReasons: z.array(z.string()).optional(), note: z.string().optional() },
19699
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
19700
+ }, wrap(async (a) => { const d = await apiGet('/api/linkedin/ads-preview', a); return ok(d.note, d); }));
19701
+ server.registerTool('set_linkedin_ads_tracking', {
19702
+ title: 'LinkedIn ads dynamic UTM tracking',
19703
+ description: 'Dynamic UTM tracking for LinkedIn ads: parameters LinkedIn appends to every ad\u2019s landing page on a campaign (or, level:"account", across the whole ad account), filled in per click. standard:true sets utm_source=linkedin, utm_medium=paid_social, utm_campaign={campaign name}, utm_content={creative id}. Or pass dynamic {key: ACCOUNT_ID | ACCOUNT_NAME | CAMPAIGN_GROUP_ID | CAMPAIGN_GROUP_NAME | CAMPAIGN_ID | CAMPAIGN_NAME | CREATIVE_ID | CREATIVE_NAME} and/or custom {key: fixed value}. A new set REPLACES the previous one; clear:true removes it; call with no parameters to read what is set. Ads do not go back through review. Read back from LinkedIn. Free.',
19704
+ inputSchema: {
19705
+ adAccountId: z.string().optional(),
19706
+ level: z.enum(['campaign', 'account']).optional().describe('default campaign when campaignId is given'),
19707
+ campaignId: z.string().optional(),
19708
+ standard: z.boolean().optional(),
19709
+ dynamic: z.record(z.string()).optional(),
19710
+ custom: z.record(z.string()).optional(),
19711
+ clear: z.boolean().optional(),
19712
+ },
19713
+ outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), tracking: z.any().optional(), note: z.string().optional() },
19714
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
19715
+ }, wrap(async (a) => { const d = await apiPost('/api/linkedin/ads-tracking', a); return ok(d.note, d); }));
18533
19716
  server.registerTool('delete_linkedin_ads_object', {
18534
19717
  title: 'Delete a LinkedIn campaign group / campaign / ad',
18535
19718
  description: 'Delete a LinkedIn campaign group, campaign or creative (level:"creative" + creativeId). LinkedIn HARD-deletes only DRAFT objects; anything that has ever run is moved to PENDING_DELETION instead — it stops serving and its reporting history is retained. The returned note says which of the two actually happened, and you must repeat that rather than claiming a clean delete. Irreversible either way, so it requires confirm:true.',
@@ -18633,7 +19816,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
18633
19816
  server.group('channels'); // LEAD SYNC reads what a connected Page's forms collected — a channel read, not paid-ads management (tool-group-truth, 2026-09-04)
18634
19817
  server.registerTool('list_linkedin_lead_forms', {
18635
19818
  title: 'List LinkedIn lead gen forms',
18636
- description: 'The LEAD GEN FORMS a LinkedIn company Page or ad account owns — id, name, state, version and the fields each one asks for (firstName, email, company …). Forms are created in Campaign Manager or on the Page; this API reads them and cannot create one. If it answers that the connection must be reconnected, say exactly that: the lead-sync permission is granted at authorise time. Read-only, free.',
19819
+ description: 'The LEAD GEN FORMS a LinkedIn company Page or ad account owns — id, name, state, version and the fields each one asks for (firstName, email, company …). Make a new one with create_linkedin_lead_form; edit or archive one with update_linkedin_lead_form. If it answers that the connection must be reconnected, say exactly that: the lead-sync permission is granted at authorise time. Read-only, free.',
18637
19820
  inputSchema: {
18638
19821
  pageId: z.string().optional().describe('the company Page that owns the form — from list_linkedin_pages; omit when one Page is shared'),
18639
19822
  adAccountId: z.string().optional().describe('read forms owned by an AD ACCOUNT instead of a Page'),
@@ -23479,6 +24662,7 @@ function memoryNoteVerdict(text) {
23479
24662
  title: 'List the Page’s / Instagram account’s own posts',
23480
24663
  description: "List the connected Facebook Page's or Instagram account's OWN existing posts — id, caption, permalink, publish date and format. THIS IS THE TOOL THAT GETS YOU THE postId every other Meta read needs: meta_post_insights, list_meta_comments and manage_meta_post all require one. Use it for \"how did our last few posts do\", to find a post the user describes loosely, or before backfill_posts. Pass target:'instagram' for the linked IG account (Stories are excluded — Meta's media edge does not return them); Facebook hides unpublished drafts unless you ask for them. Only ever reads a Page the brand has connected. Read-only, 0 credits.",
23481
24664
  inputSchema: {
24665
+ tagged: z.boolean().optional().describe('INSTAGRAM — list posts OTHER accounts tagged the brand in (UGC, creator posts, partner mentions), with each author’s @handle, instead of the brand’s own posts. Free.'),
23482
24666
  ...MANAGE_BRAND,
23483
24667
  target: z.enum(['facebook', 'instagram']).optional().describe("default facebook; 'instagram' reads the Page's linked IG business account"),
23484
24668
  account: z.string().optional().describe('which Instagram account — an @handle or id from list_connector_accounts("instagram"). Needed when several are linked, and the way an Instagram Login (standalone) account is reached; omit for the one Page-linked account.'),
@@ -23490,8 +24674,8 @@ function memoryNoteVerdict(text) {
23490
24674
  outputSchema: { target: z.string().optional(), account: z.string().nullable().optional(), pageId: z.string().optional(), posts: z.array(z.any()).optional(), cursor: z.string().nullable().optional(), note: z.string().optional(), sizeNote: z.string().optional() },
23491
24675
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
23492
24676
  }, wrap(async (a) => {
23493
- const d = await apiGet('/api/meta/posts', queryBrand(a, { ...(a.target ? { target: a.target } : {}), ...(a.account ? { account: a.account } : {}), ...(a.pageId ? { pageId: a.pageId } : {}), ...(a.limit ? { limit: a.limit } : {}), ...(a.cursor ? { cursor: a.cursor } : {}), ...(a.includeUnpublished ? { includeUnpublished: 'true' } : {}) }));
23494
- const rows = (d.posts || []).map(p => `• ${String(p.caption || '(no caption)').replace(/\s+/g, ' ').slice(0, 80)} — ${p.id}${p.publishedAt ? ` · ${String(p.publishedAt).slice(0, 10)}` : ''} · ${p.mediaKind}${p.url ? ` ${p.url}` : ''}`);
24677
+ const d = await apiGet('/api/meta/posts', queryBrand(a, { ...(a.target ? { target: a.target } : {}), ...(a.tagged ? { tagged: 'true', target: 'instagram' } : {}), ...(a.account ? { account: a.account } : {}), ...(a.pageId ? { pageId: a.pageId } : {}), ...(a.limit ? { limit: a.limit } : {}), ...(a.cursor ? { cursor: a.cursor } : {}), ...(a.includeUnpublished ? { includeUnpublished: 'true' } : {}) }));
24678
+ const rows = (d.posts || []).map(p => `• ${p.author ? `${p.author}: ` : ''}${String(p.caption || '(no caption)').replace(/\s+/g, ' ').slice(0, 80)} — ${p.id}${p.publishedAt ? ` · ${String(p.publishedAt).slice(0, 10)}` : ''} · ${p.mediaKind}${p.url ? ` ${p.url}` : ''}`);
23495
24679
  // A SHORTENED PAGE HAS TO SAY SO ON THE SURFACE PEOPLE USE (2026-09-17). `sizeNote` is how the server reports
23496
24680
  // that Meta refused the full page and it asked for fewer rows — and it was produced by the route and read by
23497
24681
  // NOBODY, so on MCP a half page was indistinguishable from the whole feed, which is precisely what the ladder's