hermoso 0.1.463 → 0.1.464
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/mcp/roster-scope.mjs +1 -0
- package/mcp/tools.mjs +163 -156
- package/package.json +2 -2
package/mcp/tools.mjs
CHANGED
|
@@ -257,7 +257,7 @@ export const CAPABILITY_MAP = [
|
|
|
257
257
|
'A) AD SPY / RESEARCH — spy on the ads already winning in any market, then mine them. find_competitors · competitor_teardown · pull_competitor_ads · research_ads (open brief) · ad libraries search_meta_ads / search_google_ads / search_linkedin_ads · organic social search_tiktok / search_instagram / search_youtube / search_reddit / search_threads · search_instagram_hashtag (LISTENING on the brand’s OWN Meta credentials rather than a scraper: the real public posts carrying a hashtag, with their captions — feed them into mine_angles or write the next post from the language you found. “recent” is the LAST 24 HOURS only, so a huge tag legitimately returns zero on a quiet day; ask again with edge “top” before saying anything about how busy it is) · instagram_profile (any Instagram @handle → the account’s NUMERIC Instagram id from Meta itself, plus its real name, bio, follower and post counts — Meta’s own numbers, not a scraper’s. It is also the ONLY way to get the id manage_meta_partnership_creator’s allowTagging list requires; professional accounts only) · find_instagram_marketplace_creators / instagram_marketplace_creator / list_instagram_marketplace_audiences (Instagram’s own creator marketplace searched as the brand, with Meta’s first-party creator insights; find_creators ranks by public posts and takes marketplace:true to add it) · fetch_social_data (any allowlisted endpoint) · mine_angles · analyze_video · check_ad_policy · list_skills / get_skill (teardowns + creative playbooks).',
|
|
258
258
|
'B) CREATE: on-brand image & video ads (real product composited in, copy + CTA baked). draft_brand / get_brand / update_brand (patch single fields without re-onboarding) / use_brand · list_brands / create_brand / delete_brand (one account holds MANY profiles: brands, clients or creators, so an agency runs every client through here; each has its own brand details, memory, swipefile, Library and connectors, and create_brand → draft_brand onboards a new one end to end) · plan_ad (concept + copy) → render_ad (the Studio quality pipeline) or generate_image / generate_video / generate_avatar (UGC creators + lip-sync) · list_creators / save_creator / delete_creator (the workspace’s REUSABLE CAST — saved creators with their portrait urls, so the SAME person stars in every ad; list them before ever generating a new one, then cast one into the ad with render_ad’s `creator`, which also skips the character-portrait render and so costs LESS than casting a stranger) · make_template_ad (native HTML ad formats) · make_motion_film (motion graphics: kinetic typography, logo intros, launch films; no video model) · clone_static / recast_motion / reframe_video / insert_scene / upscale_video / dub_video / change_voice / finish_video / fix_beat / hook_variants / stitch_video · plan_variations + score_ad (fan out + rank).',
|
|
259
259
|
'C) RAW MODEL PLAYGROUND — direct access to the full catalog (30+ image / video / voice / writing models, each with the exact per-render credit cost shown above), no ad framing: generate_image / generate_video (useBrand:false) for plain prompt-only renders, generate_voice for raw text-to-speech against any voice engine, and generate_text for the writing models (Claude / Gemini / GPT / Llama / DeepSeek…) — all against ANY catalog id.',
|
|
260
|
-
'D) ACCOUNT — hermoso_credits (balance) · billing_status (plan + your billing role) · buy_credits (one-click top-up on the saved card, or a first-purchase checkout link) · upgrade_plan / set_auto_reload (admin) · list_jobs / get_job (track async renders) · get_settings / update_settings (the LANGUAGE every ad, script and plan is written in (chat replies follow the language the user writes in) — set it once and every render obeys it, over MCP as well as in the app — plus app appearance and the weekly competitor-watch email) · list_team / invite_member / remove_member / set_role (who else can work in this profile).',
|
|
260
|
+
'D) ACCOUNT — hermoso_credits (balance) · billing_status (plan + your billing role) · buy_credits (one-click top-up on the saved card, or a first-purchase checkout link) · upgrade_plan / set_auto_reload (admin) · list_jobs / get_job (track async renders) · get_settings / update_settings (the LANGUAGE every ad, script and plan is written in (chat replies follow the language the user writes in) — set it once and every render obeys it, over MCP as well as in the app — plus app appearance and the weekly competitor-watch email) · list_alert_settings / set_alert_events / send_test_alert (where ALERTS go: Slack, Discord or a signed webhook, for renders, posts, approvals, competitor findings, leads and low credits) · list_team / invite_member / remove_member / set_role (who else can work in this profile).',
|
|
261
261
|
'E) PUBLISH & MANAGE YOUR CHANNELS — post, run ads, and organize files on the user’s OWN connected accounts (Settings ▸ Connectors), all driven over this MCP. Bring ANY file in with upload_file (desktop/external media, not just Hermoso renders). MANAGING THE CONNECTIONS THEMSELVES: list_connectors (what is linked, and what could be) · list_connector_accounts then set_connector_accounts (WHICH Facebook Pages / Instagram / Meta ad accounts / Google Ads customers / LinkedIn company Pages and ad accounts / Pinterest ad accounts / Microsoft Advertising accounts / Reddit ad accounts / Google Business listings / Google Analytics properties this profile may post to, spend from and read — one person often administers or has access to several belonging to different clients, only the chosen ones are usable anywhere, and an empty choice shares nothing) · connect_connector (connect a PASTE-A-KEY account from here: ' + Object.values(KEY_CONNECTORS).map((s) => s.label).join(', ') + '; offer it beside the Connectors page in the app and let the user choose, because a key pasted into a chat stays in its history) · disconnect_connector (drop a connection from this brand, without revoking access at the platform; confirm-gated because reconnecting a sign-in account needs a browser) · leave_connector (on a connector several teammates can each contribute their OWN account to, remove just YOURS — teammates’ accounts keep working and nothing is revoked at the provider). LINKING an account that connects through a provider sign-in screen (OAuth) is the one step that is not headless: hand the user its connect link, https://app.hermoso.ai/?connect=<provider>, or send them to Workspace ▸ Connectors in the app. META: list_meta_pages · instagram_insights (ACCOUNT-level Instagram performance — views, reach, accounts engaged, interactions, saves, profile link taps — plus the audience DEMOGRAPHICS by age / city / country / gender) · list_instagram_media (the brand’s own recent Instagram posts, and where the media id every other Instagram tool needs comes from) · list_instagram_dm_automations / save_instagram_dm_automation / test_instagram_dm_automation / delete_instagram_dm_automation (INSTAGRAM DM AUTOMATIONS — a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply; test shows what a sample would send without sending) · list_dm_automations / save_dm_automation / test_dm_automation / delete_dm_automation (THE SAME ON EVERY CHANNEL — Instagram, Facebook Page comments and ads + Messenger, and X: a keyword mention or reply gets a public reply, a DM keyword a DM; X is billed in credits per delivered event and per send) · search_instagram_audio (licensed music and original sounds an Instagram Reel may use, by keyword or trending) · list_instagram_collab_invites then respond_instagram_collab_invite (collab-post invitations waiting on the account; accept or decline one, read back from Instagram) · list_instagram_collab_media (posts this account co-authors) · like_instagram (like a post or comment as the connected account) · post_to_meta (Facebook / Instagram / Threads) · list_meta_posts (the Page’s / Instagram account’s OWN existing posts with their ids — THIS is where the postId every other Meta read needs comes from; without it an agent that did not itself just publish has no way to name a post) · list_meta_ads + meta_insights (read existing campaigns/ad sets/ads + spend/CTR/CPC, with breakdowns by age / gender / placement / country) · preview_meta_ad (Meta renders the REAL ad per placement — a link the user can look at, valid 24h) · list_meta_pixels + create_meta_pixel (the pixel a conversion-optimised campaign REQUIRES — Meta will not let a build optimise for conversions without one, and until these existed a caller had no way to discover the id they had to pass) · estimate_meta_reach (how many people a targeting spec reaches, BEFORE a budget is committed) · list_meta_audiences / create_meta_audience (website-pixel retargeting, Page + Instagram engagement audiences, and lookalikes — creating one spends nothing) · list_meta_conversations / read_meta_conversation / reply_to_meta_message (MESSENGER AND INSTAGRAM DMs — the brand’s direct-message threads and a reply to someone who wrote first. Meta only permits a reply within 24 HOURS of the person acting, and read_meta_conversation says whether that window is open BEFORE anything is drafted; Hermoso sends replies only, never a proactive message or a message tag) · subscribe_meta_webhooks / meta_webhook_status / unsubscribe_meta_webhooks / list_meta_webhook_events (REAL-TIME EVENTS — have Meta PUSH new comments, mentions, lead-form submissions and inbound DMs to Hermoso instead of polling for them. Every other inbox read asks an edge “anything new?”; this is the only way to be TOLD, and it is how a lead arrives the moment it is submitted rather than when somebody thinks to look. An empty feed is ambiguous — check meta_webhook_status first, because an unsubscribed Page is silent and looks exactly like a quiet one) · instagram_collaborators (who ACCEPTED a Collab invite on an Instagram post — publishing only SENDS the invite, so this is the only way to know whether the post is actually live on the other account too) · list_instagram_shopping_catalogs / search_instagram_shopping_products / manage_instagram_product_tags (INSTAGRAM SHOPPING — make a post SHOPPABLE. Check eligibility and the account’s taggable catalogs, find the product ids, then pass productTags to post_to_meta so tapping the picture opens the product’s price sheet inside Instagram. Tagging needs an APPROVED Instagram Shop, so check FIRST — otherwise it fails after the media is already uploaded — and note that a tag whose product is not “approved” is stored and shown to nobody. Meta publishes no way to REMOVE a tag) · create_meta_catalog / update_meta_catalog / meta_catalog_blast_radius / delete_meta_catalog (BUILD AND RETIRE A CATALOG — create one on a named business portfolio, rename or re-point it, and, before ever proposing a delete, read meta_catalog_blast_radius: a catalog delete is PERMANENT with no archive and no undo, its product sets go with it, and any ad set still bound to one keeps spending with nothing to show) · list_meta_partnership_creators / manage_meta_partnership_creator (PARTNERSHIP ADS — the creators whose content this profile may run as an advert, and who may tag this brand as a paid partner. Two separate lists, neither implying the other, and neither defaults on; adding is a REQUEST the creator must accept, and an ad naming a creator who is only PENDING fails for a reason nothing in the error says) · list_meta_catalogs / list_meta_product_sets / list_meta_catalog_products (PRODUCT CATALOGS — the merchant’s own Meta catalogs, the product SETS inside each and the products themselves with Meta’s review status. A catalog is the input to Advantage+ catalog ads, the highest-performing ecommerce format on Meta: pass productCatalogId to create_meta_campaign and productSetId to create_meta_adset / create_meta_ad, and Meta builds every impression from the product’s own image, name and price — no render needed. An empty list is a fact about which business portfolio this login administers, NEVER about whether the merchant has a catalog) · create_meta_campaign / create_meta_ad / upload_meta_asset (build) · upload_meta_playable / list_meta_playables (PLAYABLE ads: the advertiser’s own interactive HTML game, run through create_meta_ad’s playableAssetId in an App Installs ad set) · list_meta_lead_forms / create_meta_lead_form (INSTANT LEAD FORMS — the form a lead ad opens INSIDE Facebook/Instagram instead of sending the click to a website; pass the id as create_meta_ad(objective:\"OUTCOME_LEADS\", leadFormId:…) and read the submissions with read_meta_leads) · update_meta_object / delete_meta_object / set_meta_campaign_status (edit, delete, activate — every spend + delete is confirm-gated) · delete_meta_audience (remove a custom audience or lookalike — its blast radius is the PEOPLE in it and the lookalikes built from it, which Meta refuses to delete around) · manage_meta_post (edit or delete a published post). THREADS (a separate connection from Meta, on its own API): post_to_meta(target:"threads") publishes · list_threads_posts · threads_insights · list_threads_replies / reply_to_thread / hide_thread_reply · list_threads_mentions · search_threads_keyword · repost_thread (amplify a customer’s post or one of your own to the brand’s profile — the Threads retweet, and there is NO documented un-repost) · delete_thread (confirm-gated; Threads has no EDIT at all, so delete-and-repost is the only correction) · threads_publishing_limit (how much of the rolling-24h quota is left — 250 posts, 1,000 replies, 100 DELETIONS, 500 location searches; check it before a bulk clean-up, because a quota refusal otherwise reads as a broken connection). SCHEDULING (one content calendar across every channel): schedule_post (queue a post for a future time to one or MORE channels at once — Facebook / Instagram / Threads / TikTok / YouTube / LinkedIn / X / Pinterest / Bluesky / Telegram (ten; Google Business Profile is accepted but held back on Google API access) — with per-channel captions; Hermoso publishes it at that time, nothing has to stay open — it goes LIVE PUBLICLY by default, and only stages as draft/unlisted/private if the user asks, and an impossible channel+visibility pair, an over-length caption or media the channel cannot carry is REFUSED while you are still there rather than failing hours later) · list_scheduled (what is queued and what already fired, with PER-CHANNEL outcomes) · reschedule_post (move a queued post to a new time, or change its caption, media, channels or target Page/board — send only what changes) · cancel_scheduled (pull a queued post before it goes out). POST PERFORMANCE (the loop that closes research → publish → learn — Hermoso records the HOOK and SUBJECT of everything it publishes, because those exist only at the moment of publishing and can never be recovered from a post id afterwards): list_published_posts (everything this brand has published across every channel, with the hook it was written to and its measured engagement) · post_performance (which HOOKS and SUBJECTS are getting traction — engagement rates compared WITHIN a channel and NEVER summed across them, with a verdict suppressed below 5 measured posts and the reason stated) · collect_post_metrics (pull fresh numbers ~24h and ~7d after each publish; a metric a channel cannot report is recorded ABSENT with its reason and never as zero, and X is skipped unless asked because it bills per call) · backfill_posts (import a channel’s past posts so the analysis has history — dry-run and cost-quoted first, and an imported post never votes on a hook unless it matched a Hermoso creation). YOUTUBE (publish, measure AND manage): post_to_youtube (publish a finished video to the brand’s channel — PUBLIC by default; unlisted (link-only, the ad-ready setting) or private only when the user asks) · list_youtube_videos (the channel’s OWN uploads with their video ids — call this to resolve “my latest video” yourself instead of asking the user for a link; it is where the videoId every other YouTube tool needs comes from, and it sees unlisted/private uploads a public search cannot) · update_youtube_video (retitle/re-describe/re-tag, and FLIP AN UNLISTED UPLOAD PUBLIC; confirm before going public) · delete_youtube_video (take one down for good — irreversible, so the unconfirmed call reports the video’s real title, privacy, views and comments first; use update_youtube_video(privacy:"private") when they only want it out of sight) · set_youtube_thumbnail (put a Hermoso thumbnail on an uploaded video — the biggest single lever on click-through, and YouTube otherwise picks a frame at random; needs a phone-verified channel) · update_youtube_channel (brand the CHANNEL ITSELF — banner art, description, keywords, country, the trailer non-subscribers see; everything else here brands the videos, this brands the page they sit on. It MERGES with the current settings, and it reports any field YouTube accepted but silently ignored, channel title above all) · set_youtube_watermark (the subscribe badge overlaid on EVERY video on the channel, including ones uploaded later — one square image brands the whole channel at once; the API publishes no way to read it back, so it reports accepted rather than confirmed) · list_youtube_video_stats (views, likes and comments for up to 50 videos IN ONE CALL, which is how to answer "how are my last twenty uploads doing" without one youtube_video_insights per video. It carries NO titles, because VideoStatsSnippet publishes only publishTime, so join on videoId with list_youtube_videos for names. YouTube calls this endpoint "intentionally not atomic", so a short answer is normal: the missing ids are named, and a missing id is never zero views) · youtube_video_insights (per-VIDEO views, watch time, average view PERCENTAGE/retention, likes, comments, shares, subscribers gained — the numbers that say whether a hook held; youtube_channel only gives channel-wide totals) · youtube_channel_report (the same numbers BROKEN DOWN — traffic source (search vs browse vs suggested vs shorts feed), the actual search terms, country/city, device, age+gender, subscribed vs not, and the audience-RETENTION curve showing exactly where viewers left) · list_youtube_comments + list_youtube_comment_replies + reply_to_youtube_comment (read viewer questions and objections in their own words, and answer as the channel) · moderate_youtube_comment (hide, reject, spam-report or delete an abusive comment — reject is reversible, delete is not) · list_youtube_playlists + manage_youtube_playlist + manage_youtube_playlist_items (organise the channel: create playlists, add/remove/re-order videos in them) · manage_youtube_playlist_image (a custom cover on a playlist — make_thumbnail renders the artwork, this is the call that puts it on. YouTube answers every failure here as an HTTP 500 whose real reason is buried inside it, and the tool unpacks that; if it comes back refused, check channel verification first) · manage_youtube_channel_section (the SHELVES ON THE CHANNEL HOMEPAGE — put a chosen playlist or a featured channel above YouTube’s own default layout, and re-order them. Every write is PUBLIC IMMEDIATELY, a delete has no undo, and YouTube’s own section list LAGS a write by a few seconds in both directions, so never treat a list taken straight afterwards as proof either way) · list_youtube_captions + manage_youtube_caption (real subtitle TRACKS — what YouTube indexes the video by and what a viewer toggles on, which is NOT the same as captions burned into the picture; downloading one is also the quickest way to get an existing video’s script back) · list_youtube_categories (which categoryId post_to_youtube will accept in a given country) · youtube_bulk_report (THE ONLY PLACE YOUTUBE PUBLISHES THUMBNAIL IMPRESSIONS AND THUMBNAIL CTR — a different, SCHEDULED API: the first call starts a job and returns nothing, then YouTube writes one file per day, the first within 48 hours, plus a 30-day backfill. It also carries per-card and per-end-screen metrics and an uncapped list of the search terms people arrived on) · list_youtube_report_jobs (whether that thumbnail history is already accumulating, and since when — check before promising a number) · delete_youtube_report_job (stop one; the job IS the history, so deleting it throws the accumulated files away) · youtube_channel (read title + subscriber/view/video counts for reporting). TIKTOK: post_to_tiktok (post a finished video — or a PHOTO POST, TikTok’s photo/slideshow format of 1 to 35 images where a single image is just a one-slide post —LIVE to the profile, or into TikTok drafts to review in the app) · tiktok_creator_info (the creator’s REAL privacy options — read them and let the user choose before any direct post) · tiktok_post_status (follow a post_to_tiktok that came back pending: done, failed or still processing) · tiktok_account (bio, verified status, follower/following/likes/video counts) · list_tiktok_videos (their own posts with views/likes/comments/shares — either the most recent, or specific videoIds read directly however old they are). ⚠️ TIKTOK HAS NO DELETE AND NO EDIT: its API publishes no way to remove a posted video or change its caption, privacy, cover or comment/duet/stitch settings — every one of those is fixed at publish time and there is no delete scope in TikTok’s scope catalogue at all. If the user wants a TikTok taken down or changed, say plainly that it has to be done in the TikTok app rather than hunting for a tool. TIKTOK ACCOUNT AUTHORIZATION (a SECOND, separate consent on the SAME TikTok app the TikTok Ads connection uses — holding one does NOT give you the other, so a brand fully connected for ads can still be unauthorized here, and that is a real third state rather than a broken session): tiktok_account_status (which state this profile is in, the TikTok business id, the scopes the grant carries and any MISSING from it — TikTok binds scopes at authorize time and never retroactively, so only a re-authorization picks up a new one — plus the exact URL to send the user to, because authorizing is the one step that needs a browser) · list_tiktok_comments + list_tiktok_comment_replies (the comments on the brand’s OWN posts, hidden ones included — TikTok’s answer to list_meta_comments and list_youtube_comments) · comment_on_tiktok_video · reply_to_tiktok_comment · moderate_tiktok_comment (LIKE / UNLIKE / HIDE / UNHIDE / DELETE — you can only DELETE a comment this account wrote, so HIDE is the tool for a stranger’s, and TikTok warns UNHIDE may not take effect when its own moderation is what hid it) · upload_tiktok_comment_image (a new comment will not take a raw image URL; a reply will) · set_tiktok_post_ad_authorization (THIS IS WHERE A SPARK ADS AUTHORIZATION CODE COMES FROM for the brand’s OWN post — previously a human had to copy one out of the TikTok app; hand the code to authorize_tiktok_ads_spark_post) · get_tiktok_post_ad_authorization · extend_tiktok_post_ad_authorization (the days are ADDED to what is left, not set as an absolute) · delete_tiktok_post_ad_authorization. BRAND MONITORING AND AUDIENCE, on that same account authorization (these need permissions added on 2026-08-20, so a brand that authorized before then holds a grant that predates them and has to authorize once more; tiktok_account_status names exactly which are missing, and the remedy is always to authorize the TikTok ACCOUNT again rather than to touch the advertiser connection, which is a separate grant and is unaffected): list_tiktok_mentions (public posts whose caption @-mentions the brand, TikTok’s answer to x_mentions and list_threads_mentions) · list_tiktok_mention_comments (comments whose text mentions it) · get_tiktok_mention (one mention in full, for the mentions webhook, and TikTok only keeps that data 48 hours) · tiktok_mention_top_terms (the top 20 keywords and top 20 hashtags inside those mentions) · list_tiktok_brand_hashtags + manage_tiktok_brand_hashtags + list_tiktok_brand_hashtag_posts (the hashtags TikTok counts as this brand’s, up to 50, and the posts carrying them; a new one is not counted for 24 hours and cannot be removed for 7 days) · tiktok_account_insights (follower demographics by age, gender, country and city plus the daily performance series, needing a BUSINESS account with 100+ followers, and capped at 60 days rather than the 90 the mention tools cover) · tiktok_category_benchmark (the same numbers averaged across an industry, so ‘are we ahead of our category’ is answerable). ALL OF THIS IS ORGANIC LISTENING ON THE BRAND’S OWN ACCOUNT, not ad research: for competitors’ ads use the ad-library research tools instead. TIKTOK ADS (a SEPARATE connection from the TikTok posting connector above — Settings ▸ Connectors ▸ TikTok Ads; a brand that posts to TikTok every day may still have no ad account here, so never read one as the other): list_tiktok_ads_accounts (the ADVERTISER accounts this profile can act on — every other TikTok Ads tool needs an advertiserId and this is where it comes from) · list_tiktok_ads_pixels + create_tiktok_ads_pixel + list_tiktok_ads_custom_conversions + tiktok_ads_pixel_stats (CONVERSION TRACKING — a conversion-optimised ad group dies at creation with "Please select a pixel" without one, so discover the pixel and its events BEFORE building the tree; note TikTok publishes no way to DELETE a pixel, so one you create is permanent) · list_tiktok_ads_campaigns (the whole tree — campaigns, ad groups and ads with their statuses) · tiktok_ads_report (impressions, clicks, spend, CTR, CPC, conversions and video views at any level) · list_tiktok_ads_identities (the TikTok accounts an ad may post AS — MANDATORY, with NO default: call it and let the USER pick, because the ad runs publicly under whichever account is named) · search_tiktok_ads_targeting (resolve location / interest / hashtag / language ids — an ad group cannot be created without location ids, and a guessed id targets the wrong people) · list_tiktok_ads_identity_posts (the ORGANIC posts an identity has already published — where a Spark Ad’s post id comes from) · list_tiktok_ads_spark_posts (the posts authorised for Spark Ads, i.e. promoting an organic post instead of uploading a new video) · authorize_tiktok_ads_spark_post + unbind_tiktok_ads_spark_post (add a creator’s post to that authorised set with the code they generated in the TikTok app, or release it again) · upload_tiktok_ads_playable / list_tiktok_ads_playables / delete_tiktok_ads_playable (PLAYABLE ads: the advertiser’s own interactive HTML zip, run through create_tiktok_smart_ad’s playableUrls) · upload_tiktok_ads_creative (THE STEP THAT TURNS A RENDER INTO AN AD — put a finished Hermoso video on the ad account and it hands back the videoId AND the coverImageId create_tiktok_ads_ad needs; there is no other source for either) · create_tiktok_ads_campaign → create_tiktok_ads_ad_group → create_tiktok_ads_ad (the tree) · set_tiktok_ads_budget · set_tiktok_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_tiktok_ads_object (removal on TikTok is a STATUS, not a verb — the same route as set_tiktok_ads_status) · create_tiktok_smart_campaign → create_tiktok_smart_ad_group → create_tiktok_smart_ad (Smart+, TikTok’s Performance Max — born PAUSED) · list_tiktok_smart_campaigns · set_tiktok_smart_status (the Smart+ money switch, confirm-gated) · tiktok_bid_protection (the ad-credit compensation TikTok pays when a Smart+ object misses its bid) · tiktok_lead_ads_terms (read and accept TikTok’s Lead Ads terms, which a lead-generation ad needs first) · list_tiktok_ads_lead_forms + list_tiktok_ads_lead_fields + download_tiktok_ads_leads + manage_tiktok_ads_test_lead (LEAD ADS — an Instant Form is built in TikTok Ads Manager and NO API creates one, so list them to find the id a LEAD_GENERATION ad group needs. The lead REGION is required with no default: it selects which of three separate lead stores you read, and leaving it out is a THIRD value rather than “all”, so an advertiser who omits it downloads an empty file and wrongly concludes there are no leads) · list_tiktok_ads_audiences + create_tiktok_ads_audience + create_tiktok_ads_lookalike_audience + apply_tiktok_ads_audience + update_tiktok_ads_audience + delete_tiktok_ads_audience + tiktok_ads_audience_overlap (CUSTOM AUDIENCES and lookalikes — TikTok targeting is otherwise interests-and-geo only. A freshly created audience reports itself invalid for up to 48 hours BY DESIGN, so that is not a failure to retry) · list_tiktok_ads_business_centers + list_tiktok_ads_catalogs + create_tiktok_ads_catalog + list_tiktok_ads_catalog_products + list_tiktok_ads_catalog_sets + manage_tiktok_ads_catalog_feed + tiktok_ads_catalog_diagnostics (DPA / PRODUCT CATALOGS, the Shopify lane — a catalog is keyed on a BUSINESS CENTER id, NOT an advertiser id, so list the Business Centers first or every call refuses) · list_tiktok_ads_apps + list_tiktok_ads_app_events (the registered apps an APP_INSTALL campaign needs — nothing else can produce an app id) · tiktok_ads_rf_inventory_estimate + create_tiktok_ads_rf_ad_group (REACH & FREQUENCY — a RESERVATION, so it is confirm-gated like a status change rather than born paused, and it needs a per-ad-account allowlist plus a signed branding contract that no endpoint reports. Always price it with the estimate first: TikTok silently books its own maximum rather than refusing an out-of-range value) · send_tiktok_ads_events (SERVER-SIDE conversion events — there is a vendor-sanctioned test code for exercising it without entering the advertiser’s real reporting), and its offline/crm sources take the event-set ids the two tools below mint) · list_tiktok_ads_offline_event_sets + manage_tiktok_ads_offline_event_set + send_tiktok_ads_offline_events (REAL-WORLD CONVERSIONS — an in-store purchase, a phone booking, a signed contract, reported so TikTok can attribute them to the ads that caused them. The timestamp is an ISO-8601 STRING here and a Unix NUMBER on send_tiktok_ads_events; a wrong-shaped one is accepted by TikTok and attributed to nothing. There is NO test code on this pair, so everything sent is a real permanent conversion — rehearse through send_tiktok_ads_events with eventSource “offline” and a testEventCode instead. Reporting also needs the connected user to be an ADMIN or OPERATOR of the advertiser, which managing the event SETS does not) · list_tiktok_ads_crm_event_sets + create_tiktok_ads_crm_event_set (LEAD-LIFECYCLE events — sending “this lead qualified / closed” back is what makes a LEAD_GENERATION campaign optimise toward leads that convert rather than form fills. TikTok publishes create and list and nothing else, so one of these is PERMANENT) · list_tiktok_tto_accounts + list_tiktok_creator_labels + discover_tiktok_creators + tiktok_creator_leaderboard + check_tiktok_creator_status + list_tiktok_tto_brand_profiles + create_tiktok_tto_brand_profile + list_tiktok_tto_campaigns + create_tiktok_tto_campaign + update_tiktok_tto_campaign + link_tiktok_tto_video + list_tiktok_tto_link_requests + tiktok_tto_campaign_report + request_tiktok_tto_spark_authorization + get_tiktok_tto_spark_authorization + manage_tiktok_tto_anchor (TIKTOK ONE / CREATOR MARKETPLACE: INFLUENCER MARKETING, and the only place in Hermoso that does it: find creators by audience size, engagement, price and who their followers actually are, check whether they have joined TikTok One, invite them to a campaign with an invite link, ask them to tag a video to it, and read every metric SPLIT ORGANIC VERSUS PAID. Its account id is a THIRD id space; not an advertiser id and not a Business Center id; so start at list_tiktok_tto_accounts. It rides this same connection with nothing extra to apply for. IT ALSO CLOSES THE SPARK ADS LOOP: request_tiktok_tto_spark_authorization asks a creator directly and get_tiktok_tto_spark_authorization returns the code authorize_tiktok_ads_spark_post takes, which is otherwise obtainable only by the creator pasting one out of the TikTok app. Two things put a notification in a real person’s inbox; a campaign invitation and a video-linking request; and a repeated linking request is a REMINDER that TikTok caps at two, so read list_tiktok_tto_link_requests before re-sending anything) · list_tiktok_ads_stores + list_tiktok_ads_store_products (TIKTOK SHOPS: what a Shopping Ads or GMV Max campaign sells from; the store list is keyed on an ad account and the product list on a BUSINESS CENTER, which each store row names) · tiktok_ads_verification_status + list_tiktok_ads_verification_documents + submit_tiktok_ads_verification (BUSINESS VERIFICATION: an unverified account hits limits that get diagnosed as something else, so it is worth reading during onboarding. Hermoso never handles a verification DOCUMENT: submitting sends account details plus the ids of images the user uploaded in TikTok Ads Manager, and the legal name and document number can never be changed afterwards, so it is confirm-gated) · list_tiktok_ads_payment_portfolios + list_tiktok_ads_payment_portfolio_links (HOW THE AD ACCOUNTS ARE FUNDED: read-only, because "why did delivery stop" is often a funding answer, and because deciding where a customer’s money sits is not ours to do) · create_tiktok_ads_rule + list_tiktok_ads_rules + update_tiktok_ads_rule + bind_tiktok_ads_rule + set_tiktok_ads_rule_status + tiktok_ads_rule_results (AUTOMATED RULES — standing instructions TikTok runs on the account unattended. THE SECOND SPEND SWITCH ON THIS PLATFORM and gated in TWO CLASSES: a rule that can only pause, decrease or email needs confirm:true, while one that can TURN_ON an object or RAISE a budget or bid needs confirm:true AND confirmScope echoing the token list_tiktok_ads_rules prints, computed from the rule as TikTok STORES it. Every rule is created TURNED OFF and read back to prove it, because TikTok publishes no way to create one in the off position. TikTok emails rule notifications to the DEVELOPER address on the app rather than to the advertiser, so tiktok_ads_rule_results is the only place a customer sees what a rule did — and TikTok itself says this endpoint is for direct advertisers and may refuse a platform-managed account entirely). · list_tiktok_ads_comments + tiktok_ads_comment_thread + moderate_tiktok_ads_comment + reply_to_tiktok_ads_comment + delete_tiktok_ads_comment (COMMENT MODERATION on your own TikTok ads — the platform where the comment section IS the ad. HIDE is the moderation verb and works on anyone’s comment and is reversible; DELETE only ever removes a comment your OWN identity posted, which TikTok reports per comment as canDelete. Comments are scoped to an AD GROUP and to nothing else, and the time window may span at most 30 DAYS, so an empty answer means “none in these 30 days” rather than “none ever”) · list_tiktok_ads_blocked_words + manage_tiktok_ads_blocked_words (a standing 500-word filter that auto-hides any comment containing one of these across EVERY ad on the account — nothing else in Hermoso does this, and removing a word republishes every comment it had hidden) · tiktok_ads_diagnosis (TikTok’s own issues-and-suggestions verdict on your ad groups — creative, bid/budget with its full estimated-delivery tables, and a pixel that has gone quiet. It covers ACTIVE ad groups only and omits any it has nothing to say about, so an empty answer is not a clean bill of health) · get_tiktok_ads_brand_safety + set_tiktok_ads_brand_safety (what content the ads may appear next to. Two things to say out loud: TikTok applies this to Smart+ campaigns and explicitly NOT to the regular campaigns create_tiktok_ads_campaign builds, and coverAllObjectives is a ONE-WAY DOOR TikTok cannot set back). TWO THINGS HERE ARE UNLIKE EVERY OTHER AD PLATFORM: TikTok creates objects ENABLED by default, so Hermoso forces every campaign, ad group and ad PAUSED with no override and nothing serves until set_tiktok_ads_status(confirm:true); and TikTok’s QPS is 1, so every call is serialized and a tree build or a bulk read is SLOW BY DESIGN — a throttle is not a broken connection. SNAPCHAT ADS (the tenth ad platform — Settings ▸ Connectors ▸ Snapchat Ads; a SEPARATE connection from Snapchat posting): list_snapchat_ads_accounts (the organizations and AD ACCOUNTS this profile can act on — every other Snapchat tool needs an adAccountId and this is where it comes from) · list_snapchat_ads_campaigns (the whole tree — campaigns, ad squads and ads) · snapchat_ads_report (impressions, spend, swipes and video quartiles at any level) · search_snapchat_ads_targeting (resolve country / region / interest / language ids — an ad squad cannot be created without at least one country) · upload_snapchat_ads_creative (put a finished render on the ad account as MEDIA and then as the CREATIVE an ad points at — Snapchat has no upload-from-URL, so Hermoso streams the bytes) · create_snapchat_ads_campaign → create_snapchat_ads_ad_squad → create_snapchat_ads_ad (the tree, every tier born PAUSED) · set_snapchat_ads_budget · set_snapchat_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_snapchat_ads_object (a REAL delete verb here, unlike TikTok — irreversible, so offer PAUSED first). THREE THINGS TO SAY OUT LOUD ON THIS PLATFORM: money is MICRO-CURRENCY (1,000,000 = one unit), so quote plain amounts and let Hermoso convert, and never pass both units — under-converting fails loudly while double-converting asks for a budget a million times too large; the creative HEADLINE is capped at 34 characters and brandName at 32, far shorter than Meta or Google, and over-long copy is refused rather than truncated; and a Snapchat ad points at a CREATIVE, never at a media id. SNAPCHAT POSTING (Stories / Spotlights on a Public Profile) IS BUILT BUT NOT YET REACHABLE — Snap’s Public Profile API is allowlist-only and Hermoso has not been allowlisted, so the connector is deliberately not offered; say that plainly rather than looking for a tool. LINKEDIN: post_to_linkedin (publish a finished post to the connected LinkedIn PROFILE) · list_linkedin_pages (the company Pages this connection administers — call this first and let the USER pick, never guess a Page) · post_to_linkedin_page (publish as a company PAGE rather than a person — this is the one most brands actually want) · manage_linkedin_post (edit the copy of a published post, or delete it) \u00b7 list_linkedin_comments / reply_to_linkedin_comment / delete_linkedin_comment (moderate the comments on your Page\u2019s posts \u2014 a SEPARATE LinkedIn authorization grants these, see Connectors) · linkedin_page_analytics (ORGANIC Page performance — followers, follower gains, Page views, and post impressions/clicks/engagement, for the Page total or per post; this is the free organic read, NOT linkedin_ads_report). LINKEDIN ADS (full three-tier management): list_linkedin_ads_campaigns (ad accounts, then a chosen account’s campaign groups, campaigns and — with campaignId — the CREATIVES under them) · linkedin_ads_report (impressions, clicks, cost, conversions, leads) · search_linkedin_ads_targeting (resolve locations / titles / industries / seniorities / company sizes to the URNs LinkedIn demands — never invent one) · linkedin_audience_count (HOW MANY members that targeting actually reaches, before a budget is committed — and a returned 0 means fewer than 300 people, LinkedIn’s privacy floor and also its campaign minimum, never an empty audience) · linkedin_bid_pricing (LinkedIn’s own suggested bid and daily-budget range for that audience — quote it instead of guessing what LinkedIn costs) · create_linkedin_ads_campaign_group → create_linkedin_ads_campaign → create_linkedin_ads_creative (the tree, every tier born DRAFT) · set_linkedin_ads_budget / set_linkedin_ads_status / delete_linkedin_ads_object (budgets, activate/pause at any tier, delete — every spend change confirm-gated). LINKEDIN LEAD SYNC: list_linkedin_lead_forms · list_linkedin_leads / get_linkedin_lead (the LEADS its forms collected, answers named by field — PERSONAL DATA: show, never republish) · subscribe_linkedin_leads / list_linkedin_lead_events / list_linkedin_lead_subscriptions / delete_linkedin_lead_subscription (real-time push to Hermoso, optional forwardTo relay to a CRM; LinkedIn validates ONLY Hermoso’s own webhook). LinkedIn is a THREE-tier platform and the third tier is the one people forget: a campaign with no creative shows nothing, and all three tiers must be ACTIVE before a single impression is served. REDDIT ADS: list_reddit_ads_campaigns / reddit_ads_report (read the account tree + performance) · list_reddit_ads_profiles + list_reddit_ads_posts / create_reddit_ads_post / update_reddit_ads_post (the CREATIVE — a Reddit ad promotes a post) · create_reddit_ads_campaign / update_reddit_ads_campaign · create_reddit_ads_ad_group / update_reddit_ads_ad_group · create_reddit_ads_ad / update_reddit_ads_ad · create_reddit_ads_max_campaign / get_reddit_ads_max_template / update_reddit_ads_max_template (a Reddit MAX campaign: automated campaign, ad group and a template ad Reddit generates ads from, built from creative-library assets, all PAUSED) · set_reddit_ads_status (the ONLY switch that arms real spend, confirm-gated) · delete_reddit_ads_object (remove a campaign, ad group or ad — Reddit has no delete verb, removal is a status, and it refuses to delete anything touched in the last 3 hours) · delete_reddit_ads_saved_audience · search_reddit_ads_targeting / reddit_ads_forecast / reddit_ads_bid_suggestion (free planning) · list_reddit_ads_pixels + send_reddit_ads_conversions (conversion tracking — Reddit now requires a pixel on every ad group) · list_reddit_ads_audiences / create_reddit_ads_audience / update_reddit_ads_audience_users / delete_reddit_ads_audience (retargeting lists) · list_reddit_ads_saved_audiences / create_reddit_ads_saved_audience / update_reddit_ads_saved_audience · list_reddit_ads_lead_forms / create_reddit_ads_lead_form · reddit_ads_history (who changed what, when). TELEGRAM: post_to_telegram (publish to a channel, group or chat as the brand’s own bot — text up to 4096 characters, but only 1024 once any photo or video is attached; one image, one video, or an album of 2–10 in which photos and videos may be mixed. chatId IS ALWAYS REQUIRED and is never guessed: the Bot API publishes NO method that lists the chats a bot belongs to, so pass the public channel’s @username or the numeric id) · list_telegram_chats (chats that MESSAGED the bot in the last 24 hours — a shortcut for finding an id, NOT a roster, and a chat missing from it can still be posted to) · list_telegram_dms (what those chats actually SAID, newest per chat — free, and a rolling 24-hour window rather than an inbox: the Bot API has no history endpoint at all) · delete_telegram_message (confirm-gated; Telegram refuses once a message is more than 48 hours old). BLUESKY: post_to_bluesky (publish as the connected account — text up to 300 characters AND, separately, 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; either up to 10 images OR one MP4 video, never both, because a Bluesky post record carries exactly one embed; links are made clickable automatically) · delete_bluesky_post (PERMANENTLY remove one of the account’s own posts — no trash and no undelete. Call it WITHOUT confirm first: it deletes nothing and reports the post’s real text and live like/repost/reply/quote counts, and once the post has any engagement it also wants confirmText echoing its text. Takes the AT-URI or just the record key from the bsky.app link) · list_bluesky_convos / read_bluesky_dm / send_bluesky_dm / mark_bluesky_convo_read (the account’s DIRECT MESSAGES — free, 1000 characters each, text only, and they need a PRIVILEGED app password: an ordinary one posts fine and cannot chat). Replies, mentions AND direct messages all arrive in list_inbox and are answered with reply_to_inbox_item. X / TWITTER: post_to_x (publish a post — text, an image or a video render WITH alt text, a POLL, a reply, or a whole thread, and optionally restrict who may reply; X is the ONE channel that bills per API request, a post carrying a LINK costs roughly 13× one without, and each profile has a rolling 24-hour ceiling on X spend that refuses a request whole rather than publishing half of it) · delete_x_post (remove one) · x_post_metrics (the PUBLIC counts — impressions, likes, reposts, replies, quotes, bookmarks) · x_post_insights (the ADVERTISER numbers for your own posts — link clicks, profile visits, video views and completion quartiles, up to 25 posts at once; this is what says whether a creative worked, and x_post_metrics cannot tell you, but it only sees the LAST 28 HOURS) · x_post_insights_historical (the same advertiser numbers over ANY date range — the one to use for anything older than yesterday) · x_mentions (who is talking to the brand, in their own words — the read half of the reply loop, and a source of real customer language for ad copy) · list_x_dms (the brand’s X DIRECT MESSAGES, grouped into conversations, saying which are waiting on a reply — billed per message returned, and X keeps only 30 days) · send_x_dm (reply privately to one named person; never a broadcast) · x_chat_settings / update_x_chat_settings (the X account’s DM privacy: who may message the brand, the request filter, read receipts and calls). X IS THE ONE CONNECTOR THAT COSTS CREDITS PER CALL — X charges us per API request, so posting, deleting, reading metrics, reading insights and pulling mentions each bill the user, a post CONTAINING A LINK costs 13× one without, and insights and mentions are billed PER POST RETURNED. Say so before posting a thread or pulling a big page of mentions, and prefer one post over five when the content allows. X ADS (the PAID half — a SEPARATE connection from the organic tools above: its own product on its own host with OAuth 1.0a signing, and X grants API access PER AD ACCOUNT rather than per app, so the customer adds Hermoso’s X user at business.x.com → Account access before anything here resolves): list_x_ads_accounts (the ad accounts this profile can act on, WITH the permission level held on each — read it before attempting a write) · list_x_ads_funding_instruments (a campaign cannot be created without one) · list_x_ads_campaigns / list_x_ads_line_items / list_x_ads_promoted_tweets / list_x_ads_targeting (the whole tree as it stands) · x_ads_report (impressions, clicks, spend and engagements at any level) · x_ads_geo_search / x_ads_targeting_search (resolve places and targeting values to the ids X demands — never invent one) · create_x_ads_campaign → create_x_ads_line_item → create_x_ads_promoted_tweet (the tree, every tier born PAUSED with no override; A CAMPAIGN ALONE CANNOT SERVE ON X — it needs a line item and a promoted post underneath it, and the read-back says so rather than letting you call it a finished ad) · add_x_ads_targeting · update_x_ads_campaign / update_x_ads_line_item (throttle or raise spend on a running campaign without rebuilding it) · set_x_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_x_ads_object. PINTEREST — POSTING AND ADS ARE TWO SEPARATE CONNECTIONS on the same Pinterest login (Pinterest keeps ads access behind different permissions), so a brand can hold either without the other and connecting one does not connect the other; if an ads call says Pinterest Ads is not connected, that is the card to send them to, NOT the Pinterest posting one. ADS: pinterest_ads_async_report (the DEEP paid report — 914 days back where the quick one stops at 90, and three times the metric columns; generated asynchronously, so pass the returned token back rather than re-submitting) · pinterest_targeting_analytics (WHICH audience segment delivered — by keyword, interest, age, gender, location, placement) · pinterest_audience_insights (WHO the audience is: interest affinities plus demographics, the input to a creative brief rather than a performance report) · pinterest_analytics (ORGANIC performance — impressions, saves, Pin clicks, outbound clicks, for the account, the TOP PINS, the top video Pins, or one Pin; Pinterest keeps 90 days and publishes no board-level analytics at all) · create_pinterest_board (make a board — a NEW Pinterest account has none and a Pin needs one) · list_pinterest_boards (the user must pick a board — never choose one for them) · post_to_pinterest (create an image or video Pin on a chosen board, with a title, description and destination link) · list_pinterest_pins (the Pins on a board with their ids — where the pinId every Pin tool needs comes from, and it flags any Pin an ad is promoting) · update_pinterest_pin (retitle, re-describe, fix a dead link, move it — Pinterest keeps this endpoint in a limited BETA, so it may be refused outright and save_pinterest_pin is the generally-available way onto another board; a Pin’s picture can never be swapped by anyone) · save_pinterest_pin (copy a Pin onto another board) · delete_pinterest_pin (confirm-gated, and it says whether an ad is promoting the Pin first) · update_pinterest_board (rename, re-describe, or hide it — SECRET hides every Pin on the board, reversibly) · delete_pinterest_board (the heaviest one here: the board AND every Pin on it, confirm-gated with the Pin count echoed back — offer hiding it instead). GOOGLE ADS (full management): list_google_ads_campaigns (list accounts, then a customer’s campaigns + spend/CTR/CPC/conversions) · google_ads_report (any GAQL breakdown — ad groups, keywords, search terms, geo) · create_google_ads_campaign (paused) · set_google_ads_budget / set_google_ads_status (change budget, enable/pause — every spend change confirm-gated) · delete_google_ads_object (remove a campaign, ad group, ad, KEYWORD, asset LINK or conversion action — Google has no delete verb, `remove` is the terminal state and it cannot be undone; call it unconfirmed first to see the spend and the tree that go with it) · upload_google_ads_asset (add an image render or a YouTube video to the ad account’s asset library) · create_google_ads_performance_max_campaign (Google’s cross-surface campaign type, RETAIL INCLUDED — pass merchantCenterId to make it a Shopping-feed Performance Max advertising the WHOLE Merchant Center feed under one root listing group, and feedLabel to narrow it to a single feed; only PARTITIONING that feed by brand/category/custom label is refused by name) · add_google_ads_assets (sitelinks, callouts, structured snippets, call, promotion, price, lead-form, business name and logo assets, CREATED AND ATTACHED — an asset that is not attached shows nothing) · manage_google_ads_assets (list, edit, pause or detach them) · set_google_ads_tracking (UTMs / tracking template on a campaign, ad group or ad; new campaigns get standard UTMs by default) · manage_google_ads_audience + set_google_ads_audiences (remarketing lists, custom segments, affinity / in-market interests, demographics, Performance Max signals and search themes) · upload_google_ads_video (a render straight into Google Ads, no YouTube channel needed) · create_google_ads_demand_gen_campaign (YouTube Shorts / in-feed / in-stream video ads, born PAUSED) · list_google_ads_conversion_actions + create_google_ads_conversion_action (what Google counts as a result — MAXIMIZE_CONVERSIONS, TARGET_CPA, TARGET_ROAS and every Performance Max campaign are undeliverable without one, and Hermoso refuses to build them on an account that has none) · google_ads_keyword_ideas (Keyword Planner — real monthly search volume, competition and top-of-page bids; use it before choosing keywords) · google_ads_recommendations + manage_google_ads_recommendations (Google’s own advice and optimization score; apply / dismiss / auto-apply, spend-type ones only after the user’s yes) · preview_google_ads_ad (a shareable no-login preview link for a Performance Max asset group or YouTube video ad, for client approval) · google_ads_policy_issues (WHY Google disapproved or limited an ad, asset or keyword: the policy topic and the evidence) · google_ads_change_history (WHAT CHANGED ON THE ACCOUNT AND WHEN — the answer to “performance fell off a cliff on Tuesday, what happened?”. Its default source is field-level and reaches 30 days; the other source reaches 90 and is the ONLY one that sees Google Ads Editor and criterion edits, so check both before telling anyone nothing changed). GOOGLE MERCHANT CENTER (the product feed behind every Shopping ad and every free listing, on the SAME connection as Google Ads): register_merchant_developer (the ONE-TIME link between Hermoso’s Google Cloud project and the merchant’s account. Google refuses every other Merchant call until it is done, so run this first when calls are being refused) · list_merchant_accounts (which Merchant Centers this login can reach, and where the merchantCenterId every other tool needs comes from) · list_merchant_products (the feed itself, with each product’s disapprovals) · list_merchant_issues (account-level problems, the answer to "why is nothing showing at all") · merchant_issue_help + trigger_merchant_issue_action (Google’s OWN remediation steps for a problem, and the button that fires one. Several of those actions are one-shot in Google’s own words, so firing one is confirm-gated) · list_merchant_data_sources + create_merchant_data_source + delete_merchant_data_source (feeds. A product write only lands in an API-input feed, and most accounts have none until one is made, so check before writing) · upsert_merchant_product + update_merchant_product + delete_merchant_product (write the feed) · list_merchant_inventory + set_merchant_inventory (the per-STORE and per-REGION price, stock level and availability override on one product, which is what stops a Shopping ad advertising something the nearest store has sold out of. The write MERGES, because Google’s insert replaces the whole entry, and Google takes up to 30 minutes to reflect it on the product) · list_merchant_promotions + create_merchant_promotion (sale and discount badges on a listing. Google validates them asynchronously, so created is never the same as approved) · manage_merchant_notifications (Google POSTs to a URL THE MERCHANT RUNS the moment a product is disapproved, instead of someone having to poll) · merchant_account_status (WHY THE ACCOUNT IS OR IS NOT SERVING — the first thing to run when Shopping ads or free listings show nothing, and the one read that does not believe the program state: an account can report both programs ENABLED and serve in ZERO countries, because a region counts as active only where every requirement is met. It names Google’s own unmet requirements, then the settings that explain them: homepage claimed or not, business address, phone and support contact, active shipping services, return policies, terms accepted) · manage_merchant_settings (FIXES what merchant_account_status finds: shipping services merged by name so unmentioned ones are kept, return policies, business address and support contact, homepage claim, programs on or off; overwrites and removals ask first) · manage_merchant_conversion_source (WHERE MERCHANT CENTER GETS ITS CONVERSION DATA FROM, which is what free-listing and Shopping performance reporting is built on — a merchant with no conversion source sees clicks and no outcomes. Either a Google tag destination, whose MC-… id comes back only on the create and is the id the Google tag has to send conversions to, or a link to a GA4 property, which is IMMUTABLE and needs the connected Google account to be an admin there. A delete is an ARCHIVE and undelete restores it until the expiry Google reports) · merchant_quota (whether the account is simply out of daily API quota or out of product slots, which looks identical to a broken integration and is not. Google resets it at MIDDAY UTC) · merchant_report (the reports Google computes for free, including competitive visibility, best sellers and price competitiveness). MICROSOFT MERCHANT CENTER (the same job on Microsoft’s side, on the Microsoft Advertising connection): list_microsoft_merchant_stores · list_microsoft_merchant_products · upsert_microsoft_merchant_product · delete_microsoft_merchant_product · list_microsoft_merchant_issues · list_microsoft_merchant_catalogs + manage_microsoft_merchant_catalog. GOOGLE ANALYTICS (GA4 — the brand’s OWN site data, and a SEPARATE connection from Google Ads: a brand that spends on Ads every day may have no Analytics access at all, so never read one as the other): list_analytics_properties (call this FIRST — every other Analytics tool needs a NUMERIC property id, and what users actually know is the “G-XXXXXXX” Measurement ID from their tracking snippet, which no endpoint accepts; resolve it from this list rather than sending them hunting. It lists the properties SHARED WITH THIS PROFILE, not everything the Google account can see — Analytics access is handed out freely and one login often has Viewer on many clients’ properties, so the user ticks which belong to this profile and any other one is refused by name; an empty list means nothing is ticked yet, which set_connector_accounts or Settings ▸ Connectors ▸ Google Analytics ▸ Manage accounts fixes) · analytics_report (what happened — sessions, users, revenue, conversions and engagement broken down by channel, source/medium, campaign, landing page, country, device or date, i.e. the read that says whether the traffic an ad bought actually did anything) · analytics_realtime (who is on the site right now, ~30 minutes — a DIFFERENT metric set that rejects `sessions` outright, never a shortcut for analytics_report) · list_analytics_definitions (what the property already measures: its key events and its own custom dimensions, and the check to run before creating either) · create_analytics_key_event (mark an event GA4 already collects as a KEY EVENT — the 2024 rename of a conversion, and what makes it importable into Google Ads; marking an event the site never fires creates one that can never fire) · create_analytics_custom_dimension (register an event parameter the site already sends so reports can break down by it — say out loud first that a GA4 custom dimension CANNOT be deleted, only archived, and a property is capped at 50 event-scoped ones, so a typo permanently burns a slot) · list_analytics_data_streams (the streams on a property and the measurement ID (G-...) each one carries, which is what a gtag or GTM install needs and what nobody can find in the GA4 UI when asked) · get_analytics_stream_setup (the finished gtag <script> block to paste into the site — the last mile list_analytics_data_streams stops short of — plus whether enhanced measurement is really collecting scrolls, outbound clicks, site search, video, downloads and form interactions, and whether redaction is stripping campaign parameters out of recorded URLs. Web streams only. Read the master switch before believing a toggle: with enhanced measurement off for the stream, every toggle is inert whatever it says) · list_analytics_metadata (every dimension and metric this property can be asked for, including its own custom ones, which is what stops analytics_report guessing a field name) · check_analytics_compatibility (whether a dimension and metric can appear in the same report before spending a call finding out they cannot) · create_analytics_custom_metric + archive_analytics_custom_metric · archive_analytics_custom_dimension · delete_analytics_key_event (all one-way in the same sense as their create twins: archiving is not deleting and there is no un-archive) · list_analytics_google_ads_links + link_google_ads_to_analytics + unlink_google_ads_from_analytics (the join that makes a GA4 audience usable in Google Ads and a GA4 key event importable as a conversion — without it a perfectly good audience simply never appears in the ads account, with no error anywhere) · list_analytics_audiences + create_analytics_audience + archive_analytics_audience (GA4 remarketing audiences, the input to Google Ads remarketing. Archiving is one-way) · manage_analytics_measurement_protocol_secret (mint the API secret that lets the customer’s OWN SERVER send events straight into GA4, the Google twin of the conversions APIs already here for Reddit, Snapchat and OpenAI Ads. Say out loud that there is NO rotation anywhere in the API, so replacing a secret means create the new one, move every sender across, then delete the old one) · manage_analytics_channel_group (HOW GA4 BUCKETS TRAFFIC — the answer to “why is my campaign showing as Unassigned”, and the one number an ad studio is judged on. Read the Default channel group’s rules before diagnosing anything, then author your own group whose channels catch the campaigns Hermoso publishes. The rule fields are the eachScope… names, NOT the sessionSource / medium dimensions reports use, and GA4 stops at the first rule that matches so order decides everything) · manage_analytics_calculated_metric (the derived number a marketer actually reports — cost per purchase, revenue per session — built from metrics GA4 already collects and then available to analytics_report under its own permanent API name. The id is permanent, and a formula naming a metric the property does not collect is created happily and flagged invalid, so read that flag back) · manage_analytics_annotations (mark a launch or a post on the customer’s own GA4 charts, so the traffic it moved shows where they look) · analytics_data_retention (how long GA4 keeps event data — the 2-month default caps every exploration; lowering it deletes data, so it is confirm-gated). MICROSOFT ADVERTISING / BING ADS (full management, mirroring Google): list_microsoft_ads_campaigns (list the shared ad accounts, then a chosen account’s campaigns + budgets) · microsoft_ads_geo_search (resolve country / region / city names to the Microsoft location ids a campaign needs — call it when an ask is ambiguous and let the USER pick) · microsoft_ads_report (impressions, clicks, CTR, average CPC, spend, conversions — generated asynchronously, so it may come back pending and must be called again) · create_microsoft_ads_campaign (campaign → ad group → responsive search ad → keywords, always Paused; with no locations[] it is created serving WORLDWIDE, Microsoft’s own default, and the read-back warns loudly — relay that before anyone activates it) · create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (fill in an existing account) · set_microsoft_ads_budget / set_microsoft_ads_status (change budget, activate/pause — every spend change confirm-gated; Microsoft statuses are Active/Paused, never Deleted) · search_microsoft_ads_profiles / list_microsoft_ads_profile_targeting / set_microsoft_ads_profile_targeting (LinkedIn profile targeting: company, industry, job function, seniority and job title bid adjustments on a Search, Shopping or DSA campaign; confirm-gated on an ACTIVE campaign) · delete_microsoft_ads_object (a REAL delete — campaign, ad group, ad or keyword — permanent, with no undelete; call it unconfirmed first to see what goes with it) · microsoft_ads_keyword_ideas (Microsoft’s Keyword Planner — real search volume, competition and suggested bids, with NO planning-tier gate, unlike Google’s) · microsoft_ads_traffic_estimates (what those keywords would deliver at a named bid — a range, never one number) · microsoft_ads_budget_opportunities (where Microsoft says a budget is capping delivery, and what raising it is forecast to buy) · microsoft_ads_auction_insights (who ELSE is bidding on the same auctions — rival domains with their impression share, overlap and outranking share; shares of YOUR auctions, never a measure of a competitor’s whole account) · microsoft_ads_bulk_download (export the account as ONE bulk file — the only way to read ~185 Microsoft record types Hermoso cannot otherwise touch: sitelinks, callouts, structured snippets, labels, shared negative keyword lists, bid strategies, audiences, experiments, seasonality adjustments, conversion goals, asset groups, feeds) · microsoft_ads_bulk_upload (apply an edited bulk file — hundreds of objects in one request. IT IS GATED HARDER THAN ANYTHING ELSE ON THIS CONNECTOR, because a bulk file carries a Status column and can turn campaigns ON without ever touching set_microsoft_ads_status: confirm:true alone is refused, and you must first call it unconfirmed to get the row-by-row list of what it would ACTIVATE and DELETE, show that to the user, then echo both counts back as confirmActivations/confirmDeletions — or pass pauseInstead:true to land the file with every activation written as Paused) · list_microsoft_ads_conversion_goals (what the account counts as a conversion, and which goals are OFFLINE ones) · send_microsoft_ads_offline_conversions (close the loop: phone sales, in-store purchases and late-closing leads fed back so smart bidding stops optimising against website conversions alone — pass PLAIN emails and E.164 phones, hashing happens server-side to Microsoft’s own published spec) · microsoft_ads_editorial_reasons (WHY an ad or keyword was disapproved: the code, the word Microsoft objected to, where, and the appeal status) · list_microsoft_ads_audiences (the account’s Customer Match lists with their current sizes; a fresh list reads 0 for up to 48 hours and Microsoft will not use one under 300 people, so never call that a failed upload) · create_microsoft_ads_customer_list then apply_microsoft_ads_customer_list (build a Customer Match audience from PLAIN email addresses, normalized and SHA-256 hashed server-side to Microsoft’s own published spec so no plaintext ever leaves us; the user must be shown Microsoft’s Customer Match terms and agree first) · microsoft_ads_recommendations (what Microsoft ITSELF suggests changing, each one priced by Microsoft: budget raises carrying the current and recommended daily amount, new and broadened keywords, negative keywords it wants removed, and ads it has written. Every one INCREASES what the account buys, which is what they are for, so none is a free win and an empty list means Microsoft has no advice rather than that the account is optimal) · apply_microsoft_ads_recommendations (act on them, gated exactly like the bulk upload: confirm:true alone is REFUSED, so call it unconfirmed first to get every recommendation named with what it changes and Microsoft’s own cost estimate, show that to the user, then echo confirmCount and confirmCostIncrease back. Both are recomputed from a fresh read, and there is no undo) · dismiss_microsoft_ads_recommendations (take advice off the list. It cannot spend, so it needs no confirmation at all, and it is the right answer to “make it stop suggesting that” rather than applying something to clear it) · microsoft_ads_auto_apply (THE READ THAT ANSWERS “is Microsoft changing this account while nobody is looking?”, per type. An inherited account can already be opted in with nobody at the brand having done it) · set_microsoft_ads_auto_apply (turn that standing permission on or off. Switching any type ON is the strongest consent anywhere in Hermoso: Microsoft then writes and publishes its own ads under the brand’s name, deletes negative keywords so the account buys more searches, and changes conversion goals, unattended and indefinitely, with NOTHING to preview beforehand. So confirm:true is not enough and every type must be named in confirmTypes. Switching it OFF is never gated). GOOGLE BUSINESS PROFILE (the local-SEO channel — the listing panel on Google Search and Maps, which for a local business is where the demand actually is, and there is no delete): list_business_locations (the listings the connected Google account manages — call this first and let the USER pick when there is more than one; a Post on the wrong storefront is a public mistake) · post_to_google_business (publish a Post to the listing — text, ONE PHOTO and a call-to-action button; Google’s Posts API takes no video, so pass a still. EVENT and OFFER posts both require a title and a start date, and on an OFFER Google ignores the button link) · list_google_business_posts (what is showing right now, with each Post’s state) · delete_google_business_post (take one down — immediate and public, so confirm first) · list_google_business_reviews (the reviews on the listing, and which ones have NO reply yet — for a local business the highest-leverage surface there is) · reply_to_google_business_review (answer one publicly as the business; it is an UPSERT, so it replaces any existing reply) · list_google_business_questions + answer_google_business_question (the public Q&A on the listing) · google_business_search_keywords (the actual search terms people typed to find the listing — free local keyword data; low-volume terms are SUPPRESSED and come back as "fewer than N", never as zero) · google_business_insights (Search + Maps impressions, calls, website clicks, direction requests, messages, bookings — listing-level; Google discontinued per-Post insights in 2023 with no replacement, so never promise per-Post numbers) · get_business_location (everything the listing actually says — name, address, phone, website, categories, description, hours, service area — as the merchant set it; the answer to “what does our Google listing say?”) · update_business_location (change any of that — hours, phone, website, description, categories, even the name or address. It edits the live panel on Search and Maps with no draft and no undo, so call it WITHOUT confirm first: nothing is written, Google validates the payload, and you get the current value of every field you are about to change to show the user) · google_business_account (whose Business Profile account the listing is on, and whether the connected Google account’s role can edit it at all). Google gates this API behind a per-project access request and the default quota is zero, so the connection can be live and calls still refused — the error says so. CHATGPT ADS (ads under ChatGPT answers, via OpenAI’s Advertiser API — full management): list_openai_ads_campaigns (the ad account, then its campaigns, ad groups and ads with each ad’s review state) · openai_ads_report (impressions, clicks, spend, CTR, CPC, CPM at account / campaign / ad group / ad scope — run this first, it validates the key with zero spend risk) · openai_ads_geo_search (location ids) · list_openai_ads_audiences + create_openai_ads_audience (custom audiences — geo and these are the only list-based targeting this platform has; target them with customAudienceIds / excludedCustomAudienceIds on a campaign) · create_openai_ads_campaign (campaign → ad group → ad in one call, always PAUSED) · create_openai_ads_ad_group / create_openai_ads_ad (fill in an existing campaign) · update_openai_ads_object (rename, re-budget, rewrite context hints or the ad copy) · set_openai_ads_budget / set_openai_ads_status (change budget, activate, pause) · delete_openai_ads_object (ARCHIVE — this API has no delete and OpenAI say archiving is not reversible, so offer pausing first). TWO RULES THIS CHANNEL DOES NOT SHARE WITH THE OTHERS: it is connected by PASTING an Advertiser API key (no OAuth, no manager account, one key = one ad account), and it has exactly ONE creative format — a text plus image card, title 50 characters, body 100. There is NO VIDEO on ChatGPT Ads, so never offer a video ad here. GOOGLE DRIVE — ONE connection covering Drive, Sheets and Docs (full CRUD over the files Hermoso created there, plus any file the user hands over with the Google file picker in the app): save_to_drive · list_drive_files / get_drive_file · update_drive_file (rename/move/trash) · delete_drive_file · create_drive_folder. GOOGLE SHEETS (part of the Google Drive connection — export data to a spreadsheet the app creates, or read one the user picked; drive.file, no verification): create_sheet · append_to_sheet · read_sheet. GOOGLE DOCS (part of the Google Drive connection — export copy/brief/report as a doc, or read one the user picked; drive.file, no verification): create_doc · append_to_doc. GOOGLE SLIDES (part of the Google Drive connection — turn a swipefile collection into a real presentation, one slide per saved ad with the creative, brand, copy, run dates and platform; drive.file, no verification, no new scope): export_swipefile_deck — it CREATES a deck each time and cannot append to one the user already has, and a creative whose ad-library link has expired is reported rather than silently dropped. ONEDRIVE (full CRUD over the user’s Microsoft OneDrive): convert_onedrive_file (Microsoft converts a file server-side to PDF or JPG — ~130 formats including PowerPoint and Word decks, PSD, Illustrator, Sketch, 3D, video, iPhone HEIC and raw camera files; JPG needs both width and height) · save_to_onedrive · list_onedrive_files / get_onedrive_file · update_onedrive_file (rename/move) · delete_onedrive_file · create_onedrive_folder · share_onedrive_file (a view/edit link for a client) · onedrive_file_permissions (who can open it; revoke). INTERCOM (the brand’s own support inbox, nothing billed): list_intercom_conversations (filter by state, assignee, team, tag, channel, customer, dates, priority, unread, or app = the product the customer wrote from) · read_intercom_conversation · reply_to_intercom_conversation / add_intercom_note / manage_intercom_conversation (each a preview until confirm:true) · tag_intercom_conversation · list_intercom_tags · search_intercom_contacts · list_intercom_teammates · intercom_customer_language (customers’ own words for ad copy). VERCEL (the brand’s own sites, nothing billed): list_vercel_projects · list_vercel_deployments · list_vercel_domains · vercel_web_analytics (page views, visitors, top pages, referrers, countries) · vercel_event_analytics (custom-event conversions by campaign) · deploy_vercel_page (a static landing page, confirm-gated; preview by default) · delete_vercel_deployment. Use these standalone — Hermoso is a full posting/ads/file-storage control surface, not only an ad generator.',
|
|
262
262
|
'F) YOUR TOOL LIST IS A STARTING POINT, NOT THE PRODUCT \u2014 and the whole catalogue is two calls away. The listed roster is deliberately small (the core tools, plus whatever this connection asked for or you have switched on); every other tool in this map is held out of the LIST on SIZE alone and is fully built, fully live and fully callable. THE ROUTE THAT WORKS ON EVERY HOST, including the ones that cannot reload their tool list at all (claude.ai, ChatGPT): find_tools({query}) searches every tool by task or name and returns its parameters, its credit cost and its recent health in one line, then call_tool({name, args}) RUNS it \u2014 same account, same permissions, same result as if it had been listed. A direct tools/call to a name you already know works too. enable_tools({groups:[\u2026]}) additionally LISTS a whole group for hosts that re-list: `ads` (paid-campaign management on Meta, Google Ads, LinkedIn, Reddit, Microsoft, Pinterest, X, TikTok, Snapchat, ChatGPT Ads, Apple Search Ads \u2014 by far the heaviest group), `analytics` (GA4, Search Console), `channel_admin` (reading a channel back: insights, comments, DMs, catalogs, templates, webhooks, editing or deleting a published post), plus research, create, channels, files, workspace, or \'all\'. NEVER tell a user Hermoso cannot build a campaign, read a comment, answer a DM or pull a channel\u2019s numbers because you cannot see the tool \u2014 look it up and call it.',
|
|
263
263
|
].join('\n');
|
|
@@ -340,8 +340,8 @@ export const MCP_INSTRUCTIONS = [
|
|
|
340
340
|
'• AD SPY / RESEARCH: find_competitors, competitor_teardown, pull_competitor_ads, research_ads; ad libraries search_meta_ads / search_google_ads / search_linkedin_ads; organic search_tiktok / search_instagram / search_youtube / search_reddit / search_threads; fetch_social_data; mine_angles; analyze_video; check_ad_policy; list_skills / get_skill.',
|
|
341
341
|
'• CREATE (on-brand ads): render_ad (Studio quality pipeline) or generate_image / generate_video / generate_avatar render on their own; plan_ad authors a board first when the ad wants one and render_ad takes it; get_brand (what we already know) / draft_brand (onboard one) / update_brand (patch a field) manage the saved brand, which the create tools hydrate by themselves; list_creators / save_creator / delete_creator (the reusable saved CAST — re-cast the same face instead of generating a new person every time; render_ad’s `creator` stars one of them in the ad); make_template_ad (native HTML formats); make_thumbnail (YouTube / Shorts / Instagram video thumbnails + covers — use it for any thumbnail or video-cover ask, never generate_image); clone_static / recast_motion / reframe_video / insert_scene / upscale_video / dub_video / change_voice / finish_video / fix_beat / hook_variants / stitch_video; plan_variations + score_ad.',
|
|
342
342
|
'• RAW MODEL PLAYGROUND: generate_image / generate_video (useBrand:false) for prompt-only renders, generate_voice for text-to-speech, generate_text for the writing models — against any of 30+ image / video / voice / writing model ids (credit costs in hermoso_capabilities), no ad framing.',
|
|
343
|
-
'• ACCOUNT & WORKSPACES: hermoso_credits, billing_status, buy_credits (one-click top-up / first-purchase link), upgrade_plan / set_auto_reload (admin), list_jobs / get_job; list_brands / create_brand / use_brand / delete_brand (one account holds MANY profiles: brands, clients or creators, so an agency runs every client through here, each with its own brand details, memory, Library and connectors; create_brand → draft_brand onboards a new one, delete_brand is confirm-gated); get_settings / update_settings (the LANGUAGE every ad, script and plan is written in (chat replies follow the language the user writes in) — set it once and every render obeys it — plus app appearance and the weekly competitor-watch email); list_team / invite_member / remove_member / set_role.',
|
|
344
|
-
'• PUBLISH & MANAGE YOUR CHANNELS (the user’s connected accounts, over this MCP): Meta — post_to_meta (FB/IG/Threads), upload_file (post ANY external/local file), list_meta_ads + meta_insights (read campaigns/ad sets/ads + performance, broken down by age/gender/placement/country), preview_meta_ad (see the real ad per placement, 24h links), estimate_meta_reach (audience size before you spend), list_meta_audiences / create_meta_audience (retargeting + lookalikes), create_meta_campaign / create_meta_ad / upload_meta_asset (build), update_meta_object / delete_meta_object / set_meta_campaign_status (edit/delete/activate — spend + deletes confirm-gated), manage_meta_post (edit/delete a post); DM automations (a keyword comment, DM, story reply or ig.me link gets an automatic private reply) — save_dm_automation / test_dm_automation / list_dm_automations / delete_dm_automation on Instagram, Facebook (Page comments, ads, Messenger) and X (X billed in credits per event and send), and the Instagram-only save_instagram_dm_automation / test_instagram_dm_automation / list_instagram_dm_automations / delete_instagram_dm_automation; X — x_chat_settings / update_x_chat_settings (who may DM the brand on X); Microsoft Advertising (Bing Ads) — list_microsoft_ads_campaigns, microsoft_ads_report, microsoft_ads_geo_search, create_microsoft_ads_campaign / create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (all created Paused), set_microsoft_ads_budget / set_microsoft_ads_status (spend confirm-gated); ChatGPT Ads (OpenAI Advertiser API) — list_openai_ads_campaigns, openai_ads_report, openai_ads_geo_search, create_openai_ads_campaign / create_openai_ads_ad_group / create_openai_ads_ad (all created PAUSED), update_openai_ads_object, set_openai_ads_budget / set_openai_ads_status (spend + archive confirm-gated). Connected by API key; ONE creative format (a text + image card, no video); Pinterest — list_pinterest_boards then post_to_pinterest (the user picks the board); Google Business Profile — list_business_locations, post_to_google_business, list_google_business_posts, delete_google_business_post, google_business_insights, get_business_location / update_business_location (read and CHANGE what the listing says — hours, phone, website, description, categories, name, address; the edit is live on Search and Maps, so the unconfirmed call writes nothing and shows the before-and-after), google_business_account (whose account it is on and whether that role can edit it); Google Drive (ONE connection covering Drive, Sheets and Docs) — save_to_drive, list_drive_files, get_drive_file, update_drive_file, delete_drive_file, create_drive_folder, plus create_sheet / append_to_sheet / read_sheet and create_doc / append_to_doc / read_doc (Hermoso-created files, plus any file the user hands over with the Google file picker in the app); Microsoft OneDrive — save_to_onedrive, list_onedrive_files, get_onedrive_file, update_onedrive_file, delete_onedrive_file, create_onedrive_folder, share_onedrive_file, onedrive_file_permissions; MANAGING THE CONNECTIONS — list_connectors, list_connector_accounts + set_connector_accounts (which Pages / ad accounts / company Pages this profile may post to and spend from — fails closed, an empty choice shares nothing), leave_connector (remove just YOUR OWN account from a connector several teammates have each joined — theirs keep working) · disconnect_connector (confirm-gated: reconnecting needs a browser). LINKING a NEW account is the one step that is not headless (an OAuth consent screen) — send the user to Workspace ▸ Connectors in the app.',
|
|
343
|
+
'• ACCOUNT & WORKSPACES: hermoso_credits, billing_status, buy_credits (one-click top-up / first-purchase link), upgrade_plan / set_auto_reload (admin), list_jobs / get_job; list_brands / create_brand / use_brand / delete_brand (one account holds MANY profiles: brands, clients or creators, so an agency runs every client through here, each with its own brand details, memory, Library and connectors; create_brand → draft_brand onboards a new one, delete_brand is confirm-gated); get_settings / update_settings (the LANGUAGE every ad, script and plan is written in (chat replies follow the language the user writes in) — set it once and every render obeys it — plus app appearance and the weekly competitor-watch email); list_alert_settings / set_alert_events / send_test_alert (where alerts go: Slack, Discord or a signed webhook); list_team / invite_member / remove_member / set_role.',
|
|
344
|
+
'• PUBLISH & MANAGE YOUR CHANNELS (the user’s connected accounts, over this MCP): Meta — post_to_meta (FB/IG/Threads), upload_file (post ANY external/local file), list_meta_ads + meta_insights (read campaigns/ad sets/ads + performance, broken down by age/gender/placement/country), preview_meta_ad (see the real ad per placement, 24h links), estimate_meta_reach (audience size before you spend), list_meta_audiences / create_meta_audience (retargeting + lookalikes), create_meta_campaign / create_meta_ad / upload_meta_asset (build), update_meta_object / delete_meta_object / set_meta_campaign_status (edit/delete/activate — spend + deletes confirm-gated), manage_meta_post (edit/delete a post); DM automations (a keyword comment, DM, story reply or ig.me link gets an automatic private reply) — save_dm_automation / test_dm_automation / list_dm_automations / delete_dm_automation on Instagram, Facebook (Page comments, ads, Messenger) and X (X billed in credits per event and send), and the Instagram-only save_instagram_dm_automation / test_instagram_dm_automation / list_instagram_dm_automations / delete_instagram_dm_automation; X — x_chat_settings / update_x_chat_settings (who may DM the brand on X); Microsoft Advertising (Bing Ads) — list_microsoft_ads_campaigns, microsoft_ads_report, microsoft_ads_geo_search, create_microsoft_ads_campaign / create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (all created Paused), set_microsoft_ads_budget / set_microsoft_ads_status (spend confirm-gated); ChatGPT Ads (OpenAI Advertiser API) — list_openai_ads_campaigns, openai_ads_report, openai_ads_geo_search, create_openai_ads_campaign / create_openai_ads_ad_group / create_openai_ads_ad (all created PAUSED), update_openai_ads_object, set_openai_ads_budget / set_openai_ads_status (spend + archive confirm-gated). Connected by API key; ONE creative format (a text + image card, no video); Pinterest — list_pinterest_boards then post_to_pinterest (the user picks the board); Google Business Profile — list_business_locations, post_to_google_business, list_google_business_posts, delete_google_business_post, google_business_insights, get_business_location / update_business_location (read and CHANGE what the listing says — hours, phone, website, description, categories, name, address; the edit is live on Search and Maps, so the unconfirmed call writes nothing and shows the before-and-after), google_business_account (whose account it is on and whether that role can edit it); Google Drive (ONE connection covering Drive, Sheets and Docs) — save_to_drive, list_drive_files, get_drive_file, update_drive_file, delete_drive_file, create_drive_folder, plus create_sheet / append_to_sheet / read_sheet and create_doc / append_to_doc / read_doc (Hermoso-created files, plus any file the user hands over with the Google file picker in the app); Microsoft OneDrive — save_to_onedrive, list_onedrive_files, get_onedrive_file, update_onedrive_file, delete_onedrive_file, create_onedrive_folder, share_onedrive_file, onedrive_file_permissions; MANAGING THE CONNECTIONS — list_connectors, list_connector_accounts + set_connector_accounts (which Pages / ad accounts / company Pages this profile may post to and spend from — fails closed, an empty choice shares nothing), leave_connector (remove just YOUR OWN account from a connector several teammates have each joined — theirs keep working) · disconnect_connector (confirm-gated: reconnecting needs a browser). Google Merchant Center: manage_merchant_settings fixes the shipping, return, business-info and program settings that stop an account serving. LINKING a NEW account is the one step that is not headless (an OAuth consent screen) — send the user to Workspace ▸ Connectors in the app.',
|
|
345
345
|
'YOUR ROSTER IS CORE-FIRST, AND NOTHING IS MISSING OR UNFINISHED. What you are LISTED is the core tools plus whatever this connection asked for or you have switched on; the rest of the product \u2014 hundreds of tools across research, creation, publishing, paid campaigns, analytics and channel administration \u2014 is held out of the LIST on SIZE ALONE. IT IS ALL CALLABLE RIGHT NOW. find_tools({query}) searches EVERY tool, listed or not, and each row carries its parameters, its credit cost and its recent health; call_tool({name, args}) then runs it through the same handler, the same account and the same permissions; a direct tools/call to a name you already know works too. This route needs no reload and works on every host, including claude.ai and ChatGPT, which fix their tool list when the connection is made. enable_tools({groups:[\u2026]}) additionally LISTS a group for hosts that re-list \u2014 free and instant: the MOMENT the user asks to build, budget, target, report on or change a campaign, enable_tools({groups:[\'ads\']}); for sessions, conversions, revenue by channel or how their site ranks, enable_tools({groups:[\'analytics\']}); to read, moderate, reply to, measure or delete something ALREADY on a channel, enable_tools({groups:[\'channel_admin\']}). IF THE TOOLS DO NOT APPEAR after that call \u2014 some clients cache their tool list for the whole conversation \u2014 do NOT tell the user the capability does not exist and do not keep retrying: just use call_tool, which does not depend on the roster changing at all. Reconnecting with `?tools=all` on the server URL lists everything at connect time. NEVER tell a user Hermoso cannot manage their campaigns or read their analytics, cannot read a comment, answer a DM or pull a channel\u2019s numbers, because you cannot see the tool \u2014 look it up with find_tools and run it with call_tool. The full set of group names is: core, research, create, channels, channel_admin, analytics, files, workspace, ads \u2014 or \'all\'.',
|
|
346
346
|
// HINTS (2026-09-17). The advice was always in the prose; this says the keyed copy exists so a client that
|
|
347
347
|
// prefers structure does not have to parse a sentence to find it.
|
|
@@ -2234,7 +2234,7 @@ export const TOOL_GROUP_NAMES = ['core', ...Object.keys(TOOL_GROUPS)];
|
|
|
2234
2234
|
// names `create` (or all); this figure is what that session pays.
|
|
2235
2235
|
// create RE-MEASURED 2026-10-06 (42000 → 55000, measured 54,974 by tools/tool-group-truth-check.mjs): make_static_ads
|
|
2236
2236
|
// joined it (on-demand), on top of the create tools added since 09-24 that had used up the 1.3× tolerance.
|
|
2237
|
-
export const TOOL_GROUP_TOKENS = { core:
|
|
2237
|
+
export const TOOL_GROUP_TOKENS = { core: 4600, research: 8300, create: 62200, channels: 53200, channel_admin: 100900, analytics: 56800, files: 17800, workspace: 10300, ads: 346000 }; // re-measured 2026-10-27 (tools/tool-group-truth-check.mjs §8)
|
|
2238
2238
|
|
|
2239
2239
|
// THE DEFAULT ROSTER IS EVERYTHING EXCEPT `ads` AND `analytics`. Paid-campaign management across every ad platform
|
|
2240
2240
|
// is ~236K tokens on its own — more than everything else put together — because each platform carries a full
|
|
@@ -2509,9 +2509,9 @@ const executorLike = (name) => EXECUTOR_TOOLS.has(name) || WIDGET_GENERIC_PROXIE
|
|
|
2509
2509
|
// caller's own credits is metering, not a change to the environment the tool acts on, so a research read that
|
|
2510
2510
|
// bills stays read-only. destructiveHint is TRUE on draft_brand: save:true replaces the saved brand profile.
|
|
2511
2511
|
export const EXECUTOR_TOOLS = new Set(['find_tools', 'call_tool', 'enable_tools']);
|
|
2512
|
-
export const WIDGET_HOST_RENAMES = Object.freeze({ clone_video: 'remake_video_for_brand' });
|
|
2512
|
+
export const WIDGET_HOST_RENAMES = Object.freeze({ clone_video: 'remake_video_for_brand', click_to_ad: 'plan_video_ad_from_product_link' }); // click_to_ad: the scan (2026-10-27) read the name as promotional / not what it does — it PLANS the ad, render_ad makes it
|
|
2513
2513
|
export const WIDGET_HOST_ALIAS_OF = Object.freeze(Object.fromEntries(Object.entries(WIDGET_HOST_RENAMES).map(([from, to]) => [to, from])));
|
|
2514
|
-
export const WIDGET_HOST_ALIAS_TITLES = Object.freeze({ remake_video_for_brand: 'Remake a video for your brand' });
|
|
2514
|
+
export const WIDGET_HOST_ALIAS_TITLES = Object.freeze({ remake_video_for_brand: 'Remake a video for your brand', plan_video_ad_from_product_link: 'Plan a video ad from a product link' });
|
|
2515
2515
|
// ── ONE OPERATION, ONE TOOL, ON A WIDGET HOST (2026-10-18) ─────────────────────────────────────────────────────
|
|
2516
2516
|
// OpenAI's daily scan HELD 18 tools that take an `action` enum and do different things per value: "This tool appears
|
|
2517
2517
|
// to execute operations that are not individually exposed. Please expose each model-callable operation upfront as a
|
|
@@ -4348,7 +4348,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4348
4348
|
server.group('channels');
|
|
4349
4349
|
server.registerTool('post_to_bluesky', {
|
|
4350
4350
|
title: 'Post to Bluesky',
|
|
4351
|
-
description: "Use this when the user wants to post to Bluesky. Publishing costs no credits. Publish a post to Bluesky as the connected account
|
|
4351
|
+
description: "Use this when the user wants to post to Bluesky. Publishing costs no credits. Publish a post to Bluesky as the connected account. Text up to 300 characters — Bluesky ALSO caps a post at 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; Hermoso checks both before spending the round trip and says which limit and by how much. @MENTIONS written as the full handle (@alice.bsky.social) are looked up and tagged as Bluesky mentions; one that does not resolve stays plain text and the reply names it. MEDIA: either up to 10 images (imageUrls + altText; 5–10 post as a Bluesky gallery, more than 10 is refused, never cut; a picture over Bluesky's 2 MB limit is re-encoded to fit and the reply says so) OR one MP4 video (videoUrl + videoAlt), never both — a Bluesky post record carries a single embed and images and video are two different embed types. Video is MP4 only, up to 300MB at Bluesky's end (Hermoso can fetch up to 150MB from a URL), with optional WebVTT caption tracks; the aspect ratio is measured from the file. Bluesky requires a CONFIRMED EMAIL on the account before it will process any video — if it is unconfirmed you get a refusal saying so, and reconnecting will not help. Links in the text are made clickable automatically. LINK CARDS: Bluesky does NOT scrape links, so a URL posted bare renders as plain blue text — the client composing the post has to build the card. Hermoso builds one AUTOMATICALLY when the post has a URL and NO media: it fetches the page, uses its title/description and uploads its image as the card thumbnail. Pass linkCard:false to suppress it, or linkCard:{uri,title,description,thumbUrl} to control it (give both title and description and the page is not fetched at all). A POST CARRIES ONE EMBED, so a card and images/video cannot both ride: if you pass linkCard explicitly ALONGSIDE media the call is REFUSED by name rather than silently dropping one, and if the URL was merely in the text the MEDIA WINS and the reply says the card was skipped (the link stays clickable either way). Returns the post's public bsky.app URL. Connect at Settings > Connectors > Bluesky.",
|
|
4352
4352
|
inputSchema: {
|
|
4353
4353
|
account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the profile has more than one bluesky account connected (several and none named is refused by name, never guessed); omit when there is one."),
|
|
4354
4354
|
brand: HOOK_ATTR.brand,
|
|
@@ -4410,7 +4410,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4410
4410
|
server.group('channels');
|
|
4411
4411
|
server.registerTool('post_to_telegram', {
|
|
4412
4412
|
title: 'Post to Telegram',
|
|
4413
|
-
description: "Use this when the user wants to post to a Telegram channel or group. Publishing costs no credits. Publish to a Telegram channel, group or chat as the brand's own bot. WHICH CHAT IS ALWAYS REQUIRED AND IS NEVER GUESSED: pass chatId as the public channel's @username (e.g. @
|
|
4413
|
+
description: "Use this when the user wants to post to a Telegram channel or group. Publishing costs no credits. Publish to a Telegram channel, group or chat as the brand's own bot. WHICH CHAT IS ALWAYS REQUIRED AND IS NEVER GUESSED: pass chatId as the public channel's @username (e.g. @yourchannel) or its numeric id (a group is negative; a supergroup or channel starts with -100). There is no default and there cannot be one — the Telegram Bot API publishes no method that lists the chats a bot belongs to, so Hermoso genuinely cannot know them. list_telegram_chats reports the chats that have MESSAGED the bot in the last 24 hours, which is a shortcut for finding an id and is NOT a roster: a chat missing from it can still be posted to. TEXT: up to 4096 characters on a text-only message, but only 1024 the moment ANY photo or video is attached — a caption is not a message, and Hermoso refuses the over-long one before spending the round trip and says which budget applied. MEDIA: one image (imageUrl), one video (videoUrl), or an ALBUM of 2–10 (imageUrls, in order) in which photos and videos may be MIXED — pass a videoUrl alongside imageUrls and it joins the album as one more item. Hermoso uploads the bytes rather than handing Telegram a link, which is what buys the larger ceilings: 10MB per photo and 50MB per video, where a link would be 5MB and 20MB. THE BOT MUST BE IN THE CHAT — an administrator with Post Messages for a channel, an unrestricted member for a group; if it is not, Telegram refuses and the error says to add it rather than to reconnect. Returns the message id and, for a PUBLIC chat, its t.me link — a private group has no public web link, so `url` comes back null rather than as a link that would 404 for whoever you hand it to. 0 credits. Connect at Settings > Connectors > Telegram.",
|
|
4414
4414
|
inputSchema: {
|
|
4415
4415
|
account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the profile has more than one telegram account connected (several and none named is refused by name, never guessed); omit when there is one."),
|
|
4416
4416
|
brand: HOOK_ATTR.brand,
|
|
@@ -4722,7 +4722,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4722
4722
|
const _rmLine = _rm ? `\nRemake engines (recast_motion, restyle_video, edit_video engine): auto picks, for a clip with no real-looking person, ${Object.entries(_rm.defaults || {}).map(([k, v]) => `${k} → ${v}`).join(', ')}; with a person in the clip, ${Object.entries(_rm.faceDefaults || {}).map(([k, v]) => `${k} → ${v}`).join(', ')}${_rm.personClipLibraryDefaults ? ` (a person clip our face library can carry, i.e. one of our renders or faceRoute on a paid plan: ${Object.entries(_rm.personClipLibraryDefaults).map(([k, v]) => `${k} → ${v}`).join(', ')})` : ''}. seedance ${(_rm.seconds || []).join('-')}s, up to ${_rm.imagesMax} pictures, ${Object.entries(_rm.seedanceCredits || {}).map(([res, m]) => `${res} ${Object.entries(m).map(([s, c]) => `${s}s ${c}cr`).join(' / ')}`).join('; ')}${_rm.wanCredits ? `. wan clips up to ${_rm.wanMaxSourceSeconds}s, ${Object.entries(_rm.wanCredits).map(([res, m]) => `${res} ${Object.entries(m).map(([s, c]) => `${s}s ${c}cr`).join(' / ')}`).join('; ')}${_rm.wanLabel ? ` (${_rm.wanLabel})` : ''}` : ''}${_rm.wanPrimeCredits ? `. wan-prime (Wan 3.0 Prime by name) ${Object.entries(_rm.wanPrimeCredits).map(([res, m]) => `${res} ${Object.entries(m).map(([s, c]) => `${s}s ${c}cr`).join(' / ')}`).join('; ')}` : ''}` : '';
|
|
4723
4723
|
const _je = d.jobEngines; // the certified engine per job (watched side-by-side tests, 2026-10-19)
|
|
4724
4724
|
const _jeLine = _je && _je.defaults ? `\nDefault engine per job (watched side-by-side tests): ${Object.entries(_je.defaults).map(([k, v]) => `${k} → ${v}`).join(', ')}; a talking ad is one take of up to ${_je.talkingMaxSeconds}s. A model the user picks by name is kept; never switch it.` : '';
|
|
4725
|
-
const text = `Image: ${d.image ? img : 'unavailable'}\nVideo: ${d.video ? vid : 'unavailable'}\n${_lenLine}\nExtend a clip (generate_video extend:true + refVideo): by default it cuts to a NEW SHOT of what happens next (new angle, same scene, any i2v model, default ${d.jobEngines?.defaults?.extend || 'certified'})${_extLine ? `; extendMode:'continue' keeps the SAME take instead, on: ${_extLine}` : ''}${_shotLine ? `\nMulti-shot clips (generate_video shots): ${_shotLine}` : ''}${_rsLine ? `\nRestyle looks (restyle_video style): ${_rsLine}` : ''}${_rmLine}${_jeLine}\n${_pickLine}\n${_resLine}${d.pricing ? `\nPRICES ABOVE ARE THIS ACCOUNT'S OWN: ${d.pricing.memberRate ? 'no markup on image, video and voice models (the model maker\'s price
|
|
4725
|
+
const text = `Image: ${d.image ? img : 'unavailable'}\nVideo: ${d.video ? vid : 'unavailable'}\n${_lenLine}\nExtend a clip (generate_video extend:true + refVideo): by default it cuts to a NEW SHOT of what happens next (new angle, same scene, any i2v model, default ${d.jobEngines?.defaults?.extend || 'certified'})${_extLine ? `; extendMode:'continue' keeps the SAME take instead, on: ${_extLine}` : ''}${_shotLine ? `\nMulti-shot clips (generate_video shots): ${_shotLine}` : ''}${_rsLine ? `\nRestyle looks (restyle_video style): ${_rsLine}` : ''}${_rmLine}${_jeLine}\n${_pickLine}\n${_resLine}${d.pricing ? `\nPRICES ABOVE ARE THIS ACCOUNT'S OWN: ${d.pricing.memberRate ? 'no markup on image, video and voice models (exactly the model maker\'s price, 1 credit = $0.02; no fee is added to a run or at checkout)' : 'standard prices (every paid plan pays exactly the model maker\'s price on image, video and voice models; model rows carry memberCredits)'}.` : ''}${_motionLine}${_avLine}\nVoice engines (generate_voice): ${voice}\nWriting models (generate_text): ${llm}\nPRICES: every credit figure above is an estimate. ${PRICE_ESTIMATE_LINE}\ncanEdit:${d.canEdit} canAvatar:${d.canAvatar}\nRecipes (${(d.recipes || []).length}): ${(d.recipes || []).slice(0, 20).map(r => r.id).join(', ')}…\n\n${CAPABILITY_MAP}`; return ok(text + connLine, d);
|
|
4726
4726
|
}));
|
|
4727
4727
|
|
|
4728
4728
|
server.registerTool('hermoso_credits', {
|
|
@@ -4764,7 +4764,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4764
4764
|
quote_token: z.string().optional().describe('the quoteToken returned by the quote step — REQUIRED (with confirm:true) to charge; it binds the exact pack + price you quoted (10-minute validity) and makes a retried confirm idempotent'),
|
|
4765
4765
|
},
|
|
4766
4766
|
outputSchema: {
|
|
4767
|
-
packs: z.array(z.any()).optional().describe('available credit packs ({id, credits, priceUsd}) when listing'),
|
|
4767
|
+
packs: z.array(z.any()).optional().describe('available credit packs ({id, credits, priceUsd}) when listing; credits are what THIS account is granted'),
|
|
4768
4768
|
quote: z.any().optional().describe('the one-click charge quote ({packId, credits, priceUsd, card}) awaiting confirm:true'),
|
|
4769
4769
|
ok: z.boolean().optional().describe('true when a one-click top-up charge succeeded'),
|
|
4770
4770
|
credits: z.number().optional().describe('credits added by a completed top-up (or bought by the checkout link)'),
|
|
@@ -4775,9 +4775,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4775
4775
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, // confirm:true charges the saved card (one-click top-up); link path charges nothing
|
|
4776
4776
|
}, wrap(async ({ pack, confirm, quote_token }) => {
|
|
4777
4777
|
const cfg = await apiGet('/api/billing/config');
|
|
4778
|
+
// NOTHING IS ADDED AT CHECKOUT (2026-10-27): a pack's price is what the card is charged, and /api/billing/config hands
|
|
4779
|
+
// each pack with the credits THIS account is granted (server-decided; see docs/fee-at-checkout-2026-10-27.md). The quote
|
|
4780
|
+
// binds both, so the human is told exactly what they pay and what they get.
|
|
4778
4781
|
const packs = (cfg.packs || []).map(p => ({ id: p.id, credits: p.credits, priceUsd: p.priceUsd }));
|
|
4782
|
+
const priceText = (p) => `$${p.priceUsd}`;
|
|
4779
4783
|
if (!pack) {
|
|
4780
|
-
const lines = packs.map(p => `• ${p.id} — ${p.credits.toLocaleString()} credits ·
|
|
4784
|
+
const lines = packs.map(p => `• ${p.id} — ${p.credits.toLocaleString()} credits · ${priceText(p)}`).join('\n') || '(no packs configured)';
|
|
4781
4785
|
let _up = null; try { _up = (await apiGet('/api/billing/status'))?.planUpsell || null; } catch {}
|
|
4782
4786
|
return ok(`Credit packs you can buy:\n${lines}\n\nCall buy_credits again with pack="<id>". With a saved card it's a one-click charge (you'll be asked to confirm); otherwise you get a checkout link for your human.${_up ? `\n\n${_up.line} ${_up.note} Top-up credits use standard prices; upgrade_plan lists the monthly plans.` : ''}\n\nTip: to never run out again, turn on auto-reload (set_auto_reload) — it auto-tops-up from the saved card the moment your balance runs low.`, { packs });
|
|
4783
4787
|
}
|
|
@@ -4798,21 +4802,21 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4798
4802
|
const mintQuote = () => {
|
|
4799
4803
|
const t = `qt1|${match.id}|${match.priceUsd}|${Math.floor(Date.now() / 1000) + 600}|${(globalThis.crypto?.randomUUID?.() || String(Date.now())).slice(0, 8)}`;
|
|
4800
4804
|
_mintedQuotes.add(t); if (_mintedQuotes.size > 50) _mintedQuotes.delete(_mintedQuotes.values().next().value); // bounded
|
|
4801
|
-
return ok(`Ready to charge ${card}
|
|
4805
|
+
return ok(`Ready to charge ${card} ${priceText(match)} for ${match.credits.toLocaleString()} credits (one-click, no redirect — same as the app's Add credits button). Confirm with your human if they haven't already asked for this, then call buy_credits again with pack="${match.id}", confirm:true and quote_token="${t}" (valid 10 minutes).`, { quote: { packId: match.id, credits: match.credits, priceUsd: match.priceUsd, card: st.card || null, quoteToken: t, expiresInSeconds: 600 } });
|
|
4802
4806
|
};
|
|
4803
4807
|
if (!confirm || !quote_token) return mintQuote();
|
|
4804
4808
|
const qp = String(quote_token).split('|');
|
|
4805
4809
|
if (!_mintedQuotes.has(String(quote_token)) || qp[0] !== 'qt1' || qp.length < 5 || qp[1] !== match.id || Number(qp[3]) < Math.floor(Date.now() / 1000) || Number(qp[2]) !== match.priceUsd) return mintQuote(); // UNMINTED (forged/other-process — c3d6081 review: format-only validation was trivially forgeable, the token MUST come from a real quote in THIS process; a restart just re-quotes) / expired / wrong-pack / price-moved → fresh quote, never a surprise charge
|
|
4806
4810
|
let d;
|
|
4807
|
-
try { d = await apiPost('/api/billing/topup', { packId: match.id, idempotencyKey: String(quote_token), expectedPriceUsd: match.priceUsd }); } // expectedPriceUsd: server-side price binding (409s if the catalog moved under the quote)
|
|
4811
|
+
try { d = await apiPost('/api/billing/topup', { packId: match.id, idempotencyKey: String(quote_token), expectedPriceUsd: match.priceUsd, expectedCredits: match.credits }); } // expectedPriceUsd: server-side price binding (409s if the catalog moved under the quote)
|
|
4808
4812
|
catch (e) {
|
|
4809
4813
|
if (e?.status === 403) return ok(`This key doesn't have billing-admin rights on the account, so it can't charge the saved card. Ask a workspace admin to top up (the app’s Billing page → Add credits, or their own buy_credits call).`, { packs });
|
|
4810
4814
|
throw e;
|
|
4811
4815
|
}
|
|
4812
|
-
return ok(`Done — charged ${card}
|
|
4816
|
+
return ok(`Done — charged ${card} ${priceText(match)}; ${match.credits.toLocaleString()} credits are on the account now. (Receipt lands in Billing → invoice history.)`, d);
|
|
4813
4817
|
}
|
|
4814
4818
|
const d = await apiPost('/api/billing/checkout-link', { packId: pack });
|
|
4815
|
-
return ok(`Checkout link for ${match.credits.toLocaleString()} credits ($${d.amountUsd ?? match.priceUsd}):\n${d.url}\n\nGive this URL to your human to pay on Stripe's secure page — credits post automatically once payment completes, and their card saves for one-click top-ups (in-app AND via this tool) from then on. Nothing is charged until they pay.`, d);
|
|
4819
|
+
return ok(`Checkout link for ${Number(d.credits ?? match.credits).toLocaleString()} credits ($${d.amountUsd ?? match.priceUsd}):\n${d.url}\n\nGive this URL to your human to pay on Stripe's secure page — credits post automatically once payment completes, and their card saves for one-click top-ups (in-app AND via this tool) from then on. Nothing is charged until they pay.`, d);
|
|
4816
4820
|
}));
|
|
4817
4821
|
|
|
4818
4822
|
// BILLING SURFACE (read → top-up → plan/auto-reload): hermoso_credits (balance) → buy_credits (top-up link) →
|
|
@@ -4883,7 +4887,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4883
4887
|
: d.paymentMethodOnFile ? `yes${d.card ? ` (${d.card.brand} ····${d.card.last4})` : ''}` : 'no';
|
|
4884
4888
|
const _per = d.plan?.period === 'yr' ? 'yr' : 'mo';
|
|
4885
4889
|
const _price = _per === 'yr' ? (d.plan?.priceUsd ?? d.plan?.monthlyUsd) : (d.plan?.monthlyUsd ?? d.plan?.priceUsd);
|
|
4886
|
-
const _pr = d.plan?.memberRate ? `\nPricing: no markup on image, video and voice models (the model maker\'s price
|
|
4890
|
+
const _pr = d.plan?.memberRate ? `\nPricing: no markup on image, video and voice models (exactly the model maker\'s price, 1 credit = $0.02; no fee is added to a run or at checkout)${d.plan?.legacyPrice ? '; this plan keeps its legacy price (upgrade_plan lists the plans on sale; once left it cannot be chosen again)' : ''}` : d.plan?.nextRenewal ? `\nPricing: standard until the next renewal; from then on the same price with no markup on models and ${d.plan.nextRenewal.credits} credits a month` : (d.plan?.monthlyUsd || 0) > 0 ? '' : d.planUpsell ? `\nPricing: standard. ${d.planUpsell.line} ${d.planUpsell.note} Free and top-up credits use standard prices.` : '';
|
|
4887
4891
|
const text = `Plan: ${d.plan?.label} ($${_price}/${_per})${_pr}\nBalance: ${d.balanceCredits} credits\nAuto-reload: ${arLine}\nCard on file: ${cardLine}\nYour billing role: ${d.role}${d.isAdmin ? ' — you can change the plan / auto-reload' : ' — read-only; ask an admin to change the plan or auto-reload'}${withheld && d.billingNote ? `\n${d.billingNote}` : ''}`;
|
|
4888
4892
|
return ok(text, d);
|
|
4889
4893
|
}));
|
|
@@ -4944,6 +4948,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4944
4948
|
thresholdCredits: z.number().nullable().optional().describe('reload triggers below this balance'),
|
|
4945
4949
|
reloadCredits: z.number().nullable().optional().describe('credits added per reload'),
|
|
4946
4950
|
reloadPack: z.any().optional().describe('the pack charged on each reload'),
|
|
4951
|
+
reloadUsd: z.number().optional().describe('what each reload charges the card'),
|
|
4947
4952
|
capUsd: z.any().optional().describe('monthly auto-reload spend cap in USD, if set'),
|
|
4948
4953
|
status: z.string().optional().describe('auto-reload status detail'),
|
|
4949
4954
|
guidance: z.string().optional().describe('instructions when the change must be made in the app'),
|
|
@@ -4952,7 +4957,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4952
4957
|
}, wrap(async ({ enabled, thresholdCredits, reloadCredits }) => {
|
|
4953
4958
|
const d = await apiPost('/api/billing/autoreload-config', { enabled, thresholdCredits, reloadCredits });
|
|
4954
4959
|
if (d.needsCard) return ok(d.guidance || 'Add a card on file first (in the app), then auto-reload can use it.', d);
|
|
4955
|
-
if (d.applied) return ok(`Auto-reload ${d.enabled ? `ON — reloads${d.reloadCredits != null ? ' +' + d.reloadCredits.toLocaleString() + ' credits' : ''} when the balance drops below ${d.thresholdCredits} credits` : 'OFF'}.`, d);
|
|
4960
|
+
if (d.applied) return ok(`Auto-reload ${d.enabled ? `ON — reloads${d.reloadCredits != null ? ' +' + d.reloadCredits.toLocaleString() + ' credits' : ''}${Number(d.reloadUsd) > 0 ? ` ($${d.reloadUsd} each time)` : ''} when the balance drops below ${d.thresholdCredits} credits` : 'OFF'}.`, d);
|
|
4956
4961
|
return ok(d.guidance || 'Manage auto-reload in the app: Billing → Auto-reload.', d);
|
|
4957
4962
|
}));
|
|
4958
4963
|
|
|
@@ -5717,22 +5722,6 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5717
5722
|
}));
|
|
5718
5723
|
|
|
5719
5724
|
server.group('channel_admin');
|
|
5720
|
-
// ── FIND A PLACE TO TAG ON FACEBOOK / INSTAGRAM (2026-10-27, gap sweep I-23). Meta's Pages search, opened to an app
|
|
5721
|
-
// only after Page Public Metadata Access is approved; until then the route answers with that state in words.
|
|
5722
|
-
server.registerTool('search_meta_places', {
|
|
5723
|
-
title: 'Find a place to tag on Facebook or Instagram',
|
|
5724
|
-
description: 'Find a PLACE to tag on a Facebook or Instagram post by name — returns Facebook Page ids that have a location (name + address), to pass as place (Facebook) or instagramLocationId / locationId (Instagram) on post_to_meta / schedule_post. Use it when the brand has a physical location (a shop, salon, restaurant, venue) or the user names one. Uses Meta\'s Pages search, which Meta opens only after approving Hermoso\'s Page Public Metadata Access feature (pending) — until then it says so. Free.',
|
|
5725
|
-
inputSchema: {
|
|
5726
|
-
q: z.string().describe('the place name, ideally with its city, e.g. "Blue Bottle Coffee Hayes Valley"'),
|
|
5727
|
-
limit: z.number().int().min(1).max(25).optional().describe('how many (1–25, default 10)'),
|
|
5728
|
-
},
|
|
5729
|
-
outputSchema: { query: z.string().optional(), count: z.number().optional(), places: z.array(z.any()).optional(), note: z.string().optional() },
|
|
5730
|
-
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
5731
|
-
}, wrap(async (a) => {
|
|
5732
|
-
const d = await apiGet('/api/meta/places', { q: a.q, ...(a.limit ? { limit: a.limit } : {}) });
|
|
5733
|
-
const lines = (d.places || []).map(p => `• ${p.name}${p.address ? ` — ${p.address}` : ''} — id ${p.id}`);
|
|
5734
|
-
return ok(d.count ? `${d.count} place(s) for “${d.query}”:\n${lines.join('\n')}\n${d.note}` : d.note, d);
|
|
5735
|
-
}));
|
|
5736
5725
|
server.registerTool('search_threads_locations', {
|
|
5737
5726
|
title: 'Find a place to tag on Threads',
|
|
5738
5727
|
description: 'Search Threads’ public place index by name (or by latitude+longitude) and get location ids. Use this when the brand has a PHYSICAL location — a restaurant, salon, gym, store — so the post can be geotagged to it. Pass the chosen id as post_to_meta(locationId) with target:"threads".',
|
|
@@ -5994,6 +5983,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5994
5983
|
instagramPoll: z.array(z.string()).optional().describe('INSTAGRAM — attach a POLL: 2 to 4 answers of 1–25 characters each, and the caption IS the question (so a caption is required). Feed photos, carousels and Reels only (never a story), one caption add-on per post (not with instagramCommentPrompt), and only on an account connected through Meta (a Facebook Page with a linked Instagram). WRITE-ONCE: Instagram cannot add, change or remove it after publishing, so show the user the answers first.'),
|
|
5995
5984
|
instagramPollExtended: z.boolean().optional().describe('INSTAGRAM — give that poll Instagram’s extended voting duration instead of its default 3 days (Instagram’s changelog says it then stays open indefinitely). Only with instagramPoll.'),
|
|
5996
5985
|
instagramCommentPrompt: z.boolean().optional().describe('INSTAGRAM — make the caption a COMMENT PROMPT that people answer in the comments. Same limits as instagramPoll, and never together with it.'),
|
|
5986
|
+
firstComment: z.string().optional().describe('FIRST COMMENT — posted on the new post as the same account right after it publishes (Facebook or Instagram; not Threads, not a story, not an unpublished post). Up to 2,000 characters, refused rather than cut. If the comment fails the post stays live and firstComment in the result says why.'),
|
|
5997
5987
|
shareToFeed: z.boolean().optional().describe('INSTAGRAM REEL — true puts the Reel in the Feed grid as well as the Reels tab. REELS ONLY. Left unset it follows Instagram’s own default; Hermoso does not flip it either way on the user’s behalf.'),
|
|
5998
5988
|
audioName: z.string().optional().describe('INSTAGRAM REEL — the name of the Reel’s audio track, which is what viewers tap through to. REELS ONLY.'),
|
|
5999
5989
|
audioId: z.string().optional().describe('INSTAGRAM REEL — put one of Instagram’s OWN licensed music tracks under the Reel: the `id` search_instagram_audio returns. Instagram mixes it in when the Reel is published; nothing is downloaded or re-rendered. REELS ONLY, and only on an Instagram account connected through Meta (a Facebook Page with a linked Instagram), which is Instagram’s own rule — the Instagram connector cannot take it and is refused by name.'),
|
|
@@ -6155,6 +6145,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6155
6145
|
instagramPoll: z.array(z.string()).optional().describe('INSTAGRAM — attach a POLL: 2 to 4 answers of 1–25 characters each, and the caption IS the question (so a caption is required). Feed photos, carousels and Reels only (never a story), one caption add-on per post (not with instagramCommentPrompt), and only on an account connected through Meta (a Facebook Page with a linked Instagram). WRITE-ONCE: Instagram cannot add, change or remove it after publishing, so show the user the answers first.'),
|
|
6156
6146
|
instagramPollExtended: z.boolean().optional().describe('INSTAGRAM — give that poll Instagram’s extended voting duration instead of its default 3 days (Instagram’s changelog says it then stays open indefinitely). Only with instagramPoll.'),
|
|
6157
6147
|
instagramCommentPrompt: z.boolean().optional().describe('INSTAGRAM — make the caption a COMMENT PROMPT that people answer in the comments. Same limits as instagramPoll, and never together with it.'),
|
|
6148
|
+
firstComment: z.string().optional().describe('FIRST COMMENT — posted on each new post as the same account right after it publishes, on Facebook, Instagram and LinkedIn company Pages (LinkedIn needs its comments authorization, provider linkedin_community). Other channels publish without it and their result row says so. Up to 2,000 characters, refused rather than cut; never on a story. A failed comment never undoes the post.'),
|
|
6158
6149
|
shareToFeed: z.boolean().optional().describe('INSTAGRAM REEL — true puts the Reel in the Feed grid as well as the Reels tab. REELS ONLY. Left unset it follows Instagram’s own default; Hermoso does not flip it either way on the user’s behalf.'),
|
|
6159
6150
|
audioName: z.string().optional().describe('INSTAGRAM REEL — the name of the Reel’s audio track, which is what viewers tap through to. REELS ONLY.'),
|
|
6160
6151
|
audioId: z.string().optional().describe('INSTAGRAM REEL — put one of Instagram’s OWN licensed music tracks under the Reel: the `id` search_instagram_audio returns. Instagram mixes it in when the Reel is published; nothing is downloaded or re-rendered. REELS ONLY, and only on an Instagram account connected through Meta (a Facebook Page with a linked Instagram), which is Instagram’s own rule — the Instagram connector cannot take it and is refused by name.'),
|
|
@@ -6168,7 +6159,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6168
6159
|
// ── WHICH ACCOUNT (server-side SCHED_ID_FIELDS). Every one of these is an answer the publish helper REFUSES
|
|
6169
6160
|
// to guess, so a schedule that cannot carry it can only fail at fire time with nobody watching.
|
|
6170
6161
|
boardId: z.string().optional().describe('PINTEREST — REQUIRED whenever pinterest is a channel: the board the Pin goes on, from list_pinterest_boards. The user picks it; a Pin on the wrong board is a public mistake. Scheduling pinterest without one is refused immediately.'),
|
|
6171
|
-
chatId: z.string().optional().describe('TELEGRAM — REQUIRED whenever telegram is a channel: WHICH chat, group or channel the bot posts to. A public channel’s @username (@
|
|
6162
|
+
chatId: z.string().optional().describe('TELEGRAM — REQUIRED whenever telegram is a channel: WHICH chat, group or channel the bot posts to. A public channel’s @username (@yourchannel) or the numeric id (a group is negative; a supergroup or channel starts with -100). There is no default and there cannot be one — the Telegram Bot API publishes no method that lists a bot’s chats — so scheduling telegram without one is refused up front. list_telegram_chats finds ids for chats that have messaged the bot in the last 24 hours.'),
|
|
6172
6163
|
buttons: z.array(z.union([z.object({ text: z.string(), url: z.string(), style: z.enum(['danger', 'success', 'primary']).optional() }), z.array(z.object({ text: z.string(), url: z.string(), style: z.enum(['danger', 'success', 'primary']).optional() }))])).optional().describe('TELEGRAM ONLY — URL buttons under the post, as post_to_telegram takes them; never on an album.'),
|
|
6173
6164
|
parseMode: z.enum(['HTML']).optional().describe("TELEGRAM ONLY — 'HTML' for Telegram's HTML tags; omit for plain text."),
|
|
6174
6165
|
silent: z.boolean().optional().describe('TELEGRAM ONLY — no notification sound.'),
|
|
@@ -6331,6 +6322,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6331
6322
|
instagramPoll: z.array(z.string()).optional().describe('INSTAGRAM — attach a POLL: 2 to 4 answers of 1–25 characters each, and the caption IS the question (so a caption is required). Feed photos, carousels and Reels only (never a story), one caption add-on per post (not with instagramCommentPrompt), and only on an account connected through Meta (a Facebook Page with a linked Instagram). WRITE-ONCE: Instagram cannot add, change or remove it after publishing, so show the user the answers first.'),
|
|
6332
6323
|
instagramPollExtended: z.boolean().optional().describe('INSTAGRAM — give that poll Instagram’s extended voting duration instead of its default 3 days (Instagram’s changelog says it then stays open indefinitely). Only with instagramPoll.'),
|
|
6333
6324
|
instagramCommentPrompt: z.boolean().optional().describe('INSTAGRAM — make the caption a COMMENT PROMPT that people answer in the comments. Same limits as instagramPoll, and never together with it.'),
|
|
6325
|
+
firstComment: z.string().optional().describe('FIRST COMMENT — posted on each new post as the same account right after it publishes, on Facebook, Instagram and LinkedIn company Pages (LinkedIn needs its comments authorization, provider linkedin_community). Other channels publish without it and their result row says so. Up to 2,000 characters, refused rather than cut; never on a story. A failed comment never undoes the post.'),
|
|
6334
6326
|
shareToFeed: z.boolean().optional().describe('INSTAGRAM REEL — whether the Reel also shows in the Feed grid.'),
|
|
6335
6327
|
audioName: z.string().optional().describe('INSTAGRAM REEL — replaces the audio track name; an empty string removes it.'),
|
|
6336
6328
|
audioId: z.string().optional().describe('INSTAGRAM REEL — put one of Instagram’s OWN licensed music tracks under the Reel: the `id` search_instagram_audio returns. Instagram mixes it in when the Reel is published; nothing is downloaded or re-rendered. REELS ONLY; an empty string removes it, and only on an Instagram account connected through Meta (a Facebook Page with a linked Instagram), which is Instagram’s own rule — the Instagram connector cannot take it and is refused by name.'),
|
|
@@ -8723,7 +8715,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8723
8715
|
title: 'Subscribe a Facebook Page to real-time events',
|
|
8724
8716
|
description: 'Have Meta PUSH events to Hermoso instead of Hermoso polling for them. Subscribe a Page to `feed` (comments, posts, likes and shares — this is what makes a new comment ARRIVE rather than be polled for), `mention` (someone mentions the Page), `leadgen` (a lead-ad form submission delivered the moment it happens, which turns "read the leads when asked" into "the leads arrive"), and `messages` (an inbound Messenger or Instagram DM — THE ONLY WAY to be told about one, and Meta’s 24-hour reply window starts the moment it arrives, so being told rather than polling is time on the clock). Read what has arrived with list_meta_webhook_events. THE ANSWER IS READ BACK FROM META, never the 200: Meta can accept a subscription and record FEWER fields than you asked for, and a caller told "subscribed to leadgen" whose leads never arrive has no way to discover why — so any field Meta did not record is named. Fields Hermoso does not consume (message_reactions, messaging_postbacks, message_echoes) are refused by name with the reason, so an agent gets a fact rather than "unsupported". 0 credits.',
|
|
8725
8717
|
inputSchema: {
|
|
8726
|
-
fields: z.array(z.enum(['feed', 'mention', 'leadgen', 'messages', 'marketing_messages', 'marketing_message_echoes', 'marketing_message_deliveries', 'marketing_message_reads', 'marketing_message_clicks', 'marketing_message_delivery_failed'])).describe('what to be told about. `marketing_messages` is shorthand for the five marketing_message_* outcome fields (sent / delivered / read / clicked / failed of a paid Messenger marketing message, read with get_messenger_marketing_message_status). Pass every field the Page should keep: Meta does not document whether a subscribe merges or replaces the list, and the read-back shows what it recorded. At least one — Meta marks it required, and defaulting it would decide on the brand’s behalf what they hear about.'),
|
|
8718
|
+
fields: z.array(z.enum(['feed', 'mention', 'leadgen', 'messages', 'marketing_messages', 'marketing_message_echoes', 'marketing_message_deliveries', 'marketing_message_reads', 'marketing_message_clicks', 'marketing_message_delivery_failed'])).describe('what to be told about. `marketing_messages` is shorthand for the five marketing_message_* outcome fields (sent / delivered / read / clicked / failed of a paid Messenger marketing message, read with get_messenger_marketing_message_status). Pass every field the Page should keep: Meta does not document whether a subscribe merges or replaces the list, and the read-back shows what it recorded. DM automation’s own fields (messaging_postbacks, messaging_referrals) are kept when already present, so subscribing never breaks it. At least one — Meta marks it required, and defaulting it would decide on the brand’s behalf what they hear about.'),
|
|
8727
8719
|
pageId: z.string().optional().describe('Facebook Page id — omit when only one Page is connected'),
|
|
8728
8720
|
},
|
|
8729
8721
|
outputSchema: { pageId: z.string().optional(), page: z.string().optional(), asked: z.array(z.string()).optional(), subscribed: z.boolean().nullable().optional(), fields: z.array(z.string()).optional(), summary: z.string().optional(), retry: z.string().optional(), callback: z.string().optional() },
|
|
@@ -9655,10 +9647,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9655
9647
|
server.group('analytics');
|
|
9656
9648
|
const shopifyMoney = z.object({ amount: z.number().describe('MAJOR units: 40 means 40.00'), currency: z.string().describe('3-letter, e.g. USD') });
|
|
9657
9649
|
server.registerTool('record_shopify_marketing_activity', {
|
|
9658
|
-
title: 'Record an ad or campaign in Shopify
|
|
9650
|
+
title: 'Record an ad or campaign in Shopify > Marketing',
|
|
9659
9651
|
description: "Record an ad or campaign the merchant is running (on Meta, Google, TikTok, email, anywhere) in their Shopify admin under Marketing, so Shopify's own reports attribute storefront visits and sales to it by its UTM. REPORTING ONLY: it publishes nothing and spends nothing. Pass title, remoteUrl (where the ad is managed, e.g. the Ads Manager link), the utm {campaign, source, medium} the ad's links carry, and optionally tactic (AD default, RETARGETING, POST, LINK, NEWSLETTER, …), channel (SOCIAL default, SEARCH, DISPLAY, EMAIL, REFERRAL), status (ACTIVE default, PAUSED, SCHEDULED, INACTIVE, DELETED_EXTERNALLY), budget {type DAILY|LIFETIME, amount, currency}, adSpend {amount, currency}, start / end / scheduledEnd dates and previewImageUrl. remoteId is YOUR stable id for the activity (the ad platform's campaign id is ideal); calling again with the same remoteId UPDATES that one row, and every field you leave out is KEPT from the existing record (Shopify's own upsert would erase it; the reply lists what was kept). Read back from Shopify. Needs the store to have approved Hermoso's marketing permission: if it has not, the reply says to open Hermoso once from the Shopify admin to approve it. Only works for accounts created by installing Hermoso from the Shopify App Store. Free.",
|
|
9660
9652
|
inputSchema: {
|
|
9661
|
-
title: z.string().optional().describe('what the merchant sees in Shopify
|
|
9653
|
+
title: z.string().optional().describe('what the merchant sees in Shopify > Marketing; required on the first recording'),
|
|
9662
9654
|
remoteUrl: z.string().optional().describe('https link where this ad or campaign is managed or viewed; required on the first recording'),
|
|
9663
9655
|
remoteId: z.string().optional().describe('your stable id for this activity (e.g. the ad platform campaign id); derived from platform + campaignId or the utm campaign when omitted'),
|
|
9664
9656
|
platform: z.string().optional().describe('where it runs, e.g. meta, google_ads, tiktok_ads; only used to derive remoteId'),
|
|
@@ -9677,7 +9669,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9677
9669
|
}, wrap(async (a) => { const d = await apiPost('/api/shopify/marketing-activity', a); return ok(d.note, d); }));
|
|
9678
9670
|
server.registerTool('list_shopify_marketing_activities', {
|
|
9679
9671
|
title: 'List Shopify marketing activities',
|
|
9680
|
-
description: "The marketing activities recorded in the merchant's Shopify
|
|
9672
|
+
description: "The marketing activities recorded in the merchant's Shopify > Marketing by Hermoso (and other apps): title, tactic, channel, status, UTM, budget, ad spend, dates and the remoteId each was recorded under. Filter by remoteId or a Shopify search query (e.g. title:summer). Newest first; pass cursor for more. Read-only, free. Needs the store to have approved Hermoso's marketing permission. Only works for accounts created by installing Hermoso from the Shopify App Store.",
|
|
9681
9673
|
inputSchema: { limit: z.number().optional().describe('1–100, default 25'), cursor: z.string().optional(), remoteId: z.string().optional(), query: z.string().optional().describe('Shopify search syntax, e.g. title:summer or tactic:AD') },
|
|
9682
9674
|
outputSchema: { shop: z.string().optional(), ok: z.boolean().optional(), count: z.number().optional(), activities: z.array(z.any()).optional(), pageInfo: z.any().optional(), note: z.string().optional() },
|
|
9683
9675
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -9687,7 +9679,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9687
9679
|
}));
|
|
9688
9680
|
server.registerTool('delete_shopify_marketing_activity', {
|
|
9689
9681
|
title: 'Delete a Shopify marketing activity',
|
|
9690
|
-
description: "Remove a marketing activity Hermoso recorded from the merchant's Shopify
|
|
9682
|
+
description: "Remove a marketing activity Hermoso recorded from the merchant's Shopify > Marketing, by remoteId or id. Shopify stops attributing visits and sales to it and it cannot be undone, so without confirm:true NOTHING is deleted and the reply names exactly what would go: show the user, get a yes, then call again with confirm:true. Read back afterwards. Only works for accounts created by installing Hermoso from the Shopify App Store. Free.",
|
|
9691
9683
|
inputSchema: { remoteId: z.string().optional(), id: z.string().optional().describe('gid://shopify/MarketingActivity/…'), confirm: z.boolean().optional() },
|
|
9692
9684
|
outputSchema: { shop: z.string().optional(), ok: z.boolean().optional(), deleted: z.boolean().optional(), id: z.string().optional(), title: z.string().optional(), note: z.string().optional() },
|
|
9693
9685
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
@@ -10620,7 +10612,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10620
10612
|
}));
|
|
10621
10613
|
server.registerTool('google_ads_recommendations', {
|
|
10622
10614
|
title: 'Google Ads recommendations + optimization score',
|
|
10623
|
-
description: 'Google Ads’ own advice for the account: the optimization score (account and per campaign), every open recommendation with what applying it would change (budget $X
|
|
10615
|
+
description: 'Google Ads’ own advice for the account: the optimization score (account and per campaign), every open recommendation with what applying it would change (budget $X -> $Y, a keyword, a bid target, a Google-written ad) and Google’s weekly impact estimate, each tagged spend (changes what the account buys) or serves (changes what an ad shows), plus which types Google is auto-applying by itself. An empty list means Google has no advice yet, never that the account is optimal. Ids go stale fast, so act on a fresh read. Read-only, 0 credits.',
|
|
10624
10616
|
inputSchema: {
|
|
10625
10617
|
customerId: z.string().optional().describe('10-digit account id, omit for the brand’s default account'),
|
|
10626
10618
|
campaignId: z.string().optional().describe('only recommendations for this campaign'),
|
|
@@ -12253,7 +12245,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12253
12245
|
}));
|
|
12254
12246
|
server.registerTool('create_mixpanel_annotation', {
|
|
12255
12247
|
title: 'Mark the Mixpanel timeline',
|
|
12256
|
-
description: "MARK THE MIXPANEL TIMELINE: write what just happened into Mixpanel at the moment it happened, so every chart afterwards shows it in place. The same closed loop as create_posthog_annotation and create_amplitude_annotation, worth reaching for unprompted after a campaign goes live, a creative is swapped or a landing page changes. `date` is \"YYYY-MM-DD HH:mm:ss\" (Mixpanel's own format, charted in the PROJECT's timezone), \"YYYY-MM-DD\", or an ISO timestamp with its offset (
|
|
12248
|
+
description: "MARK THE MIXPANEL TIMELINE: write what just happened into Mixpanel at the moment it happened, so every chart afterwards shows it in place. The same closed loop as create_posthog_annotation and create_amplitude_annotation, worth reaching for unprompted after a campaign goes live, a creative is swapped or a landing page changes. `date` is \"YYYY-MM-DD HH:mm:ss\" (Mixpanel's own format, charted in the PROJECT's timezone), \"YYYY-MM-DD\", or an ISO timestamp with its offset (written in the project's timezone so the mark sits at that exact instant, read back to prove it); omit it for NOW. tags are numeric annotation-tag ids. The stored row is read back. Mixpanel only lets a service account with at least the ANALYST role write annotations; a lower role is refused by name, and that is a role change in Mixpanel, not a reconnect. 0 credits. Needs Mixpanel connected.",
|
|
12257
12249
|
inputSchema: { description: z.string().describe('the text shown on the chart, e.g. "Meta: summer UGC campaign went live"'), date: z.string().optional().describe('"YYYY-MM-DD HH:mm:ss", "YYYY-MM-DD" or ISO with offset; default now'), tags: z.array(z.number()).optional().describe('annotation-tag ids') },
|
|
12258
12250
|
outputSchema: { project: z.string().optional(), created: z.boolean().optional(), annotation: z.any().optional(), dateNote: z.string().optional(), warning: z.string().optional() },
|
|
12259
12251
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
@@ -12646,8 +12638,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12646
12638
|
}));
|
|
12647
12639
|
server.registerTool('create_hubspot_contact', {
|
|
12648
12640
|
title: 'Create or update a HubSpot contact',
|
|
12649
|
-
description: "Create a contact in the brand's HubSpot by email, or UPDATE the contact that already has that email — HubSpot's duplicate answer carries the existing id, so this is an UPSERT and never makes a twin. Optional firstname, lastname, phone, company, website, lifecyclestage, hs_lead_status, plus any other writable contact property in `properties` (by internal name). The contact is READ BACK after the write; the reply says created or updated. Free. Needs HubSpot connected.",
|
|
12650
|
-
inputSchema: { email: z.string().describe('the contact email — the key HubSpot de-duplicates on'), firstname: z.string().optional(), lastname: z.string().optional(), phone: z.string().optional(), company: z.string().optional(), website: z.string().optional(), lifecyclestage: z.string().optional(), hs_lead_status: z.string().optional(), properties: z.record(z.any()).optional().describe('any other writable contact properties, by internal name') },
|
|
12641
|
+
description: "Create a contact in the brand's HubSpot by email, or UPDATE the contact that already has that email — HubSpot's duplicate answer carries the existing id, so this is an UPSERT and never makes a twin. Optional firstname, lastname, phone, company, website, lifecyclestage, hs_lead_status, ownerId (the sales rep to assign it to — an owner id from list_hubspot_owners), plus any other writable contact property in `properties` (by internal name). The contact is READ BACK after the write; the reply says created or updated. Free. Needs HubSpot connected.",
|
|
12642
|
+
inputSchema: { email: z.string().describe('the contact email — the key HubSpot de-duplicates on'), firstname: z.string().optional(), lastname: z.string().optional(), phone: z.string().optional(), company: z.string().optional(), website: z.string().optional(), lifecyclestage: z.string().optional(), hs_lead_status: z.string().optional(), ownerId: z.string().optional().describe('HubSpot owner id (digits) from list_hubspot_owners'), properties: z.record(z.any()).optional().describe('any other writable contact properties, by internal name') },
|
|
12651
12643
|
outputSchema: { ok: z.boolean().optional(), action: z.string().optional(), contact: z.any().optional(), note: z.string().optional() },
|
|
12652
12644
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
12653
12645
|
}, wrap(async (a) => { const d = await apiPost('/api/hubspot/contact', a); return ok(d.note, d); }));
|
|
@@ -12663,13 +12655,33 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12663
12655
|
}));
|
|
12664
12656
|
server.registerTool('hubspot_deals_report', {
|
|
12665
12657
|
title: 'HubSpot deals report',
|
|
12666
|
-
description: "Deals from the brand's HubSpot created in the window (default the last 90 days): count and amount by stage (labels resolved through the deal pipelines, closed-won recognised from the stage's own metadata), by original source, BY CAMPAIGN (source
|
|
12658
|
+
description: "Deals from the brand's HubSpot created in the window (default the last 90 days): count and amount by stage (labels resolved through the deal pipelines, closed-won recognised from the stage's own metadata), by original source, BY CAMPAIGN (source -> platform -> campaign from HubSpot's Original Source drill-downs, so PAID_SOCIAL splits into the actual campaigns), BY OWNER (the sales rep holding each deal, by name), a monthly created / closed-won series, and totals. Capped at 2,000 deals and says so. Read-only, free. Needs HubSpot connected.",
|
|
12667
12659
|
inputSchema: { since: z.string().optional().describe('YYYY-MM-DD, default 90 days ago'), until: z.string().optional().describe('YYYY-MM-DD, default today') },
|
|
12668
|
-
outputSchema: { ok: z.boolean().optional(), since: z.string().optional(), until: z.string().optional(), byStage: z.array(z.any()).optional(), bySource: z.array(z.any()).optional(), byCampaign: z.array(z.any()).optional(), monthly: z.array(z.any()).optional(), totals: z.any().optional(), capped: z.boolean().optional(), note: z.string().optional() },
|
|
12660
|
+
outputSchema: { ok: z.boolean().optional(), since: z.string().optional(), until: z.string().optional(), byStage: z.array(z.any()).optional(), bySource: z.array(z.any()).optional(), byCampaign: z.array(z.any()).optional(), byOwner: z.array(z.any()).optional(), ownersNote: z.string().optional(), monthly: z.array(z.any()).optional(), totals: z.any().optional(), capped: z.boolean().optional(), note: z.string().optional() },
|
|
12669
12661
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12670
12662
|
}, wrap(async (a) => {
|
|
12671
12663
|
const d = await apiPost('/api/hubspot/deals/report', a);
|
|
12672
|
-
return ok([d.note, `By stage: ${(d.byStage || []).map(x => `${x.pipeline ? x.pipeline + ' / ' : ''}${x.stage} ${x.count} (${x.amount})`).join('; ') || 'none'}`, `By source: ${(d.bySource || []).map(x => `${x.source} ${x.count} (${x.amount})`).join('; ') || 'none'}`, `By campaign: ${(d.byCampaign || []).slice(0, 15).map(x => `${x.source} / ${x.platform} / ${x.campaign} ${x.count} (${x.amount})`).join('; ') || 'none'}`, `Monthly: ${(d.monthly || []).map(m => `${m.month} created ${m.created} (${m.createdAmount}), won ${m.closedWon} (${m.closedWonAmount})`).join('; ') || 'none'}`].join('\n'), d);
|
|
12664
|
+
return ok([d.note, `By stage: ${(d.byStage || []).map(x => `${x.pipeline ? x.pipeline + ' / ' : ''}${x.stage} ${x.count} (${x.amount})`).join('; ') || 'none'}`, `By source: ${(d.bySource || []).map(x => `${x.source} ${x.count} (${x.amount})`).join('; ') || 'none'}`, `By campaign: ${(d.byCampaign || []).slice(0, 15).map(x => `${x.source} / ${x.platform} / ${x.campaign} ${x.count} (${x.amount})`).join('; ') || 'none'}`, `By owner: ${(d.byOwner || []).map(x => `${x.owner} ${x.count} (${x.amount})`).join('; ') || 'none'}${d.ownersNote ? ' — ' + d.ownersNote : ''}`, `Monthly: ${(d.monthly || []).map(m => `${m.month} created ${m.created} (${m.createdAmount}), won ${m.closedWon} (${m.closedWonAmount})`).join('; ') || 'none'}`].join('\n'), d);
|
|
12665
|
+
}));
|
|
12666
|
+
server.registerTool('list_hubspot_companies', {
|
|
12667
|
+
title: 'List or search HubSpot companies',
|
|
12668
|
+
description: "COMPANIES (accounts) from the brand's OWN HubSpot CRM, newest first: name, domain, website, LinkedIn company page, industry, employees, annual revenue, lifecycle stage, lead status, country/city, owner (rep name), original source + drill-downs, associated contacts and deals, total revenue — plus a de-duplicated `domains` list, which is what LinkedIn company targeting and account-based (ABM) audiences are built from. search matches name / domain / website / phone; filters lifecycleStage (e.g. customer, opportunity, salesqualifiedlead), industry (HubSpot's internal value, e.g. COMPUTER_SOFTWARE), ownerId, createdSince (YYYY-MM-DD). Read-only, free. Needs HubSpot connected.",
|
|
12669
|
+
inputSchema: { search: z.string().optional().describe('name, domain, website or phone'), lifecycleStage: z.string().optional().describe('internal value, e.g. customer, opportunity, salesqualifiedlead, lead'), industry: z.string().optional().describe("HubSpot's internal industry value, e.g. COMPUTER_SOFTWARE"), ownerId: z.string().optional().describe('owner id from list_hubspot_owners'), createdSince: z.string().optional().describe('YYYY-MM-DD'), limit: z.number().optional().describe('1–100, default 25'), after: z.string().optional().describe('pagination cursor from the previous page') },
|
|
12670
|
+
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), total: z.number().nullable().optional(), companies: z.array(z.any()).optional(), domains: z.array(z.string()).optional(), after: z.string().nullable().optional(), ownersNote: z.string().optional(), note: z.string().optional() },
|
|
12671
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12672
|
+
}, wrap(async (a) => {
|
|
12673
|
+
const d = await apiGet('/api/hubspot/companies', a);
|
|
12674
|
+
return ok(d.count ? `${d.note}\n${d.companies.map(c => `• ${c.name || '(no name)'}${c.domain ? ` ${c.domain}` : ''}${c.lifecycleStage ? ` · ${c.lifecycleStage}` : ''}${c.industry ? ` · ${c.industry}` : ''}${c.employees != null ? ` · ${c.employees} staff` : ''}${c.ownerName || c.ownerId ? ` · owner ${c.ownerName || c.ownerId}` : ''}${c.source ? ` · ${c.source}` : ''}${c.deals ? ` · ${c.deals} deal(s)` : ''} (${c.id})`).join('\n')}${d.after ? '\n(more: pass after=' + d.after + ')' : ''}${d.ownersNote ? '\n' + d.ownersNote : ''}` : d.note, d);
|
|
12675
|
+
}));
|
|
12676
|
+
server.registerTool('list_hubspot_owners', {
|
|
12677
|
+
title: 'List HubSpot owners (sales reps)',
|
|
12678
|
+
description: "The sales reps (OWNERS) in the brand's HubSpot: id, name, email, teams. An owner's id is what create_hubspot_contact's ownerId assigns a lead to, and what list_hubspot_companies filters by. search narrows by name or email; archived:true lists DEACTIVATED owners instead. Read-only, free. Needs HubSpot connected.",
|
|
12679
|
+
inputSchema: { search: z.string().optional().describe('name or email contains'), archived: z.boolean().optional().describe('true = deactivated owners only') },
|
|
12680
|
+
outputSchema: { ok: z.boolean().optional(), archived: z.boolean().optional(), count: z.number().optional(), owners: z.array(z.any()).optional(), capped: z.boolean().optional(), note: z.string().optional() },
|
|
12681
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12682
|
+
}, wrap(async (a) => {
|
|
12683
|
+
const d = await apiGet('/api/hubspot/owners', a);
|
|
12684
|
+
return ok(d.count ? `${d.note}\n${d.owners.map(o => `• ${o.name}${o.email ? ` <${o.email}>` : ''}${o.teams.length ? ` · ${o.teams.map(t => t.name).join(', ')}` : ''} (id ${o.id})`).join('\n')}` : d.note, d);
|
|
12673
12685
|
}));
|
|
12674
12686
|
server.registerTool('get_hubspot_lead_sync', {
|
|
12675
12687
|
title: 'Read the automatic HubSpot lead sync',
|
|
@@ -12824,49 +12836,6 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12824
12836
|
outputSchema: { workspace: z.string().optional(), days: z.number().optional(), read: z.number().optional(), snippets: z.array(z.any()).optional(), note: z.string().optional() },
|
|
12825
12837
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12826
12838
|
}, wrap(async (a) => { const d = await apiPost('/api/intercom/customer-language', a); return ok(`${d.note || ''}${(d.snippets || []).length ? `\n${d.snippets.map((s) => `- "${s.text}"`).join('\n')}` : ''}`, d); }));
|
|
12827
|
-
// ---------- Intercom HELP CENTER + NEWS (2026-10-27): the same /api/intercom routes the Studio tools call ----------
|
|
12828
|
-
// Drafts are written freely; anything a customer SEES (publish, unpublish, a live edit, a delete) needs confirm:true
|
|
12829
|
-
// after the preview the route returns. Every write is read back from Intercom. Rules: lib/intercom-content.mjs.
|
|
12830
|
-
const icArtLine = (x) => `"${x.title || '(untitled)'}" (${x.id}), ${x.state}${x.hasUnpublishedChanges ? ', changes staged' : ''}${x.url ? `: ${x.url}` : ''}`;
|
|
12831
|
-
const icNewsLine = (x) => `"${x.title || '(untitled)'}" (${x.id}), ${x.state}${(x.newsfeedIds || []).length ? ` | newsfeed ${x.newsfeedIds.join(', ')}` : ' | in no newsfeed'}${(x.labels || []).length ? ` | labels: ${x.labels.join(', ')}` : ''}`;
|
|
12832
|
-
const icContentText = (d) => {
|
|
12833
|
-
if (d.dryRun) return `${d.note || ''}\n${d.preview || ''}`;
|
|
12834
|
-
if (d.what === 'articles') return d.count ? `${d.count} Help Center article(s) (page ${d.page} of ${d.totalPages}):\n${d.articles.map((x) => `- ${icArtLine(x)}${x.preview ? `\n ${x.preview}` : ''}`).join('\n')}${d.nextPage ? `\n(more: pass page=${d.nextPage})` : ''}` : 'This Intercom workspace has no Help Center articles. That is a real empty list, not a failed read.';
|
|
12835
|
-
if (d.what === 'collections') return d.count ? `${d.count} collection(s):\n${d.collections.map((c) => `- ${c.name} (${c.id})`).join('\n')}` : 'No Help Center collections yet.';
|
|
12836
|
-
if (d.what === 'article') { const x = d.article || {}; return `${d.draft ? 'STAGED DRAFT of ' : ''}${icArtLine(x)}${x.description ? `\n${x.description}` : ''}\n\n${x.body || '(empty)'}`; }
|
|
12837
|
-
if (d.what === 'news') return d.count ? `${d.count} news item(s) (page ${d.page} of ${d.totalPages}):\n${d.items.map((x) => `- ${icNewsLine(x)}${x.preview ? `\n ${x.preview}` : ''}`).join('\n')}${d.nextPage ? `\n(more: pass page=${d.nextPage})` : ''}` : 'No news items in this Intercom workspace. That is a real empty list, not a failed read.';
|
|
12838
|
-
if (d.what === 'newsfeeds') return d.count ? `${d.count} newsfeed(s): ${d.newsfeeds.map((f) => `${f.name} (${f.id})`).join(', ')}` : 'This Intercom workspace has no newsfeeds.';
|
|
12839
|
-
if (d.what === 'news_item') { const x = d.item || {}; return `${icNewsLine(x)}\n\n${x.body || '(empty)'}`; }
|
|
12840
|
-
return d.note || '';
|
|
12841
|
-
};
|
|
12842
|
-
server.registerTool('list_intercom_articles', {
|
|
12843
|
-
title: 'Read the Intercom Help Center',
|
|
12844
|
-
description: `The brand's Intercom HELP CENTER. what:"articles" (default) lists articles newest-updated first: title, state (published or draft), whether changes are staged on a live article, link, preview. what:"article" + articleId reads one in full; what:"draft" + articleId reads the STAGED changes on a published article; what:"collections" lists the collections an article can sit in. page / perPage to page through. Needs the token's "Read and List articles" permission. Read-only, free. ${icNeeds}`,
|
|
12845
|
-
inputSchema: { what: z.enum(['articles', 'article', 'draft', 'collections']).optional(), articleId: z.string().optional(), page: z.number().optional(), perPage: z.number().optional().describe('1-150, default 25') },
|
|
12846
|
-
outputSchema: { workspace: z.string().optional(), what: z.string().optional(), count: z.number().optional(), articles: z.array(z.any()).optional(), collections: z.array(z.any()).optional(), article: z.any().optional(), nextPage: z.number().nullable().optional() },
|
|
12847
|
-
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12848
|
-
}, wrap(async (a) => { const d = await apiGet('/api/intercom/articles', a); return ok(icContentText(d), d); }));
|
|
12849
|
-
server.registerTool('manage_intercom_article', {
|
|
12850
|
-
title: 'Write an Intercom Help Center article',
|
|
12851
|
-
description: `Write a Help Center article in the brand's Intercom. action:"create" makes a DRAFT — title, body (markdown) or bodyHtml, description, collectionId, authorId (default: the connected teammate) — that nobody outside the team sees. action:"update" edits a draft in place; on a PUBLISHED article it STAGES the change as a draft, so the live article is untouched until it is published. action:"publish" puts a draft or the staged changes on the PUBLIC Help Center (and in Fin's answers), "unpublish" takes it off (it stays a draft), "delete" removes it: those three return a preview first and need confirm:true once the user has seen it. Every result is read back from Intercom. Needs "Read and Write Articles". Free. ${icNeeds}`,
|
|
12852
|
-
inputSchema: { action: z.enum(['create', 'update', 'publish', 'unpublish', 'delete']).optional().describe('default: create without articleId, update with one'), articleId: z.string().optional(), title: z.string().optional(), description: z.string().optional(), body: z.string().optional().describe('the article in markdown'), bodyHtml: z.string().optional().describe('the article in HTML, instead of body'), collectionId: z.string().optional(), authorId: z.string().optional(), locales: z.array(z.string()).optional().describe('multilingual Help Centers: the locales whose staged changes to publish'), confirm: z.boolean().optional() },
|
|
12853
|
-
outputSchema: { workspace: z.string().optional(), ok: z.boolean().optional(), action: z.string().optional(), dryRun: z.boolean().optional(), confirmed: z.boolean().optional(), staged: z.boolean().optional(), deleted: z.boolean().optional(), article: z.any().optional(), preview: z.string().optional(), note: z.string().optional() },
|
|
12854
|
-
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
12855
|
-
}, wrap(async (a) => { const d = await apiPost('/api/intercom/article', { ...a, confirm: a.confirm === true }); return ok(icContentText(d), d); }));
|
|
12856
|
-
server.registerTool('list_intercom_news', {
|
|
12857
|
-
title: 'Read Intercom news items',
|
|
12858
|
-
description: `The brand's Intercom NEWS — in-app announcements in the Messenger newsfeed. what:"news" (default) lists news items newest first with state (draft or live), newsfeeds and labels; what:"item" + newsItemId reads one in full; what:"newsfeeds" lists the newsfeeds an item can be published to. Needs "Read and list news items and newsfeeds". Read-only, free. ${icNeeds}`,
|
|
12859
|
-
inputSchema: { what: z.enum(['news', 'item', 'newsfeeds']).optional(), newsItemId: z.string().optional(), page: z.number().optional(), perPage: z.number().optional().describe('1-150, default 25') },
|
|
12860
|
-
outputSchema: { workspace: z.string().optional(), what: z.string().optional(), count: z.number().optional(), items: z.array(z.any()).optional(), newsfeeds: z.array(z.any()).optional(), item: z.any().optional(), nextPage: z.number().nullable().optional() },
|
|
12861
|
-
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12862
|
-
}, wrap(async (a) => { const d = await apiGet('/api/intercom/news', a); return ok(icContentText(d), d); }));
|
|
12863
|
-
server.registerTool('manage_intercom_news_item', {
|
|
12864
|
-
title: 'Write an Intercom news item',
|
|
12865
|
-
description: `Write an in-app NEWS item (a launch or offer announcement) in the brand's Intercom. action:"create" makes a DRAFT: title, body (plain text) or bodyHtml, labels, reactions (at most 5, one emoji each), coverImageUrl (.png or .jpg), deliverSilently, newsfeedIds, senderId (default: the connected teammate). action:"update" edits it and KEEPS its labels, reactions and newsfeeds unless you pass new ones (Intercom itself would clear them); editing a LIVE item needs confirm:true. action:"publish" (needs newsfeedIds, or a newsfeed it already has), "unpublish" and "delete" return a preview first and need confirm:true once the user has seen it. Read back from Intercom. Needs "Read and write news items and newsfeeds". Free. ${icNeeds}`,
|
|
12866
|
-
inputSchema: { action: z.enum(['create', 'update', 'publish', 'unpublish', 'delete']).optional().describe('default: create without newsItemId, update with one'), newsItemId: z.string().optional(), title: z.string().optional(), body: z.string().optional().describe('plain text'), bodyHtml: z.string().optional(), labels: z.array(z.string()).optional().describe('replaces the labels'), reactions: z.array(z.string()).optional().describe('replaces the reactions; at most 5'), newsfeedIds: z.array(z.string()).optional().describe('replaces the newsfeeds (list_intercom_news what:"newsfeeds")'), coverImageUrl: z.string().optional(), deliverSilently: z.boolean().optional(), senderId: z.string().optional(), confirm: z.boolean().optional() },
|
|
12867
|
-
outputSchema: { workspace: z.string().optional(), ok: z.boolean().optional(), action: z.string().optional(), dryRun: z.boolean().optional(), confirmed: z.boolean().optional(), deleted: z.boolean().optional(), item: z.any().optional(), preview: z.string().optional(), note: z.string().optional() },
|
|
12868
|
-
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
12869
|
-
}, wrap(async (a) => { const d = await apiPost('/api/intercom/news-item', { ...a, confirm: a.confirm === true }); return ok(icContentText(d), d); }));
|
|
12870
12839
|
server.group('ads'); // end of the measurement block — back to paid-campaign management
|
|
12871
12840
|
// ---------- Microsoft Advertising (Bing Ads): read + manage. Same spend law as Google — everything is created
|
|
12872
12841
|
// Paused, only an explicit confirm:true arms real money, and every narration comes from a READ-BACK.
|
|
@@ -12884,7 +12853,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12884
12853
|
// callers written before the rename; /v1 still refuses it by name and the server reads adAccountId first.
|
|
12885
12854
|
const msAcct = {
|
|
12886
12855
|
adAccountId: z.string().optional().describe('Microsoft ad account id — omit to use the single shared account'),
|
|
12887
|
-
accountId: z.string().optional().describe('older name for adAccountId
|
|
12856
|
+
accountId: z.string().optional().describe('older name for adAccountId (MCP only)'),
|
|
12888
12857
|
};
|
|
12889
12858
|
const msKeywordShape = z.array(z.object({
|
|
12890
12859
|
text: z.string().describe('≤100 characters'),
|
|
@@ -13695,6 +13664,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13695
13664
|
return ok(`${d.note}\n${(d.results || []).map(r => `• ${r.canonicalName || r.name} — id ${r.id} (${r.type}${r.countryCode ? `, ${r.countryCode}` : ''})`).join('\n')}`, d);
|
|
13696
13665
|
}));
|
|
13697
13666
|
server.group('ads');
|
|
13667
|
+
// X ADS NAMES ITS AD ACCOUNT adAccountId (2026-10-27). Over the REST API (/v1) `accountId` names a Hermoso workspace and
|
|
13668
|
+
// the tenancy belt refuses it in every body, so an X Ads tool that took `accountId` could not be called over /v1 at
|
|
13669
|
+
// all. `accountId` stays accepted over MCP as the older name; the server reads adAccountId first (xAdsNamedAccount).
|
|
13670
|
+
const xAcct = {
|
|
13671
|
+
adAccountId: z.string().optional().describe('X ad account id (list_x_ads_accounts); omit if only one is shared'),
|
|
13672
|
+
accountId: z.string().optional().describe('older name for adAccountId (MCP only)'),
|
|
13673
|
+
};
|
|
13698
13674
|
server.registerTool('list_x_ads_accounts', {
|
|
13699
13675
|
title: 'List X ad accounts',
|
|
13700
13676
|
description: 'List the X (Twitter) ad accounts this profile can act on, with the PERMISSION LEVEL held on each so you can tell an admin grant from a read-only one before attempting a write. X grants API access PER AD ACCOUNT, not per app: the customer adds Hermoso’s X user to their ad account at business.x.com -> Account access, and it appears here. Read-only, free.',
|
|
@@ -13707,8 +13683,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13707
13683
|
}, wrap(async () => { const d = await apiGet('/api/x-ads/shared-accounts', {}); return ok(d.note, d); }));
|
|
13708
13684
|
server.registerTool('list_x_ads_campaigns', {
|
|
13709
13685
|
title: 'List X ads campaigns',
|
|
13710
|
-
description: 'List campaigns on an X ad account — status, budgets, and whether X considers each servable. Omit
|
|
13711
|
-
inputSchema: {
|
|
13686
|
+
description: 'List campaigns on an X ad account — status, budgets, and whether X considers each servable. Omit adAccountId when only one account is reachable and it resolves itself. Read-only, free.',
|
|
13687
|
+
inputSchema: { ...xAcct, limit: z.number().optional() },
|
|
13712
13688
|
outputSchema: { accountId: z.string().optional(), count: z.number().optional(), campaigns: z.array(z.any()).optional(), note: z.string().optional() },
|
|
13713
13689
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
13714
13690
|
}, wrap(async (a) => {
|
|
@@ -13719,7 +13695,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13719
13695
|
title: 'Build an X ads campaign (paused)',
|
|
13720
13696
|
description: 'Create a campaign on X (Twitter). ALWAYS CREATED PAUSED with no override — it spends NOTHING until set_x_ads_status(confirm:true). A CAMPAIGN ALONE CANNOT SERVE ON X: it needs a line item and a promoted post underneath it, and the read-back says so rather than letting you call it a finished ad. Requires a funding instrument (a payment method on the X ad account) — omit fundingInstrumentId to be shown the usable ones, and if there are none this refuses with that reason instead of failing at X. Budgets are in the ad account’s own currency. Everything is READ BACK from X before you are told it exists; print the returned note verbatim.',
|
|
13721
13697
|
inputSchema: {
|
|
13722
|
-
|
|
13698
|
+
...xAcct,
|
|
13723
13699
|
name: z.string(),
|
|
13724
13700
|
fundingInstrumentId: z.string().optional().describe('omit to be shown the account’s usable funding instruments'),
|
|
13725
13701
|
dailyBudget: z.number().optional().describe('in the ad account’s currency'),
|
|
@@ -13734,7 +13710,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13734
13710
|
title: 'Pause or activate an X campaign or line item',
|
|
13735
13711
|
description: 'Pause or ACTIVATE an X ads CAMPAIGN (campaignId) or ONE LINE ITEM inside it (lineItemId) — pass exactly one. ACTIVATING STARTS REAL SPEND on the next auction, so it requires confirm:true — this is the only switch on X that arms money. Tell the user the budget and what will start spending BEFORE you pass confirm. DELIVERY ON X IS THE AND OF BOTH LEVELS: an ACTIVE line item under a PAUSED campaign serves nothing, so the result reads the PARENT back too and states whether anything can actually spend rather than letting you infer it. Pausing a line item is the REVERSIBLE way to take one ad group out of delivery — deleting it is not.',
|
|
13736
13712
|
inputSchema: {
|
|
13737
|
-
|
|
13713
|
+
...xAcct,
|
|
13738
13714
|
campaignId: z.string().optional().describe('the whole campaign — pass this OR lineItemId'),
|
|
13739
13715
|
lineItemId: z.string().optional().describe('one ad group — pass this OR campaignId'),
|
|
13740
13716
|
status: z.enum(['ACTIVE', 'PAUSED']),
|
|
@@ -14190,13 +14166,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14190
14166
|
|
|
14191
14167
|
server.registerTool('create_apple_ads_creative', {
|
|
14192
14168
|
title: 'Create an Apple Ads creative',
|
|
14193
|
-
description: 'Create an Apple Ads creative — what a person sees before and after tapping the ad. Pick creativeType: DEFAULT_PRODUCT_PAGE renders the app’s standard App Store listing and needs only adamId; CUSTOM_PRODUCT_PAGE renders a custom page built in App Store Connect and needs adamId AND productPageId (from list_apple_ads_product_pages); LOCAL_ADS_SEARCH_CREATIVE is Apple Maps and needs
|
|
14169
|
+
description: 'Create an Apple Ads creative — what a person sees before and after tapping the ad. Pick creativeType: DEFAULT_PRODUCT_PAGE renders the app’s standard App Store listing and needs only adamId; CUSTOM_PRODUCT_PAGE renders a custom page built in App Store Connect and needs adamId AND productPageId (from list_apple_ads_product_pages); LOCAL_ADS_SEARCH_CREATIVE is Apple Maps and needs mapsBrandId (from list_apple_ads_brands) plus assetIds you uploaded with upload_apple_ads_asset — that is the one creative type where YOUR OWN IMAGE is the ad. YOU CANNOT SUPPLY YOUR OWN IMAGERY FOR AN APP STORE AD — Apple renders the pre-tap ad from the product page itself, so the way to change how an App Store ad LOOKS is to edit that page (or build a Custom Product Page) in App Store Connect; say that plainly rather than looking for a parameter that does not exist. The destination type is derived from the creative type and never asked for. A creative SPENDS NOTHING and has no status of its own: it serves only once an ad references it and that ad, its ad group and its campaign are all ENABLED. Apple validates asynchronously, so a fresh creative is usually PENDING for a moment and an ad may only reference a VALID one — that is a wait, not a failure. Type and destination are immutable afterwards. Free.',
|
|
14194
14170
|
inputSchema: {
|
|
14195
14171
|
name: z.string().describe('REQUIRED.'),
|
|
14196
14172
|
creativeType: z.enum(['DEFAULT_PRODUCT_PAGE', 'CUSTOM_PRODUCT_PAGE', 'LOCAL_ADS_SEARCH_CREATIVE']).optional().describe('Default DEFAULT_PRODUCT_PAGE.'),
|
|
14197
14173
|
adamId: z.string().optional().describe('REQUIRED for App Store creatives — the App Store id.'),
|
|
14198
14174
|
productPageId: z.string().optional().describe('REQUIRED for CUSTOM_PRODUCT_PAGE; refused on DEFAULT_PRODUCT_PAGE, which is the app’s standard listing.'),
|
|
14199
|
-
|
|
14175
|
+
mapsBrandId: z.string().optional().describe('Apple Maps only — the brand this creative belongs to.'), brandId: z.string().optional().describe('older name for mapsBrandId (MCP only)'),
|
|
14200
14176
|
creativeSubtype: z.enum(['BUSINESS_LOGO', 'BUSINESS_ASSET']).optional().describe('Apple Maps only. Default BUSINESS_LOGO.'),
|
|
14201
14177
|
assetIds: z.array(z.string()).optional().describe('Apple Maps only — ids from upload_apple_ads_asset.'),
|
|
14202
14178
|
localizedText: z.record(z.any()).optional().describe('Apple Maps only — promo copy per locale, e.g. {"en-US":"Visit us today"}.'),
|
|
@@ -14234,10 +14210,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14234
14210
|
|
|
14235
14211
|
server.registerTool('upload_apple_ads_asset', {
|
|
14236
14212
|
title: 'Upload an Apple Ads image asset',
|
|
14237
|
-
description: 'Upload an image into the Apple Ads asset library for an APPLE MAPS brand creative. APPLE MAPS ONLY, and that is Apple’s rule rather than ours: it refuses uploads for App Store apps outright, because an App Store ad is rendered from the app’s product page in App Store Connect and not from an image anyone uploads. That refusal is stated up front instead of being sent and rejected, so if you want a different-looking App Store ad the answer is App Store Connect, not this tool. PNG, JPG or HEIC. Pass imageUrl (upload_file turns a local file into one) and
|
|
14213
|
+
description: 'Upload an image into the Apple Ads asset library for an APPLE MAPS brand creative. APPLE MAPS ONLY, and that is Apple’s rule rather than ours: it refuses uploads for App Store apps outright, because an App Store ad is rendered from the app’s product page in App Store Connect and not from an image anyone uploads. That refusal is stated up front instead of being sent and rejected, so if you want a different-looking App Store ad the answer is App Store Connect, not this tool. PNG, JPG or HEIC. Pass imageUrl (upload_file turns a local file into one) and mapsBrandId, which list_apple_ads_brands returns. Apple processes the upload asynchronously, so re-read it with list_apple_ads_assets until its eligibility says it is ready before referencing it in a creative. Uploading spends nothing. Free.',
|
|
14238
14214
|
inputSchema: {
|
|
14239
14215
|
imageUrl: z.string().describe('REQUIRED — a PNG, JPG or HEIC image Hermoso can fetch.'),
|
|
14240
|
-
|
|
14216
|
+
mapsBrandId: z.string().optional().describe('REQUIRED: the Apple Maps brand this asset belongs to.'), brandId: z.string().optional().describe('older name for mapsBrandId (MCP only)'),
|
|
14241
14217
|
filename: z.string().optional().describe('Decides the format Apple is told; defaults to the name in the URL.'),
|
|
14242
14218
|
promotedObjectType: z.enum(['BUSINESS_BRAND']).optional().describe('Only BUSINESS_BRAND is accepted.'),
|
|
14243
14219
|
},
|
|
@@ -14247,10 +14223,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14247
14223
|
|
|
14248
14224
|
server.registerTool('list_apple_ads_assets', {
|
|
14249
14225
|
title: 'List Apple Ads image assets',
|
|
14250
|
-
description: 'Images in the Apple Ads asset library with format, orientation, dimensions and eligibility — use it to check whether an uploaded asset has finished processing before referencing it in a creative. Filter by
|
|
14226
|
+
description: 'Images in the Apple Ads asset library with format, orientation, dimensions and eligibility — use it to check whether an uploaded asset has finished processing before referencing it in a creative. Filter by mapsBrandId (Apple’s promotedObjectId), promotedObjectType or assetType, or pass id for one. CROPS AND OTHER VARIANTS NEVER APPEAR IN A LIST: Apple omits them from query results, so a variant has to be fetched by its own id. Querying covers both App Store apps and Apple Maps brands even though only Maps assets can be uploaded. Read-only, free.',
|
|
14251
14227
|
inputSchema: {
|
|
14252
14228
|
id: z.string().optional().describe('Fetch one asset — the only way to see a crop or variant.'),
|
|
14253
|
-
|
|
14229
|
+
mapsBrandId: z.string().optional().describe('Scope to one brand or app (promotedObjectId).'), brandId: z.string().optional().describe('older name for mapsBrandId (MCP only)'),
|
|
14254
14230
|
promotedObjectType: z.enum(['APPSTORE_APP', 'BUSINESS_BRAND']).optional(),
|
|
14255
14231
|
assetType: z.enum(['IMAGE']).optional(),
|
|
14256
14232
|
limit: z.number().optional(), offset: z.number().optional(),
|
|
@@ -14266,9 +14242,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14266
14242
|
// targeting, and Brand → Asset → Creative → Ad.
|
|
14267
14243
|
server.registerTool('list_apple_ads_brands', {
|
|
14268
14244
|
title: 'List Apple Maps brands',
|
|
14269
|
-
description: 'The Apple Maps brands on this Apple Ads account. A BRAND IS THE ROOT OF EVERY ADS-ON-APPLE-MAPS CAMPAIGN and its id is what three other calls need: it is the promotedObjectId of a BUSINESS_BRAND campaign, the
|
|
14245
|
+
description: 'The Apple Maps brands on this Apple Ads account. A BRAND IS THE ROOT OF EVERY ADS-ON-APPLE-MAPS CAMPAIGN and its id is what three other calls need: it is the promotedObjectId of a BUSINESS_BRAND campaign, the mapsBrandId upload_apple_ads_asset uploads against, and the mapsBrandId a LOCAL_ADS_SEARCH_CREATIVE carries. A brand must read eligibility ELIGIBLE before Apple will let a campaign use it. YOU CANNOT CREATE A BRAND HERE and that is Apple’s design, not a gap: brands come from Apple Business Connect, and Ads on Apple Maps additionally need the ad account to carry productFeatures BUSINESS_BRAND_MANUAL plus a BUSINESS_BRAND delegation, neither of which any API can grant. AN EMPTY LIST IS THE NORMAL ANSWER FOR AN APP STORE ADVERTISER and does not mean anything is broken — App Store campaigns need no brand at all. Read-only, free.',
|
|
14270
14246
|
inputSchema: {
|
|
14271
|
-
|
|
14247
|
+
mapsBrandId: z.string().optional().describe('Fetch one brand by id.'), brandId: z.string().optional().describe('older name for mapsBrandId (MCP only)'),
|
|
14272
14248
|
name: z.string().optional(), countryOrRegion: z.string().optional().describe('Two-letter code, e.g. "US".'),
|
|
14273
14249
|
eligibilityStatus: z.string().optional().describe('Usually ELIGIBLE — only an ELIGIBLE brand can be used in a campaign.'),
|
|
14274
14250
|
limit: z.number().optional(), offset: z.number().optional(),
|
|
@@ -14279,10 +14255,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14279
14255
|
|
|
14280
14256
|
server.registerTool('list_apple_ads_locations', {
|
|
14281
14257
|
title: 'List Apple Maps business locations',
|
|
14282
|
-
description: 'The physical business locations behind an Apple Maps brand — the shops, restaurants or service centres an Ads-on-Apple-Maps campaign promotes. Filter by
|
|
14258
|
+
description: 'The physical business locations behind an Apple Maps brand — the shops, restaurants or service centres an Ads-on-Apple-Maps campaign promotes. Filter by mapsBrandId (do: without it Apple returns every location across every brand, which can be a very large page), plus status, eligibility or address. LOCATIONS ARE READ-ONLY THROUGH THIS API BY APPLE’S DESIGN: Apple Business Connect creates and maintains them, so a missing shop, a wrong address or a closure is fixed there and appears here afterwards — there is no create, update or delete to look for. Only a location with status OPEN and eligibility ELIGIBLE is targetable. You do not target locations directly: collect the ids you want, put them in a location group with create_apple_ads_location_group, then point an ad group at that group. Read-only, free.',
|
|
14283
14259
|
inputSchema: {
|
|
14284
14260
|
id: z.string().optional().describe('Fetch one location.'),
|
|
14285
|
-
|
|
14261
|
+
mapsBrandId: z.string().optional().describe('Strongly recommended — scopes to one brand.'), brandId: z.string().optional().describe('older name for mapsBrandId (MCP only)'),
|
|
14286
14262
|
status: z.enum(['OPEN', 'OPENING_SOON', 'CLOSED', 'MOVED', 'TEMPORARILY_CLOSED']).optional().describe('Usually OPEN — only OPEN locations are targetable.'),
|
|
14287
14263
|
eligibilityStatus: z.enum(['ELIGIBLE', 'INELIGIBLE', 'LIMITED', 'PENDING', 'UNDEFINED']).optional(),
|
|
14288
14264
|
countryOrRegion: z.string().optional().describe('Two-letter code.'),
|
|
@@ -14296,9 +14272,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14296
14272
|
|
|
14297
14273
|
server.registerTool('list_apple_ads_location_groups', {
|
|
14298
14274
|
title: 'List Apple Maps location groups',
|
|
14299
|
-
description: 'The location groups on this Apple Ads account — named sets of a brand’s shops that an ad group targets. Each row carries its type (STATIC or DYNAMIC), Apple’s systemStatus and groupTotal, the number of locations currently in it. READ groupTotal BEFORE TARGETING A GROUP: a DYNAMIC group sits at PENDING while Apple evaluates its rules, and a group that evaluated to ZERO is Apple’s one silent failure here — it reports no error, so a rule spelled "CA" instead of "California" builds an empty group that looks fine. Soft-deleted groups are excluded unless you pass includeDeleted. There is no filter for "which groups contain this location": query by
|
|
14275
|
+
description: 'The location groups on this Apple Ads account — named sets of a brand’s shops that an ad group targets. Each row carries its type (STATIC or DYNAMIC), Apple’s systemStatus and groupTotal, the number of locations currently in it. READ groupTotal BEFORE TARGETING A GROUP: a DYNAMIC group sits at PENDING while Apple evaluates its rules, and a group that evaluated to ZERO is Apple’s one silent failure here — it reports no error, so a rule spelled "CA" instead of "California" builds an empty group that looks fine. Soft-deleted groups are excluded unless you pass includeDeleted. There is no filter for "which groups contain this location": query by mapsBrandId and inspect membership. Read-only, free.',
|
|
14300
14276
|
inputSchema: {
|
|
14301
|
-
id: z.string().optional().describe('Fetch one group.'), brandId: z.string().optional(),
|
|
14277
|
+
id: z.string().optional().describe('Fetch one group.'), mapsBrandId: z.string().optional(), brandId: z.string().optional().describe('older name for mapsBrandId (MCP only)'),
|
|
14302
14278
|
name: z.string().optional().describe('Substring match.'),
|
|
14303
14279
|
groupType: z.enum(['STATIC', 'DYNAMIC']).optional(),
|
|
14304
14280
|
includeDeleted: z.boolean().optional().describe('Deleted groups are hidden by default.'),
|
|
@@ -14310,10 +14286,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14310
14286
|
|
|
14311
14287
|
server.registerTool('create_apple_ads_location_group', {
|
|
14312
14288
|
title: 'Create an Apple Maps location group',
|
|
14313
|
-
description: 'Create a location group — the set of a brand’s shops an Apple Maps ad group promotes. TWO KINDS. STATIC takes an explicit locationIds list (from list_apple_ads_locations) and is VALID immediately. DYNAMIC takes rules and maintains itself as the brand opens and closes shops, but starts PENDING while Apple evaluates them — wait for systemStatus VALID before targeting it. A RULE THAT MATCHES NOTHING IS APPLE’S ONE SILENT FAILURE HERE: it returns no error and simply builds a group with groupTotal 0, so adminArea must be the FULL English name ("California", never "CA") and locality must be countryOrRegion|adminArea|locality ("US|New York|Brooklyn", never "Brooklyn") — both shapes are refused here before the group is built. A group SPENDS NOTHING and does not decide who sees the ad or how near they are: it only scopes WHICH shops the ad group promotes, while radius and geo targeting on the ad group decide reach.
|
|
14289
|
+
description: 'Create a location group — the set of a brand’s shops an Apple Maps ad group promotes. TWO KINDS. STATIC takes an explicit locationIds list (from list_apple_ads_locations) and is VALID immediately. DYNAMIC takes rules and maintains itself as the brand opens and closes shops, but starts PENDING while Apple evaluates them — wait for systemStatus VALID before targeting it. A RULE THAT MATCHES NOTHING IS APPLE’S ONE SILENT FAILURE HERE: it returns no error and simply builds a group with groupTotal 0, so adminArea must be the FULL English name ("California", never "CA") and locality must be countryOrRegion|adminArea|locality ("US|New York|Brooklyn", never "Brooklyn") — both shapes are refused here before the group is built. A group SPENDS NOTHING and does not decide who sees the ad or how near they are: it only scopes WHICH shops the ad group promotes, while radius and geo targeting on the ad group decide reach. mapsBrandId and the ad account are locked in permanently at creation. Target it afterwards with create_apple_ads_ad_group(targeting.locationGroupIds) or update_apple_ads_object(level:"adgroup"). Free.',
|
|
14314
14290
|
inputSchema: {
|
|
14315
14291
|
name: z.string().describe('REQUIRED.'),
|
|
14316
|
-
|
|
14292
|
+
mapsBrandId: z.string().optional().describe('REQUIRED: from list_apple_ads_brands. Immutable afterwards.'), brandId: z.string().optional().describe('older name for mapsBrandId (MCP only)'),
|
|
14317
14293
|
groupType: z.enum(['STATIC', 'DYNAMIC']).optional().describe('Default STATIC. Immutable afterwards.'),
|
|
14318
14294
|
locationIds: z.array(z.string()).optional().describe('REQUIRED for STATIC — ids from list_apple_ads_locations.'),
|
|
14319
14295
|
rules: z.array(z.object({ field: z.enum(['adminArea', 'locality', 'postalCode', 'locationId']), operator: z.enum(['EQUALS', 'NOT_EQUALS', 'IN', 'NOT_IN']), value: z.any() })).optional().describe('REQUIRED for DYNAMIC. value is a string for EQUALS/NOT_EQUALS and an array for IN/NOT_IN.'),
|
|
@@ -14596,7 +14572,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14596
14572
|
title: 'Change an X campaign budget or settings',
|
|
14597
14573
|
description: "Change a LIVE X campaign — budget, name, delivery pacing. THIS IS HOW YOU THROTTLE OR RAISE SPEND on a running campaign without rebuilding it, and lowering dailyBudget is the fastest way to slow money down short of pausing. Budgets are in the ad account's own currency. Only fields X actually applies are offered: startTime/endTime are DEPRECATED on an X campaign (its schedule lives on the LINE ITEM — use update_x_ads_line_item) and frequency capping needs an X account feature we cannot enable, so both are refused BY NAME with the reason instead of being sent and silently ignored. THE READ-BACK IS A DIFF against the before-state: a field X did not move is reported as REFUSED, never counted as applied. Print the returned note verbatim.",
|
|
14598
14574
|
inputSchema: {
|
|
14599
|
-
|
|
14575
|
+
...xAcct, campaignId: z.string().describe('from list_x_ads_campaigns'),
|
|
14600
14576
|
name: z.string().optional(),
|
|
14601
14577
|
dailyBudget: z.number().optional().describe("in the ad account's currency"),
|
|
14602
14578
|
totalBudget: z.number().optional(),
|
|
@@ -14610,7 +14586,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14610
14586
|
title: 'Change an X line item (ad group)',
|
|
14611
14587
|
description: "Change an X line item — BID, bid strategy, SCHEDULE, goal or name. An X campaign's start and end times are deprecated, so the schedule genuinely lives HERE. Lowering `bidAmount` is also the fix when X refuses a campaign budget with “Bid is too close to Budget”. NOT changeable after creation: `objective` and `productType` — X answers 200 and silently keeps the old value (measured), so both are refused BY NAME here and a different objective means a NEW line item. Changing `goal` also requires `bidAmount` (X refuses the goal alone) and that is stated up front rather than relayed. A line-item dailyBudget/totalBudget is only legal when the parent campaign is NOT budget-optimised — that is the campaign's setting, so those two are forwarded and X's own refusal names the remedy rather than Hermoso blocking a legal edit. THE READ-BACK IS A DIFF against an independent re-read: a field X accepted but did not move is reported as REFUSED. Print the returned note verbatim.",
|
|
14612
14588
|
inputSchema: {
|
|
14613
|
-
|
|
14589
|
+
...xAcct, lineItemId: z.string().describe('from list_x_ads_line_items'),
|
|
14614
14590
|
name: z.string().optional(),
|
|
14615
14591
|
bidAmount: z.number().optional().describe("in the ad account's currency"),
|
|
14616
14592
|
bidStrategy: z.enum(['AUTO', 'GUARANTEED', 'MAX', 'TARGET']).optional(),
|
|
@@ -14625,7 +14601,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14625
14601
|
server.registerTool('list_x_ads_promoted_tweets', {
|
|
14626
14602
|
title: 'List X promoted posts',
|
|
14627
14603
|
description: "List the promoted posts (the CREATIVES) attached to an X line item, or across the whole ad account. Two things only this can tell you: each post's APPROVAL STATUS, so an ad X rejected — which can never serve however the statuses are set — is visible rather than mysterious; and the promoted-tweet ID, which is the only way to remove one. NOTE a promoted post cannot be PAUSED on X (its PUT accepts only an approval appeal), so the reversible way to stop it is to pause its LINE ITEM. Read-only, free.",
|
|
14628
|
-
inputSchema: {
|
|
14604
|
+
inputSchema: { ...xAcct, lineItemId: z.string().optional().describe('scope to one line item'), limit: z.number().optional() },
|
|
14629
14605
|
outputSchema: { accountId: z.string().optional(), count: z.number().optional(), promotedTweets: z.array(z.any()).optional(), note: z.string().optional() },
|
|
14630
14606
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
14631
14607
|
}, wrap(async (a) => {
|
|
@@ -14635,7 +14611,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14635
14611
|
server.registerTool('list_x_ads_targeting', {
|
|
14636
14612
|
title: 'List an X line item’s targeting',
|
|
14637
14613
|
description: "List every targeting criterion an X line item carries, WITH THE ID of each — the id needed to remove one with delete_x_ads_object. READ THE EMPTY CASE CORRECTLY: no targeting criteria on X means the line item is UNRESTRICTED and will reach the broadest possible audience once ACTIVE — it does NOT mean it cannot serve. Read-only, free.",
|
|
14638
|
-
inputSchema: {
|
|
14614
|
+
inputSchema: { ...xAcct, lineItemId: z.string() },
|
|
14639
14615
|
outputSchema: { accountId: z.string().optional(), lineItemId: z.string().optional(), count: z.number().optional(), targeting: z.array(z.any()).optional(), note: z.string().optional() },
|
|
14640
14616
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
14641
14617
|
}, wrap(async (a) => {
|
|
@@ -14645,7 +14621,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14645
14621
|
server.registerTool('list_x_ads_funding_instruments', {
|
|
14646
14622
|
title: 'List X ad account payment methods',
|
|
14647
14623
|
description: "List the funding instruments (payment methods) on an X ad account — type, currency, credit limit, credit remaining, and whether each can currently fund a campaign. THIS IS THE ANSWER TO “why is my X campaign not delivering?” whenever the cause is a cancelled card or an exhausted credit line, which is invisible from the campaign itself, and it shows what a campaign will spend against BEFORE anyone activates it. Hermoso cannot add a payment method — that is done at ads.x.com. Read-only, free.",
|
|
14648
|
-
inputSchema: {
|
|
14624
|
+
inputSchema: { ...xAcct },
|
|
14649
14625
|
outputSchema: { accountId: z.string().optional(), count: z.number().optional(), usableCount: z.number().optional(), fundingInstruments: z.array(z.any()).optional(), note: z.string().optional() },
|
|
14650
14626
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
14651
14627
|
}, wrap(async (a) => { const d = await apiGet('/api/x-ads/funding-instruments', a); return ok(d.note, d); }));
|
|
@@ -14671,7 +14647,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14671
14647
|
title: 'Delete an X ads object (permanent, cascades)',
|
|
14672
14648
|
description: "PERMANENTLY delete an X campaign, line item, promoted post, promoted account or targeting criterion — or ARCHIVE a lead form (it stops collecting and its leads can no longer be exported, so run get_x_ads_leads first), or remove a card, a Media Library item (type media: id is the media_key) or a custom audience (custom_audience). X CASCADES AND PUBLISHES NO UNDO: deleting a campaign destroys its line items and their promoted posts too — verified live, the children answer 404 the moment the parent is deleted. RUN IT WITHOUT confirm FIRST: that deletes nothing and reports the REAL blast radius read back off X (what is underneath it, whether it is live, whether it has spent). Show the user exactly that, get an unambiguous yes, then call again with confirm:true — and, for anything with children / live delivery / real spend, also confirmName set to its exact name and confirmChildren set to the real count, because confirm:true alone proves you meant to delete SOMETHING and cannot prove you aimed at the right object. TO STOP DELIVERY WITHOUT DESTROYING ANYTHING use set_x_ads_status PAUSED, which is reversible — except for a promoted post, which cannot be paused on X at all, so pause its LINE ITEM instead. Removing a targeting criterion WIDENS the audience rather than narrowing it. The result is confirmed by re-reading the object, never by X's 200.",
|
|
14673
14649
|
inputSchema: {
|
|
14674
|
-
|
|
14650
|
+
...xAcct,
|
|
14675
14651
|
type: z.enum(['campaign', 'line_item', 'promoted_tweet', 'targeting_criterion', 'promoted_account', 'lead_form', 'card', 'media', 'custom_audience']),
|
|
14676
14652
|
id: z.string().describe('campaign/line-item id, or the id from list_x_ads_promoted_tweets / list_x_ads_targeting'),
|
|
14677
14653
|
lineItemId: z.string().optional().describe('REQUIRED for type "targeting_criterion" — X cannot look one up without its line item'),
|
|
@@ -14689,7 +14665,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14689
14665
|
server.registerTool('list_x_ads_line_items', {
|
|
14690
14666
|
title: 'List X ads line items',
|
|
14691
14667
|
description: "List the line items on an X ad account — X's name for an ad group, and the level that carries the objective, the placements, the bid, the targeting and the creatives. Pass campaignId to scope it to one campaign. `servable` is X's own verdict on whether the line item could run. Read-only, free.",
|
|
14692
|
-
inputSchema: {
|
|
14668
|
+
inputSchema: { ...xAcct, campaignId: z.string().optional(), limit: z.number().optional() },
|
|
14693
14669
|
outputSchema: { accountId: z.string().optional(), count: z.number().optional(), lineItems: z.array(z.any()).optional(), note: z.string().optional() },
|
|
14694
14670
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
14695
14671
|
}, wrap(async (a) => {
|
|
@@ -14700,7 +14676,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14700
14676
|
title: 'Build an X ads line item (paused)',
|
|
14701
14677
|
description: "Create a line item — X's ad group — under a campaign. ALWAYS CREATED PAUSED with no override. THE TREE ON X IS campaign -> line item -> promoted post, and a campaign ALONE CANNOT SERVE: this is the middle level, and it still cannot serve until you attach a post with create_x_ads_promoted_tweet. Targeting attaches HERE (add_x_ads_targeting), never to the campaign. `objective` is validated before dispatch because X answers an invalid one with a 500 that reads like an outage; note that WEBSITE_CONVERSIONS and SITE_VISITS are `goal` values and are NOT objectives. `bidStrategy` MAX/TARGET require a bidAmount; AUTO lets X set it. Omitting startTime records now, and the read-back says so. Everything is READ BACK from X before you are told it exists; print the returned note verbatim.",
|
|
14702
14678
|
inputSchema: {
|
|
14703
|
-
|
|
14679
|
+
...xAcct,
|
|
14704
14680
|
campaignId: z.string().describe('from create_x_ads_campaign or list_x_ads_campaigns'),
|
|
14705
14681
|
name: z.string().optional(),
|
|
14706
14682
|
objective: z.enum(['APP_ENGAGEMENTS', 'APP_INSTALLS', 'ENGAGEMENTS', 'FOLLOWERS', 'PREROLL_VIEWS', 'REACH', 'VIDEO_VIEWS', 'WEBSITE_CLICKS']).describe('one per campaign (X refuses a second objective). For an X LEAD AD use WEBSITE_CLICKS with a lead-form card \u2014 X retired LEAD_GENERATION'),
|
|
@@ -14720,7 +14696,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14720
14696
|
title: 'Attach an X account to a follower line item (paused)',
|
|
14721
14697
|
description: 'THE CREATIVE OF AN X FOLLOWER CAMPAIGN. A line item with objective FOLLOWERS (productType PROMOTED_ACCOUNT, which create_x_ads_line_item now defaults to for FOLLOWERS) promotes an X ACCOUNT, and until one is attached here it has nothing to show and never serves. Omit userId and the ad account\u2019s own promotable account is used; if it may promote several, the choice is refused rather than guessed (list_x_ads_promotable_users shows them). A line item of any other product type is refused for free, naming X\u2019s INCOMPATIBLE_LINE_ITEM rule. You may ALSO attach a post with create_x_ads_promoted_tweet, which X adds as a mobile timeline placement. The attachment is READ BACK from the line item before it is reported. Nothing spends: the campaign and line item stay PAUSED until set_x_ads_status(confirm:true). X cannot edit a promoted account, only remove it (delete_x_ads_object type:"promoted_account"). Free.',
|
|
14722
14698
|
inputSchema: {
|
|
14723
|
-
|
|
14699
|
+
...xAcct, lineItemId: z.string().describe('a PROMOTED_ACCOUNT line item (objective FOLLOWERS) from create_x_ads_line_item'),
|
|
14724
14700
|
userId: z.string().optional().describe('the numeric X user id to promote \u2014 omit for the ad account\u2019s own promotable account'),
|
|
14725
14701
|
},
|
|
14726
14702
|
outputSchema: { accountId: z.string().optional(), lineItemId: z.string().optional(), promotedAccountId: z.string().optional(), userId: z.string().optional(), approvalStatus: z.string().nullable().optional(), verified: z.boolean().optional(), note: z.string().optional() },
|
|
@@ -14729,7 +14705,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14729
14705
|
server.registerTool('list_x_ads_promotable_users', {
|
|
14730
14706
|
title: 'List the X accounts an ad account may promote',
|
|
14731
14707
|
description: 'The X accounts this ad account may promote \u2014 FULL (can be promoted for followers with create_x_ads_promoted_account and have its posts promoted) or RETWEETS_ONLY (posts only). Read-only, free.',
|
|
14732
|
-
inputSchema: {
|
|
14708
|
+
inputSchema: { ...xAcct },
|
|
14733
14709
|
outputSchema: { accountId: z.string().optional(), users: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
|
|
14734
14710
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
14735
14711
|
}, wrap(async (a) => { const d = await apiGet('/api/x-ads/promotable-users', a); return ok(`${d.note}\n${(d.users || []).map(u => `\u00b7 user ${u.userId} \u2014 ${u.type}`).join('\n')}`, d); }));
|
|
@@ -14741,15 +14717,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14741
14717
|
server.registerTool('list_x_ads_lead_forms', {
|
|
14742
14718
|
title: 'List X lead forms',
|
|
14743
14719
|
description: "List the lead forms on an X ad account (or one, with leadFormId) — fields, privacy policy, and the customer-list audience X builds from each form's leads. Read-only, free.",
|
|
14744
|
-
inputSchema: {
|
|
14720
|
+
inputSchema: { ...xAcct, leadFormId: z.string().optional(), withDeleted: z.boolean().optional().describe('include archived forms'), limit: z.number().optional() },
|
|
14745
14721
|
outputSchema: { accountId: z.string().optional(), leadForms: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
|
|
14746
14722
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
14747
14723
|
}, wrap(async (a) => { const d = await apiGet('/api/x-ads/lead-forms', a); return ok(`${d.note}\n${(d.leadForms || []).map(f => `• ${f.name} — id ${f.id}${f.deleted ? ' (archived)' : ''}, fields ${(f.fields || []).join(', ')}`).join('\n')}`, d); }));
|
|
14748
14724
|
server.registerTool('create_x_ads_lead_form', {
|
|
14749
14725
|
title: 'Create an X lead form',
|
|
14750
|
-
description: "Create an X lead form — the native form an X lead ad opens, so people sign up without leaving X. The X lead ad chain: lead form
|
|
14726
|
+
description: "Create an X lead form — the native form an X lead ad opens, so people sign up without leaving X. The X lead ad chain: lead form -> create_x_ads_card(leadFormId) -> create_x_ads_post(cardUri, lineItemId) on a WEBSITE_CLICKS line item (X retired the LEAD_GENERATION objective) -> get_x_ads_leads. Needs name, purpose, fields (at least one required), and an https privacy policy URL + company name. X ties every form to a conversion event: an ad account with no X Pixel needs conversionEventId, and the refusal says so plainly. X also builds a customer-list audience of the form's leads. Read back from X. Free.",
|
|
14751
14727
|
inputSchema: {
|
|
14752
|
-
|
|
14728
|
+
...xAcct, name: z.string(), purpose: z.string().describe('what the leads are used for (shown to the person)'),
|
|
14753
14729
|
fields: z.array(xLeadField).describe('at least one must not be optional'), privacyPolicyUrl: z.string().describe('https'), companyName: z.string(),
|
|
14754
14730
|
headline: z.string().optional().describe('intro card headline'), description: z.string().optional().describe('intro card text'),
|
|
14755
14731
|
thankYouHeadline: z.string().optional(), thankYouDescription: z.string().optional(), thankYouUrl: z.string().optional().describe('https link on the thank-you button'), thankYouButton: z.string().optional(),
|
|
@@ -14762,7 +14738,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14762
14738
|
title: 'Change an X lead form',
|
|
14763
14739
|
description: "Change an X lead form. Only the fields you pass change — but X replaces the fields list and each section (intro, thank-you page, privacy policy) WHOLE, so pass the complete new value of any you touch. An archived form cannot be changed. Read back from X. Free.",
|
|
14764
14740
|
inputSchema: {
|
|
14765
|
-
|
|
14741
|
+
...xAcct, leadFormId: z.string(), name: z.string().optional(), purpose: z.string().optional(), fields: z.array(xLeadField).optional(),
|
|
14766
14742
|
privacyPolicyUrl: z.string().optional(), companyName: z.string().optional(), headline: z.string().optional(), description: z.string().optional(),
|
|
14767
14743
|
thankYouHeadline: z.string().optional(), thankYouDescription: z.string().optional(), thankYouUrl: z.string().optional(), thankYouButton: z.string().optional(), conversionEventId: z.string().optional(),
|
|
14768
14744
|
},
|
|
@@ -14772,21 +14748,21 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14772
14748
|
server.registerTool('get_x_ads_leads', {
|
|
14773
14749
|
title: 'Read the leads from an X lead form',
|
|
14774
14750
|
description: "Read the leads submitted to an X lead form over a window (default the last 30 days) — each lead's answers keyed by field (EMAIL, FULL_NAME…). X builds the export as a job; this waits up to ~45s and, if X is still working, returns an exportId to collect later with no new job. X lets only an ACCOUNT ADMIN export leads, and refuses an ARCHIVED form, so export before archiving. format csv/xlsx returns a download link instead. The result is personal information. Free.",
|
|
14775
|
-
inputSchema: {
|
|
14751
|
+
inputSchema: { ...xAcct, leadFormId: z.string().optional(), startTime: z.string().optional().describe('ISO 8601'), endTime: z.string().optional().describe('ISO 8601; default now'), days: z.number().optional().describe('window length when startTime is omitted; default 30'), format: z.enum(['json', 'csv', 'xlsx']).optional(), exportId: z.string().optional().describe('collect an export that was still running') },
|
|
14776
14752
|
outputSchema: { accountId: z.string().optional(), exportId: z.string().optional(), leadFormId: z.string().optional(), state: z.string().optional(), count: z.number().optional(), leads: z.array(z.any()).nullable().optional(), downloadUrl: z.string().optional(), window: z.any().optional(), note: z.string().optional() },
|
|
14777
14753
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
14778
14754
|
}, wrap(async (a) => { const d = await apiGet('/api/x-ads/leads', a); return ok(`${d.note}${d.leads ? `\n${JSON.stringify(d.leads.slice(0, 50).map(l => ({ submittedAt: l.submittedAt, fields: l.fields })))}` : ''}`, d); }));
|
|
14779
14755
|
server.registerTool('upload_x_ads_media', {
|
|
14780
14756
|
title: 'Upload media to an X ad account',
|
|
14781
14757
|
description: "Upload an image or video (any URL — a Hermoso render, an upload_file URL, a public file) to an X ad account and return its media_key, for create_x_ads_card or create_x_ads_post. Added to the ad account's Media Library and read back. A card image must be 1:1 ≥600×600, 1.91:1 ≥600×314, 16:9 ≥600×337, 9:16 ≥600×1066, 2:3 ≥600×900 or 4:5 ≥600×750. Free.",
|
|
14782
|
-
inputSchema: {
|
|
14758
|
+
inputSchema: { ...xAcct, url: z.string(), name: z.string().optional().describe('label in the Media Library') },
|
|
14783
14759
|
outputSchema: { accountId: z.string().optional(), mediaKey: z.string().optional(), mediaId: z.string().optional(), mediaType: z.string().optional(), width: z.number().nullable().optional(), height: z.number().nullable().optional(), note: z.string().optional() },
|
|
14784
14760
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
14785
14761
|
}, wrap(async (a) => { const d = await apiPost('/api/x-ads/media', a); return ok(d.note, d); }));
|
|
14786
14762
|
server.registerTool('list_x_ads_media', {
|
|
14787
14763
|
title: 'List an X ad account’s Media Library',
|
|
14788
14764
|
description: "List the X ad account's Media Library — media keys usable in cards and promoted-only posts. Read-only, free.",
|
|
14789
|
-
inputSchema: {
|
|
14765
|
+
inputSchema: { ...xAcct, mediaType: z.enum(['IMAGE', 'VIDEO', 'GIF']).optional(), limit: z.number().optional() },
|
|
14790
14766
|
outputSchema: { accountId: z.string().optional(), media: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
|
|
14791
14767
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
14792
14768
|
}, wrap(async (a) => { const d = await apiGet('/api/x-ads/media', a); return ok(`${d.note}\n${(d.media || []).map(m => `• ${m.name || '(untitled)'} — ${m.mediaKey} (${m.mediaType} ${m.width}×${m.height})`).join('\n')}`, d); }));
|
|
@@ -14794,7 +14770,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14794
14770
|
title: 'Build an X ad card',
|
|
14795
14771
|
description: "Create an X card — the big image/video with a headline or button that an X ad clicks through on. ONE destination: websiteUrl (a website card, with a headline `title`), leadFormId (opens an X lead form — headline, or a button with buttonLabel), or an app (iosAppId/googlePlayAppId + countryCode, a button). Media: mediaKey, or 2–6 mediaKeys for a swipeable card (upload_x_ads_media). utm {source,medium,campaign,content,term} is appended to websiteUrl without overwriting the URL's own. Read back from X; a card is used by create_x_ads_post(cardUri). Free.",
|
|
14796
14772
|
inputSchema: {
|
|
14797
|
-
|
|
14773
|
+
...xAcct, name: z.string(), mediaKey: z.string().optional(), mediaKeys: z.array(z.string()).optional(),
|
|
14798
14774
|
title: z.string().optional().describe('headline, for website and lead-form cards'), websiteUrl: z.string().optional(), utm: xUtm.optional(),
|
|
14799
14775
|
leadFormId: z.string().optional(), buttonLabel: z.enum(['BOOK', 'CONNECT', 'INSTALL', 'OPEN', 'ORDER', 'PLAY', 'SHOP']).optional(),
|
|
14800
14776
|
iosAppId: z.string().optional(), googlePlayAppId: z.string().optional(), countryCode: z.string().optional(), iosDeepLink: z.string().optional(), googlePlayDeepLink: z.string().optional(),
|
|
@@ -14805,7 +14781,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14805
14781
|
server.registerTool('list_x_ads_cards', {
|
|
14806
14782
|
title: 'List X ad cards',
|
|
14807
14783
|
description: 'List the cards on an X ad account with their card_uri and destination. Read-only, free.',
|
|
14808
|
-
inputSchema: {
|
|
14784
|
+
inputSchema: { ...xAcct, query: z.string().optional().describe('name prefix'), limit: z.number().optional() },
|
|
14809
14785
|
outputSchema: { accountId: z.string().optional(), cards: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
|
|
14810
14786
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
14811
14787
|
}, wrap(async (a) => { const d = await apiGet('/api/x-ads/cards', a); return ok(`${d.note}\n${(d.cards || []).map(c => `• ${c.name} — ${c.cardUri} (${c.cardType}) → ${c.destination || '?'}`).join('\n')}`, d); }));
|
|
@@ -14813,7 +14789,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14813
14789
|
title: 'Write a promoted-only X post for ads',
|
|
14814
14790
|
description: "Write an X post FOR ADS — PROMOTED-ONLY by default (X's 'nullcast'): it never appears on the brand's timeline or in followers' feeds and serves only as an ad, so you can test several creatives without spamming followers. Carry a card (cardUri from create_x_ads_card) OR up to 4 media (mediaKeys from upload_x_ads_media). Posted as the ad account's own promotable user (asUserId to choose). Pass lineItemId to attach it straight to a line item (which stays PAUSED). promotedOnly:false makes it a normal public post and needs confirmPublic:true. Read back from X. Free.",
|
|
14815
14791
|
inputSchema: {
|
|
14816
|
-
|
|
14792
|
+
...xAcct, text: z.string().optional(), cardUri: z.string().optional(), mediaKeys: z.array(z.string()).optional(),
|
|
14817
14793
|
name: z.string().optional().describe('internal name in ads.x.com'), promotedOnly: z.boolean().optional().describe('default true'), confirmPublic: z.boolean().optional(),
|
|
14818
14794
|
asUserId: z.string().optional(), conversationSettings: z.enum(['EVERYONE', 'FOLLOWING', 'MENTIONED_USERS']).optional(),
|
|
14819
14795
|
videoTitle: z.string().optional(), videoDescription: z.string().optional(), videoCta: z.enum(['VISIT_SITE', 'WATCH_NOW']).optional(), videoCtaUrl: z.string().optional(), utm: xUtm.optional(),
|
|
@@ -14825,14 +14801,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14825
14801
|
server.registerTool('list_x_ads_posts', {
|
|
14826
14802
|
title: 'List the posts an X ad account can promote',
|
|
14827
14803
|
description: 'List the posts an X ad account can promote — promoted-only ones by default (timeline NULLCAST), or ORGANIC / ALL. Read-only, free.',
|
|
14828
|
-
inputSchema: {
|
|
14804
|
+
inputSchema: { ...xAcct, timeline: z.enum(['ALL', 'NULLCAST', 'ORGANIC']).optional(), tweetType: z.enum(['DRAFT', 'PUBLISHED', 'SCHEDULED']).optional(), tweetIds: z.array(z.string()).optional(), asUserId: z.string().optional(), limit: z.number().optional() },
|
|
14829
14805
|
outputSchema: { accountId: z.string().optional(), posts: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
|
|
14830
14806
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
14831
14807
|
}, wrap(async (a) => { const d = await apiGet('/api/x-ads/posts', a); return ok(`${d.note}\n${(d.posts || []).map(p => `• ${p.id}${p.nullcast ? ' (promoted-only)' : ''}${p.cardUri ? ` card ${p.cardUri}` : ''} — ${String(p.text || '').slice(0, 100)}`).join('\n')}`, d); }));
|
|
14832
14808
|
server.registerTool('preview_x_ads_post', {
|
|
14833
14809
|
title: 'Preview an X ad post',
|
|
14834
14810
|
description: 'See X posts exactly as the ad will render — X returns a preview page per post (works for promoted-only posts). Read-only, free.',
|
|
14835
|
-
inputSchema: {
|
|
14811
|
+
inputSchema: { ...xAcct, tweetIds: z.array(z.string()), tweetType: z.enum(['DRAFT', 'PUBLISHED', 'SCHEDULED']).optional() },
|
|
14836
14812
|
outputSchema: { accountId: z.string().optional(), previews: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
|
|
14837
14813
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
14838
14814
|
}, wrap(async (a) => { const d = await apiGet('/api/x-ads/preview', a); return ok(d.note, d); }));
|
|
@@ -14847,21 +14823,21 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14847
14823
|
server.registerTool('list_x_ads_audiences', {
|
|
14848
14824
|
title: 'List X custom audiences',
|
|
14849
14825
|
description: 'List the custom audiences (customer lists) on an X ad account — size, whether each can be targeted yet and why not. Read-only, free.',
|
|
14850
|
-
inputSchema: {
|
|
14826
|
+
inputSchema: { ...xAcct, audienceIds: z.array(z.string()).optional(), query: z.string().optional().describe('name prefix'), shared: z.boolean().optional().describe('lists shared with this account instead of owned'), limit: z.number().optional() },
|
|
14851
14827
|
outputSchema: { accountId: z.string().optional(), audiences: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
|
|
14852
14828
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
14853
14829
|
}, wrap(async (a) => { const d = await apiGet('/api/x-ads/audiences', a); return ok(`${d.note}\n${(d.audiences || []).map(x => `• ${x.name} — id ${x.id}, ${x.size != null ? `~${x.size} people` : 'size not reported'}${x.targetable === false ? ` (not targetable yet: ${(x.notTargetableBecause || []).join(', ')})` : ''}`).join('\n')}`, d); }));
|
|
14854
14830
|
server.registerTool('create_x_ads_audience', {
|
|
14855
14831
|
title: 'Create an X custom audience',
|
|
14856
14832
|
description: "Create an X custom audience (a customer list for retargeting or exclusion), optionally filled with people in the same call. Pass ordinary emails, phones (E.164), X handles, X user ids or device ids — they are normalised and SHA-256 hashed on Hermoso's server before X sees them; 64-hex hashes pass as is; anything unusable is named, never sent. X needs up to 48h and about 100 matched people before it can be targeted (add_x_ads_targeting with targetingType CUSTOM_AUDIENCE). Read back. Free.",
|
|
14857
|
-
inputSchema: {
|
|
14833
|
+
inputSchema: { ...xAcct, name: z.string().describe('unique per account'), description: z.string().optional(), ...xPeople, expiresAt: z.string().optional().describe('ISO 8601; X defaults to 13 months') },
|
|
14858
14834
|
outputSchema: { accountId: z.string().optional(), audienceId: z.string().optional(), audience: z.any().optional(), users: z.any().optional(), note: z.string().optional() },
|
|
14859
14835
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
14860
14836
|
}, wrap(async (a) => { const d = await apiPost('/api/x-ads/audience', a); return ok(d.note, d); }));
|
|
14861
14837
|
server.registerTool('update_x_ads_audience', {
|
|
14862
14838
|
title: 'Change an X custom audience',
|
|
14863
14839
|
description: 'Change an X custom audience: rename / redescribe it, and/or ADD or REMOVE people (operation add|remove) — same identifiers as create_x_ads_audience, hashed on our server. X reports how many it processed and Hermoso relays it; a mismatch is flagged as not fully applied. Read back. Free.',
|
|
14864
|
-
inputSchema: {
|
|
14840
|
+
inputSchema: { ...xAcct, audienceId: z.string(), name: z.string().optional(), description: z.string().optional(), operation: z.enum(['add', 'remove']).optional(), ...xPeople, effectiveAt: z.string().optional(), expiresAt: z.string().optional() },
|
|
14865
14841
|
outputSchema: { accountId: z.string().optional(), audienceId: z.string().optional(), audience: z.any().optional(), users: z.any().optional(), note: z.string().optional() },
|
|
14866
14842
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
14867
14843
|
}, wrap(async (a) => { const d = await apiPost('/api/x-ads/audience-update', a); return ok(d.note, d); }));
|
|
@@ -14869,7 +14845,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14869
14845
|
title: 'Send conversions to X (Conversion API)',
|
|
14870
14846
|
description: "Report conversions to X server-side (the X Conversion API) — purchases, sign-ups, leads — so X attributes them to the ads that caused them and conversion bidding (goal WEBSITE_CONVERSIONS_V2) has something to optimise toward. pixelId and each eventId come from X Events Manager. Each event needs a click id (twclid from the landing URL), an email or an E.164 phone — emails and phones are SHA-256 hashed on Hermoso's server; IP + user agent only count together or beside one of those. Send conversionId to deduplicate against the X Pixel. Free.",
|
|
14871
14847
|
inputSchema: {
|
|
14872
|
-
|
|
14848
|
+
...xAcct, pixelId: z.string().describe('the X Pixel (event source) id from Events Manager'), eventId: z.string().optional().describe('default Event ID for every event'),
|
|
14873
14849
|
events: z.array(z.object({
|
|
14874
14850
|
eventId: z.string().optional(), conversionTime: z.string().optional().describe('ISO 8601; default now'), twclid: z.string().optional(),
|
|
14875
14851
|
email: z.string().optional(), phone: z.string().optional(), ipAddress: z.string().optional(), userAgent: z.string().optional(),
|
|
@@ -14884,7 +14860,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14884
14860
|
title: 'Attach a post to an X line item (paused)',
|
|
14885
14861
|
description: "Attach an existing X post to a line item so the line item has a creative — the LAST piece of the tree, and without it nothing can ever serve however the statuses are set. The tweet id is the number at the end of the post URL (x.com/<handle>/status/<id>), not the URL. Several ids may be attached at once; X creates one promoted-tweet row per post. ATTACHMENT IS VERIFIED by re-reading the line item's own promoted posts rather than by trusting X's echo, and if that read cannot run the note says UNCONFIRMED instead of claiming success. Everything Hermoso built above this is PAUSED, so attaching a post starts no spend. Print the returned note verbatim.",
|
|
14886
14862
|
inputSchema: {
|
|
14887
|
-
|
|
14863
|
+
...xAcct, lineItemId: z.string().describe('from create_x_ads_line_item'),
|
|
14888
14864
|
tweetIds: z.array(z.string()).describe('post ids — digits only, from the end of the post URL'),
|
|
14889
14865
|
},
|
|
14890
14866
|
outputSchema: { accountId: z.string().optional(), lineItemId: z.string().optional(), promotedTweetIds: z.array(z.string()).optional(), verifiedCount: z.number().nullable().optional(), note: z.string().optional() },
|
|
@@ -14894,7 +14870,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14894
14870
|
title: 'Target an X ads line item',
|
|
14895
14871
|
description: "Add targeting criteria to an X LINE ITEM. Targeting does NOT attach to a campaign on X — a campaign carries only budget and funding. Pass locationIds resolved with x_ads_geo_search, and/or criteria[] for any of X's other 37 targeting vocabularies when you already hold the ids. X takes ONE criterion per API call, so a set is several calls: each is reported individually, a partial failure NAMES what did not apply, and the line item's FULL targeting is read back afterwards so the answer is what the line item carries rather than what was sent. Adds no spend — the line item stays PAUSED.",
|
|
14896
14872
|
inputSchema: {
|
|
14897
|
-
|
|
14873
|
+
...xAcct, lineItemId: z.string(),
|
|
14898
14874
|
locationIds: z.array(z.string()).optional().describe('opaque ids from x_ads_geo_search'),
|
|
14899
14875
|
criteria: z.array(z.record(z.any())).optional().describe('[{targetingType, targetingValue, operatorType?}] — operatorType defaults to EQ'),
|
|
14900
14876
|
},
|
|
@@ -14915,7 +14891,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14915
14891
|
title: 'X ads performance report',
|
|
14916
14892
|
description: "Performance stats for X campaigns, line items or promoted posts. `placement` here is SINGULAR and comes from a FOUR-value set (ALL_ON_TWITTER / PUBLISHER_NETWORK / SPOTLIGHT / TREND) — deliberately not the twelve placements a line item accepts; do not carry one across. THE TRAP THIS REPORTS: an entity id that does not exist on the account answers 200 with every metric null, which is indistinguishable from a real zero, so an all-null response is FLAGGED rather than narrated as zero performance. Max 20 ids per call. Read-only, free.",
|
|
14917
14893
|
inputSchema: {
|
|
14918
|
-
|
|
14894
|
+
...xAcct,
|
|
14919
14895
|
entity: z.enum(['ACCOUNT', 'CAMPAIGN', 'FUNDING_INSTRUMENT', 'LINE_ITEM', 'MEDIA_CREATIVE', 'ORGANIC_TWEET', 'PROMOTED_ACCOUNT', 'PROMOTED_TWEET']).optional().describe('default CAMPAIGN'),
|
|
14920
14896
|
entityIds: z.array(z.string()).describe('max 20'),
|
|
14921
14897
|
startTime: z.string().optional().describe('ISO 8601; defaults to 7 days before endTime'),
|
|
@@ -15825,7 +15801,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15825
15801
|
}, wrap(async (a) => { const d = await apiPost('/api/pinterest/customer-list/update', a); return ok(d.note, d); }));
|
|
15826
15802
|
server.registerTool('pinterest_ads_keyword_ideas', {
|
|
15827
15803
|
title: 'Pinterest keyword ideas',
|
|
15828
|
-
description: 'Pinterest KEYWORD RESEARCH through the Pinterest Ads connection. mode:"related" (default): terms related to each of your terms ("workout"
|
|
15804
|
+
description: 'Pinterest KEYWORD RESEARCH through the Pinterest Ads connection. mode:"related" (default): terms related to each of your terms ("workout" -> "yoga workout", "workout motivation"); mode:"suggested": popular searches that BEGIN with the term; mode:"metrics": Pinterest-wide search volume for up to 2,000 keywords in one country. Feeds ad-group KEYWORD targeting and Pin titles. Read-only, 0 credits.',
|
|
15829
15805
|
inputSchema: { adAccountId: pinAcct, mode: z.enum(['related', 'suggested', 'metrics']).optional(), terms: z.array(z.string()), limit: z.number().optional().describe('suggested: 1–10'), countryCode: z.string().optional().describe('metrics: two-letter country, default US') },
|
|
15830
15806
|
outputSchema: { ok: z.boolean().optional(), mode: z.string().optional(), count: z.number().optional(), groups: z.array(z.any()).optional(), terms: z.array(z.any()).optional(), keywords: z.array(z.any()).optional(), note: z.string().optional() },
|
|
15831
15807
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -18511,6 +18487,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18511
18487
|
: d.videos ? d.videos.map(v => `• ${v.shareUrl || v.videoId}`).join('\n') : (d.keywords || []).join(' · ');
|
|
18512
18488
|
return ok(`${d.note}\n${body}`, d);
|
|
18513
18489
|
}));
|
|
18490
|
+
// → `channel_admin` (2026-10-27). These three spend the ACCOUNT-HOLDER token on /api/tiktok-account/ (the brand's
|
|
18491
|
+
// own posts, Spark candidates among them, and hashtag ideas for an organic post) — the same grant and the same
|
|
18492
|
+
// group as the account-holder run above. They were written inside the ads span and inherited `ads` by position.
|
|
18493
|
+
server.group('channel_admin');
|
|
18514
18494
|
server.registerTool('tiktok_video_insights', {
|
|
18515
18495
|
title: 'TikTok post insights',
|
|
18516
18496
|
description: 'Per-POST analytics for the connected TikTok account: views, reach, likes, comments, shares, saves, average watch time, full-watch rate, the RETENTION CURVE (share still watching at each second — the hook read), traffic sources (For You, Follow, Search, Sound, profile…), new followers, profile views, website clicks, lead submissions and the audience by country, gender and new-vs-returning. Newest posts first, up to 20 a page (cursor for more), or name videoIds. Data lags 24-48 hours. Needs the TikTok ACCOUNT authorization; an older one says which permission to re-authorize for. Read-only, free.',
|
|
@@ -18552,6 +18532,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18552
18532
|
const d = await apiGet('/api/tiktok-account/hashtag-suggestions', a);
|
|
18553
18533
|
return ok(`${d.note}\n${(d.suggestions || []).map(h => `#${h.hashtag}${h.posts != null ? ` (${h.posts} posts)` : ''}`).join(' ')}`, d);
|
|
18554
18534
|
}));
|
|
18535
|
+
server.group('ads'); // back to the TikTok ADVERTISER surface
|
|
18555
18536
|
// ══ AUTOMATED RULES (2026-08-19) ══════════════════════════════════════════════════════════════════════════════
|
|
18556
18537
|
// Held since the 2026-08-19 approval and recorded as DELIBERATELY unbuilt — "a rule is standing permission to
|
|
18557
18538
|
// move money with no human in the loop, and every spend switch in this product is confirm-gated for exactly
|
|
@@ -19319,7 +19300,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
19319
19300
|
}));
|
|
19320
19301
|
server.registerTool('snapchat_ads_report', {
|
|
19321
19302
|
title: 'Snapchat ad performance',
|
|
19322
|
-
description: 'Read Snapchat ad performance — impressions, spend, swipes and video quartiles — at ad account, campaign, ad squad or ad level. THREE THINGS TO KNOW BEFORE CALLING: granularity is required (TOTAL is the default); DAY and HOUR granularity REQUIRE startTime and endTime AND both must land exactly on the start of an hour, which Snapchat refuses otherwise and Hermoso refuses for free before spending the call; and SPEND COMES BACK IN MICRO-CURRENCY, so divide by 1,000,000 before quoting money to anyone.
|
|
19303
|
+
description: 'Read Snapchat ad performance — impressions, spend, swipes and video quartiles — at ad account, campaign, ad squad or ad level. THREE THINGS TO KNOW BEFORE CALLING: granularity is required (TOTAL is the default); DAY and HOUR granularity REQUIRE startTime and endTime AND both must land exactly on the start of an hour, which Snapchat refuses otherwise and Hermoso refuses for free before spending the call; and SPEND COMES BACK IN MICRO-CURRENCY, so divide by 1,000,000 before quoting money to anyone. It changes nothing on the ad account; with async:true it starts a report file on Snapchat that is kept for seven days. Free.',
|
|
19323
19304
|
inputSchema: {
|
|
19324
19305
|
adAccountId: z.string().optional(),
|
|
19325
19306
|
level: z.enum(['adaccount', 'campaign', 'adsquad', 'ad']).optional().describe('default campaign'),
|
|
@@ -19329,18 +19310,20 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
19329
19310
|
endTime: z.string().optional().describe('ISO 8601, on the start of an hour'),
|
|
19330
19311
|
fields: z.string().optional().describe('comma-separated metrics — default impressions,spend,swipes'),
|
|
19331
19312
|
breakdown: z.string().optional().describe('object-level breakdown: ad, adsquad (campaign stats only) or campaign (ad-account stats only)'),
|
|
19332
|
-
swipeUpAttributionWindow: z.enum(['1_DAY', '7_DAY', '28_DAY']).optional().describe('how long after a SWIPE a conversion still counts. Omit to use the ad account default.
|
|
19313
|
+
swipeUpAttributionWindow: z.enum(['1_DAY', '7_DAY', '28_DAY']).optional().describe('how long after a SWIPE a conversion still counts in this report. Omit to use the ad account default. It only changes how this report counts conversions; the ad account itself is not changed.'),
|
|
19333
19314
|
viewAttributionWindow: z.enum(['none', '1_HOUR', '3_HOUR', '6_HOUR', '1_DAY', '7_DAY']).optional().describe("how long after a VIEW (no swipe) a conversion still counts; 'none' attributes no view-throughs at all. Omit to use the ad account default."),
|
|
19334
19315
|
reportDimension: z.enum(['country', 'country,os', 'region', 'dma', 'gender', 'age', 'age,gender', 'os', 'os,country', 'make', 'lifestyle_category']).optional().describe('break the numbers down by AUDIENCE — age, gender, age,gender, country, region, dma, os, device make or interest (lifestyle_category). Not with HOUR. region / dma / make / lifestyle_category / the os+country pairs carry DELIVERY metrics only (no conversion_* fields)'),
|
|
19335
19316
|
conversionSourceTypes: z.string().optional().describe('comma-separated: web, app, offline, total, total_off_platform, total_on_platform — splits conversions by where they happened'),
|
|
19336
19317
|
actionReportTime: z.enum(['conversion', 'impression']).optional().describe('count a conversion on the day it happened (conversion, default) or on the day of the ad impression (impression)'),
|
|
19337
19318
|
omitEmpty: z.boolean().optional().describe('drop days/entities with zero on every metric'),
|
|
19338
|
-
async: z.boolean().optional().describe('for long ranges or wide breakdowns: Snapchat builds a CSV/Excel file
|
|
19319
|
+
async: z.boolean().optional().describe('for long ranges or wide breakdowns: starts a report run on Snapchat that builds a downloadable CSV/Excel file (kept seven days) and returns its reportRunId'),
|
|
19339
19320
|
asyncFormat: z.enum(['csv', 'excel']).optional(),
|
|
19340
19321
|
reportRunId: z.string().optional().describe('fetch an async report started earlier — same level and id'),
|
|
19341
19322
|
},
|
|
19342
19323
|
outputSchema: { adAccountId: z.string().optional(), level: z.string().optional(), id: z.string().optional(), granularity: z.string().optional(), rows: z.array(z.any()).optional(), read: z.boolean().optional(), reportDimension: z.string().optional(), reportRunId: z.string().nullable().optional(), asyncStatus: z.string().nullable().optional(), downloadUrl: z.string().nullable().optional(), note: z.string().optional() },
|
|
19343
|
-
|
|
19324
|
+
// readOnlyHint FALSE (OpenAI scan 2026-10-27, held twice as "readOnlyHint true but a user-visible side effect"): async:true
|
|
19325
|
+
// STARTS a report run on Snapchat — a stored, downloadable file — so the honest hint is not read-only. Not destructive.
|
|
19326
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
19344
19327
|
}, wrap(async (a) => {
|
|
19345
19328
|
const d = await apiGet('/api/snapchat-ads/report', a);
|
|
19346
19329
|
if (d.reportRunId || d.downloadUrl) return ok(d.note, d);
|
|
@@ -19385,7 +19368,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
19385
19368
|
}));
|
|
19386
19369
|
server.registerTool('upload_snapchat_ads_creative', {
|
|
19387
19370
|
title: 'Upload a render to Snapchat as media + creative',
|
|
19388
|
-
description: 'PUT A FINISHED HERMOSO RENDER ONTO THE SNAPCHAT AD ACCOUNT so an ad can point at it — create_snapchat_ads_ad needs the creativeId it returns. Pass the public https url of a render; SNAPCHAT HAS NO UPLOAD-FROM-URL, so Hermoso fetches the bytes and posts them as multipart. This does TWO things in one call — it uploads the MEDIA and then builds the CREATIVE that wraps it — because a Snapchat ad points at a CREATIVE and never at a media id, and stopping after the upload leaves an asset nothing can use. A PUBLIC PROFILE IS MANDATORY: Snapchat has required profile_properties on every ad creative since 2024-02-26, so
|
|
19371
|
+
description: 'PUT A FINISHED HERMOSO RENDER ONTO THE SNAPCHAT AD ACCOUNT so an ad can point at it — create_snapchat_ads_ad needs the creativeId it returns. Pass the public https url of a render; SNAPCHAT HAS NO UPLOAD-FROM-URL, so Hermoso fetches the bytes and posts them as multipart. This does TWO things in one call — it uploads the MEDIA and then builds the CREATIVE that wraps it — because a Snapchat ad points at a CREATIVE and never at a media id, and stopping after the upload leaves an asset nothing can use. A PUBLIC PROFILE IS MANDATORY: Snapchat has required profile_properties on every ad creative since 2024-02-26, so snapProfileId is REQUIRED and a call without one is refused BEFORE any bytes move (which is what stops an orphan media row being left on the ad account for a creative that could never be created). Get one from list_snapchat_ads_profiles, or read it in Snapchat Ads Manager. headline is required too. COPY LIMITS ARE SHORT AND ENFORCED: headline max 34 characters, brandName max 32, and over-long copy is REFUSED rather than truncated, because shipping words nobody wrote is worse than a refusal. To build a SECOND creative on media already uploaded, pass mediaId instead of url — nothing is downloaded or uploaded again. A file over 32MB is refused by name (Snapchat requires a chunked upload flow above that which Hermoso does not implement). Free.',
|
|
19389
19372
|
inputSchema: {
|
|
19390
19373
|
adAccountId: z.string().optional(),
|
|
19391
19374
|
url: z.string().optional().describe('public https url of the render — a Hermoso render URL already is one; for any other file run it through upload_file first. Omit only when reusing mediaId.'),
|
|
@@ -19400,7 +19383,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
19400
19383
|
leadGenerationFormId: z.string().optional().describe('build a LEAD_GENERATION creative on this lead form (list_snapchat_ads_lead_forms / create_snapchat_ads_lead_form)'),
|
|
19401
19384
|
utm: z.object({ source: z.string().optional(), medium: z.string().optional(), campaign: z.string().optional(), content: z.string().optional(), term: z.string().optional() }).optional().describe('UTM parameters appended to landingPageUrl. Snapchat URL macros such as {{campaign.name}}, {{adSet.name}}, {{ad.id}} are kept literal so Snapchat fills them in'),
|
|
19402
19385
|
addUtm: z.boolean().optional().describe('append utm_source=snapchat, utm_medium=paid_social, utm_campaign={{campaign.name}}, utm_content={{ad.id}} (utm overrides any of them). Nothing is added unless asked'),
|
|
19403
|
-
|
|
19386
|
+
snapProfileId: z.string().optional().describe('REQUIRED (or profileId): the Snapchat Public Profile the ad posts as. Snapchat has rejected every creative without one since 2024-02-26. From list_snapchat_ads_profiles, or Snapchat Ads Manager.'),
|
|
19387
|
+
profileId: z.string().optional().describe('older name for snapProfileId (MCP only)'),
|
|
19404
19388
|
creative: z.boolean().optional().describe('set false to upload the MEDIA ONLY and build the creative yourself — the default true is what an ad actually needs'),
|
|
19405
19389
|
},
|
|
19406
19390
|
outputSchema: { adAccountId: z.string().optional(), mediaId: z.string().optional(), creativeId: z.string().optional(), kind: z.string().optional(), name: z.string().optional(), bytes: z.number().optional(), reusedMedia: z.boolean().optional(), creativeType: z.string().optional(), landingPageUrl: z.string().optional(), leadGenerationFormId: z.string().optional(), note: z.string().optional() },
|
|
@@ -19487,7 +19471,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
19487
19471
|
adSquadId: z.string().describe('the ad squad this ad belongs to'),
|
|
19488
19472
|
name: z.string().describe('max 375 characters'),
|
|
19489
19473
|
creativeId: z.string().describe('from upload_snapchat_ads_creative — NOT a mediaId'),
|
|
19490
|
-
type: z.string().optional().describe('omit it — Hermoso reads the creative and picks the ad type Snapchat pairs with it (WEB_VIEW
|
|
19474
|
+
type: z.string().optional().describe('omit it — Hermoso reads the creative and picks the ad type Snapchat pairs with it (WEB_VIEW -> REMOTE_WEBPAGE, LEAD_GENERATION -> LEAD_GENERATION, SNAP_AD -> SNAP_AD). Pass one only to override.'),
|
|
19491
19475
|
},
|
|
19492
19476
|
outputSchema: { id: z.string().optional(), adAccountId: z.string().optional(), adSquadId: z.string().optional(), creativeId: z.string().optional(), name: z.string().optional(), status: z.string().optional(), type: z.string().optional(), reviewStatus: z.string().optional(), verified: z.boolean().optional(), note: z.string().optional() },
|
|
19493
19477
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
@@ -19604,7 +19588,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
19604
19588
|
}));
|
|
19605
19589
|
server.registerTool('update_snapchat_ads_object', {
|
|
19606
19590
|
title: 'Edit a Snapchat campaign, ad squad, ad or creative',
|
|
19607
|
-
description: "Edit a live or paused Snapchat object IN PLACE, only where Snapchat allows it. campaign / ad: name, startTime, endTime. adsquad: name, startTime, endTime, bid, bidStrategy, pixelId, audiences / excludeAudiences (changing targeting sends its ads back to review and briefly pauses a live squad). creative: name, headline (max 34), brandName (max 32), callToAction, landingPageUrl, utm / addUtm — the URL only on WEB_VIEW creatives; a creative edit re-submits every ad using it for review. Refused BY NAME because Snapchat does not allow them: an ad's creative, a squad's optimization goal or billing event, a creative's type. Status and budget are set_snapchat_ads_status and set_snapchat_ads_budget. confirm:true required — without it NOTHING changes and you get each field's before
|
|
19591
|
+
description: "Edit a live or paused Snapchat object IN PLACE, only where Snapchat allows it. campaign / ad: name, startTime, endTime. adsquad: name, startTime, endTime, bid, bidStrategy, pixelId, audiences / excludeAudiences (changing targeting sends its ads back to review and briefly pauses a live squad). creative: name, headline (max 34), brandName (max 32), callToAction, landingPageUrl, utm / addUtm — the URL only on WEB_VIEW creatives; a creative edit re-submits every ad using it for review. Refused BY NAME because Snapchat does not allow them: an ad's creative, a squad's optimization goal or billing event, a creative's type. Status and budget are set_snapchat_ads_status and set_snapchat_ads_budget. confirm:true required — without it NOTHING changes and you get each field's before -> after to show the user. The answer is the READ-BACK, per field.",
|
|
19608
19592
|
inputSchema: {
|
|
19609
19593
|
adAccountId: z.string().optional(),
|
|
19610
19594
|
level: z.enum(['campaign', 'adsquad', 'ad', 'creative']),
|
|
@@ -19624,7 +19608,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
19624
19608
|
landingPageUrl: z.string().optional().describe('https only, max 2048 characters'),
|
|
19625
19609
|
utm: z.object({ source: z.string().optional(), medium: z.string().optional(), campaign: z.string().optional(), content: z.string().optional(), term: z.string().optional() }).optional().describe('UTM parameters set on the landing page URL; Snapchat macros like {{campaign.name}} stay literal'),
|
|
19626
19610
|
addUtm: z.boolean().optional().describe('set utm_source=snapchat, utm_medium=paid_social, utm_campaign={{campaign.name}}, utm_content={{ad.id}}'),
|
|
19627
|
-
confirm: z.boolean().optional().describe('REQUIRED true — without it nothing changes and you get the before
|
|
19611
|
+
confirm: z.boolean().optional().describe('REQUIRED true — without it nothing changes and you get the before -> after'),
|
|
19628
19612
|
},
|
|
19629
19613
|
outputSchema: { adAccountId: z.string().optional(), level: z.string().optional(), id: z.string().optional(), name: z.string().optional(), changes: z.array(z.any()).optional(), verified: z.boolean().optional(), reviewStatus: z.string().optional(), note: z.string().optional() },
|
|
19630
19614
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
@@ -19765,6 +19749,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
19765
19749
|
videoThumbnailUrl: z.string().optional().describe('the COVER IMAGE for a videoUrl post — a Hermoso-hosted image (a render, or any picture of the user\u2019s via upload_file). Without it LinkedIn adds a system-generated thumbnail, which on an ad is usually whatever the first frame happens to be. Like captions this can only be set WHILE the video is uploaded, never afterwards. Requires videoUrl. This is NOT linkThumbnailUrl, which is the picture on a link-preview card.'),
|
|
19766
19750
|
coverAtMs: z.number().optional().describe('THE VIDEO COVER of a videoUrl post, as ONE frame: milliseconds from the start (7000 = the frame at 7s). That frame is uploaded as LinkedIn’s video thumbnail (only possible while the video uploads). videoThumbnailUrl wins.'),
|
|
19767
19751
|
visibility: z.enum(['PUBLIC', 'CONNECTIONS']).optional().describe('default PUBLIC'),
|
|
19752
|
+
firstComment: z.string().optional().describe('FIRST COMMENT — posted on the new post AS THE PAGE right after it publishes. Needs the LinkedIn comments authorization on this brand (provider linkedin_community). Up to 2,000 characters, refused rather than cut. If the comment fails the post stays live and firstComment in the result says why.'),
|
|
19768
19753
|
targetAudience: z.object({ geoLocations: z.array(z.string()).optional(), industries: z.array(z.string()).optional(), seniorities: z.array(z.string()).optional(), jobFunctions: z.array(z.string()).optional(), staffCountRanges: z.array(z.enum(['SIZE_1', 'SIZE_2_TO_10', 'SIZE_11_TO_50', 'SIZE_51_TO_200', 'SIZE_201_TO_500', 'SIZE_501_TO_1000', 'SIZE_1001_TO_5000', 'SIZE_5001_TO_10000', 'SIZE_10001_OR_MORE'])).optional(), degrees: z.array(z.string()).optional(), fieldsOfStudy: z.array(z.string()).optional(), organizations: z.array(z.string()).optional() }).optional().describe('LINKEDIN COMPANY PAGE POST ONLY — show the post only to Page followers matching these facets (URNs or bare numeric ids; search_linkedin_ads_targeting finds them). LinkedIn requires the matching audience to be over 300 followers and refuses a smaller one. Personal-profile posts cannot be targeted.'),
|
|
19769
19754
|
platformCover: z.boolean().optional().describe('VIDEO COVER. Omit it (the default) and Hermoso sets the video\u2019s best frame \u2014 the same frame as its Library thumbnail \u2014 as the cover (LinkedIn\u2019s video thumbnail upload). true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins.'),
|
|
19770
19755
|
},
|
|
@@ -20665,8 +20650,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
20665
20650
|
// Renders go in (an upload, and for an image a new editable design); finished designs come back out into the Library.
|
|
20666
20651
|
// Free (no model call). Nothing here deletes or overwrites anything in Canva.
|
|
20667
20652
|
server.group('files');
|
|
20668
|
-
server.registerTool('
|
|
20669
|
-
title: '
|
|
20653
|
+
server.registerTool('save_to_canva', {
|
|
20654
|
+
title: 'Save to Canva',
|
|
20670
20655
|
description: "Send Hermoso renders to the user's Canva: each is uploaded to their Canva Uploads and, for an IMAGE, opened as a new editable Canva design (Canva makes designs from images only; a video lands in Uploads to drag into any design). Pass Library ids (L0… from list_library), Hermoso render links, or any public https image/video link (upload_file turns a local file into one); optional title, folderId (list_canva_folder / create_canva_folder), asDesign:false for the upload only. Answers with each design's edit link. Free. Needs Canva connected (Settings > Connectors > Canva).",
|
|
20671
20656
|
inputSchema: { ids: z.array(z.string()).optional().describe('Library ids, e.g. L0'), urls: z.array(z.string()).optional().describe('render links or any public https image/video link'), title: z.string().optional(), folderId: z.string().optional(), asDesign: z.boolean().optional().describe('default true: an image becomes a new design') },
|
|
20672
20657
|
outputSchema: { ok: z.boolean().optional(), sent: z.number().optional(), failed: z.number().optional(), results: z.array(z.any()).optional(), note: z.string().optional() },
|
|
@@ -20684,7 +20669,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
20684
20669
|
}));
|
|
20685
20670
|
server.registerTool('export_canva_design', {
|
|
20686
20671
|
title: 'Export a Canva design',
|
|
20687
|
-
description: "Export one of the user's Canva designs (designId or its Canva link) and save the files into the Hermoso Library, ready to post, schedule or use as ad creative. format png (default), jpg,
|
|
20672
|
+
description: "Export one of the user's Canva designs (designId or its Canva link) and save the files into the Hermoso Library, ready to post, schedule or use as ad creative. format png (default), jpg, mp4 or gif (pdf and pptx return Canva's 24-hour download link instead of a Library file); pages [1,3] for some pages; quality = 1-100 for JPG or e.g. vertical_1080p for MP4 (defaults follow the design's shape); proQuality:true for Canva's pro quality (fails on unlicensed premium elements). Free.",
|
|
20688
20673
|
inputSchema: { designId: z.string().describe('design id or Canva link'), format: z.enum(['png', 'jpg', 'pdf', 'mp4', 'gif', 'pptx']).optional(), pages: z.array(z.number()).optional(), quality: z.union([z.string(), z.number()]).optional(), width: z.number().optional(), height: z.number().optional(), transparentBackground: z.boolean().optional(), proQuality: z.boolean().optional() },
|
|
20689
20674
|
outputSchema: { ok: z.boolean().optional(), pending: z.boolean().optional(), designId: z.string().optional(), title: z.string().optional(), format: z.string().optional(), files: z.array(z.any()).optional(), landed: z.boolean().optional(), note: z.string().optional() },
|
|
20690
20675
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
@@ -20718,7 +20703,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
20718
20703
|
}));
|
|
20719
20704
|
server.registerTool('create_canva_folder', {
|
|
20720
20705
|
title: 'Create a Canva folder',
|
|
20721
|
-
description: "Create a folder in the user's Canva (in their Projects by default, or inside parentFolderId).
|
|
20706
|
+
description: "Create a folder in the user's Canva (in their Projects by default, or inside parentFolderId). save_to_canva's folderId then files renders into it. Free.",
|
|
20722
20707
|
inputSchema: { name: z.string(), parentFolderId: z.string().optional().describe('root (default), uploads or a folder id') },
|
|
20723
20708
|
outputSchema: { ok: z.boolean().optional(), folder: z.any().optional(), note: z.string().optional() },
|
|
20724
20709
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
@@ -21189,7 +21174,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21189
21174
|
// A product page link → the product read from its own page (saved to the brand's catalog), its real packshot checked and
|
|
21190
21175
|
// locked as the product photo, and a UGC-style video plan. Stops at the plan like clone_video: render_ad is the spend.
|
|
21191
21176
|
server.registerTool('click_to_ad', {
|
|
21192
|
-
title: '
|
|
21177
|
+
title: 'Plan a video ad from a product link',
|
|
21193
21178
|
description: 'Paste a product page link (Shopify, Amazon or any store page) and get a creator-style video ad planned for that product. Hermoso reads the page (name, photos, what it says about the product, price), saves the product to the brand\'s catalog, checks the page\'s real product photo and pins it to this ad (the brand\'s saved default photo is not changed), then plans the ad. A store that hides its page from servers is read in a real browser; failing that the name comes from the link and the photo from a search, and the reply says so. Nothing renders here: show the user the concept, then call render_ad with the returned creative (dryRun:true first for the exact price). Works with or without a saved brand.',
|
|
21194
21179
|
inputSchema: {
|
|
21195
21180
|
url: z.string().describe('the product page link, https://…'),
|
|
@@ -23065,8 +23050,8 @@ function memoryNoteVerdict(text) {
|
|
|
23065
23050
|
}));
|
|
23066
23051
|
server.registerTool('set_alert_events', {
|
|
23067
23052
|
title: 'Choose which alerts a channel receives',
|
|
23068
|
-
description: "Choose which event groups one alert channel receives. provider: slack, discord or webhook. events: any of renders (a render finished or failed), posts (a scheduled post published, partly published or failed), approvals (autopilot drafts waiting for review), watch (new competitor ads found by the competitor watch), credits (credits running low), leads (a new lead-form lead from Meta, LinkedIn or Snapchat) — or \"all\". An empty list keeps the channel connected and sends it nothing. Unknown names are refused by name. Read back from the stored setting. Free.",
|
|
23069
|
-
inputSchema: { provider: alertSinkEnum, events: z.union([z.array(z.enum(['renders', 'posts', 'approvals', 'watch', 'credits', 'leads', 'all'])), z.literal('all')]).describe('event groups, or "all"') },
|
|
23053
|
+
description: "Choose which event groups one alert channel receives. provider: slack, discord or webhook. events: any of renders (a render finished or failed), posts (a scheduled post published, partly published or failed), approvals (autopilot drafts waiting for review), watch (new competitor ads found by the competitor watch), credits (credits running low), leads (a new lead-form lead from Meta, LinkedIn or Snapchat), connections (an account connected to or disconnected from the brand: account.connected / account.disconnected), inbox (a new Facebook or Instagram comment or direct message: comment.received / message.received; opt-in, never on unless named) — or \"all\". An empty list keeps the channel connected and sends it nothing. Unknown names are refused by name. Read back from the stored setting. Free.",
|
|
23054
|
+
inputSchema: { provider: alertSinkEnum, events: z.union([z.array(z.enum(['renders', 'posts', 'approvals', 'watch', 'credits', 'leads', 'connections', 'inbox', 'all'])), z.literal('all')]).describe('event groups, or "all"') },
|
|
23070
23055
|
outputSchema: { provider: z.string().optional(), events: z.array(z.string()).nullable().optional(), note: z.string().optional() },
|
|
23071
23056
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
23072
23057
|
}, wrap(async (a) => { const d = await apiPost('/api/alerts/events', a); return ok(d.note, d); }));
|
|
@@ -23079,10 +23064,12 @@ function memoryNoteVerdict(text) {
|
|
|
23079
23064
|
}, wrap(async (a) => { const d = await apiPost('/api/alerts/test', a); return ok(d.note, d); }));
|
|
23080
23065
|
server.registerTool('rotate_webhook_signing_secret', {
|
|
23081
23066
|
title: 'Rotate the webhook signing secret',
|
|
23082
|
-
description: "Create (or replace) the signing secret for this brand's generic alert webhook and return it ONCE — it is never readable again, so hand it to the user to store. Every event carries Standard Webhooks headers: webhook-id, webhook-timestamp and webhook-signature = v1,<base64 HMAC-SHA256 of \"id.timestamp.body\"> keyed by the base64 part after whsec_. On a rotation the OLD secret keeps signing alongside the new one for 24 hours (two signatures in the header),
|
|
23067
|
+
description: "Create (or replace) the signing secret for this brand's generic alert webhook and return it ONCE — it is never readable again, so hand it to the user to store. Every event carries Standard Webhooks headers: webhook-id, webhook-timestamp and webhook-signature = v1,<base64 HMAC-SHA256 of \"id.timestamp.body\"> keyed by the base64 part after whsec_. On a rotation the OLD secret keeps signing alongside the new one for 24 hours (two signatures in the header), then stops: a receiver still holding only the old secret rejects events after that, so rotate only when the user means to replace it. Free.",
|
|
23083
23068
|
inputSchema: {},
|
|
23084
23069
|
outputSchema: { signingSecret: z.string().optional(), previousStillSignsUntil: z.string().nullable().optional(), note: z.string().optional() },
|
|
23085
|
-
|
|
23070
|
+
// destructiveHint TRUE (OpenAI scan 2026-10-27): a rotation REPLACES the secret, and 24 hours later the old one stops
|
|
23071
|
+
// signing, so a receiver that was not updated starts rejecting every event — a hard-to-reverse change.
|
|
23072
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
23086
23073
|
}, wrap(async () => { const d = await apiPost('/api/alerts/webhook-secret', {}); return ok(`${d.note}\nSigning secret: ${d.signingSecret}`, d); }));
|
|
23087
23074
|
// ── CONNECTOR WRITES. Connecting needs a browser (OAuth consent) and is correctly NOT headless — but the other two
|
|
23088
23075
|
// halves of connector management are, and were web-only: DISCONNECTING, and choosing WHICH accounts a brand may
|
|
@@ -24220,6 +24207,26 @@ function memoryNoteVerdict(text) {
|
|
|
24220
24207
|
return adsOut('posts', all.length, all.slice(0, nAds(limit)), '', 'threads');
|
|
24221
24208
|
}));
|
|
24222
24209
|
|
|
24210
|
+
// ── FIND A PLACE TO TAG ON INSTAGRAM / FACEBOOK (2026-10-27, gap sweep I-23). Meta's own Pages search is closed to
|
|
24211
|
+
// this app, so the server searches the web for facebook.com Pages and reads each one through our data provider; it
|
|
24212
|
+
// needs no connected account, which is why it sits in research, and it is billed in credits like its siblings.
|
|
24213
|
+
server.registerTool('search_meta_places', {
|
|
24214
|
+
title: 'Find a place to tag on Instagram or Facebook',
|
|
24215
|
+
description: 'Find a PLACE to tag on an Instagram or Facebook post: a shop, cafe, restaurant, venue or landmark. Give its name with the city ("Blue Bottle Coffee Oakland"), or paste its Facebook Page link, an Instagram place link (instagram.com/explore/locations/...) or its numeric id. Returns a handful of matching Facebook Pages that have an address: pageId, name, address, category, link. Pass the chosen pageId as locationId (Instagram) or place (Facebook) on post_to_meta, or instagramLocationId on schedule_post. These are matches to CONFIRM with the user (check the name and address; a place tag is public), and Instagram checks the place when the post is made. Needs no connected account. Spends about 1 credit per Page it checks (a name search checks up to 5 Pages, so about 5 to 7 credits); a pasted Facebook Page link is about 1 credit; an Instagram place link or a numeric id costs nothing.',
|
|
24216
|
+
inputSchema: {
|
|
24217
|
+
query: z.string().describe('the place name with its city (e.g. "Blue Bottle Coffee Oakland"), or a Facebook Page link, an Instagram place link, or a numeric id'),
|
|
24218
|
+
near: z.string().optional().describe('optional city or area to narrow a name search, e.g. "Paris"'),
|
|
24219
|
+
limit: z.number().int().min(1).max(8).optional().describe('how many Pages to check on a name search, 1 to 8 (default 5); each is about 1 credit'),
|
|
24220
|
+
},
|
|
24221
|
+
outputSchema: { query: z.string().optional(), near: z.string().optional(), count: z.number().optional(), places: z.array(z.any()).optional(), skipped: z.array(z.any()).optional(), warning: z.string().optional(), note: z.string().optional() },
|
|
24222
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
24223
|
+
}, wrap(async (a) => {
|
|
24224
|
+
const d = await apiGet('/api/meta/places', { query: a.query, ...(a.near ? { near: a.near } : {}), ...(a.limit ? { limit: a.limit } : {}) });
|
|
24225
|
+
const lines = (d.places || []).map((p) => `• ${p.name || '(name not read)'}${p.address ? `, ${p.address}` : ''}${p.category ? ` (${p.category})` : ''}: pageId ${p.pageId}${p.link ? ` · ${p.link}` : ''}`);
|
|
24226
|
+
const sk = (d.skipped || []).map((x) => `• ${x.name || x.link}: ${x.why}`);
|
|
24227
|
+
return ok([d.warning ? `⚠ ${d.warning}` : '', d.count ? `${d.count} place(s) for “${d.query}”${d.near ? ` near ${d.near}` : ''}:` : '', ...lines, sk.length ? 'Not offered as a tag:' : '', ...sk, d.note].filter(Boolean).join('\n'), d);
|
|
24228
|
+
}));
|
|
24229
|
+
|
|
24223
24230
|
server.registerTool('fetch_social_data', {
|
|
24224
24231
|
title: 'Fetch social data',
|
|
24225
24232
|
description: "Generic escape hatch for any ALLOWLISTED long-tail social/web endpoint the dedicated search_* tools don't cover — e.g. {path:'/v1/instagram/profile', params:{handle:'nike'}}. Allowlisted platform families: TikTok (+ TikTok Shop), Instagram, YouTube, Facebook (organic profiles/posts/events/marketplace), LinkedIn (organic posts/companies), Twitter/X, Reddit, Threads, Snapchat, Pinterest, Twitch, Bluesky, Truth Social, Rumble, Spotify, SoundCloud, GitHub, Google search, link-in-bio pages (Linktree etc.). Param names vary per endpoint (profiles use `handle`, keyword searches use `query`, Reddit uses `subreddit`). WARNING: returns RAW provider JSON — large and messy; prefer the dedicated search_* tools. Spends credits.",
|