hermoso 0.1.534 → 0.1.535

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mcp/tools.mjs CHANGED
@@ -44,6 +44,7 @@ import { registerLibraryApp, installEntrypointIcons, chatgptExtOn, EXT_PREFS_KEY
44
44
  import { STATIC_BATCH, normalizeRatios, renderList } from './static-batch.mjs';
45
45
  import { findClientLocalMedia, clientLocalMediaError, MEDIA_PARAM_NOTE } from './media-ref.mjs'; // ONE rule: a media reference is a URL, never a path on the caller's machine (2026-11-05)
46
46
  import { dataUriPayload } from './upload-payload.mjs'; // a dataUri whose payload is not base64 is refused by what it holds, never decoded to noise (2026-11-04)
47
+ import { parseBrandFonts, parseBrandPalette, describeBrandFonts, PALETTE_MAX } from './brand-look.mjs'; // the brand's fonts + palette: the one parser every door uses (2026-11-13)
47
48
  import { joinQueuedImageNote } from './queued-image-note.mjs'; // the queued generate_image note says the logo's corner once (journey QA 2026-11-03)
48
49
  import { CHATGPT_ROSTER_FAMILIES, CHATGPT_ROSTER_LEFT_OUT } from './chatgpt-roster.mjs'; // the ranked ChatGPT families, GENERATED by tools/chatgpt-roster-rank.mjs
49
50
  import { directoryNeutral, paramFallback, makeDirectoryNeutral, directoryToolNames } from './directory-neutral.mjs'; // directory scope: no instructions about other tools or model behaviour in any description (2026-11-06)
@@ -298,7 +299,7 @@ export const CAPABILITY_MAP = [
298
299
  '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).',
299
300
  '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.',
300
301
  '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).',
301
- '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_meta_stories (the Stories live right now on Instagram and Facebook Pages) / get_meta_story (one story id: live details, or that it expired) / instagram_story_insights (a Story’s reach, views, replies, link clicks and navigation, kept readable after its 24 hours end) / 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) · meta_catalog_suggestions (which catalog Meta would show under a new ad) · create_meta_ad_rule + list_meta_ad_rules + update_meta_ad_rule + set_meta_ad_rule_status + run_meta_ad_rule + meta_ad_rule_history + set_meta_ad_rule_limits + delete_meta_ad_rule, and their Google Ads twins create_google_ads_rule + list_google_ads_rules + update_google_ads_rule + set_google_ads_rule_status + run_google_ads_rule + google_ads_rule_history + undo_google_ads_rule_action + set_google_ads_rule_limits + delete_google_ads_rule (AUTOMATED RULES — standing instructions that pause, re-budget or re-bid with nobody watching. Meta runs its own rules; Google Ads has no rules API, so Hermoso runs those on its own schedule. Every rule is born OFF. Switching one on needs confirm:true when it can only pause, decrease or notify, and confirm:true PLUS confirmScope (the scopeToken the list tool prints, computed from the rule as stored) when it can enable anything or raise a budget or bid; per-account limits cap how far any rule may move a budget or bid in a day) · 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 POST INSIGHTS, also on the TikTok account authorization: tiktok_video_insights (per-POST analytics for the brand’s own posts — views, reach, average watch time, the per-second RETENTION curve, traffic sources, followers gained) · tiktok_spark_recommendations (TikTok rates the account’s organic posts HIGH / MEDIUM / LOW as Spark Ads candidates, optionally for an objective) · tiktok_hashtag_suggestions (10 hashtag ideas for a caption keyword, with each tag’s post count). 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) · update_tiktok_ads_ad (EDIT a live ad in place — ad text, call-to-action, landing URL, UTMs, name, video and cover, carousel sound or Instant Form — instead of deleting and rebuilding it, which throws away its review history and delivery learning) · preview_tiktok_ads_ad (see an ad as it renders on TikTok, or preview a video BEFORE any ad is built, with TikTok’s own fix-it tips per placement) · tiktok_ads_review_info (TikTok’s review verdict per ad — approved, approved for only part of its targeting, or rejected — with its REASONS and suggested fix) · prereview_tiktok_ads_creative (the same review verdict BEFORE any ad exists, per video, image, ad text or landing page, for one country) · list_tiktok_ads_music (the ad-cleared sound a CAROUSEL ad needs) · tiktok_ads_creative_fatigue (TikTok’s own verdict on whether a creative is WEARING OUT, per day, with spend and cost per conversion — the signal to swap in a fresh variation) · tiktok_ads_video_retention (per-SECOND ad video performance: where viewers swipe away, the hook read for the next variation) · tiktok_ads_trending_hashtags + tiktok_trends (TikTok’s OWN first-party trend data: top hashtags by country, industry and period; trending pre-cleared Commercial Music Library sounds, the top videos on a sound, top search terms) · 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) · manage_tiktok_ads_negative_keywords (searches an ad must not show for: the whole ad account, a campaign or an ad group) · tiktok_bid_protection (the ad-credit compensation TikTok pays when a Smart+ object misses its bid) · preview_tiktok_smart_ad (see a Smart+ ad as it renders on TikTok, existing or not yet created, with its disclaimer and add-on card) · 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) · create_tiktok_ads_account (CREATE A NEW TIKTOK AD ACCOUNT for a business that has none, in a Business Center the user’s own login administers and in their company name; two steps, call without confirm first, and it can never be deleted) · create_tiktok_ads_business_center (when that login administers NO Business Center, create one first in the user’s company name; same two steps, every field asked of the user, never guessed) · 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): 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) · BOOST AN EXISTING LINKEDIN POST (a personal post, or a Company Page post with organization; an opt-in on the LinkedIn connection whose refusal carries the enable link): linkedin_boost_status (can it be boosted / is it already) · boost_linkedin_post (free preview without confirm; confirm:true creates it and LAUNCHES spend, there is no paused state) · end_linkedin_boost · linkedin_boost_analytics · 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 (read only: Reddit retired creating lead forms) · 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). WORDPRESS: post_to_wordpress (a blog post on the brand’s own self-hosted site — publish, draft, pending review, private, or scheduled natively with a future date; the first image is featured, the rest inline, a video embedded; a title is required) · update_wordpress_post · delete_wordpress_post (trash, or permanent; confirm-gated) · list_wordpress_posts · get_wordpress_post · list_wordpress_terms / create_wordpress_term. WordPress has no per-post views. DISCORD + SLACK: post_to_discord / post_to_slack (an ANNOUNCEMENT through the brand’s announcement webhook, never its alert one — Discord 2000 characters with files up to 20 MB attached, larger as a link, mentions off unless asked; Slack 40,000 characters with images inline and a video as a link) WHOP: post_to_whop (a FORUM post — title, Markdown, images and video, pin, members-only or public, paywall, poll — or a CHAT message, into the brand’s whop as the Hermoso app it installed) · list_whop_destinations · list_whop_products · edit_whop_post · get_whop_post · delete_whop_post (chat messages; Whop’s API cannot delete a forum post). A whop is connected from Hermoso (Connectors ▸ Whop) or by pressing Open Hermoso in the whop’s dashboard; nothing is billed through Whop. · edit_discord_message / delete_discord_message / get_discord_message (its reactions). A Slack webhook post cannot be edited or deleted. 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) · set_microsoft_ads_bidding (change a campaign’s BID STRATEGY — EnhancedCpc, MaxClicks, MaxConversions with an optional target CPA, MaxConversionValue with an optional target ROAS, TargetImpressionShare — read back) · list_microsoft_ads_negative_keywords + add_microsoft_ads_negative_keywords + remove_microsoft_ads_negative_keywords + manage_microsoft_ads_negative_keyword_list (NEGATIVE keywords: the searches a campaign or ad group must NOT pay for, Exact or Phrase only, and SHARED negative lists reused across campaigns; removing one on a LIVE campaign lets those searches trigger paid ads again, so it is confirm-gated) · list_microsoft_ads_uet_tags + create_microsoft_ads_uet_tag (the UET site tag and its install snippet — conversion goals, remarketing and conversion bidding all need one) · create_microsoft_ads_conversion_goal (Url / Event / OfflineConversion / Duration / PagesViewedPerVisit goals, read back) · list_microsoft_ads_audience_targeting + set_microsoft_ads_audience_targeting (put an audience — a customer list, remarketing, in-market — to work on a campaign or ad group, or EXCLUDE it, with a bid adjustment) · create_microsoft_ads_image_ad (turn a render into an IMAGE ad on the Microsoft Audience Network or a multimedia ad on Search; the picture is cropped to the 1.91:1 and 1:1 sizes Microsoft requires) · upload_microsoft_ads_video (put a render on the account as a video asset, or check one still processing; Microsoft refuses clips under 6 seconds) · 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) · get_microsoft_ads_account_properties / set_microsoft_ads_account_properties (account-level settings: tracking template, final URL suffix, MSCLKID auto-tagging, view-through conversions, business attributes, AI asset enhancement opt-out and the AI-detected content watermark policy, read back after every change). 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). MAILCHIMP (same shape): create_mailchimp_template · create_mailchimp_campaign · send_mailchimp_campaign (confirm:true + the count) · mailchimp_report and more. KLAVIYO (the brand’s own email, nothing billed): create_klaviyo_template (an ad + on-brand copy → an email) · create_klaviyo_campaign (a draft) · send_klaviyo_campaign (needs confirm:true + the recipient count) · klaviyo_report and more. 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.',
302
+ 'E) PUBLISH & MANAGE YOUR CHANNELS — post, run ads, and organize files on the user’s OWN connected accounts (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 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_meta_stories (the Stories live right now on Instagram and Facebook Pages) / get_meta_story (one story id: live details, or that it expired) / instagram_story_insights (a Story’s reach, views, replies, link clicks and navigation, kept readable after its 24 hours end) / 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) · meta_catalog_suggestions (which catalog Meta would show under a new ad) · create_meta_ad_rule + list_meta_ad_rules + update_meta_ad_rule + set_meta_ad_rule_status + run_meta_ad_rule + meta_ad_rule_history + set_meta_ad_rule_limits + delete_meta_ad_rule, and their Google Ads twins create_google_ads_rule + list_google_ads_rules + update_google_ads_rule + set_google_ads_rule_status + run_google_ads_rule + google_ads_rule_history + undo_google_ads_rule_action + set_google_ads_rule_limits + delete_google_ads_rule (AUTOMATED RULES — standing instructions that pause, re-budget or re-bid with nobody watching. Meta runs its own rules; Google Ads has no rules API, so Hermoso runs those on its own schedule. Every rule is born OFF. Switching one on needs confirm:true when it can only pause, decrease or notify, and confirm:true PLUS confirmScope (the scopeToken the list tool prints, computed from the rule as stored) when it can enable anything or raise a budget or bid; per-account limits cap how far any rule may move a budget or bid in a day) · 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 POST INSIGHTS, also on the TikTok account authorization: tiktok_video_insights (per-POST analytics for the brand’s own posts — views, reach, average watch time, the per-second RETENTION curve, traffic sources, followers gained) · tiktok_spark_recommendations (TikTok rates the account’s organic posts HIGH / MEDIUM / LOW as Spark Ads candidates, optionally for an objective) · tiktok_hashtag_suggestions (10 hashtag ideas for a caption keyword, with each tag’s post count). TIKTOK ADS (a SEPARATE connection from the TikTok posting connector above — 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) · update_tiktok_ads_ad (EDIT a live ad in place — ad text, call-to-action, landing URL, UTMs, name, video and cover, carousel sound or Instant Form — instead of deleting and rebuilding it, which throws away its review history and delivery learning) · preview_tiktok_ads_ad (see an ad as it renders on TikTok, or preview a video BEFORE any ad is built, with TikTok’s own fix-it tips per placement) · tiktok_ads_review_info (TikTok’s review verdict per ad — approved, approved for only part of its targeting, or rejected — with its REASONS and suggested fix) · prereview_tiktok_ads_creative (the same review verdict BEFORE any ad exists, per video, image, ad text or landing page, for one country) · list_tiktok_ads_music (the ad-cleared sound a CAROUSEL ad needs) · tiktok_ads_creative_fatigue (TikTok’s own verdict on whether a creative is WEARING OUT, per day, with spend and cost per conversion — the signal to swap in a fresh variation) · tiktok_ads_video_retention (per-SECOND ad video performance: where viewers swipe away, the hook read for the next variation) · tiktok_ads_trending_hashtags + tiktok_trends (TikTok’s OWN first-party trend data: top hashtags by country, industry and period; trending pre-cleared Commercial Music Library sounds, the top videos on a sound, top search terms) · 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) · manage_tiktok_ads_negative_keywords (searches an ad must not show for: the whole ad account, a campaign or an ad group) · tiktok_bid_protection (the ad-credit compensation TikTok pays when a Smart+ object misses its bid) · preview_tiktok_smart_ad (see a Smart+ ad as it renders on TikTok, existing or not yet created, with its disclaimer and add-on card) · 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) · create_tiktok_ads_account (CREATE A NEW TIKTOK AD ACCOUNT for a business that has none, in a Business Center the user’s own login administers and in their company name; two steps, call without confirm first, and it can never be deleted) · create_tiktok_ads_business_center (when that login administers NO Business Center, create one first in the user’s company name; same two steps, every field asked of the user, never guessed) · 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 — Connectors ▸ Snapchat Ads): 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) · BOOST AN EXISTING LINKEDIN POST (a personal post, or a Company Page post with organization; an opt-in on the LinkedIn connection whose refusal carries the enable link): linkedin_boost_status (can it be boosted / is it already) · boost_linkedin_post (free preview without confirm; confirm:true creates it and LAUNCHES spend, there is no paused state) · end_linkedin_boost · linkedin_boost_analytics · 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 (read only: Reddit retired creating lead forms) · 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). WORDPRESS: post_to_wordpress (a blog post on the brand’s own self-hosted site — publish, draft, pending review, private, or scheduled natively with a future date; the first image is featured, the rest inline, a video embedded; a title is required) · update_wordpress_post · delete_wordpress_post (trash, or permanent; confirm-gated) · list_wordpress_posts · get_wordpress_post · list_wordpress_terms / create_wordpress_term. WordPress has no per-post views. DISCORD + SLACK: post_to_discord / post_to_slack (an ANNOUNCEMENT through the brand’s announcement webhook, never its alert one — Discord 2000 characters with files up to 20 MB attached, larger as a link, mentions off unless asked; Slack 40,000 characters with images inline and a video as a link) WHOP: post_to_whop (a FORUM post — title, Markdown, images and video, pin, members-only or public, paywall, poll — or a CHAT message, into the brand’s whop as the Hermoso app it installed) · list_whop_destinations · list_whop_products · edit_whop_post · get_whop_post · delete_whop_post (chat messages; Whop’s API cannot delete a forum post). A whop is connected from Hermoso (Connectors ▸ Whop) or by pressing Open Hermoso in the whop’s dashboard; nothing is billed through Whop. · edit_discord_message / delete_discord_message / get_discord_message (its reactions). A Slack webhook post cannot be edited or deleted. 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 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) · set_microsoft_ads_bidding (change a campaign’s BID STRATEGY — EnhancedCpc, MaxClicks, MaxConversions with an optional target CPA, MaxConversionValue with an optional target ROAS, TargetImpressionShare — read back) · list_microsoft_ads_negative_keywords + add_microsoft_ads_negative_keywords + remove_microsoft_ads_negative_keywords + manage_microsoft_ads_negative_keyword_list (NEGATIVE keywords: the searches a campaign or ad group must NOT pay for, Exact or Phrase only, and SHARED negative lists reused across campaigns; removing one on a LIVE campaign lets those searches trigger paid ads again, so it is confirm-gated) · list_microsoft_ads_uet_tags + create_microsoft_ads_uet_tag (the UET site tag and its install snippet — conversion goals, remarketing and conversion bidding all need one) · create_microsoft_ads_conversion_goal (Url / Event / OfflineConversion / Duration / PagesViewedPerVisit goals, read back) · list_microsoft_ads_audience_targeting + set_microsoft_ads_audience_targeting (put an audience — a customer list, remarketing, in-market — to work on a campaign or ad group, or EXCLUDE it, with a bid adjustment) · create_microsoft_ads_image_ad (turn a render into an IMAGE ad on the Microsoft Audience Network or a multimedia ad on Search; the picture is cropped to the 1.91:1 and 1:1 sizes Microsoft requires) · upload_microsoft_ads_video (put a render on the account as a video asset, or check one still processing; Microsoft refuses clips under 6 seconds) · 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) · get_microsoft_ads_account_properties / set_microsoft_ads_account_properties (account-level settings: tracking template, final URL suffix, MSCLKID auto-tagging, view-through conversions, business attributes, AI asset enhancement opt-out and the AI-detected content watermark policy, read back after every change). 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). MAILCHIMP (same shape): create_mailchimp_template · create_mailchimp_campaign · send_mailchimp_campaign (confirm:true + the count) · mailchimp_report and more. KLAVIYO (the brand’s own email, nothing billed): create_klaviyo_template (an ad + on-brand copy → an email) · create_klaviyo_campaign (a draft) · send_klaviyo_campaign (needs confirm:true + the recipient count) · klaviyo_report and more. 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.',
302
303
  '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.',
303
304
  ].join('\n');
304
305
 
@@ -334,7 +335,7 @@ export const MCP_INSTRUCTIONS = [
334
335
  '• RESEARCH winning ads: find_competitors, competitor_teardown, pull_competitor_ads, research_ads, search_meta_ads, search_google_ads, search_linkedin_ads, search_tiktok, search_instagram, search_youtube, search_reddit, search_threads, mine_angles, analyze_video, check_ad_policy.',
335
336
  '• CREATE on-brand ads: render_ad, generate_image, generate_video, generate_avatar, make_template_ad, make_motion_film, make_thumbnail, make_explainer, plan_ad, plan_variations; get_brand / draft_brand / update_brand; list_creators / save_creator; edit_video, dub_video, clip_video, reframe_video, upscale_video, stitch_video.',
336
337
  '• RAW MODELS, prompt only: generate_image / generate_video with useBrand:false, generate_voice, generate_text, upload_file (any file becomes a URL every tool accepts).',
337
- '• PUBLISH & SCHEDULE to the user\'s OWN accounts: post_to_meta (+Threads), post_to_x, post_to_linkedin, post_to_tiktok, post_to_youtube, post_to_pinterest, post_to_bluesky, post_to_telegram, post_to_google_business, post_to_wordpress, post_to_discord, post_to_slack; schedule_post (+ list/reschedule/cancel); list_connectors. Not connected: say to connect it in Settings ▸ Connectors.',
338
+ '• PUBLISH & SCHEDULE to the user\'s OWN accounts: post_to_meta (+Threads), post_to_x, post_to_linkedin, post_to_tiktok, post_to_youtube, post_to_pinterest, post_to_bluesky, post_to_telegram, post_to_google_business, post_to_wordpress, post_to_discord, post_to_slack; schedule_post (+ list/reschedule/cancel); list_connectors. Not connected: say to connect it in Connectors.',
338
339
  // INSTAGRAM_PATHS_NOTE — an inline copy of lib/instagram-paths.mjs (the twins ship without lib/); tools/instagram-paths-check.mjs asserts they are byte-equal.
339
340
  // THE LAST LINE OF THE HEAD, AND THE ONE THAT MAKES THE SHORT ROSTER SAFE (2026-09-17). Your tool LIST is now
340
341
  // core-first; every name in the bullets above is held out of it on SIZE and answers a call anyway. Say that
@@ -354,7 +355,7 @@ export const MCP_INSTRUCTIONS = [
354
355
  // POSITION: immediately AFTER the ADS bullet, because some hosts truncate this string to ~2KB and all five areas
355
356
  // plus "ADS on eleven platforms" must stay inside that window — put it above and it evicts the ads bullet, which
356
357
  // causes the exact failure it exists to stop. LENGTH: §2d of agent-surface-guidance-check caps the whole string,
357
- // so it says only what is NEW and points at nothing already named below — `list_connectors`, `Workspace ▸
358
+ // so it says only what is NEW and points at nothing already named below — `list_connectors`, `
358
359
  // Connectors`, `?connect=<provider>` and the CLI escape hatch are all already in here, further down.
359
360
  'A tool NOT in your roster is filtered out — that account is not connected here. Never say Hermoso lacks a platform.',
360
361
  // SECOND LINE, before the capability map: those four jobs read as four STAGES, and by the time an agent has
@@ -380,7 +381,7 @@ export const MCP_INSTRUCTIONS = [
380
381
  '• 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.',
381
382
  '• 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; list_creators / save_creator / delete_creator (the reusable saved CAST — re-cast the same face; 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.',
382
383
  '• 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.',
383
- '• 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); 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 hours, phone, website, description, categories, name, address; live on Search and Maps, so an unconfirmed call writes nothing and previews the diff), google_business_account (whose account, and whether that role can edit); 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 the user picks 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). 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.',
384
+ '• 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); 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 hours, phone, website, description, categories, name, address; live on Search and Maps, so an unconfirmed call writes nothing and previews the diff), google_business_account (whose account, and whether that role can edit); 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 the user picks 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). 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 Connectors in the app.',
384
385
  '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 works on every host, including claude.ai and ChatGPT, which fix their tool list at connect. 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, do NOT tell the user the capability does not exist or keep retrying: use call_tool, which does not depend on the roster changing. `?tools=all` lists everything at connect. NEVER tell a user Hermoso cannot manage campaigns, read analytics or comments, answer a DM or pull channel 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\'.',
385
386
  // HINTS (2026-09-17). The advice was always in the prose; this says the keyed copy exists so a client that
386
387
  // prefers structure does not have to parse a sentence to find it.
@@ -405,7 +406,7 @@ export const MCP_INSTRUCTIONS_DIRECTORY = [
405
406
  '• ANALYTICS: meta_insights, google_ads_report and the other per-platform reports, analytics_report, search_console_performance.',
406
407
  '• PROFILE: get_brand, draft_brand, update_brand, list_connectors, list_brands, use_brand, hermoso_credits.',
407
408
  'Not every tool is in your starting list: find_tools({query}) finds one and call_tool({name,args}) runs it.',
408
- 'Confirm with the user before anything is published, deleted or set to spend. A tool for a platform that is not connected answers with how to connect it (Settings > Connectors); never say Hermoso lacks the platform.',
409
+ 'Confirm with the user before anything is published, deleted or set to spend. A tool for a platform that is not connected answers with how to connect it (Connectors); never say Hermoso lacks the platform.',
409
410
  ].join('\n');
410
411
  // ── CHATGPT GETS ITS OWN INSTRUCTIONS (2026-09-30) ──────────────────────────────────────────────────────────────
411
412
  // OpenAI's automated plugin scan marked MCP_INSTRUCTIONS "need further review", and read against the plugin
@@ -452,7 +453,7 @@ export const MCP_INSTRUCTIONS_WIDGET = [
452
453
  // beside it, named by Keyword Planner volume (the owner, 2026-11-06; evidence in memory mcp-directory-listings.md).
453
454
  export const APP_TITLES = Object.freeze({ create: 'Hermoso: AI Ad Generator', ads: 'Hermoso: Ads Manager', social: 'Hermoso: Social Media Manager' });
454
455
  const APP_CONFIRM = 'CONFIRM with the user before posting or scheduling, messaging customers, turning on a campaign or changing a budget, deleting anything, or replacing a saved profile.';
455
- const APP_WORKSPACE = 'WORKSPACE: get_brand, list_brands, use_brand, create_brand, list_connectors (accounts are connected in the Hermoso app, Settings > Connectors), hermoso_credits, report_bug, request_feature.';
456
+ const APP_WORKSPACE = 'WORKSPACE: get_brand, list_brands, use_brand, create_brand, list_connectors (accounts are connected in the Hermoso app, Connectors), hermoso_credits, report_bug, request_feature.';
456
457
  const APP_ADS_LINES = [
457
458
  "Hermoso Ads Manager runs and reports on the user's own paid campaigns on Meta, Google, Microsoft, TikTok, LinkedIn, Pinterest, Reddit, Snapchat, X, Apple and ChatGPT Ads, and reads website and business analytics: Google Analytics, Search Console, Bing Webmaster, Tag Manager, PostHog, Mixpanel, Amplitude, Merchant Center, Stripe and HubSpot.",
458
459
  '• CAMPAIGNS: list_meta_ads, meta_insights, list_google_ads_campaigns, google_ads_report, tiktok_ads_report, linkedin_ads_report and the other list_*_campaigns / *_report tools read campaigns and results; analyze_campaigns compares them. create_* tools build campaigns, ad sets and ads PAUSED and read them back; set_*_status and set_*_budget control spend.',
@@ -522,10 +523,22 @@ async function imageBlock(url) {
522
523
  const ct = (r.headers.get('content-type') || 'image/jpeg').split(';')[0];
523
524
  if (!/^image\//.test(ct)) return null;
524
525
  const buf = Buffer.from(await r.arrayBuffer());
525
- if (buf.length > 1_400_000) return null;
526
+ // A FINISHED AD IS OFTEN A 1.5 MB PNG (2026-11-13, claude.ai showed only "view the ad"): a raster over IMAGE_BLOCK_MAX is
527
+ // shown as the server's display-sized JPEG of the same picture (/api/img/preview, our own files only); the link the
528
+ // caller gets is still the full-size original. A preview that cannot be read keeps the old answer: no block, the link.
529
+ if (buf.length > IMAGE_BLOCK_MAX && /^image\/(png|jpe?g|webp)$/i.test(ct) && typeof url === 'string' && /^https?:\/\//i.test(url)) {
530
+ try {
531
+ const p = await fetch(`${API_BASE}/api/img/preview?url=${encodeURIComponent(url)}`);
532
+ const pb = p.ok && /^image\/jpeg/i.test(p.headers.get('content-type') || '') ? Buffer.from(await p.arrayBuffer()) : null;
533
+ if (pb && pb.length && pb.length <= IMAGE_BLOCK_HARD_MAX) return { type: 'image', data: pb.toString('base64'), mimeType: 'image/jpeg' };
534
+ } catch {}
535
+ }
536
+ if (buf.length > IMAGE_BLOCK_HARD_MAX) return null;
526
537
  return { type: 'image', data: buf.toString('base64'), mimeType: ct };
527
538
  } catch { return null; }
528
539
  }
540
+ // Above IMAGE_BLOCK_MAX a raster is sent as its display-sized preview; nothing above IMAGE_BLOCK_HARD_MAX is ever inlined.
541
+ const IMAGE_BLOCK_MAX = 700_000, IMAGE_BLOCK_HARD_MAX = 1_400_000;
529
542
  // THE MCP CAPTURE SEAM (2026-08-05). Two halves, and the split is what stops the ledger double-counting:
530
543
  // • `toolCtx.run({tool})` stamps `x-hermoso-tool` on every /api call this tool makes, so the SERVER's route()
531
544
  // records the failure under the TOOL NAME. A path cannot do that — plan_ad, render_ad and make_template_ad all
@@ -538,9 +551,9 @@ async function imageBlock(url) {
538
551
  // This gate used to DETECT the condition with a prose regex over the error message
539
552
  // (/isn.?t connected|connect your .* (account|channel)|add it under .*connectors/i). Prose is not a classifier, and
540
553
  // it over-matched in exactly the way metaErr's permission regex did three times ([[meta-error-class-check]]):
541
- // • A SHARING refusal — "… isn’t shared with this brand. Add it under Settings ▸ Connectors ▸ … ▸ Manage
554
+ // • A SHARING refusal — "… isn’t shared with this brand. Add it under Connectors ▸ … ▸ Manage
542
555
  // accounts, then retry." (server.js, 400/403, connector ALREADY LINKED) — hit `add it under .*connectors` and
543
- // got "Ask your human to connect it at app.hermoso.ai (Settings ▸ Connectors)" bolted on. That instruction is
556
+ // got "Ask your human to connect it at app.hermoso.ai (Connectors)" bolted on. That instruction is
544
557
  // false (it IS connected), it cannot fix anything (re-doing OAuth does not tick a Page/property), and it
545
558
  // directly contradicts the correct Manage-accounts guidance sitting in the same message. Observed live
546
559
  // 2026-08-10 on a Google Analytics property-sharing 403.
@@ -593,7 +606,7 @@ const CONNECT_LEGACY_PROVIDERS = [
593
606
  // is a well-formed app deep link; only a server that predates it gets the bare link rebuilt here.
594
607
  const CONNECT_LINK_HINT = (provider, url) => `\nHand your human this one-click connect link: ${/^https?:\/\/[^\s?#]+\/\?connect=[a-z_]{1,40}(?:&brand=[A-Za-z0-9_-]{1,64})?$/.test(String(url || '')) ? url : `https://app.hermoso.ai/?connect=${provider}`} — it opens Hermoso, signs them in if needed, and starts the connection. Then retry.`;
595
608
  const KEY_CONNECT_HINT = (provider, msg) => (Object.prototype.hasOwnProperty.call(KEY_CONNECTORS, provider) && !/connect_connector/.test(String(msg || ''))) ? `\nOr, if the user would rather not open a browser, connect it right here with connect_connector (provider "${provider}"); a key pasted into this chat stays in its history, which the app's own paste box avoids.` : '';
596
- const CONNECT_ASK_HINT = `\nAsk your human to connect it at https://app.hermoso.ai (Settings ▸ Connectors), then retry.`;
609
+ const CONNECT_ASK_HINT = `\nAsk your human to connect it at https://app.hermoso.ai (Connectors), then retry.`;
597
610
  // PURE, and module-scope on purpose: tools/mcp-not-connected-gate-check.mjs lifts and RUNS this exact function over
598
611
  // a table of real error shapes, so the classification is tested rather than read. Returns the sentence to APPEND, or
599
612
  // '' when the failure is not a not-connected one (which must leave the caller's own message alone).
@@ -602,7 +615,7 @@ const CONNECT_ASK_HINT = `\nAsk your human to connect it at https://app.hermoso.
602
615
  // lib/instagram-paths.mjs (the twins ship without lib/; the Meta help-center URLs it rests on are cited there), asserted
603
616
  // byte-equal by tools/instagram-paths-check.mjs. Said when a Meta ADS tool answers "Meta is not connected" — the moment an
604
617
  // Instagram-only user has asked for ads. Pure, so the check runs it.
605
- export const INSTAGRAM_ADS_PATH_NOTE = 'INSTAGRAM BUT NO META AD ACCOUNT: Hermoso builds and reads ads only through the Meta connector, and Meta says a Meta ad account needs a personal Facebook profile, so say that plainly and walk them through the cheapest path: (1) log in to Facebook with a personal profile (or create one); every profile already has an ad account in Ads Manager (adsmanager.facebook.com); (2) create a Facebook Page for the business (Facebook > Pages > Create Page; it never has to post); (3) in the Instagram app, Edit profile > Page > Connect or create, and pick that Page; (4) add a payment method in Ads Manager on a computer or browser; (5) connect Meta in Hermoso (Settings > Connectors > Meta) and tick the Page, the Instagram account and the ad account. Only if they will not use Facebook at all, the fallback is a boost from instagram.com in a browser, never the iPhone or iPad Instagram app: Meta says boosts paid in the iOS app carry Apple\u2019s service fee and boosting on the web avoids it. A boost made that way stays in Instagram, and Hermoso cannot build or read it.';
618
+ export const INSTAGRAM_ADS_PATH_NOTE = 'INSTAGRAM BUT NO META AD ACCOUNT: Hermoso builds and reads ads only through the Meta connector, and Meta says a Meta ad account needs a personal Facebook profile, so say that plainly and walk them through the cheapest path: (1) log in to Facebook with a personal profile (or create one); every profile already has an ad account in Ads Manager (adsmanager.facebook.com); (2) create a Facebook Page for the business (Facebook > Pages > Create Page; it never has to post); (3) in the Instagram app, Edit profile > Page > Connect or create, and pick that Page; (4) add a payment method in Ads Manager on a computer or browser; (5) connect Meta in Hermoso (Connectors > Meta) and tick the Page, the Instagram account and the ad account. Only if they will not use Facebook at all, the fallback is a boost from instagram.com in a browser, never the iPhone or iPad Instagram app: Meta says boosts paid in the iOS app carry Apple\u2019s service fee and boosting on the web avoids it. A boost made that way stays in Instagram, and Hermoso cannot build or read it.';
606
619
  const META_ADS_TOOL_RE = /^(?:(?:create|list|update|delete|set|preview|estimate|upload|find)_meta_(?:campaigns?|adsets?|ads?|audiences?|asset|reach|object|campaign_status)|meta_insights)$/;
607
620
  export function metaAdsSetupNote(tool, e) {
608
621
  if (Number(e?.status) !== 401 || String(e?.connector || '') !== 'meta' || !META_ADS_TOOL_RE.test(String(tool || ''))) return null;
@@ -900,6 +913,9 @@ const wrap = (fn) => {
900
913
  // FREE CREDITS WAITING (2026-10-25): the server's sentence already names the one fix (open the app once, signed in) and
901
914
  // drops its top-up advice, so it passes through as written on every host, and neither buy_credits nor the pricing page is named.
902
915
  else if (e?.welcomeClaim && e.welcomeClaim.url) _hints.push(welcomeClaimHint(e.welcomeClaim));
916
+ // EARNED FREE CREDITS WERE JUST PAID (2026-11-13, server settleEarnedRewards): the sentence says so and asks for the same request
917
+ // again; it is not a purchase moment, on any host.
918
+ else if (e?.rewardsSettled && e.rewardsSettled.credits > 0) _hints.push({ do: `call ${_tool} again with the same arguments`, why: `${e.rewardsSettled.credits} free credits this account had earned were just added, so the same request can now run` });
903
919
  else if (_widget && /not enough credits|out of credits|insufficient credits|needs (a paid plan|the Pro plan)/i.test(msg)) { msg = neutralCreditsText(msg, e?.buy && typeof e.buy === 'object' ? { need: e.buy.need, balance: e.buy.balance } : {}); _hints.push(...creditsLowHints()); }
904
920
  else if (_buy) { _hints.push(...buyDoorsHints(_buy, _tool)); msg += `\n${buyDoorsText(_buy, _tool)}`; }
905
921
  else if (/not enough credits|out of credits|needs (a paid plan|the Pro plan)/i.test(msg)) _hints.push({ do: 'buy_credits({})', why: 'this account cannot cover the call; buy_credits quotes on a saved card or returns a checkout link, and billing_status shows the balance and the billing role' }), msg += `\nRun buy_credits to top up (credit packs): with a saved card it quotes (quoteToken included) then one-click charges on confirm:true + quote_token; with no card yet it returns a checkout link your human pays once (the card saves for one-click after). billing_status shows your balance, plan + billing role; if you're an admin, upgrade_plan moves to a bigger monthly plan (a person pays on Stripe). hermoso_credits shows the balance; hermoso_capabilities lists per-model credit costs.${/no markup on models/i.test(msg) ? ' Starter, Pro and Studio make credits go twice as far: upgrade_plan moves the user to one of them, with no markup on models (a person pays on Stripe).' : ''}`;
@@ -914,9 +930,12 @@ const wrap = (fn) => {
914
930
  // so an agent relays it instead of retrying or telling the user to reconnect. Read from the STRUCTURED field.
915
931
  if (e?.metaAuthHold === true) _hints.push({ do: 'stop retrying; ask the user to clear Meta\u2019s security check: as the Facebook profile that connected Hermoso, on a computer (not the Ads Manager app), create a NEW draft ad in Ads Manager, click Start authentication (or Fix errors) on the error it shows and complete it, or follow a red banner if one is shown; then run the same call again', why: 'Meta refuses new or edited ads from that profile until it re-authenticates; the connection and permissions are fine and reconnecting with the same profile does not clear it', ...(e?.remedy?.login ? { who: e.remedy.who } : {}) });
916
932
  // A RATE LIMIT SAYS HOW LONG, IN A NUMBER AND A SENTENCE (2026-11-02: an agent called set_youtube_thumbnail 73 times on "wait a few minutes"). Read from the structured field the client carried.
917
- if (Number(e?.status) === 429 && e?.retryAfter > 0) { const _w = Number(e.retryAfter), _ww = _w < 90 ? `${Math.round(_w)} seconds` : `${Math.round(_w / 60)} minutes`; msg = `${msg} Do not call ${_tool} again yet: wait ${_ww} first. Calling it sooner is refused the same way and can make the limit last longer.`; _hints.push({ do: `wait ${_ww}, then call ${_tool} once`, why: 'the platform is rate-limiting this call, so an immediate retry cannot succeed' }); }
933
+ // THE PER-ACCOUNT YOUTUBE LIMIT LIFTS AT MIDNIGHT PACIFIC, NOT IN A MINUTE (2026-11-13): the server's sentence already says when, so the
934
+ // generic "wait N seconds" below would contradict it. Read from the structured code, never the prose.
935
+ if (e?.apiCode === 'youtube_account_limit') _hints.push({ do: 'stop calling YouTube tools that upload or change anything for this account until midnight Pacific, and tell the user in the words of this message; reading videos and comments still works', why: 'this account has used its share of the shared YouTube allowance for today, so a retry before the reset is refused the same way and nothing is lost by waiting' });
936
+ else if (Number(e?.status) === 429 && e?.retryAfter > 0) { const _w = Number(e.retryAfter), _ww = _w < 90 ? `${Math.round(_w)} seconds` : `${Math.round(_w / 60)} minutes`; msg = `${msg} Do not call ${_tool} again yet: wait ${_ww} first. Calling it sooner is refused the same way and can make the limit last longer.`; _hints.push({ do: `wait ${_ww}, then call ${_tool} once`, why: 'the platform is rate-limiting this call, so an immediate retry cannot succeed' }); }
918
937
  { const _ig = metaAdsSetupNote(_tool, e); if (_ig) { msg += `\n${_ig.text}`; _hints.push(_ig.hint); } }
919
- if (Number(e?.status) === 401 && e?.connector) _hints.push({ do: `have the user connect "${e.connector}" (Settings \u25b8 Connectors, or the one-click link in this message)`, why: `${e.connector} is not connected in this workspace, so this tool can only answer 401 until it is` });
938
+ if (Number(e?.status) === 401 && e?.connector) _hints.push({ do: `have the user connect "${e.connector}" (Connectors, or the one-click link in this message)`, why: `${e.connector} is not connected in this workspace, so this tool can only answer 401 until it is` });
920
939
  // THE FREE PLAN'S POSTING ALLOWANCE / AUTOPILOT ON A PAID PLAN (2026-11-02): read from the structured remedy, never the
921
940
  // prose. The fix is a plan, not credits, so the agent is pointed at upgrade_plan (or, on a widget host, at the app).
922
941
  if (e?.remedy?.code === 'posting_limit' || e?.remedy?.code === 'autopilot_paid_only' || e?.remedy?.code === 'model_paid_only') {
@@ -3462,7 +3481,7 @@ export async function legacyToolAnswer(name, request, extra, ctx) {
3462
3481
  reportDeadEnd('stale_roster', name, `${name} no longer exists — the host's tool list is an older published snapshot`);
3463
3482
  if (spec.gone) {
3464
3483
  const text = spec.gone === 'reddit'
3465
- ? `${name} is no longer offered: Hermoso does not post to Reddit; Reddit Ads is supported (the reddit_ads tools), organic Reddit posting is not. There is nothing the user can connect, so do not point them at Settings ▸ Connectors.`
3484
+ ? `${name} is no longer offered: Hermoso does not post to Reddit; Reddit Ads is supported (the reddit_ads tools), organic Reddit posting is not. There is nothing the user can connect, so do not point them at Connectors.`
3466
3485
  : spec.gone === 'reddit_lead_form'
3467
3486
  ? `${name} is no longer offered: Reddit retired onsite lead forms (the API sunset on 2026-09-21, the create call answers 410 Gone since 2026-09-30, and ads still using one were paused), so nothing can create one. Existing forms stay readable with list_reddit_ads_lead_forms. For new leads from Reddit, use a form on the advertiser's own landing page inside a LEAD_GENERATION campaign (create_reddit_ads_campaign), measured with list_reddit_ads_pixels and send_reddit_ads_conversions. Reddit Ads itself is fully supported.`
3468
3487
  : `${name} is no longer offered by Hermoso.`;
@@ -3995,19 +4014,19 @@ export async function ingestFileParam(tool, args, upload = apiUploadUrl) {
3995
4014
  export const SLIM_TOOL_DESCRIPTIONS = Object.freeze({
3996
4015
  make_thumbnail: 'Render a click-driving YOUTUBE / Shorts / Instagram THUMBNAIL or video cover through the full production pipeline (concept, casting, scene, render, tweaks, text), not a bare image prompt. Use it for any "thumbnail", "video cover" or MrBeast-style packaging ask INSTEAD of generate_image. Default: 4 variants at 1K (YouTube shows 1280x720 at most), each billed; 2K or 4K only when the user asks. dryRun:true prices it free. The headline overlay is free. WHO IS IN FRAME — never assume or silently substitute a stranger. A framework with a person and no face photo is refused (nothing charged): ask the user once — themselves (a face photo, identity-locked), a generated person (`castGenericPerson:true`), or a people-free framework. TEXT — default is a CLEAN render with the headline TYPESET over it (free, legible, correctly spelled): pass `headline`. `bakeText:true` only on an explicit ask for words painted INTO the image. ALWAYS check the returned postRenderCheck against the image before presenting it.',
3997
4016
  schedule_post: 'Use this to schedule a social media post for later, on one channel or several. Costs no credits (X alone bills per call: hermoso_credits). FREE PLAN: 5 posts a month on up to 3 channels (a post counts once; UTC month); paid plans are unlimited. Over it the post is refused and nothing is queued: relay the refusal as written, never retry or split the post. A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order, never just slide 1. Name the time in `at`, or pass `useQueue:true` for the brand’s next free POSTING SLOT (“just queue it”). Hermoso never guesses the account: Pinterest needs `boardId` (list_pinterest_boards), else refused; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages), else it goes to the person’s own profile. Several Facebook Pages need `pageId` (list_meta_pages), for Facebook and for an Instagram that posts through a Page: none named is refused at scheduling with the Pages listed, so ask the user which. Resolve these FIRST; let the user pick. Posts GO LIVE PUBLICLY by default.',
3998
- post_to_x: 'Use this when the user wants to post to X (Twitter) now. Publish to the user’s connected X (Twitter) account — a single post, a post with an image or video render attached, a reply to an existing post, or a whole THREAD (pass `thread` as an array and each part is posted as a reply to the one before). This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Text is NEVER truncated. Write altText whenever you attach a render. COSTS CREDITS at X’s per-post price, every post in a thread billed: a post that contains a link (any web address, and every quote post) is X’s link rate, far higher than one without (hermoso_credits gives both figures). When the user asks what a post will cost, call this with dryRun:true: it posts NOTHING and returns that exact post’s costCredits. THIS TOOL POSTS ORGANICALLY — it does not create an ad campaign. Needs X connected (Settings > Connectors > X).',
4017
+ post_to_x: 'Use this when the user wants to post to X (Twitter) now. Publish to the user’s connected X (Twitter) account — a single post, a post with an image or video render attached, a reply to an existing post, or a whole THREAD (pass `thread` as an array and each part is posted as a reply to the one before). This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Text is NEVER truncated. Write altText whenever you attach a render. COSTS CREDITS at X’s per-post price, every post in a thread billed: a post that contains a link (any web address, and every quote post) is X’s link rate, far higher than one without (hermoso_credits gives both figures). When the user asks what a post will cost, call this with dryRun:true: it posts NOTHING and returns that exact post’s costCredits. THIS TOOL POSTS ORGANICALLY — it does not create an ad campaign. Needs X connected (Connectors > X).',
3999
4018
  clip_video: 'Cut ONE long video into several RANKED, ready-to-post short clips (podcast, webinar, interview, talk, long ad cut -> Reels/Shorts/TikTok). Transcribes with timestamps, picks the strongest SELF-CONTAINED moments, then cuts + reframes each with ffmpeg — no video model renders anything, so it is fast and cheap. NOT supported: TikTok / Instagram / Facebook links, and anything age-restricted, private, members-only, geo-blocked or still LIVE — those fail fast with the real reason and are fully refunded, so ask for a direct file or an upload rather than retrying. SUBTITLES ARE BURNED IN BY DEFAULT (white sentence case timed by the spoken words, thin black outline, bottom safe band, no box) because short-form is watched on mute; captions:false for clean footage. TIMING IS APPROXIMATE (per-sentence, not word-level); never promise frame-accurate sync.',
4000
- post_to_bluesky: '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 and says which limit. 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. Returns the post\'s public bsky.app URL. Connect at Settings > Connectors > Bluesky.',
4019
+ post_to_bluesky: '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 and says which limit. 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. Returns the post\'s public bsky.app URL. Connect at Connectors > Bluesky.',
4001
4020
  make_explainer: 'AI HOST EPISODE: format \'host_episode\' = one presenter talking to camera, the camera changing every piece (frontal, three-quarter, close), the same host and set throughout, native voice, 16:9. It returns a 480p DRAFT; HD (720p) is a SEPARATE call with `fromDraft` (quote it with dryRun, run it only when the user asks). Otherwise: turn a TOPIC into a finished narrated explainer video, in one of TWO LANES (`lane`). BURNED ON-SCREEN TEXT IS OFF BY DEFAULT; the film ships clean unless the user asks; `captions:true` adds held key points and `subtitles:true` adds subtitles timed to the narration. Takes SEVERAL minutes — one image render per frame; NOT for a short product ad: use render_ad, generate_video or make_template_ad.',
4002
4021
  multiply_ad: 'MULTIPLY a winning video ad into N variants: each gets a NEW character, outfit, location and/or objects while the cut, the camera motion, the pacing and the ORIGINAL AUDIO stay exactly as they were, and the source\'s own captions and on-screen text stay exactly as they are. ONE JOB PER VARIANT: get_job each until done; never describe a variant before its URL arrives. COST: quoteOnly:true returns the exact credits free: state that first. A paid planning read (planCredits) runs next, then each variant bills perVariantCredits. dryRun:true pays for that read; pass its `plan` back to render without paying again. No ad: list_winning_ads. New words: rewrite_ad_line.',
4003
4022
  set_post_refill: 'Turn AUTOPILOT POSTING (autoposting, the posting refill) on, change it or pause it. Available on paid plans: on the Free plan switching it on is refused, so relay that and do not retry. PASS ONLY WHAT CHANGES. It always makes NEW posts (a fresh image, carousel or video for each) and never re-posts the Library. Ask the user for anything missing (goal, mode, channels) rather than guessing. `mode` picks what happens to each batch: "review" (the default) keeps the fresh posts as drafts until a human approves them (run_post_refill approve), "auto" schedules them straight away. `enabled:false` is the PAUSE — it removes the recurring job outright, and posts already queued are left alone (cancel those with cancel_scheduled).',
4004
4023
  post_to_linkedin_page: 'Use this when the user wants to post to a LinkedIn company Page. Publishing costs no credits. This is a DIFFERENT thing from post_to_linkedin, which publishes to the person’s own profile: pick the one the user actually asked for and never substitute. organizationId comes from list_linkedin_pages; omit it only when the account administers exactly one Page. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. LinkedIn does NOT allow the image, video, captions or thumbnail of a published post to be swapped afterwards, so get all of that right first (the copy can still be edited with manage_linkedin_post).',
4005
- post_to_telegram: '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). 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. 0 credits. Connect at Settings > Connectors > Telegram.',
4024
+ post_to_telegram: '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). 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. 0 credits. Connect at Connectors > Telegram.',
4006
4025
  generate_image: 'Use this when the user asks for an image ad, ad creative, product photo or social post graphic for their business or product, or an ANIMATED GIF (format gif) or SVG file (format svg). It renders a finished image on a real image model, on their saved brand (product photos, logo), and returns a URL ready to post. For a video, use render_ad. The saved brand’s product photo is used automatically (see get_brand / list_product_photos), so never ask the user to upload it again. MULTI-BRAND: hydration uses the SAVED brand; for a different brand (a fresh draft_brand) pass its own productImages/logo as refImages with useBrand:false, or the wrong product lands in the shot. model = a catalog id from hermoso_capabilities (omit for the default). Seconds. Spends credits.',
4007
4026
  make_motion_film: 'MOTION GRAPHICS and ANIMATED VIDEO in one call: kinetic typography (animated text), a logo animation or intro, a product launch film, an animated social cut. Pick it for \'animated video\', \'motion graphics\', \'kinetic text\', \'text animation\', \'logo animation\', \'logo intro\', \'animated explainer\' asks. STEPS: without confirm it is a FREE quote that gives both prices (length, the music\'s tempo; the spec in structuredContent): say both; preview:true is a 1-credit contact sheet of the still version; confirm:true renders. Only the user\'s or the brand\'s words appear: never invent a claim, number or review.',
4008
- post_to_youtube: 'Use this when the user wants to upload a video or Short to YouTube. Publishing costs no credits. Publish a finished video to the brand’s connected YouTube channel. Pass a Hermoso render URL (or an upload_file url for a local/external file). PUBLISHES PUBLICLY BY DEFAULT: a plain "post this to YouTube" puts it ON the channel (confirm the title with the user, as for any publish) and notifies subscribers as YouTube does. Pass the privacy the user states instead: "unlisted" (link-only) or "private" (eyes-only). A video meant to run as a YouTube/Google AD should go up "unlisted" — private videos CANNOT be used as ads. Never choose unlisted for music on your own. Needs a connected YouTube channel (Settings > Connectors > YouTube).',
4027
+ post_to_youtube: 'Use this when the user wants to upload a video or Short to YouTube. Publishing costs no credits. Publish a finished video to the brand’s connected YouTube channel. Pass a Hermoso render URL (or an upload_file url for a local/external file). PUBLISHES PUBLICLY BY DEFAULT: a plain "post this to YouTube" puts it ON the channel (confirm the title with the user, as for any publish) and notifies subscribers as YouTube does. Pass the privacy the user states instead: "unlisted" (link-only) or "private" (eyes-only). A video meant to run as a YouTube/Google AD should go up "unlisted" — private videos CANNOT be used as ads. Never choose unlisted for music on your own. Needs a connected YouTube channel (Connectors > YouTube).',
4009
4028
  hook_variants: 'HOOK MULTIPLIER: give ONE finished video ad N NEW OPENING HOOKS, N complete versions to A/B test. Pass the video\'s FILE URL (a render, job result, list_library or upload_file). 1-5 versions, default 3. COST: quoteOnly:true returns the exact credits free: state that first. A paid planning read (planCredits) runs next, then each planned version bills like fix_beat for the hook\'s seconds. dryRun:true pays for that read; pass its `plan` back to render exactly those. Returns ONE JOB PER VERSION; call get_job on each until done, never describe a version before its URL arrives.',
4010
- post_x_article: 'Publish a long-form ARTICLE to the user’s connected X (Twitter) account — X’s own long-form format, which is a different thing from a long POST. THE HARD LIMIT TO PLAN AROUND: X allows only about 10 Article DRAFTS and 5 Article PUBLISHES per account per DAY, it publishes that cap nowhere, and it counts a FAILED attempt against them — so never iterate on an article by republishing it, and use `publish: false` to save a draft for the user to read in X’s own composer when they want to review before it goes out. This PUBLISHES immediately and PUBLICLY: show the user the full text and get an explicit yes first, because a published Article can NEVER be edited (X’s own rule — edit_x_post will refuse it) and the only remedy is to delete and republish, which costs another of the five. Costs credits (X bills per API request, and this is three of them). Needs X connected (Settings > Connectors > X).',
4029
+ post_x_article: 'Publish a long-form ARTICLE to the user’s connected X (Twitter) account — X’s own long-form format, which is a different thing from a long POST. THE HARD LIMIT TO PLAN AROUND: X allows only about 10 Article DRAFTS and 5 Article PUBLISHES per account per DAY, it publishes that cap nowhere, and it counts a FAILED attempt against them — so never iterate on an article by republishing it, and use `publish: false` to save a draft for the user to read in X’s own composer when they want to review before it goes out. This PUBLISHES immediately and PUBLICLY: show the user the full text and get an explicit yes first, because a published Article can NEVER be edited (X’s own rule — edit_x_post will refuse it) and the only remedy is to delete and republish, which costs another of the five. Costs credits (X bills per API request, and this is three of them). Needs X connected (Connectors > X).',
4011
4030
  render_ad: 'Use this when the user asks for a video ad, Reel, TikTok ad, UGC video or commercial for their business or product It renders a finished ad on a real video model, on their brand (real product photos, brand voice), as an mp4 ready to post: timed shots, clean speech, text set in post, no music, captions or end card unless asked. Pass plan_ad’s full output as `creative` (plan_ad turns a one-line ask into the concept). Most models cap a clip at 15s and the longest at 30s: dryRun:true shows the act split free, before spending. The brand’s saved product photo (get_brand / list_product_photos) is the real product in the shots, with no upload needed. CAST A SAVED CREATOR with `creator` (list_creators) so the same person stars in every ad; otherwise each render casts a new face. The user’s own pictures (creative.inserts, or `inserts`) are laid in full screen at their seconds. An app shown on a device screen with no real screens to lay there returns needsAppScreens, nothing charged: ask, then pass screenChoice or screens. 1–3 min; poll get_job while it renders. Spends credits.',
4012
4031
  });
4013
4032
  export const slimPointer = (name) => ` Full rules: get_instructions({topic:'${name}'}).`;
@@ -4040,7 +4059,7 @@ export function defForHost(name, def, widgetHost) {
4040
4059
  // by-reference property) and a def with nothing to rename comes back as itself.
4041
4060
  const WIDGET_RENAME_RE = new RegExp(`\\b(${Object.keys(WIDGET_HOST_RENAMES).join('|')})\\b`, 'g');
4042
4061
  const _widgetRenamed = new WeakMap();
4043
- // …AND A "(Settings > Connectors > X, or connect_connector)" CLAUSE LOSES ITS SECOND HALF THERE (2026-10-18). On ChatGPT a
4062
+ // …AND A "(Connectors > X, or connect_connector)" CLAUSE LOSES ITS SECOND HALF THERE (2026-10-18). On ChatGPT a
4044
4063
  // key is connected in the Hermoso app, never pasted into the conversation, and OpenAI's scan held tools whose text
4045
4064
  // offered that (post_to_bluesky / post_to_telegram, 2026-10-16). The app half of the sentence stays.
4046
4065
  const WIDGET_KEY_PASTE_RE = /,? or connect_connector\b[^.;)]*/g;
@@ -4175,7 +4194,7 @@ export const holdReasonFor = (name, ctx) => {
4175
4194
  if (ctx.app && ctx.directory === 'scoped' && (WITHHELD_FROM_DIRECTORY.has(name) || (DIRECTORY_BANNED_GROUPS.includes(ctx.groupOf[name]) && !APP_DIRECTORY_CREATE_ALLOW.has(name)))) return 'directory'; // the Claude cage answers for generation
4176
4195
  if (ctx.app && toolHeldBackByApp(name, ctx.groupOf[name], ctx) && !(ctx.widgetHost && appWidgetUnlisted(name))) return 'app_scope'; // another focused app's tool (a name renamed again on the app stays callable)
4177
4196
  if (toolHeldBackByConnectors(name, ctx.conn)) {
4178
- // NOT CONNECTED vs NOT OFFERED are different answers (2026-09-03). "Connect it under Settings ▸ Connectors" is
4197
+ // NOT CONNECTED vs NOT OFFERED are different answers (2026-09-03). "Connect it under Connectors" is
4179
4198
  // right for a provider with a tile and a dead end for one without (google_business: no tile until Google grants
4180
4199
  // the API). `offered` is null on a server that predates the field.
4181
4200
  const prov = toolProvider(name);
@@ -4189,8 +4208,8 @@ export const holdReasonFor = (name, ctx) => {
4189
4208
  // the same advice keyed {do, why} so an agent can act on it without parsing the sentence. Derived from the SAME
4190
4209
  // `why` the sentence is, so the two can never disagree about what the hold was.
4191
4210
  export const holdHints = (name, why, ctx = null) => {
4192
- if (why === 'not_connected') { const p = toolProvider(name) || 'that'; const _ig = metaAdsSetupNote(canonicalToolName(name), { status: 401, connector: p }); return [...(_ig ? [_ig.hint] : []), { do: `have the user connect "${p}" under Settings \u25b8 Connectors${Object.prototype.hasOwnProperty.call(KEY_CONNECTORS, p) ? ', or connect_connector right here if it is a paste-a-key account' : ''}, then call ${name} again`, why: `${name} needs the "${p}" connection and this workspace has not made it` }]; }
4193
- if (why === 'not_offered') return [{ do: `do not offer this capability and do not send the user to Settings \u25b8 Connectors`, why: `Hermoso does not offer the "${toolProvider(name) || 'required'}" connection yet, so there is nothing the user can connect` }];
4211
+ if (why === 'not_connected') { const p = toolProvider(name) || 'that'; const _ig = metaAdsSetupNote(canonicalToolName(name), { status: 401, connector: p }); return [...(_ig ? [_ig.hint] : []), { do: `have the user connect "${p}" under Connectors${Object.prototype.hasOwnProperty.call(KEY_CONNECTORS, p) ? ', or connect_connector right here if it is a paste-a-key account' : ''}, then call ${name} again`, why: `${name} needs the "${p}" connection and this workspace has not made it` }]; }
4212
+ if (why === 'not_offered') return [{ do: `do not offer this capability and do not send the user to Connectors`, why: `Hermoso does not offer the "${toolProvider(name) || 'required'}" connection yet, so there is nothing the user can connect` }];
4194
4213
  if (why === 'shopify_store') return [{ do: 'do not offer this here; if the user runs a Shopify store, tell them to install Hermoso from the Shopify App Store and open it from their Shopify admin', why: `${name} ${SHOPIFY_STORE_NOTE}` }];
4195
4214
  if (why === 'unavailable') return [{ do: 'do not offer this capability; carry on with the tools that are listed', why: `${name} is not available in Hermoso yet` }];
4196
4215
  if (why === 'host_policy' && executorLike(name)) return [{ do: 'call the Hermoso tools in your list directly', why: `on this host every Hermoso operation is its own listed tool, so ${name} is not offered` }];
@@ -4204,9 +4223,9 @@ export const holdReasonText = (name, why, ctx = null) => {
4204
4223
  if (why === 'not_offered') {
4205
4224
  const prov = toolProvider(name) || 'that';
4206
4225
  const because = ctx?.conn?.gated?.[prov] ? ` (${ctx.conn.gated[prov]})` : '';
4207
- return `${name} is not available: Hermoso does not offer the "${prov}" connection yet${because}. There is nothing the user can connect, so do not point them at Settings ▸ Connectors and do not offer this capability.`;
4226
+ return `${name} is not available: Hermoso does not offer the "${prov}" connection yet${because}. There is nothing the user can connect, so do not point them at Connectors and do not offer this capability.`;
4208
4227
  }
4209
- if (why === 'shopify_store') return `${name} ${SHOPIFY_STORE_NOTE} Do not send the user to Settings \u25b8 Connectors for it; there is no Shopify tile to connect.`;
4228
+ if (why === 'shopify_store') return `${name} ${SHOPIFY_STORE_NOTE} Do not send the user to Connectors for it; there is no Shopify tile to connect.`;
4210
4229
  if (why === 'unavailable') return `${name} is not available in Hermoso yet. Do not offer it or point the user at a setting for it; nothing they can connect turns it on.`;
4211
4230
  // ONE sentence, two reasons: OpenAI's generic-executor rule for EXECUTOR_TOOLS, its commerce policy for the rest.
4212
4231
  if (why === 'host_policy' && WIDGET_WITHHELD_WHY[name]) return `${name} is not offered on this host: ${WIDGET_WITHHELD_WHY[name]}.`;
@@ -4222,7 +4241,7 @@ export const holdReasonText = (name, why, ctx = null) => {
4222
4241
  const ttCreate = name === 'create_tiktok_ads_account';
4223
4242
  const link = tpl.replace('{provider}', prov) + (ttCreate ? `${tpl.includes('?') ? '&' : '?'}next=tiktok-ad-account` : '');
4224
4243
  const _ig = metaAdsSetupNote(canonicalToolName(name), { status: 401, connector: prov }); // Instagram but no Meta ad account: the steps, said where they ask (INSTAGRAM_ADS_PATH_NOTE)
4225
- return `${name} needs the "${prov}" connection and this workspace has not made it.${connectionBrandNote(ctx?.conn?.brand)}${metaAlternativeNote(name)}${_ig ? ` ${_ig.text}` : ''} Connect it under Settings ▸ Connectors in the Hermoso app${isKey ? ', or right here with connect_connector if the user prefers' : `, or hand the user this one-click link: ${link} (it opens Hermoso on this profile and goes straight to the sign-in${ttCreate ? ', then to the form that creates the TikTok ad account' : ''})`}, then call again.`;
4244
+ return `${name} needs the "${prov}" connection and this workspace has not made it.${connectionBrandNote(ctx?.conn?.brand)}${metaAlternativeNote(name)}${_ig ? ` ${_ig.text}` : ''} Connect it under Connectors in the Hermoso app${isKey ? ', or right here with connect_connector if the user prefers' : `, or hand the user this one-click link: ${link} (it opens Hermoso on this profile and goes straight to the sign-in${ttCreate ? ', then to the form that creates the TikTok ad account' : ''})`}, then call again.`;
4226
4245
  }
4227
4246
  if (why === 'app_scope') return `${name} is not part of this Hermoso app; it is in ${APP_HOME_TEXT(name, ctx)}. Carry on with the tools this connection lists.`;
4228
4247
  if (why === 'directory') return `${name} is not part of this connection, which carries research, publishing, inbox, ads, analytics and account tools.`;
@@ -4423,7 +4442,7 @@ const makeEnableToolsHandler = (ctx) => async ({ groups }) => {
4423
4442
  // an account that has not connected the platform yet, because they could only answer 401. Say that, and say where
4424
4443
  // the one-click fix is — the failure this sentence prevents is an agent telling a user we cannot run their ads.
4425
4444
  const held = heldBack
4426
- ? ` ${heldBack} more tool${heldBack === 1 ? ' is' : 's are'} built and ready but not listed because their account is not connected in this workspace yet — Hermoso supports them all; connect the account under Workspace ▸ Connectors (https://app.hermoso.ai/?connect=<provider>, connect_connector for a paste-a-key account, or list_connectors to see what is linked) and they appear.`
4445
+ ? ` ${heldBack} more tool${heldBack === 1 ? ' is' : 's are'} built and ready but not listed because their account is not connected in this workspace yet — Hermoso supports them all; connect the account under Connectors (https://app.hermoso.ai/?connect=<provider>, connect_connector for a paste-a-key account, or list_connectors to see what is linked) and they appear.`
4427
4446
  : '';
4428
4447
  const route = 'call find_tools to locate the tool and call_tool to run it by name — that needs no reload and works on every host; or reconnect'
4429
4448
  + ' with `?tools=all` on the server URL, or run the `hermoso` CLI, which reaches every tool with no roster at all.';
@@ -5554,7 +5573,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5554
5573
  server.group('channels');
5555
5574
  server.registerTool('post_to_bluesky', {
5556
5575
  title: 'Post to Bluesky',
5557
- 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 and says which limit. @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. 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.",
5576
+ 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 and says which limit. @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. 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 Connectors > Bluesky.",
5558
5577
  inputSchema: {
5559
5578
  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."),
5560
5579
  brand: HOOK_ATTR.brand,
@@ -5584,7 +5603,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5584
5603
  server.group('channel_admin');
5585
5604
  server.registerTool('delete_bluesky_post', {
5586
5605
  title: 'Delete a post from the connected Bluesky account',
5587
- description: "PERMANENTLY delete one of the connected Bluesky account's OWN posts. IRREVERSIBLE — the AT Protocol removes the record from the account's repo, there is no trash and no undelete, and the post's likes, reposts, replies and quotes go with it. Call it WITHOUT confirm first: nothing is deleted, and it reports the post's REAL text and its live like / repost / reply / quote counts read back from Bluesky. Show the user that, get an unambiguous yes, then call again with confirm:true — plus, once the post has ANY engagement, confirmText echoing the post's own text (the first 40 characters is enough; any longer leading run works too). confirmText exists because confirming that you meant to delete SOMETHING does not prove you aimed at the right post, and a wrong id must not be confirmable blind. A brand-new post with nothing on it stays a ONE-call delete. Identify the post by its AT-URI or by just its RECORD KEY — the short id at the end of its bsky.app link, e.g. 3mtc4n3fibn2x. Deleting only ever works on the connected account's own posts; another account's URI is refused. 0 credits. Needs Bluesky connected (Settings > Connectors > Bluesky, or connect_connector).",
5606
+ description: "PERMANENTLY delete one of the connected Bluesky account's OWN posts. IRREVERSIBLE — the AT Protocol removes the record from the account's repo, there is no trash and no undelete, and the post's likes, reposts, replies and quotes go with it. Call it WITHOUT confirm first: nothing is deleted, and it reports the post's REAL text and its live like / repost / reply / quote counts read back from Bluesky. Show the user that, get an unambiguous yes, then call again with confirm:true — plus, once the post has ANY engagement, confirmText echoing the post's own text (the first 40 characters is enough; any longer leading run works too). confirmText exists because confirming that you meant to delete SOMETHING does not prove you aimed at the right post, and a wrong id must not be confirmable blind. A brand-new post with nothing on it stays a ONE-call delete. Identify the post by its AT-URI or by just its RECORD KEY — the short id at the end of its bsky.app link, e.g. 3mtc4n3fibn2x. Deleting only ever works on the connected account's own posts; another account's URI is refused. 0 credits. Needs Bluesky connected (Connectors > Bluesky, or connect_connector).",
5588
5607
  inputSchema: {
5589
5608
  ...MANAGE_BRAND,
5590
5609
  uri: z.string().describe("the post's AT-URI (at://did:plc:…/app.bsky.feed.post/…) as post_to_bluesky returned it, the handle form of the same URI for the CONNECTED account only (at://<its handle>/app.bsky.feed.post/…), or just its record key (3mtc4n3fibn2x)"),
@@ -5616,7 +5635,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5616
5635
  server.group('channels');
5617
5636
  server.registerTool('post_to_telegram', {
5618
5637
  title: 'Post to Telegram',
5619
- 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.",
5638
+ 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 Connectors > Telegram.",
5620
5639
  inputSchema: {
5621
5640
  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."),
5622
5641
  brand: HOOK_ATTR.brand,
@@ -5667,7 +5686,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5667
5686
  description: "Read what people have SENT to the connected Telegram bot — the newest message from each chat, newest chat first, so you can see who is waiting on a reply. Reply with post_to_telegram using the chatId shown. "
5668
5687
  + "Warning: THIS IS A ROLLING 24-HOUR WINDOW, NOT AN INBOX. Telegram keeps undelivered updates for 24 hours and publishes NO history endpoint at all, so anything older is unrecoverable — never report an empty result as 'you have no messages', report it as 'nothing in the last 24 hours'. "
5669
5688
  + "Two more Telegram rules worth stating before someone concludes the feature is broken: a bot that has an outgoing WEBHOOK configured gets nothing from this at all (Telegram's own rule, and the reply says so), and a bot can never message someone first — they have to write to it. "
5670
- + "Free — no vendor charge and no credits. Needs Telegram connected (Settings > Connectors > Telegram, or connect_connector).",
5689
+ + "Free — no vendor charge and no credits. Needs Telegram connected (Connectors > Telegram, or connect_connector).",
5671
5690
  inputSchema: { limit: z.number().optional().describe('how many raw updates to scan, 1-100 (default 100). Messages are grouped per chat, so this is not the number of rows you get back.') },
5672
5691
  outputSchema: { messages: z.array(z.any()).optional(), count: z.number().optional(), webhookSet: z.boolean().optional(), windowHours: z.number().optional(), note: z.string().optional() },
5673
5692
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
@@ -5687,7 +5706,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5687
5706
  // and nothing to echo back. A confirmText gate would be asking the caller to echo something we invented.
5688
5707
  server.registerTool('delete_telegram_message', {
5689
5708
  title: 'Delete a Telegram message',
5690
- description: "PERMANENTLY delete one message the bot posted to a Telegram chat. Call it WITHOUT confirm first: nothing is deleted and you get a sentence to show the user. There is deliberately NO preview of the message — the Bot API has no method that reads one message back, so anything shown would be invented, and for the same reason the result after deleting is Telegram’s own success answer rather than a verified read-back. TWO VENDOR LIMITS, both Telegram’s and neither ours: \"A message can only be deleted if it was sent less than 48 hours ago\", and in a CHANNEL the bot needs the Post Messages right to remove even its own posts. Takes the same chatId as post_to_telegram plus the messageId post_to_telegram returned. 0 credits. Needs Telegram connected (Settings > Connectors > Telegram, or connect_connector).",
5709
+ description: "PERMANENTLY delete one message the bot posted to a Telegram chat. Call it WITHOUT confirm first: nothing is deleted and you get a sentence to show the user. There is deliberately NO preview of the message — the Bot API has no method that reads one message back, so anything shown would be invented, and for the same reason the result after deleting is Telegram’s own success answer rather than a verified read-back. TWO VENDOR LIMITS, both Telegram’s and neither ours: \"A message can only be deleted if it was sent less than 48 hours ago\", and in a CHANNEL the bot needs the Post Messages right to remove even its own posts. Takes the same chatId as post_to_telegram plus the messageId post_to_telegram returned. 0 credits. Needs Telegram connected (Connectors > Telegram, or connect_connector).",
5691
5710
  inputSchema: {
5692
5711
  ...MANAGE_BRAND,
5693
5712
  chatId: z.string().describe("the chat the message is in — the same @username or numeric id it was posted with"),
@@ -5706,7 +5725,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5706
5725
  // Studio agent and both MCP twins can never disagree about what a WordPress post is.
5707
5726
  server.registerTool('post_to_wordpress', {
5708
5727
  title: 'Publish a WordPress blog post',
5709
- description: "Use this when the user wants to publish, draft or schedule a BLOG POST on their WordPress site. Publishing costs no credits. A title is REQUIRED and the text becomes the body: plain text or light markdown (## headings, - lists, **bold**, [links](url)) is turned into WordPress blocks, and HTML is sent as written. status: publish (default), draft, pending (submit for review), private (logged-in editors only) or future with a date — a future `date` makes WordPress schedule it natively. The FIRST image becomes the FEATURED image, every other image is uploaded and placed inline, and a video is uploaded and embedded (if the site's host refuses the file as too large, the post plays it from its own link and the reply says so). categories and tags take names or ids; a name the site lacks is CREATED and the reply says which. An over-long title is refused, never cut. WordPress has NO per-post views in its API: never quote a view count for a WordPress post. Self-hosted WordPress only — a WordPress.com site needs its own OAuth sign-in and cannot be connected. Not connected: the user connects it under Workspace > Connectors > WordPress, or you call connect_connector with provider wordpress (siteUrl, username, appPassword).",
5728
+ description: "Use this when the user wants to publish, draft or schedule a BLOG POST on their WordPress site. Publishing costs no credits. A title is REQUIRED and the text becomes the body: plain text or light markdown (## headings, - lists, **bold**, [links](url)) is turned into WordPress blocks, and HTML is sent as written. status: publish (default), draft, pending (submit for review), private (logged-in editors only) or future with a date — a future `date` makes WordPress schedule it natively. The FIRST image becomes the FEATURED image, every other image is uploaded and placed inline, and a video is uploaded and embedded (if the site's host refuses the file as too large, the post plays it from its own link and the reply says so). categories and tags take names or ids; a name the site lacks is CREATED and the reply says which. An over-long title is refused, never cut. WordPress has NO per-post views in its API: never quote a view count for a WordPress post. Self-hosted WordPress only — a WordPress.com site needs its own OAuth sign-in and cannot be connected. Not connected: the user connects it under Connectors > WordPress, or you call connect_connector with provider wordpress (siteUrl, username, appPassword).",
5710
5729
  inputSchema: {
5711
5730
  brand: HOOK_ATTR.brand,
5712
5731
  ...INTENT_SHORT,
@@ -6279,7 +6298,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
6279
6298
  const c = await apiGet('/api/connectors');
6280
6299
  const on = (c.connectors || []).filter(x => x.connected).map(x => x.provider);
6281
6300
  const offered = c.providers || [];
6282
- connLine = `\nConnected channels (usable NOW): ${on.join(', ') || 'none — the user links these in Settings ▸ Connectors'}`
6301
+ connLine = `\nConnected channels (usable NOW): ${on.join(', ') || 'none — the user links these in Connectors'}`
6283
6302
  + `\nOffered but not linked: ${offered.filter(p => !on.includes(p)).join(', ') || 'none'}`
6284
6303
  + `\nAny channel absent from BOTH lists is not available on this build — do not tell the user to connect it.`
6285
6304
  + `\nOnly the accounts a user TICKED under a connector's "Manage accounts" are usable; list_connector_accounts shows them and set_connector_accounts changes them.`;
@@ -6743,7 +6762,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
6743
6762
  const d = await apiDelete(`/api/brands/${encodeURIComponent(hit.id)}?${q}`);
6744
6763
  const dis = d.connectorsDisconnected || [];
6745
6764
  const nq = +d.scheduledPostsCancelled || 0; // its queued posts go with it (2026-10-01)
6746
- return ok(`Deleted “${hit.name}” and everything in it.${dis.length ? ` Disconnected: ${dis.join(', ')}. Those connections can be restored for ${d.connectorsRecoverableForDays || 30} days from Settings ▸ Connectors; after that they are gone.` : ''}${nq ? ` Cancelled ${nq} scheduled post${nq === 1 ? '' : 's'} that had not published yet.` : ''}${d.brandsLeft === 0 ? ' That was the last profile on this account: create_brand makes a new one.' : ''}`, d);
6765
+ return ok(`Deleted “${hit.name}” and everything in it.${dis.length ? ` Disconnected: ${dis.join(', ')}. Those connections can be restored for ${d.connectorsRecoverableForDays || 30} days from Connectors; after that they are gone.` : ''}${nq ? ` Cancelled ${nq} scheduled post${nq === 1 ? '' : 's'} that had not published yet.` : ''}${d.brandsLeft === 0 ? ' That was the last profile on this account: create_brand makes a new one.' : ''}`, d);
6747
6766
  }));
6748
6767
 
6749
6768
 
@@ -7321,17 +7340,17 @@ function buildTools(rawServer, opts = {}, sink = null) {
7321
7340
  return ok(`${d.count} result(s) for "${d.q}":\n${lines.join('\n') || '(none)'}`, d);
7322
7341
  }));
7323
7342
 
7324
- // ---------- META publishing + ads management (needs a connected Meta account: Settings ▸ Connectors ▸ Meta) ----------
7343
+ // ---------- META publishing + ads management (needs a connected Meta account: Connectors ▸ Meta) ----------
7325
7344
  server.group('channels');
7326
7345
  server.registerTool('list_meta_pages', {
7327
7346
  title: 'List Meta pages & ad accounts',
7328
- description: 'List the Facebook Pages (with any linked Instagram business account) and ad accounts on the connected Meta account — use before post_to_meta / create_meta_campaign to pick the target. Requires the user to have connected Meta (Settings > Connectors > Meta); returns a connect hint if not.',
7347
+ description: 'List the Facebook Pages (with any linked Instagram business account) and ad accounts on the connected Meta account — use before post_to_meta / create_meta_campaign to pick the target. Requires the user to have connected Meta (Connectors > Meta); returns a connect hint if not.',
7329
7348
  inputSchema: {},
7330
7349
  outputSchema: { pages: z.array(z.any()).optional(), adAccounts: z.array(z.any()).optional() },
7331
7350
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
7332
7351
  }, wrap(async () => {
7333
7352
  const [pg, aa] = await Promise.all([apiGet('/api/meta/pages').catch((e) => ({ __err: e.message })), apiGet('/api/meta/adaccounts').catch((e) => ({ __err: e.message }))]);
7334
- if (pg.__err && /connect/i.test(pg.__err)) return { content: [{ type: 'text', text: 'No Meta account connected yet — connect it in Settings ▸ Connectors ▸ Meta, then try again.' }], isError: true };
7353
+ if (pg.__err && /connect/i.test(pg.__err)) return { content: [{ type: 'text', text: 'No Meta account connected yet — connect it in Connectors ▸ Meta, then try again.' }], isError: true };
7335
7354
  // “none” IS NOT AN ANSWER HERE (2026-08-04). Meta is the connector that answers an unreachable workspace with
7336
7355
  // 200 + a flag instead of a 401 — `/api/meta/pages` returns `{pages: [], needsSelection: true}` both when Meta
7337
7356
  // was never connected AND when it is connected with nothing ticked. Only the ERROR shape was handled, so every
@@ -7339,7 +7358,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
7339
7358
  // their Page list is empty, and leaving the agent to conclude the brand has no Facebook presence. It is the
7340
7359
  // same trap `list_connector_accounts` already special-cases for this provider; the two branches are not worth
7341
7360
  // a second round trip to separate, so name both and give the one action that fixes either.
7342
- if (pg.needsSelection && !(pg.pages || []).length) return { content: [{ type: 'text', text: 'No Facebook Page is shared with this profile, so there is nothing to post to or advertise from. Either Meta is not connected to this workspace yet, or it is connected and no Pages have been ticked for this profile — both are fixed in the same place: Settings ▸ Connectors ▸ Meta ▸ Manage accounts (or list_connector_accounts / set_connector_accounts). This is NOT a report that the account has no Pages. If list_connector_accounts shows NO Page to tick, Meta did not list one for this connection: do not send the user to tick it; tell them to reconnect Meta and, in Meta\'s dialog, choose the Page and the business that owns it, leaving business_management switched on.' }], isError: true };
7361
+ if (pg.needsSelection && !(pg.pages || []).length) return { content: [{ type: 'text', text: 'No Facebook Page is shared with this profile, so there is nothing to post to or advertise from. Either Meta is not connected to this workspace yet, or it is connected and no Pages have been ticked for this profile — both are fixed in the same place: Connectors ▸ Meta ▸ Manage accounts (or list_connector_accounts / set_connector_accounts). This is NOT a report that the account has no Pages. If list_connector_accounts shows NO Page to tick, Meta did not list one for this connection: do not send the user to tick it; tell them to reconnect Meta and, in Meta\'s dialog, choose the Page and the business that owns it, leaving business_management switched on.' }], isError: true };
7343
7362
  const pages = pg.pages || [], adAccounts = aa.adAccounts || [];
7344
7363
  return ok(`Pages: ${pages.map(p => p.name + (p.instagram ? ` (IG @${p.instagram.username})` : '')).join(', ') || 'none'}\nAd accounts: ${adAccounts.map(a => `${a.name} (act_${a.accountId}, ${a.currency}${a.accountStatus && a.accountStatus !== 'ACTIVE' ? `, Meta status ${a.accountStatus}${a.canDeliver ? ' (still delivering)' : ' (not delivering)'}` : a.active === false ? ', not active' : ''})`).join(', ') || 'none'}`, { pages, adAccounts });
7345
7364
  }));
@@ -7518,7 +7537,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
7518
7537
  server.group('channels');
7519
7538
  server.registerTool('list_whatsapp_accounts', {
7520
7539
  title: 'WhatsApp Business accounts and numbers',
7521
- description: 'The WhatsApp Business Accounts SHARED WITH THIS PROFILE and the phone numbers registered on each — the ids every other WhatsApp tool needs, plus each number’s QUALITY RATING, which is what decides how many messages Meta will let it send. Start here. A WABA with no number cannot send anything and the reply says so: Hermoso does not register or verify numbers, that is WhatsApp Manager. A business portfolio that could not be read is REPORTED rather than dropped — an account missing from this list would read as "the brand has no WhatsApp", which is a claim about their business and not about our read. TWO ACCOUNTS CAN CARRY THE SAME DISPLAY NAME — always quote the `display` field or the id when naming one to a user, never the bare name. ONLY THE ACCOUNTS SHARED WITH THIS PROFILE ARE REACHABLE. One Meta login often administers WhatsApp for several businesses, so a human ticks which belong to this profile under Settings > Connectors > Meta > Manage accounts (or set_connector_accounts(provider:"meta")); nothing else here can see or touch the rest. With none ticked every WhatsApp tool refuses and names that as the way out. With exactly ONE ticked, wabaId is optional — pass it only to disambiguate. Read-only, 0 credits (WhatsApp conversations are billed by META to the business directly, never in Hermoso credits). Needs Meta connected.',
7540
+ description: 'The WhatsApp Business Accounts SHARED WITH THIS PROFILE and the phone numbers registered on each — the ids every other WhatsApp tool needs, plus each number’s QUALITY RATING, which is what decides how many messages Meta will let it send. Start here. A WABA with no number cannot send anything and the reply says so: Hermoso does not register or verify numbers, that is WhatsApp Manager. A business portfolio that could not be read is REPORTED rather than dropped — an account missing from this list would read as "the brand has no WhatsApp", which is a claim about their business and not about our read. TWO ACCOUNTS CAN CARRY THE SAME DISPLAY NAME — always quote the `display` field or the id when naming one to a user, never the bare name. ONLY THE ACCOUNTS SHARED WITH THIS PROFILE ARE REACHABLE. One Meta login often administers WhatsApp for several businesses, so a human ticks which belong to this profile under Connectors > Meta > Manage accounts (or set_connector_accounts(provider:"meta")); nothing else here can see or touch the rest. With none ticked every WhatsApp tool refuses and names that as the way out. With exactly ONE ticked, wabaId is optional — pass it only to disambiguate. Read-only, 0 credits (WhatsApp conversations are billed by META to the business directly, never in Hermoso credits). Needs Meta connected.',
7522
7541
  inputSchema: {},
7523
7542
  outputSchema: { count: z.number().optional(), accounts: z.array(z.any()).optional(), unreadable: z.array(z.any()).optional(), note: z.string().optional(), optIn: z.string().optional(), pricing: z.string().optional() },
7524
7543
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -7674,7 +7693,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
7674
7693
  server.group('channels');
7675
7694
  server.registerTool('post_to_meta', {
7676
7695
  title: 'Post to Facebook, Instagram or Threads',
7677
- description: 'Use this when the user wants to post now to Facebook, Instagram or Threads. Publish to a connected Facebook Page, its linked Instagram, OR the brand’s Threads account — text/link/image/VIDEO/CAROUSEL. A MULTI-SLIDE creative is a CAROUSEL, not several posts: pass the slides in order as imageUrls[] and they publish as ONE swipeable post (Instagram album, Threads carousel, Facebook multi-photo post). Never publish slide 1 of a deck on its own — the creative tells the viewer to swipe. target:"facebook" (default) posts to the Page; target:"instagram" publishes a photo or Reel to the linked IG business account (needs an image or video); target:"threads" posts to the connected Threads account (text, image, or video). Works with any media, an ad made here or the user’s own file: imageUrl/videoUrl accept a public https URL, a data: URI, or a Hermoso /generated path; for a LOCAL file (e.g. on the user’s desktop) call upload_file first and pass the url it returns. INSTAGRAM COLLAB: pass `collaborators` (up to 3 usernames) to invite other accounts to CO-AUTHOR the post — it then shows on their profile too once they accept. This PUBLISHES immediately — confirm the copy + media with the user first. Needs a connected Meta account (Settings > Connectors > Meta) with posting permission; Threads needs its own connection.',
7696
+ description: 'Use this when the user wants to post now to Facebook, Instagram or Threads. Publish to a connected Facebook Page, its linked Instagram, OR the brand’s Threads account — text/link/image/VIDEO/CAROUSEL. A MULTI-SLIDE creative is a CAROUSEL, not several posts: pass the slides in order as imageUrls[] and they publish as ONE swipeable post (Instagram album, Threads carousel, Facebook multi-photo post). Never publish slide 1 of a deck on its own — the creative tells the viewer to swipe. target:"facebook" (default) posts to the Page; target:"instagram" publishes a photo or Reel to the linked IG business account (needs an image or video); target:"threads" posts to the connected Threads account (text, image, or video). Works with any media, an ad made here or the user’s own file: imageUrl/videoUrl accept a public https URL, a data: URI, or a Hermoso /generated path; for a LOCAL file (e.g. on the user’s desktop) call upload_file first and pass the url it returns. INSTAGRAM COLLAB: pass `collaborators` (up to 3 usernames) to invite other accounts to CO-AUTHOR the post — it then shows on their profile too once they accept. This PUBLISHES immediately — confirm the copy + media with the user first. Needs a connected Meta account (Connectors > Meta) with posting permission; Threads needs its own connection.',
7678
7697
  // LOOSE ON PURPOSE (2026-09-30): an argument this schema does not declare is FORWARDED, not stripped, so a caption
7679
7698
  // sent as `caption` or a picture as `image_url` reaches the server and is refused BY NAME with the right field
7680
7699
  // (metaPostAliasRefusal). Stripped, it vanished and the call read as empty: five identical refusals in 35 minutes.
@@ -8257,7 +8276,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
8257
8276
  d.targets?.summary ? `WHERE IT POSTS: ${d.targets.summary}` : '',
8258
8277
  d.creditNote || '',
8259
8278
  `Posting times: ${(d.postingTimes || []).join(', ') || 'none set (add some on the Schedule tab)'} ${d.postingSchedule?.timezone || 'UTC'}. It fills ${d.daysAhead} day(s) ahead; ${d.queuedPosts} post(s) queued now.`,
8260
- (d.channelsView || []).length ? `Channels:\n${d.channelsView.map((c) => ` ${c.on ? '[on] ' : '[off]'} ${c.label} (${c.channel}): ${c.on ? `${c.postsPerDay} a day${c.times.length ? ` at ${c.times.join(', ')}` : ''}` : 'not posting'}${c.note ? ` · ${c.note}` : ''}${(c.accounts || []).length > 1 ? ` · accounts: ${c.accounts.map((a) => a.name).join(', ')}${c.pickedAccounts ? ` (picked: ${c.pickedAccounts === 'all' ? 'all' : c.accounts.filter((a) => c.pickedAccounts.includes(a.id)).map((a) => a.name).join(', ')})` : ''}` : ''}`).join('\n')}` : (d.connectedUnreadable ? 'Channels: could not be read just now.' : 'Channels: none connected — autopilot has nowhere to post. Connect one under Workspace ▸ Connectors.'),
8279
+ (d.channelsView || []).length ? `Channels:\n${d.channelsView.map((c) => ` ${c.on ? '[on] ' : '[off]'} ${c.label} (${c.channel}): ${c.on ? `${c.postsPerDay} a day${c.times.length ? ` at ${c.times.join(', ')}` : ''}` : 'not posting'}${c.note ? ` · ${c.note}` : ''}${(c.accounts || []).length > 1 ? ` · accounts: ${c.accounts.map((a) => a.name).join(', ')}${c.pickedAccounts ? ` (picked: ${c.pickedAccounts === 'all' ? 'all' : c.accounts.filter((a) => c.pickedAccounts.includes(a.id)).map((a) => a.name).join(', ')})` : ''}` : ''}`).join('\n')}` : (d.connectedUnreadable ? 'Channels: could not be read just now.' : 'Channels: none connected — autopilot has nowhere to post. Connect one under Connectors.'),
8261
8280
  `Makes ${d.postsPerDayMade || 0} new post(s) a day, every one a fresh render made for it${d.estCreditsPerDay != null && d.postsPerDayMade ? `, about ${d.estCreditsPerDay} credits a day at today's prices` : ''}.${d.zeroBudget ? ` ${d.budgetNote}` : ''} Used: ${d.spend?.today || 0} credits today, ${d.spend?.week || 0} this week.`,
8262
8281
  `Brief: goal ${d.brief?.goal || 'not set (required to switch it on)'}${d.brief?.goalNote ? ` (${d.brief.goalNote})` : ''} · format ${d.brief?.formats || 'mix'}${(d.brief?.pillars || []).length ? ` · pillars: ${d.brief.pillars.join('; ')}` : (d.suggestedPillars || []).length ? ` · no pillars yet; suggested from the brand: ${d.suggestedPillars.join('; ')}` : ''}${(d.brief?.avoid || []).length ? ` · avoid: ${d.brief.avoid.join('; ')}` : ''}`,
8263
8282
  (d.brief?.learnings || []).length || (d.brief?.editExamples || []).length ? `Learned from reviews (every batch follows these): ${(d.brief.learnings || []).map((x) => `"${x}"`).join('; ') || 'no notes'}${(d.brief.editExamples || []).length ? ` · imitates the user's last ${d.brief.editExamples.length} caption rewrite(s)` : ''}. Edit the list with set_post_refill learnings.` : '',
@@ -8364,7 +8383,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
8364
8383
  }));
8365
8384
  server.registerTool('post_to_linkedin', {
8366
8385
  title: 'Publish to LinkedIn',
8367
- description: 'Use this when the user wants to post to their personal LinkedIn profile. Publishing costs no credits. Publish a post to the user’s connected LinkedIn profile — text, and optionally an image (imageUrl), a VIDEO (videoUrl, with captionsSrt and a videoThumbnailUrl cover), a 2–20 image MULTI-IMAGE post (imageUrls), a LINK post with a real preview card (linkUrl + linkTitle / linkDescription / linkThumbnailUrl — LinkedIn never scrapes the page), a DOCUMENT post (documentUrl: a PDF / PPTX readers swipe through), and @mentions of companies (mentions). The image does NOT have to be something Hermoso generated: LinkedIn is served the bytes from us, so the URL must be Hermoso-hosted, and upload_file turns ANY file the user already has into exactly that. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Needs a connected LinkedIn account (Settings > Connectors > LinkedIn).',
8386
+ description: 'Use this when the user wants to post to their personal LinkedIn profile. Publishing costs no credits. Publish a post to the user’s connected LinkedIn profile — text, and optionally an image (imageUrl), a VIDEO (videoUrl, with captionsSrt and a videoThumbnailUrl cover), a 2–20 image MULTI-IMAGE post (imageUrls), a LINK post with a real preview card (linkUrl + linkTitle / linkDescription / linkThumbnailUrl — LinkedIn never scrapes the page), a DOCUMENT post (documentUrl: a PDF / PPTX readers swipe through), and @mentions of companies (mentions). The image does NOT have to be something Hermoso generated: LinkedIn is served the bytes from us, so the URL must be Hermoso-hosted, and upload_file turns ANY file the user already has into exactly that. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Needs a connected LinkedIn account (Connectors > LinkedIn).',
8368
8387
  inputSchema: {
8369
8388
  ...HOOK_ATTR,
8370
8389
  text: z.string().describe('the post text'),
@@ -8398,7 +8417,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
8398
8417
  // below say so explicitly, because an agent that fires ten posts to "see what sticks" is spending the user's money.
8399
8418
  server.registerTool('post_to_x', {
8400
8419
  title: 'Publish a post to X (Twitter)',
8401
- description: 'Use this when the user wants to post to X (Twitter) now. Publish to the user’s connected X (Twitter) account — a single post, a post with an image or video render attached, a reply to an existing post, or a whole THREAD (pass `thread` as an array and each part is posted as a reply to the one before). This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Can also run a POLL (2-4 options) instead of media, share a CLIP of another post’s video (`clip`), and restrict who may reply. LENGTH IS THE POSTING ACCOUNT’S, NOT A FLAT 280: 280 characters on an ordinary account, but UP TO 25,000 if that account has X Premium. Hermoso reads the account’s own subscription from X and sends the post rather than refusing something it may be entitled to; if X declines it on length, the reply says so and names the subscription X reported. Over 25,000 is refused for free — that is X’s own ceiling on every account. Text is NEVER truncated. AND A LONG POST IS CHEAPER THAN A THREAD: it is ONE billed X call where the same words split across five posts are five, so prefer one long post over threading when the account has Premium. Write altText whenever you attach a render. COSTS CREDITS at X’s per-post price, every post in a thread billed: a post that contains a link (any web address, and every quote post) is X’s link rate, far higher than one without (hermoso_credits gives both figures). When the user asks what a post will cost, call this with dryRun:true: it posts NOTHING and returns that exact post’s costCredits. Each profile also has a rolling 24-hour ceiling on what it can spend at X, and a request that would cross it is refused WHOLE before anything publishes, so a batch of posts is bounded rather than open-ended. THIS TOOL POSTS ORGANICALLY — it does not create an ad campaign. X ads are built with the X Ads campaign tools (campaign, then line item, then promoted post), and a promoted post needs a post id, so publish here first and promote that post. Needs X connected (Settings > Connectors > X).',
8420
+ description: 'Use this when the user wants to post to X (Twitter) now. Publish to the user’s connected X (Twitter) account — a single post, a post with an image or video render attached, a reply to an existing post, or a whole THREAD (pass `thread` as an array and each part is posted as a reply to the one before). This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Can also run a POLL (2-4 options) instead of media, share a CLIP of another post’s video (`clip`), and restrict who may reply. LENGTH IS THE POSTING ACCOUNT’S, NOT A FLAT 280: 280 characters on an ordinary account, but UP TO 25,000 if that account has X Premium. Hermoso reads the account’s own subscription from X and sends the post rather than refusing something it may be entitled to; if X declines it on length, the reply says so and names the subscription X reported. Over 25,000 is refused for free — that is X’s own ceiling on every account. Text is NEVER truncated. AND A LONG POST IS CHEAPER THAN A THREAD: it is ONE billed X call where the same words split across five posts are five, so prefer one long post over threading when the account has Premium. Write altText whenever you attach a render. COSTS CREDITS at X’s per-post price, every post in a thread billed: a post that contains a link (any web address, and every quote post) is X’s link rate, far higher than one without (hermoso_credits gives both figures). When the user asks what a post will cost, call this with dryRun:true: it posts NOTHING and returns that exact post’s costCredits. Each profile also has a rolling 24-hour ceiling on what it can spend at X, and a request that would cross it is refused WHOLE before anything publishes, so a batch of posts is bounded rather than open-ended. THIS TOOL POSTS ORGANICALLY — it does not create an ad campaign. X ads are built with the X Ads campaign tools (campaign, then line item, then promoted post), and a promoted post needs a post id, so publish here first and promote that post. Needs X connected (Connectors > X).',
8402
8421
  inputSchema: {
8403
8422
  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 x account connected (several and none named is refused by name, never guessed); omit when there is one."),
8404
8423
  ...HOOK_ATTR,
@@ -8445,7 +8464,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
8445
8464
  }));
8446
8465
  server.registerTool('post_x_article', {
8447
8466
  title: 'Publish a long-form Article to X',
8448
- description: 'Publish a long-form ARTICLE to the user’s connected X (Twitter) account — X’s own long-form format, which is a different thing from a long POST. Give it a `title` and a `body` written in MARKDOWN (or plain prose) and Hermoso converts it into the DraftJS `content_state` structure X requires: headings, paragraphs, bulleted and numbered lists, blockquotes, bold / italic / strikethrough, links, horizontal rules, fenced code blocks and pipe tables all carry across. FORMATTING IS NEVER SILENTLY DROPPED — anything X Articles cannot represent (inline `code`, an inline image) REFUSES the article for free and names exactly what and why, and `allowLossy: true` is the explicit way to publish it as plain text anyway. THE HARD LIMIT TO PLAN AROUND: X allows only about 10 Article DRAFTS and 5 Article PUBLISHES per account per DAY, it publishes that cap nowhere, and it counts a FAILED attempt against them — so never iterate on an article by republishing it, and use `publish: false` to save a draft for the user to read in X’s own composer when they want to review before it goes out. This PUBLISHES immediately and PUBLICLY: show the user the full text and get an explicit yes first, because a published Article can NEVER be edited (X’s own rule — edit_x_post will refuse it) and the only remedy is to delete and republish, which costs another of the five. An Article appears in the timeline as a title card, not as body text; readers open it. Costs credits (X bills per API request, and this is three of them). Needs X connected (Settings > Connectors > X).',
8467
+ description: 'Publish a long-form ARTICLE to the user’s connected X (Twitter) account — X’s own long-form format, which is a different thing from a long POST. Give it a `title` and a `body` written in MARKDOWN (or plain prose) and Hermoso converts it into the DraftJS `content_state` structure X requires: headings, paragraphs, bulleted and numbered lists, blockquotes, bold / italic / strikethrough, links, horizontal rules, fenced code blocks and pipe tables all carry across. FORMATTING IS NEVER SILENTLY DROPPED — anything X Articles cannot represent (inline `code`, an inline image) REFUSES the article for free and names exactly what and why, and `allowLossy: true` is the explicit way to publish it as plain text anyway. THE HARD LIMIT TO PLAN AROUND: X allows only about 10 Article DRAFTS and 5 Article PUBLISHES per account per DAY, it publishes that cap nowhere, and it counts a FAILED attempt against them — so never iterate on an article by republishing it, and use `publish: false` to save a draft for the user to read in X’s own composer when they want to review before it goes out. This PUBLISHES immediately and PUBLICLY: show the user the full text and get an explicit yes first, because a published Article can NEVER be edited (X’s own rule — edit_x_post will refuse it) and the only remedy is to delete and republish, which costs another of the five. An Article appears in the timeline as a title card, not as body text; readers open it. Costs credits (X bills per API request, and this is three of them). Needs X connected (Connectors > X).',
8449
8468
  inputSchema: {
8450
8469
  ...HOOK_ATTR,
8451
8470
  title: z.string().describe('the Article title — X requires one and refuses a draft without it. This is what shows on the timeline card.'),
@@ -8594,7 +8613,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
8594
8613
  + "COSTS CREDITS PER MESSAGE RETURNED plus one account lookup, so keep maxResults small (default 10) and tell the user the cost before pulling a big page. "
8595
8614
  + "X KEEPS 30 DAYS of direct-message history and offers no date filter, so a short list can mean an old conversation rather than a quiet inbox — say so rather than reporting silence. "
8596
8615
  + "There is no conversation-list endpoint at X: this builds the thread list by reading events and grouping them, so a conversation whose last message is older than the page you pulled will not appear. "
8597
- + "Needs X connected, AND an X account connected before direct messages shipped must be reconnected once (Settings > Connectors > X) — X only grants permissions at authorize time.",
8616
+ + "Needs X connected, AND an X account connected before direct messages shipped must be reconnected once (Connectors > X) — X only grants permissions at authorize time.",
8598
8617
  inputSchema: {
8599
8618
  maxResults: z.number().optional().describe('how many messages to pull, 1-100 (default 10) — EVERY ONE IS BILLED, and omitting it would make X return 100'),
8600
8619
  conversationId: z.string().optional().describe('read just this conversation (from a previous call). One-to-one ids look like "12-345"; group ids are one long number.'),
@@ -8623,7 +8642,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
8623
8642
  description: "Send ONE direct message from the connected X account, either into an existing conversation (conversationId) or to a named account (participantId — the NUMERIC id, not an @handle; sending to someone with no existing thread starts one). "
8624
8643
  + "THIS IS A PRIVATE MESSAGE TO A REAL PERSON AND IT CANNOT BE TAKEN BACK: show the user the exact wording and the exact recipient and get an explicit yes BEFORE calling. Never send the same message to several people — send one, to one named recipient. "
8625
8644
  + "X may refuse a message to someone whose settings do not accept DMs from accounts they do not follow; that refusal comes from X, not from Hermoso. Costs credits per send. "
8626
- + "Needs X connected with direct-message access — an X account connected before DMs shipped must be reconnected once (Settings > Connectors > X).",
8645
+ + "Needs X connected with direct-message access — an X account connected before DMs shipped must be reconnected once (Connectors > X).",
8627
8646
  inputSchema: {
8628
8647
  text: z.string().describe('the message, exactly as it should be sent'),
8629
8648
  conversationId: z.string().optional().describe('reply into an existing conversation (from list_x_dms)'),
@@ -8645,7 +8664,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
8645
8664
  server.group('channels');
8646
8665
  server.registerTool('list_pinterest_boards', {
8647
8666
  title: 'List Pinterest boards',
8648
- description: 'List the boards on the user’s connected Pinterest account — id, name, privacy and pin count. ALWAYS call this before post_to_pinterest and let the USER pick: Pinterest requires a board and Hermoso never chooses one for them. Read-only, 0 credits. Needs Pinterest connected (Settings > Connectors > Pinterest).',
8667
+ description: 'List the boards on the user’s connected Pinterest account — id, name, privacy and pin count. ALWAYS call this before post_to_pinterest and let the USER pick: Pinterest requires a board and Hermoso never chooses one for them. Read-only, 0 credits. Needs Pinterest connected (Connectors > Pinterest).',
8649
8668
  inputSchema: { privacy: z.enum(['ALL', 'PUBLIC', 'PROTECTED', 'SECRET']).optional().describe('filter by board privacy; default is everything the connection can see') },
8650
8669
  outputSchema: { count: z.number().optional(), boards: z.array(z.object({ id: z.string().optional(), name: z.string().optional(), privacy: z.string().optional(), description: z.string().optional(), pins: z.number().nullable().optional(), followers: z.number().nullable().optional() })).optional() },
8651
8670
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -8793,7 +8812,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
8793
8812
  server.group('channels');
8794
8813
  server.registerTool('post_to_pinterest', {
8795
8814
  title: 'Create a Pin',
8796
- description: 'Use this when the user wants to publish a Pin to Pinterest. Publishing costs no credits. Create a Pin on one of the user’s Pinterest boards from any finished visual they have — image, video, or a 2–5 slide CAROUSEL (pass the slides in order as imageUrls[] and Pinterest publishes one swipeable Pin). Title, description and destination link ride along. The link is what makes a Pin drive traffic, so ask for it rather than omitting it, and the description is the text Pinterest search actually reads. boardId is REQUIRED: call list_pinterest_boards first and let the user choose. This PUBLISHES to their public profile — confirm the board, title and link before calling. Video Pins take a minute or two while Pinterest ingests the file. Needs Pinterest connected (Settings > Connectors > Pinterest).',
8815
+ description: 'Use this when the user wants to publish a Pin to Pinterest. Publishing costs no credits. Create a Pin on one of the user’s Pinterest boards from any finished visual they have — image, video, or a 2–5 slide CAROUSEL (pass the slides in order as imageUrls[] and Pinterest publishes one swipeable Pin). Title, description and destination link ride along. The link is what makes a Pin drive traffic, so ask for it rather than omitting it, and the description is the text Pinterest search actually reads. boardId is REQUIRED: call list_pinterest_boards first and let the user choose. This PUBLISHES to their public profile — confirm the board, title and link before calling. Video Pins take a minute or two while Pinterest ingests the file. Needs Pinterest connected (Connectors > Pinterest).',
8797
8816
  inputSchema: {
8798
8817
  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 pinterest account connected (several and none named is refused by name, never guessed); omit when there is one."),
8799
8818
  ...HOOK_ATTR,
@@ -9007,7 +9026,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
9007
9026
  server.group('channels');
9008
9027
  server.registerTool('list_business_locations', {
9009
9028
  title: 'List Google business listings',
9010
- description: 'List the Google Business Profile listings SHARED WITH THIS PROFILE — id, title, address, website and Maps link. These are the only listings anything here can post to or read: one Google login often manages several businesses (an agency manages its clients’), and the user ticks which of them belong to this profile. Call this before posting whenever more than one is shared and let the USER pick: a Post on the wrong storefront is a public mistake Hermoso will not make for them. If nothing is shared, ask the user to choose — list_connector_accounts("google_business") then set_connector_accounts — and never name or guess a listing. Read-only, 0 credits. Needs Google Business Profile connected (Settings > Connectors > Google Business Profile).',
9029
+ description: 'List the Google Business Profile listings SHARED WITH THIS PROFILE — id, title, address, website and Maps link. These are the only listings anything here can post to or read: one Google login often manages several businesses (an agency manages its clients’), and the user ticks which of them belong to this profile. Call this before posting whenever more than one is shared and let the USER pick: a Post on the wrong storefront is a public mistake Hermoso will not make for them. If nothing is shared, ask the user to choose — list_connector_accounts("google_business") then set_connector_accounts — and never name or guess a listing. Read-only, 0 credits. Needs Google Business Profile connected (Connectors > Google Business Profile).',
9011
9030
  inputSchema: {},
9012
9031
  outputSchema: { count: z.number().optional(), shared: z.number().optional(), missing: z.array(z.string()).optional(), locations: z.array(z.object({ id: z.string().optional(), account: z.string().optional(), accountName: z.string().optional(), title: z.string().optional(), address: z.string().optional(), website: z.string().optional(), phone: z.string().optional(), mapsUrl: z.string().optional(), canPost: z.boolean().optional() })).optional() },
9013
9032
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -9015,13 +9034,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
9015
9034
  // THE SHARED SET, NEVER THE ROSTER. /api/google-business/locations walks every Business Profile account the
9016
9035
  // Google login manages; it is the owner-only PICKER route, reached through list_connector_accounts alone.
9017
9036
  const d = await apiGet('/api/google-business/shared-locations');
9018
- if (!d.shared) return ok('No Google Business listing is shared with this profile yet, so there is nothing to post to. The user chooses which listings belong to this profile: Workspace ▸ Connectors ▸ Google Business Profile ▸ Manage accounts, or list_connector_accounts then set_connector_accounts. Do not name or guess one.', d);
9019
- if (!d.count) return ok(`The ${d.shared === 1 ? 'listing' : `${d.shared} listings`} shared with this profile ${d.shared === 1 ? 'is' : 'are'} no longer reachable on this Google connection — removed, or the connected account lost manager access. Ask the user to re-pick under Workspace ▸ Connectors ▸ Google Business Profile ▸ Manage accounts (or call list_connector_accounts with provider google_business, then set_connector_accounts).`, d);
9037
+ if (!d.shared) return ok('No Google Business listing is shared with this profile yet, so there is nothing to post to. The user chooses which listings belong to this profile: Connectors ▸ Google Business Profile ▸ Manage accounts, or list_connector_accounts then set_connector_accounts. Do not name or guess one.', d);
9038
+ if (!d.count) return ok(`The ${d.shared === 1 ? 'listing' : `${d.shared} listings`} shared with this profile ${d.shared === 1 ? 'is' : 'are'} no longer reachable on this Google connection — removed, or the connected account lost manager access. Ask the user to re-pick under Connectors ▸ Google Business Profile ▸ Manage accounts (or call list_connector_accounts with provider google_business, then set_connector_accounts).`, d);
9020
9039
  return ok(`${d.count} listing${d.count === 1 ? '' : 's'} shared with this profile: ${(d.locations || []).map(l => `${l.title || '(untitled)'} (${l.id})`).join(', ')}.${d.count > 1 ? ' Show these to the user and let them pick which one to post to.' : ''} These are the ONLY listings usable here; any other listing on this Google account is not shared and must never be named or offered.`, d);
9021
9040
  }));
9022
9041
  server.registerTool('post_to_google_business', {
9023
9042
  title: 'Post to Google Business Profile',
9024
- description: 'Publish a Post to the brand’s Google Business Profile — the panel that appears on Google Search and Maps for the business. Text, optionally ONE PHOTO, and a call-to-action button. Google’s Posts API accepts NO VIDEO, so pass a still image. This PUBLISHES immediately and publicly on the business listing — show the user the exact text, photo and button and get an explicit yes BEFORE calling. If the account manages several listings, call list_business_locations first and pass locationId. EVENT and OFFER posts both REQUIRE a title and a start date (Google’s rule). On an OFFER, Google IGNORES the button’s link — pass redeemOnlineUrl instead. A CALL button dials the number on the listing and takes no link. Needs Google Business Profile connected (Settings > Connectors > Google Business Profile).',
9043
+ description: 'Publish a Post to the brand’s Google Business Profile — the panel that appears on Google Search and Maps for the business. Text, optionally ONE PHOTO, and a call-to-action button. Google’s Posts API accepts NO VIDEO, so pass a still image. This PUBLISHES immediately and publicly on the business listing — show the user the exact text, photo and button and get an explicit yes BEFORE calling. If the account manages several listings, call list_business_locations first and pass locationId. EVENT and OFFER posts both REQUIRE a title and a start date (Google’s rule). On an OFFER, Google IGNORES the button’s link — pass redeemOnlineUrl instead. A CALL button dials the number on the listing and takes no link. Needs Google Business Profile connected (Connectors > Google Business Profile).',
9025
9044
  inputSchema: {
9026
9045
  ...HOOK_ATTR,
9027
9046
  summary: z.string().optional().describe('the body text of the Post'),
@@ -9164,7 +9183,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
9164
9183
  // gbpAccountInfo, so these stay thin and no surface can carry a weaker gate than another.
9165
9184
  server.registerTool('get_business_location', {
9166
9185
  title: 'Read a Google Business Profile listing',
9167
- description: 'Read everything Google holds on one of the brand’s Google Business Profile listings — business name, address, phone numbers, website, categories, description, regular and special hours, service area, labels, store code, open state, and whether the listing can carry a Post at all. This is the listing AS THE MERCHANT LAST SET IT, which is exactly what update_business_location edits; it can differ from what Google Maps shows today, because Google and the public can suggest changes on top. Call it before offering to change anything, and to answer “what does our Google listing actually say?”. Read-only, 0 credits. Needs Google Business Profile connected (Settings > Connectors > Google Business Profile).',
9186
+ description: 'Read everything Google holds on one of the brand’s Google Business Profile listings — business name, address, phone numbers, website, categories, description, regular and special hours, service area, labels, store code, open state, and whether the listing can carry a Post at all. This is the listing AS THE MERCHANT LAST SET IT, which is exactly what update_business_location edits; it can differ from what Google Maps shows today, because Google and the public can suggest changes on top. Call it before offering to change anything, and to answer “what does our Google listing actually say?”. Read-only, 0 credits. Needs Google Business Profile connected (Connectors > Google Business Profile).',
9168
9187
  inputSchema: { locationId: z.string().optional().describe('which listing, e.g. \'locations/123\' from list_business_locations — only needed when more than one is shared with this profile') },
9169
9188
  outputSchema: { locationId: z.string().optional(), account: z.string().optional(), title: z.string().optional(), readMask: z.string().optional(), location: z.record(z.any()).optional() },
9170
9189
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -9226,7 +9245,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
9226
9245
  };
9227
9246
  server.registerTool('post_to_youtube', {
9228
9247
  title: 'Post a video to YouTube',
9229
- description: 'Use this when the user wants to upload a video or Short to YouTube. Publishing costs no credits. Publish a finished video to the brand’s connected YouTube channel. Pass a Hermoso render URL (or an upload_file url for a local/external file). PUBLISHES PUBLICLY BY DEFAULT: a plain "post this to YouTube" puts it ON the channel (confirm the title with the user, as for any publish) and notifies subscribers as YouTube does. Pass the privacy the user states instead: "unlisted" (link-only) or "private" (eyes-only). A video meant to run as a YouTube/Google AD should go up "unlisted" — private videos CANNOT be used as ads. SCHEDULE it with publishAt, FILE it under the right categoryId (the default 22 "People & Blogs" is wrong for most ads), SUBSCRIBER NOTIFICATIONS FOLLOW PRIVACY — a public publish announces the video to the channel’s subscribers (YouTube’s own default), while unlisted/private uploads stay quiet; pass notifySubscribers explicitly to override either way. THUMBNAIL: pass thumbnailUrl, or a frame of the video is set for free; a thumbnail YouTube refuses never fails the upload, and thumbnailNote says why. YOUTUBE MUSIC: YouTube’s API takes no music track. Only when the user wants a track from YouTube’s own Audio Library: BEFORE uploading, tell them plainly it will go up UNLISTED (not on their channel, nobody sees it) so they can add the track in YouTube Studio on desktop (Content > the video > Editor > Audio), and that THEY must then switch it to Public there themselves; get their yes, then pass privacy:"unlisted". Never choose unlisted for music on your own. Needs a connected YouTube channel (Settings > Connectors > YouTube).',
9248
+ description: 'Use this when the user wants to upload a video or Short to YouTube. Publishing costs no credits. Publish a finished video to the brand’s connected YouTube channel. Pass a Hermoso render URL (or an upload_file url for a local/external file). PUBLISHES PUBLICLY BY DEFAULT: a plain "post this to YouTube" puts it ON the channel (confirm the title with the user, as for any publish) and notifies subscribers as YouTube does. Pass the privacy the user states instead: "unlisted" (link-only) or "private" (eyes-only). A video meant to run as a YouTube/Google AD should go up "unlisted" — private videos CANNOT be used as ads. SCHEDULE it with publishAt, FILE it under the right categoryId (the default 22 "People & Blogs" is wrong for most ads), SUBSCRIBER NOTIFICATIONS FOLLOW PRIVACY — a public publish announces the video to the channel’s subscribers (YouTube’s own default), while unlisted/private uploads stay quiet; pass notifySubscribers explicitly to override either way. THUMBNAIL: pass thumbnailUrl, or a frame of the video is set for free; a thumbnail YouTube refuses never fails the upload, and thumbnailNote says why. YOUTUBE MUSIC: YouTube’s API takes no music track. Only when the user wants a track from YouTube’s own Audio Library: BEFORE uploading, tell them plainly it will go up UNLISTED (not on their channel, nobody sees it) so they can add the track in YouTube Studio on desktop (Content > the video > Editor > Audio), and that THEY must then switch it to Public there themselves; get their yes, then pass privacy:"unlisted". Never choose unlisted for music on your own. Needs a connected YouTube channel (Connectors > YouTube).',
9230
9249
  inputSchema: {
9231
9250
  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 youtube account connected (several and none named is refused by name, never guessed); omit when there is one."),
9232
9251
  ...HOOK_ATTR,
@@ -9688,14 +9707,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
9688
9707
  }));
9689
9708
  server.registerTool('hide_x_reply', {
9690
9709
  title: 'Hide or unhide a reply on X',
9691
- description: 'Hide a reply under the brand’s own X post (hidden:false to unhide) — spam and abuse moderation. A hidden reply is not deleted; it moves behind "View hidden replies". Only works on replies in a conversation the connected account started. Needs the tweet.moderate.write permission: an X connection made before 2026-10-27 must be reconnected once under Settings > Connectors > X (the tool says so without calling X). Costs credits (X bills per API call).',
9710
+ description: 'Hide a reply under the brand’s own X post (hidden:false to unhide) — spam and abuse moderation. A hidden reply is not deleted; it moves behind "View hidden replies". Only works on replies in a conversation the connected account started. Needs the tweet.moderate.write permission: an X connection made before 2026-10-27 must be reconnected once under Connectors > X (the tool says so without calling X). Costs credits (X bills per API call).',
9692
9711
  inputSchema: { replyId: z.string().describe('the REPLY’s id, or its x.com link'), hidden: z.boolean().optional().describe('default true; false unhides') },
9693
9712
  outputSchema: { replyId: z.string().optional(), hidden: z.boolean().optional(), costCredits: z.number().optional(), note: z.string().optional() },
9694
9713
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
9695
9714
  }, wrap(async (a = {}) => { const d = await apiPost('/api/x/hide-reply', a); return ok(d.note, d); }));
9696
9715
  server.registerTool('list_x_blocks', {
9697
9716
  title: 'Who the connected X account has blocked',
9698
- description: 'The accounts the connected X account has BLOCKED, with handle, name, bio and follower count. READ-ONLY: X refuses the block and unblock WRITE on the access tier Hermoso\'s app runs on (measured 2026-09-15: X\'s own "attached to a Project" refusal with the scope granted), so blocking itself is done on x.com and there is no block tool here. Needs the block.read permission: an X connection made before 2026-09-15 must be reconnected once under Settings > Connectors > X. Costs credits (X bills per API call).',
9717
+ description: 'The accounts the connected X account has BLOCKED, with handle, name, bio and follower count. READ-ONLY: X refuses the block and unblock WRITE on the access tier Hermoso\'s app runs on (measured 2026-09-15: X\'s own "attached to a Project" refusal with the scope granted), so blocking itself is done on x.com and there is no block tool here. Needs the block.read permission: an X connection made before 2026-09-15 must be reconnected once under Connectors > X. Costs credits (X bills per API call).',
9699
9718
  inputSchema: { maxResults: z.number().optional().describe('1–1000, default 100'), paginationToken: z.string().optional().describe('next page token from the previous reply') },
9700
9719
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
9701
9720
  }, wrap(async (a = {}) => {
@@ -9705,7 +9724,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
9705
9724
  }));
9706
9725
  // X LIVE BROADCASTS (2026-09-21) — 16 operations in X's OpenAPI 2.168 as five tools; gates and payloads live in
9707
9726
  // lib/x-broadcast.mjs on the server, so every refusal below is the server's own sentence and costs nothing.
9708
- const XB_SCOPE_NOTE = ' Needs X connected with the broadcast permissions added 2026-09-21: an X connection made before then must be reconnected once under Settings > Connectors > X (the tool says so without calling X). Costs credits (X bills per call).';
9727
+ const XB_SCOPE_NOTE = ' Needs X connected with the broadcast permissions added 2026-09-21: an X connection made before then must be reconnected once under Connectors > X (the tool says so without calling X). Costs credits (X bills per call).';
9709
9728
  server.registerTool('list_x_broadcasts', {
9710
9729
  title: 'List X live broadcasts',
9711
9730
  description: "The connected X account's LIVE VIDEO broadcasts: past and current ones (title, state, share URL, live and total viewers, start/end) and SCHEDULED ones (start, end, whether it goes live automatically or waits for go_live). kind 'all' (default) reads both, 'live' or 'scheduled' one; pass id for a single broadcast. Read-only." + XB_SCOPE_NOTE,
@@ -9814,7 +9833,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
9814
9833
  // X CHAT (2026-10-02) — the /2/chat/* endpoints X's OpenAPI 2.169 added, as four tools; gates and payloads live in
9815
9834
  // lib/x-chat.mjs on the server, so every refusal below is the server's own sentence and costs nothing. X Chat is
9816
9835
  // END-TO-END ENCRYPTED: Hermoso holds no conversation key, so nothing here reads or writes message text or group names.
9817
- const XC_SCOPE_NOTE = ' Needs X connected with direct-message access: an X connection made before 2026-08-25 must be reconnected once under Workspace > Connectors > X (the tool says so without calling X). Costs credits (X bills per call).';
9836
+ const XC_SCOPE_NOTE = ' Needs X connected with direct-message access: an X connection made before 2026-08-25 must be reconnected once under Connectors > X (the tool says so without calling X). Costs credits (X bills per call).';
9818
9837
  const xcSettingsLine = (s = {}) => `who can message: ${s.allowDmsFrom ?? '?'}${s.alwaysAllowSubscribers ? ' (subscribers always)' : ''}; quality filter ${s.qualityFilter == null ? '?' : s.qualityFilter ? 'on' : 'off'}; read receipts ${s.readReceipts == null ? '?' : s.readReceipts ? 'on' : 'off'}; calls ${s.callsEnabled == null ? '?' : s.callsEnabled ? 'on' : 'off'}`;
9819
9838
  server.registerTool('x_chat_settings', {
9820
9839
  title: 'Read the X Chat privacy settings',
@@ -9910,7 +9929,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
9910
9929
  }));
9911
9930
  server.registerTool('x_account', {
9912
9931
  title: 'Read the connected X account',
9913
- description: 'ACCOUNT-LEVEL numbers for the connected X account \u2014 FOLLOWERS, following, total posts, listed count, media count \u2014 plus display name, bio, location, verified status and when the account was created. This is the tool for \u201chow many followers do we have on X\u201d, \u201cis our X growing\u201d, or to confirm whose account is linked before posting; for a single post use x_post_metrics or x_post_insights instead. A counter X does not return comes back under `absent` with the reason and is NEVER reported as zero. Read-only. Rides the account lookup Hermoso already makes when it posts \u2014 X bills per request and not per field \u2014 so it costs at most one User: Read and is free on a cached lookup. Needs X connected (Settings > Connectors > X).',
9932
+ description: 'ACCOUNT-LEVEL numbers for the connected X account \u2014 FOLLOWERS, following, total posts, listed count, media count \u2014 plus display name, bio, location, verified status and when the account was created. This is the tool for \u201chow many followers do we have on X\u201d, \u201cis our X growing\u201d, or to confirm whose account is linked before posting; for a single post use x_post_metrics or x_post_insights instead. A counter X does not return comes back under `absent` with the reason and is NEVER reported as zero. Read-only. Rides the account lookup Hermoso already makes when it posts \u2014 X bills per request and not per field \u2014 so it costs at most one User: Read and is free on a cached lookup. Needs X connected (Connectors > X).',
9914
9933
  inputSchema: {},
9915
9934
  outputSchema: { id: z.string().optional(), username: z.string().optional(), name: z.string().nullable().optional(), verified: z.boolean().optional(), verifiedType: z.string().nullable().optional(), protected: z.boolean().optional(), bio: z.string().nullable().optional(), location: z.string().nullable().optional(), avatar: z.string().nullable().optional(), createdAt: z.string().nullable().optional(), subscriptionType: z.string().nullable().optional(), metrics: z.record(z.number()).optional(), absent: z.record(z.string()).optional(), costCredits: z.number().optional() },
9916
9935
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -10112,7 +10131,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
10112
10131
  }));
10113
10132
  server.registerTool('bluesky_account', {
10114
10133
  title: 'Read a Bluesky account',
10115
- description: 'ACCOUNT-LEVEL numbers for a Bluesky account \u2014 FOLLOWERS, following and total posts \u2014 plus display name, bio, avatar and when it was created. With no argument it reads the CONNECTED account, which is the answer to \u201chow many followers do we have on Bluesky\u201d; pass `actor` (a handle or DID) to read any other account, which is free and is how you size a competitor there. The reply says which one it read: `self` is true only for the connected account, compared on the DID because a Bluesky handle can change. A counter Bluesky does not return comes back under `absent` with the reason and is NEVER reported as zero \u2014 all three are optional in the lexicon. Read-only, 0 credits, no extra permission. Needs Bluesky connected (Settings > Connectors > Bluesky, or connect_connector).',
10134
+ description: 'ACCOUNT-LEVEL numbers for a Bluesky account \u2014 FOLLOWERS, following and total posts \u2014 plus display name, bio, avatar and when it was created. With no argument it reads the CONNECTED account, which is the answer to \u201chow many followers do we have on Bluesky\u201d; pass `actor` (a handle or DID) to read any other account, which is free and is how you size a competitor there. The reply says which one it read: `self` is true only for the connected account, compared on the DID because a Bluesky handle can change. A counter Bluesky does not return comes back under `absent` with the reason and is NEVER reported as zero \u2014 all three are optional in the lexicon. Read-only, 0 credits, no extra permission. Needs Bluesky connected (Connectors > Bluesky, or connect_connector).',
10116
10135
  inputSchema: { actor: z.string().optional().describe('a Bluesky handle (hermoso.ai or @hermoso.ai) or a did:plc:\u2026 \u2014 omit it to read the connected account') },
10117
10136
  outputSchema: { did: z.string().nullable().optional(), handle: z.string().nullable().optional(), displayName: z.string().nullable().optional(), bio: z.string().nullable().optional(), avatar: z.string().nullable().optional(), banner: z.string().nullable().optional(), createdAt: z.string().nullable().optional(), indexedAt: z.string().nullable().optional(), self: z.boolean().optional(), metrics: z.record(z.number()).optional(), absent: z.record(z.string()).optional(), costCredits: z.number().optional() },
10118
10137
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -10234,7 +10253,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
10234
10253
  }));
10235
10254
  server.registerTool('tiktok_creator_info', {
10236
10255
  title: 'Read the connected TikTok creator’s posting options',
10237
- description: 'Read the connected TikTok creator’s REAL posting options BEFORE posting: which privacy levels THEY are allowed to use, whether comments / duet / stitch are available on their account, their maximum video length, and their nickname. TikTok REQUIRES that the user is shown these actual options and picks a privacy level — never assume or default one. Call this first, show the options, get the user’s pick, then call post_to_tiktok with destination:"post". The SAME privacy options govern PHOTO posts (slideshows), not just video — TikTok takes the same four levels on both. Needs TikTok connected (Settings > Connectors > TikTok).',
10256
+ description: 'Read the connected TikTok creator’s REAL posting options BEFORE posting: which privacy levels THEY are allowed to use, whether comments / duet / stitch are available on their account, their maximum video length, and their nickname. TikTok REQUIRES that the user is shown these actual options and picks a privacy level — never assume or default one. Call this first, show the options, get the user’s pick, then call post_to_tiktok with destination:"post". The SAME privacy options govern PHOTO posts (slideshows), not just video — TikTok takes the same four levels on both. Needs TikTok connected (Connectors > TikTok).',
10238
10257
  inputSchema: {},
10239
10258
  outputSchema: { nickname: z.string().nullable().optional(), username: z.string().nullable().optional(), avatar: z.string().nullable().optional(), privacyOptions: z.array(z.string()).optional(), commentDisabled: z.boolean().optional(), duetDisabled: z.boolean().optional(), stitchDisabled: z.boolean().optional(), maxDurationSeconds: z.number().nullable().optional() },
10240
10259
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -10245,7 +10264,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
10245
10264
  server.group('channels');
10246
10265
  server.registerTool('post_to_tiktok', {
10247
10266
  title: 'Post a video or photo post to TikTok',
10248
- description: 'Use this when the user wants to post a video or photo post to TikTok now. Publishing costs no credits. Publish to the user’s connected TikTok account — a finished VIDEO, or a PHOTO POST (TikTok’s photo/slideshow format). A photo post carries 1 to 35 images and ONE image is simply a one-slide photo post, so there is nothing special to do for a single picture: pass imageUrls, in the order the slides should appear, and optionally coverIndex. Pass videoUrl for a video. Never pass both — TikTok has no mixed post. TWO destinations. destination:"post" (THE DEFAULT) publishes it LIVE on their profile: TikTok requires the user to CHOOSE the privacy themselves (no default is allowed), so call tiktok_creator_info, show them their real privacy options, and get their choice and an explicit yes before calling. destination:"draft" is ONLY for when the user asks for a draft, or wants to add a TikTok sound or trending audio (TikTok’s API takes no sound for a VIDEO): BEFORE sending, tell them plainly it lands in their TikTok inbox as a DRAFT, that they add the sound in TikTok’s editor, and that THEY must publish it from the TikTok app — nothing goes live until they do. Never pick draft on your own. A photo post published with destination:"post" gets a TikTok-recommended track automatically (autoAddMusic, default on) that they can change in the app. Pass Hermoso render URLs (or upload_file urls for local/external files). Needs TikTok connected (Settings > Connectors > TikTok).',
10267
+ description: 'Use this when the user wants to post a video or photo post to TikTok now. Publishing costs no credits. Publish to the user’s connected TikTok account — a finished VIDEO, or a PHOTO POST (TikTok’s photo/slideshow format). A photo post carries 1 to 35 images and ONE image is simply a one-slide photo post, so there is nothing special to do for a single picture: pass imageUrls, in the order the slides should appear, and optionally coverIndex. Pass videoUrl for a video. Never pass both — TikTok has no mixed post. TWO destinations. destination:"post" (THE DEFAULT) publishes it LIVE on their profile: TikTok requires the user to CHOOSE the privacy themselves (no default is allowed), so call tiktok_creator_info, show them their real privacy options, and get their choice and an explicit yes before calling. destination:"draft" is ONLY for when the user asks for a draft, or wants to add a TikTok sound or trending audio (TikTok’s API takes no sound for a VIDEO): BEFORE sending, tell them plainly it lands in their TikTok inbox as a DRAFT, that they add the sound in TikTok’s editor, and that THEY must publish it from the TikTok app — nothing goes live until they do. Never pick draft on your own. A photo post published with destination:"post" gets a TikTok-recommended track automatically (autoAddMusic, default on) that they can change in the app. Pass Hermoso render URLs (or upload_file urls for local/external files). Needs TikTok connected (Connectors > TikTok).',
10249
10268
  inputSchema: {
10250
10269
  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 tiktok account connected (several and none named is refused by name, never guessed); omit when there is one."),
10251
10270
  ...HOOK_ATTR,
@@ -10279,7 +10298,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
10279
10298
  // `tiktok_post_status` on 2026-10-01 and dead-ended. Same read as the web app's /api/tiktok/publish-status.
10280
10299
  server.registerTool('tiktok_post_status', {
10281
10300
  title: 'Check whether a TikTok post finished publishing',
10282
- description: 'Follow a TikTok post that post_to_tiktok sent but TikTok was still processing (it came back pending:true). Pass the publishId it returned. Answers one of three states: done (PUBLISH_COMPLETE, with the public `url` once TikTok\u2019s moderation has named it; `urlStatus` is ready, pending, private or draft), failed (with TikTok\u2019s reason), or still processing, which is normal for a few minutes and is NOT a failure. Never post the video again while it is processing. Free, read-only. Needs TikTok connected (Settings > Connectors > TikTok).',
10301
+ description: 'Follow a TikTok post that post_to_tiktok sent but TikTok was still processing (it came back pending:true). Pass the publishId it returned. Answers one of three states: done (PUBLISH_COMPLETE, with the public `url` once TikTok\u2019s moderation has named it; `urlStatus` is ready, pending, private or draft), failed (with TikTok\u2019s reason), or still processing, which is normal for a few minutes and is NOT a failure. Never post the video again while it is processing. Free, read-only. Needs TikTok connected (Connectors > TikTok).',
10283
10302
  inputSchema: {
10284
10303
  publishId: z.string().describe('the publishId post_to_tiktok returned'),
10285
10304
  account: z.string().optional().describe('which connected TikTok account the post went out on (@handle or id from list_connector_accounts) when the profile has more than one'),
@@ -10296,7 +10315,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
10296
10315
  server.group('channel_admin');
10297
10316
  server.registerTool('tiktok_account', {
10298
10317
  title: 'Read the connected TikTok account',
10299
- description: 'Read the connected TikTok account: display name, username, bio, verified status, and their follower / following / total-likes / video counts. Use it for “how many followers do we have on TikTok”, “how is our TikTok doing”, or to confirm whose account is linked before posting. Read-only. Needs TikTok connected (Settings > Connectors > TikTok).',
10318
+ description: 'Read the connected TikTok account: display name, username, bio, verified status, and their follower / following / total-likes / video counts. Use it for “how many followers do we have on TikTok”, “how is our TikTok doing”, or to confirm whose account is linked before posting. Read-only. Needs TikTok connected (Connectors > TikTok).',
10300
10319
  inputSchema: {},
10301
10320
  outputSchema: { openId: z.string().nullable().optional(), displayName: z.string().nullable().optional(), username: z.string().nullable().optional(), avatar: z.string().nullable().optional(), bio: z.string().nullable().optional(), profileLink: z.string().nullable().optional(), verified: z.boolean().optional(), followers: z.number().nullable().optional(), following: z.number().nullable().optional(), likes: z.number().nullable().optional(), videos: z.number().nullable().optional(), partial: z.boolean().optional() },
10302
10321
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -11267,7 +11286,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
11267
11286
  }));
11268
11287
  server.registerTool('list_meta_lead_forms', {
11269
11288
  title: 'List Meta instant (lead) forms',
11270
- description: 'List the INSTANT LEAD FORMS on a connected Facebook Page — id, name, status, how many leads each has collected and what each one asks. This is where the formId every other lead tool needs comes from, and calling it before create_meta_lead_form is how you avoid building a duplicate. Read-only, free. NEEDS the pages_manage_ads permission, which Hermoso’s Meta consent screen asks for. If Meta answers "Requires pages_manage_ads", the user must RECONNECT Meta under Settings > Connectors > Meta — a connection made before that permission was added cannot gain it by retrying. Call the tool rather than pre-refusing: any refusal comes from Meta and names the one thing that fixes it.',
11289
+ description: 'List the INSTANT LEAD FORMS on a connected Facebook Page — id, name, status, how many leads each has collected and what each one asks. This is where the formId every other lead tool needs comes from, and calling it before create_meta_lead_form is how you avoid building a duplicate. Read-only, free. NEEDS the pages_manage_ads permission, which Hermoso’s Meta consent screen asks for. If Meta answers "Requires pages_manage_ads", the user must RECONNECT Meta under Connectors > Meta — a connection made before that permission was added cannot gain it by retrying. Call the tool rather than pre-refusing: any refusal comes from Meta and names the one thing that fixes it.',
11271
11290
  inputSchema: {
11272
11291
  pageId: z.string().optional().describe('which connected Page (from list_meta_pages) — required only if the profile has more than one'),
11273
11292
  limit: z.number().optional().describe('max rows (1–100, default 25)'),
@@ -11369,7 +11388,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
11369
11388
  server.group('channel_admin'); // Messenger marketing — the marker at the end of this block re-opens 'ads'
11370
11389
  server.registerTool('list_messenger_subscribers', {
11371
11390
  title: 'List Messenger marketing-message subscribers',
11372
- description: 'The people who OPTED IN to marketing messages from a Facebook Page — their subscription tokens, the only address a paid Messenger marketing message can go to (there is no "message everyone"). Also reports whether Messenger marketing is turned on for this Meta connection. It is an OPTIONAL add-on, not part of the default Meta connect: when it is off, the Messenger marketing tools answer with the link that turns it on (Workspace > Connectors > Meta > Enable Messenger marketing), never with \'reconnect Meta\'; Meta then asks the user to confirm the Pages and pick an ad account, and lists only ones their own Facebook profile fully controls. Free.',
11391
+ description: 'The people who OPTED IN to marketing messages from a Facebook Page — their subscription tokens, the only address a paid Messenger marketing message can go to (there is no "message everyone"). Also reports whether Messenger marketing is turned on for this Meta connection. It is an OPTIONAL add-on, not part of the default Meta connect: when it is off, the Messenger marketing tools answer with the link that turns it on (Connectors > Meta > Enable Messenger marketing), never with \'reconnect Meta\'; Meta then asks the user to confirm the Pages and pick an ad account, and lists only ones their own Facebook profile fully controls. Free.',
11373
11392
  inputSchema: {
11374
11393
  pageId: z.string().optional().describe('Facebook Page id (from list_meta_pages); defaults to the one shared Page'),
11375
11394
  limit: z.number().optional().describe('1–1000, default 100'),
@@ -11989,7 +12008,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
11989
12008
  }));
11990
12009
  server.registerTool('list_google_ads_campaigns', {
11991
12010
  title: 'List Google Ads accounts / campaigns',
11992
- description: 'Read the connected Google Ads account(s). Call with NO customerId to list the accessible accounts (customerId + name + currency) — do this first to pick a target. Call WITH customerId to list that account’s campaigns (id, name, status, daily budget, channel) plus performance metrics (impressions, clicks, CTR, avg CPC, cost, conversions). Date window: datePreset (LAST_7_DAYS | LAST_30_DAYS | TODAY | THIS_MONTH | LAST_90_DAYS …) or since+until (YYYY-MM-DD). Read-only, free. Needs Google Ads connected (Settings > Connectors > Google Ads).',
12011
+ description: 'Read the connected Google Ads account(s). Call with NO customerId to list the accessible accounts (customerId + name + currency) — do this first to pick a target. Call WITH customerId to list that account’s campaigns (id, name, status, daily budget, channel) plus performance metrics (impressions, clicks, CTR, avg CPC, cost, conversions). Date window: datePreset (LAST_7_DAYS | LAST_30_DAYS | TODAY | THIS_MONTH | LAST_90_DAYS …) or since+until (YYYY-MM-DD). Read-only, free. Needs Google Ads connected (Connectors > Google Ads).',
11993
12012
  inputSchema: {
11994
12013
  customerId: z.string().optional().describe('10-digit account id (dashes ok) — omit to list accessible accounts'),
11995
12014
  status: z.enum(['ENABLED', 'PAUSED', 'REMOVED']).optional().describe('filter campaigns by status'),
@@ -12012,7 +12031,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
12012
12031
  const lines = (d.accounts || []).map(c => `• ${c.name} (${c.customerId})`);
12013
12032
  return ok((d.accounts || []).length
12014
12033
  ? `${d.accounts.length} Google Ads account(s) shared with this profile:\n${lines.join('\n')}\nPass a customerId to see its campaigns.`
12015
- : 'No Google Ads account is shared with this profile yet — choose which ones it may use in Settings ▸ Connectors ▸ Google Ads ▸ Manage accounts (or with set_connector_accounts).', d);
12034
+ : 'No Google Ads account is shared with this profile yet — choose which ones it may use in Connectors ▸ Google Ads ▸ Manage accounts (or with set_connector_accounts).', d);
12016
12035
  }
12017
12036
  const d = await apiGet('/api/google-ads/campaigns', { customerId: a.customerId, status: a.status, datePreset: a.datePreset, since: a.since, until: a.until, metrics: a.metrics === false ? 'false' : undefined, loginCustomerId: a.loginCustomerId, limit: a.limit });
12018
12037
  const lines = (d.campaigns || []).map(c => `• ${c.name} (${c.id}) — ${c.status}${c.dailyBudgetUsd != null ? `, ${moneyText(c.dailyBudget ?? c.dailyBudgetUsd, c.currency || d.currency)}/day` : ''}${c.metrics ? `, ${c.metrics.impressions} impr · ${c.metrics.clicks} clk · ${moneyText(c.metrics.cost ?? c.metrics.costUsd, c.currency || d.currency)} spend · ${c.metrics.conversions} conv` : ''}`);
@@ -12839,7 +12858,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
12839
12858
  // of these says so, and list_analytics_properties exists to RESOLVE it rather than making the user go find it.
12840
12859
  server.registerTool('list_analytics_properties', {
12841
12860
  title: 'List the GA4 properties shared with this profile',
12842
- description: 'The GA4 properties SHARED WITH THIS PROFILE, with their numeric property ids, display names and the Analytics account each sits under. CALL THIS FIRST — every other Analytics tool needs a PROPERTY ID, which is NUMERIC (e.g. 123456789) and is NOT the "G-XXXXXXX" Measurement ID people usually know from their tracking snippet; the API accepts the numeric id and nothing else. Resolve the property yourself from this list instead of asking the user to go and find one, and only ask when two names are genuinely ambiguous. THIS IS NOT EVERY PROPERTY THE GOOGLE ACCOUNT CAN SEE, deliberately: Analytics access is handed out freely, so one login often has Viewer on many different clients\' properties, and the user ticks which ones belong to THIS profile. Only ticked properties can be reported on, and any other one is refused by name. An empty list means nothing is ticked yet — say so and point the user at Settings > Connectors > Google Analytics > Manage accounts (or call list_connector_accounts / set_connector_accounts with provider "google_analytics"); never name or guess a property. Read-only, 0 credits. Needs Google Analytics connected (Settings > Connectors > Google Analytics).',
12861
+ description: 'The GA4 properties SHARED WITH THIS PROFILE, with their numeric property ids, display names and the Analytics account each sits under. CALL THIS FIRST — every other Analytics tool needs a PROPERTY ID, which is NUMERIC (e.g. 123456789) and is NOT the "G-XXXXXXX" Measurement ID people usually know from their tracking snippet; the API accepts the numeric id and nothing else. Resolve the property yourself from this list instead of asking the user to go and find one, and only ask when two names are genuinely ambiguous. THIS IS NOT EVERY PROPERTY THE GOOGLE ACCOUNT CAN SEE, deliberately: Analytics access is handed out freely, so one login often has Viewer on many different clients\' properties, and the user ticks which ones belong to THIS profile. Only ticked properties can be reported on, and any other one is refused by name. An empty list means nothing is ticked yet — say so and point the user at Connectors > Google Analytics > Manage accounts (or call list_connector_accounts / set_connector_accounts with provider "google_analytics"); never name or guess a property. Read-only, 0 credits. Needs Google Analytics connected (Connectors > Google Analytics).',
12843
12862
  inputSchema: {},
12844
12863
  outputSchema: { properties: z.array(z.object({ property: z.string().optional(), displayName: z.string().optional(), account: z.string().optional(), propertyType: z.string().optional() })).optional(), count: z.number().optional(), shared: z.number().optional(), missing: z.array(z.string()).optional() },
12845
12864
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -12850,8 +12869,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
12850
12869
  // "nothing ticked" and "the ticked ones vanished" are different problems with different fixes — a single
12851
12870
  // sentence covering both sends half of the users to the wrong place.
12852
12871
  return ok(d.shared
12853
- ? `The ${d.shared} propert${d.shared === 1 ? 'y' : 'ies'} shared with this profile ${d.shared === 1 ? 'is' : 'are'} no longer visible to the connected Google account (access removed in GA4 ▸ Admin ▸ Property access management, or deleted). Ask the user to re-pick under Settings ▸ Connectors ▸ Google Analytics ▸ Manage accounts (or call list_connector_accounts with provider google_analytics, then set_connector_accounts).`
12854
- : 'No GA4 property is shared with this profile yet, so there is nothing to read. Ask the user to pick which properties belong to this profile under Settings ▸ Connectors ▸ Google Analytics ▸ Manage accounts (or call set_connector_accounts with provider "google_analytics"). Do not name or guess one.', d);
12872
+ ? `The ${d.shared} propert${d.shared === 1 ? 'y' : 'ies'} shared with this profile ${d.shared === 1 ? 'is' : 'are'} no longer visible to the connected Google account (access removed in GA4 ▸ Admin ▸ Property access management, or deleted). Ask the user to re-pick under Connectors ▸ Google Analytics ▸ Manage accounts (or call list_connector_accounts with provider google_analytics, then set_connector_accounts).`
12873
+ : 'No GA4 property is shared with this profile yet, so there is nothing to read. Ask the user to pick which properties belong to this profile under Connectors ▸ Google Analytics ▸ Manage accounts (or call set_connector_accounts with provider "google_analytics"). Do not name or guess one.', d);
12855
12874
  }));
12856
12875
  server.registerTool('analytics_report', {
12857
12876
  title: 'Run a GA4 report',
@@ -13309,7 +13328,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13309
13328
  // and list_tag_manager_tags answers the second one explicitly.
13310
13329
  server.registerTool('list_tag_manager_containers', {
13311
13330
  title: 'List the Tag Manager containers shared with this profile',
13312
- description: "The Google Tag Manager containers SHARED WITH THIS PROFILE, with the exact containerPath every other Tag Manager tool needs. CALL THIS FIRST. A container is addressed by PATH, \"accounts/<id>/containers/<id>\", never by the GTM-XXXXXX public id a human reads off the snippet: no Tag Manager method accepts the public id, so passing one is refused. Each row carries the public id beside the path so you can match what the user says to what the API wants. THIS IS NOT EVERY CONTAINER THE GOOGLE ACCOUNT CAN SEE: one agency login routinely administers every client's container, and a container is not data about a client, it is control of what runs on their website, so the user ticks which containers belong to THIS profile and only those are reachable. An empty list means nothing is ticked yet: say so and point the user at Settings, Connectors, Google Tag Manager, Manage accounts (or call list_connector_accounts with provider google_tag_manager, then set_connector_accounts). Never name or guess a container. Read-only, free. Read-only, 0 credits. Needs Google Tag Manager connected.",
13331
+ description: "The Google Tag Manager containers SHARED WITH THIS PROFILE, with the exact containerPath every other Tag Manager tool needs. CALL THIS FIRST. A container is addressed by PATH, \"accounts/<id>/containers/<id>\", never by the GTM-XXXXXX public id a human reads off the snippet: no Tag Manager method accepts the public id, so passing one is refused. Each row carries the public id beside the path so you can match what the user says to what the API wants. THIS IS NOT EVERY CONTAINER THE GOOGLE ACCOUNT CAN SEE: one agency login routinely administers every client's container, and a container is not data about a client, it is control of what runs on their website, so the user ticks which containers belong to THIS profile and only those are reachable. An empty list means nothing is ticked yet: say so and point the user at Connectors, Google Tag Manager, Manage accounts (or call list_connector_accounts with provider google_tag_manager, then set_connector_accounts). Never name or guess a container. Read-only, free. Read-only, 0 credits. Needs Google Tag Manager connected.",
13313
13332
  inputSchema: {},
13314
13333
  outputSchema: { containers: z.array(z.object({ containerPath: z.string().optional(), publicId: z.string().optional(), name: z.string().optional(), accountName: z.string().optional() })).optional(), count: z.number().optional(), shared: z.number().optional(), missing: z.array(z.string()).optional(), note: z.string().optional() },
13315
13334
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -13319,8 +13338,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
13319
13338
  if (rows.length) return ok(`${rows.length} Tag Manager container(s) shared with this profile:\n${rows.join('\n')}`, d);
13320
13339
  // "nothing ticked" and "the ticked ones vanished" are different problems with different fixes.
13321
13340
  return ok(d.shared
13322
- ? `The ${d.shared} container${d.shared === 1 ? '' : 's'} shared with this profile ${d.shared === 1 ? 'is' : 'are'} no longer visible to the connected Google account (its permission was removed in Tag Manager ▸ Admin ▸ User Management, or the container was deleted). Ask the user to re-pick under Settings ▸ Connectors ▸ Google Tag Manager ▸ Manage accounts (or call list_connector_accounts with provider google_tag_manager, then set_connector_accounts).`
13323
- : 'No Tag Manager container is shared with this profile yet, so there is nothing to read. Ask the user to pick which containers belong to this profile under Settings ▸ Connectors ▸ Google Tag Manager ▸ Manage accounts (or call set_connector_accounts with provider "google_tag_manager"). Do not name or guess one.', d);
13341
+ ? `The ${d.shared} container${d.shared === 1 ? '' : 's'} shared with this profile ${d.shared === 1 ? 'is' : 'are'} no longer visible to the connected Google account (its permission was removed in Tag Manager ▸ Admin ▸ User Management, or the container was deleted). Ask the user to re-pick under Connectors ▸ Google Tag Manager ▸ Manage accounts (or call list_connector_accounts with provider google_tag_manager, then set_connector_accounts).`
13342
+ : 'No Tag Manager container is shared with this profile yet, so there is nothing to read. Ask the user to pick which containers belong to this profile under Connectors ▸ Google Tag Manager ▸ Manage accounts (or call set_connector_accounts with provider "google_tag_manager"). Do not name or guess one.', d);
13324
13343
  }));
13325
13344
  server.registerTool('list_tag_manager_tags', {
13326
13345
  title: 'What is actually firing on the site',
@@ -13477,7 +13496,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13477
13496
 
13478
13497
  server.registerTool('list_search_console_sites', {
13479
13498
  title: 'List the Search Console properties shared with this profile',
13480
- description: 'The Google Search Console properties SHARED WITH THIS PROFILE, each with the exact property string every other Search Console tool takes and the connected account\'s permission level on it. CALL THIS FIRST. A property is EITHER "sc-domain:example.com" (a Domain property, covering every scheme and subdomain) OR the full URL-prefix form "https://example.com/" including scheme and trailing slash — Google treats those as different properties and one of them will 403, so resolve it here rather than guessing, and never pass a bare domain. THIS IS NOT EVERY PROPERTY THE GOOGLE ACCOUNT CAN SEE, deliberately: one login commonly holds a dozen 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 — say so and point the user at Settings > Connectors > Google Search Console > Manage accounts (or call list_connector_accounts / set_connector_accounts with provider "google_search_console"); never name or guess a property. A row whose permissionLevel is siteUnverifiedUser will refuse every later call. Read-only, 0 credits. Needs Google Search Console connected.',
13499
+ description: 'The Google Search Console properties SHARED WITH THIS PROFILE, each with the exact property string every other Search Console tool takes and the connected account\'s permission level on it. CALL THIS FIRST. A property is EITHER "sc-domain:example.com" (a Domain property, covering every scheme and subdomain) OR the full URL-prefix form "https://example.com/" including scheme and trailing slash — Google treats those as different properties and one of them will 403, so resolve it here rather than guessing, and never pass a bare domain. THIS IS NOT EVERY PROPERTY THE GOOGLE ACCOUNT CAN SEE, deliberately: one login commonly holds a dozen 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 — say so and point the user at Connectors > Google Search Console > Manage accounts (or call list_connector_accounts / set_connector_accounts with provider "google_search_console"); never name or guess a property. A row whose permissionLevel is siteUnverifiedUser will refuse every later call. Read-only, 0 credits. Needs Google Search Console connected.',
13481
13500
  inputSchema: {},
13482
13501
  outputSchema: { sites: z.array(z.object({ siteUrl: z.string().optional(), permissionLevel: z.string().optional() })).optional(), count: z.number().optional(), shared: z.number().optional(), missing: z.array(z.string()).optional() },
13483
13502
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -13487,8 +13506,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
13487
13506
  if (rows.length) return ok(`${rows.length} Search Console propert(ies) shared with this profile:\n${rows.join('\n')}`, d);
13488
13507
  // "nothing ticked" and "the ticked ones vanished" are different problems with different fixes.
13489
13508
  return ok(d.shared
13490
- ? `The ${d.shared} propert${d.shared === 1 ? 'y' : 'ies'} shared with this profile ${d.shared === 1 ? 'is' : 'are'} no longer visible to the connected Google account (verification removed under Settings ▸ Users and permissions, or the property deleted). Ask the user to re-pick under Settings ▸ Connectors ▸ Google Search Console ▸ Manage accounts (or call list_connector_accounts with provider google_search_console, then set_connector_accounts).`
13491
- : 'No Search Console property is shared with this profile yet, so there is nothing to read. Ask the user to pick which properties belong to this profile under Settings ▸ Connectors ▸ Google Search Console ▸ Manage accounts (or call set_connector_accounts with provider "google_search_console"). Do not name or guess one.', d);
13509
+ ? `The ${d.shared} propert${d.shared === 1 ? 'y' : 'ies'} shared with this profile ${d.shared === 1 ? 'is' : 'are'} no longer visible to the connected Google account (verification removed under Settings ▸ Users and permissions, or the property deleted). Ask the user to re-pick under Connectors ▸ Google Search Console ▸ Manage accounts (or call list_connector_accounts with provider google_search_console, then set_connector_accounts).`
13510
+ : 'No Search Console property is shared with this profile yet, so there is nothing to read. Ask the user to pick which properties belong to this profile under Connectors ▸ Google Search Console ▸ Manage accounts (or call set_connector_accounts with provider "google_search_console"). Do not name or guess one.', d);
13492
13511
  }));
13493
13512
  server.registerTool('search_console_performance', {
13494
13513
  title: 'What people searched for on Google, and what they clicked',
@@ -13583,7 +13602,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13583
13602
  }));
13584
13603
  server.registerTool('add_search_console_site', {
13585
13604
  title: 'Add a property to Search Console',
13586
- description: 'Add a property to the connected Google account. TWO THINGS THAT MUST REACH THE USER, and the answer states both: (1) ADDING IS NOT VERIFYING — the property arrives with the account as an unverified user and EVERY read on it is refused until ownership is proven with a DNS record, an HTML file or a tag, which no API can do and which the user completes in Search Console itself; (2) the new property is NOT yet shared with this profile, so someone has to tick it under Settings > Connectors > Google Search Console > Manage accounts (or call list_connector_accounts with provider google_search_console, then set_connector_accounts) before any tool here can use it. The permission level is READ BACK from Google, so the answer says which of those two states it is actually in. 0 credits.',
13605
+ description: 'Add a property to the connected Google account. TWO THINGS THAT MUST REACH THE USER, and the answer states both: (1) ADDING IS NOT VERIFYING — the property arrives with the account as an unverified user and EVERY read on it is refused until ownership is proven with a DNS record, an HTML file or a tag, which no API can do and which the user completes in Search Console itself; (2) the new property is NOT yet shared with this profile, so someone has to tick it under Connectors > Google Search Console > Manage accounts (or call list_connector_accounts with provider google_search_console, then set_connector_accounts) before any tool here can use it. The permission level is READ BACK from Google, so the answer says which of those two states it is actually in. 0 credits.',
13587
13606
  inputSchema: {
13588
13607
  siteUrl: z.string().describe('"sc-domain:example.com" for a Domain property (covers every scheme and subdomain), or the full URL-prefix form "https://example.com/". These are different properties — pick deliberately.'),
13589
13608
  },
@@ -13618,7 +13637,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13618
13637
  // which is the one thing here that has no free Google equivalent.
13619
13638
  server.registerTool('list_bing_webmaster_sites', {
13620
13639
  title: 'List the Bing Webmaster sites shared with this profile',
13621
- description: 'The Bing Webmaster Tools sites SHARED WITH THIS PROFILE, each exactly as Bing holds it (e.g. "https://example.com"). CALL THIS FIRST — every other Bing tool takes that exact string. THIS IS NOT EVERY SITE THE CONNECTION CAN REACH: Microsoft issues one key per USER covering every site that Bing account verified, 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 — point the user at Settings > Connectors > Bing Webmaster Tools > Manage accounts (or call set_connector_accounts with provider "bing_webmaster") and never name or guess a site. A site flagged not verified will refuse every later call. Read-only, 0 credits.',
13640
+ description: 'The Bing Webmaster Tools sites SHARED WITH THIS PROFILE, each exactly as Bing holds it (e.g. "https://example.com"). CALL THIS FIRST — every other Bing tool takes that exact string. THIS IS NOT EVERY SITE THE CONNECTION CAN REACH: Microsoft issues one key per USER covering every site that Bing account verified, 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 — point the user at Connectors > Bing Webmaster Tools > Manage accounts (or call set_connector_accounts with provider "bing_webmaster") and never name or guess a site. A site flagged not verified will refuse every later call. Read-only, 0 credits.',
13622
13641
  inputSchema: {},
13623
13642
  outputSchema: { sites: z.array(z.object({ siteUrl: z.string().optional(), isVerified: z.boolean().optional() })).optional(), count: z.number().optional(), shared: z.number().optional(), missing: z.array(z.string()).optional() },
13624
13643
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -13627,8 +13646,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
13627
13646
  const rows = (d.sites || []).map(s => `• ${s.siteUrl}${s.isVerified ? '' : ' (NOT VERIFIED in Bing — every call on it will be refused)'}`);
13628
13647
  if (rows.length) return ok(`${rows.length} Bing Webmaster site(s) shared with this profile:\n${rows.join('\n')}`, d);
13629
13648
  return ok(d.shared
13630
- ? `The ${d.shared} site(s) shared with this profile are no longer visible to the connected Bing account. Ask the user to re-pick under Settings ▸ Connectors ▸ Bing Webmaster Tools ▸ Manage accounts (or call list_connector_accounts with provider bing_webmaster, then set_connector_accounts).`
13631
- : 'No Bing Webmaster site is shared with this profile yet. Ask the user to pick which of their verified sites belong to this profile under Settings ▸ Connectors ▸ Bing Webmaster Tools ▸ Manage accounts (or call list_connector_accounts with provider bing_webmaster, then set_connector_accounts). Do not name or guess one.', d);
13649
+ ? `The ${d.shared} site(s) shared with this profile are no longer visible to the connected Bing account. Ask the user to re-pick under Connectors ▸ Bing Webmaster Tools ▸ Manage accounts (or call list_connector_accounts with provider bing_webmaster, then set_connector_accounts).`
13650
+ : 'No Bing Webmaster site is shared with this profile yet. Ask the user to pick which of their verified sites belong to this profile under Connectors ▸ Bing Webmaster Tools ▸ Manage accounts (or call list_connector_accounts with provider bing_webmaster, then set_connector_accounts). Do not name or guess one.', d);
13632
13651
  }));
13633
13652
  server.registerTool('bing_webmaster_traffic', {
13634
13653
  title: 'Bing clicks and impressions',
@@ -13825,7 +13844,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13825
13844
  }));
13826
13845
  server.registerTool('add_bing_webmaster_site', {
13827
13846
  title: 'Add a site to the Bing Webmaster account',
13828
- description: 'Add a site to the connected Bing Webmaster account. Important: ADDING IS NOT VERIFYING, and saying so is most of this tool\'s value: the site arrives UNVERIFIED and every read on it is refused until an ownership proof is placed on the site itself — an XML file at the root, a meta tag in the home page <head>, or a CNAME DNS record — which no API can do. Place one, then call verify_bing_webmaster_site. Microsoft documents that adding a site which is already there does NOT error, so a success here is not even evidence anything changed, which is why the answer is read back from Bing\'s site list. It is also NOT shared with this profile until the user ticks it under Settings > Connectors > Bing Webmaster Tools > Manage accounts (or call list_connector_accounts with provider bing_webmaster, then set_connector_accounts). 0 credits.',
13847
+ description: 'Add a site to the connected Bing Webmaster account. Important: ADDING IS NOT VERIFYING, and saying so is most of this tool\'s value: the site arrives UNVERIFIED and every read on it is refused until an ownership proof is placed on the site itself — an XML file at the root, a meta tag in the home page <head>, or a CNAME DNS record — which no API can do. Place one, then call verify_bing_webmaster_site. Microsoft documents that adding a site which is already there does NOT error, so a success here is not even evidence anything changed, which is why the answer is read back from Bing\'s site list. It is also NOT shared with this profile until the user ticks it under Connectors > Bing Webmaster Tools > Manage accounts (or call list_connector_accounts with provider bing_webmaster, then set_connector_accounts). 0 credits.',
13829
13848
  inputSchema: { siteUrl: z.string().describe('the site with its scheme, e.g. "https://example.com"') },
13830
13849
  outputSchema: { siteUrl: z.string().optional(), added: z.boolean().optional(), confirmed: z.boolean().nullable().optional(), isVerified: z.boolean().nullable().optional(), shared: z.boolean().optional(), note: z.string().optional() },
13831
13850
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
@@ -13882,7 +13901,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13882
13901
  // questions about traffic; these answer WHICH HUMAN DID WHAT — per-user funnels, retention cohorts and
13883
13902
  // session replay, which GA4 structurally has no equivalent for. All three are PASTE-AN-API-KEY, so
13884
13903
  // there is no OAuth consent screen and no sensitive scope; the user creates a key in the vendor's own
13885
- // settings and pastes it under Settings ▸ Connectors.
13904
+ // settings and pastes it under Connectors.
13886
13905
  //
13887
13906
  // THE ONE RULE THAT OUTRANKS THE REST, and it must survive into every description below: PostHog's own
13888
13907
  // policy is that "Third-party connectors must use batch exports, not /query. Connectors built on
@@ -13891,7 +13910,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
13891
13910
  // tool copy SAYS SO — an agent that has not been told the rule will cheerfully write the loop.
13892
13911
  server.registerTool('list_posthog_projects', {
13893
13912
  title: 'List the PostHog projects this key can see',
13894
- description: 'The PostHog projects the connected personal API key can see, and which ONE this profile is pointed at. Only the ACTIVE project is readable. That is deliberate: PostHog\'s API otherwise falls back to "the last project you visited in the UI", which would make every answer depend on the user\'s browsing history, so Hermoso pins a project at connect time instead of relying on their implicit default. To move this profile to a different project, the user reconnects PostHog under Settings > Connectors > PostHog, or with connect_connector, with that project id. Read-only, 0 credits.',
13913
+ description: 'The PostHog projects the connected personal API key can see, and which ONE this profile is pointed at. Only the ACTIVE project is readable. That is deliberate: PostHog\'s API otherwise falls back to "the last project you visited in the UI", which would make every answer depend on the user\'s browsing history, so Hermoso pins a project at connect time instead of relying on their implicit default. To move this profile to a different project, the user reconnects PostHog under Connectors > PostHog, or with connect_connector, with that project id. Read-only, 0 credits.',
13895
13914
  inputSchema: {},
13896
13915
  outputSchema: { projects: z.array(z.any()).optional(), count: z.number().optional(), active: z.string().optional(), note: z.string().optional() },
13897
13916
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -14339,7 +14358,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14339
14358
  return ok([head, ...rows, d.note || '', d.pageNote || ''].filter(Boolean).join('\n'), d);
14340
14359
  }));
14341
14360
  // ---------- Vercel (2026-10-16): the brand's OWN websites — projects, deployments, domains, Web Analytics, and a landing page deployed from files ----------
14342
- const VC_NEEDS = ' Needs Vercel connected (Settings > Connectors > Vercel, or connect_connector with an access token).';
14361
+ const VC_NEEDS = ' Needs Vercel connected (Connectors > Vercel, or connect_connector with an access token).';
14343
14362
  // One renderer for page views and custom events; `by` may be one dimension or two (2026-10-27).
14344
14363
  const vcText = (d, events) => {
14345
14364
  const t = d.totals || {}, by = [].concat(d.by || []).join(' x ');
@@ -14453,7 +14472,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14453
14472
  return ok(d.note, d);
14454
14473
  }));
14455
14474
  // ---------- Mailchimp (2026-11-03): the brand's OWN email marketing — audiences, segments, campaigns, templates, File Manager, reports ----------
14456
- const MC_NEEDS = ' Needs Mailchimp connected (Settings > Connectors > Mailchimp, or connect_connector with an API key).';
14475
+ const MC_NEEDS = ' Needs Mailchimp connected (Connectors > Mailchimp, or connect_connector with an API key).';
14457
14476
  const mcPct = (x) => (x == null ? '?' : `${(x * 100).toFixed(1)}%`);
14458
14477
  const mcPage = { limit: z.number().optional(), offset: z.number().optional() };
14459
14478
  server.registerTool('list_mailchimp_audiences', {
@@ -14600,7 +14619,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14600
14619
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
14601
14620
  }, wrap(async (a) => { const d = await apiPost('/api/mailchimp/sync-leads', a); return ok(d.note, d); }));
14602
14621
  // ---------- Klaviyo (2026-11-03): the brand's OWN email marketing — lists, segments, campaigns, flows, templates, images, performance ----------
14603
- const KL_NEEDS = ' Needs Klaviyo connected (Settings > Connectors > Klaviyo, or connect_connector with a private API key).';
14622
+ const KL_NEEDS = ' Needs Klaviyo connected (Connectors > Klaviyo, or connect_connector with a private API key).';
14604
14623
  const klPage = { limit: z.number().optional(), cursor: z.string().optional().describe('nextCursor from a previous call') };
14605
14624
  const klPct = (x) => (x == null ? '?' : `${(x * 100).toFixed(1)}%`);
14606
14625
  const klGroups = (seg) => async (a = {}) => {
@@ -14780,7 +14799,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14780
14799
  // ---------- Stripe (2026-09-09): the brand's OWN revenue, read-only via a restricted key ----------
14781
14800
  server.registerTool('stripe_report', {
14782
14801
  title: 'Stripe revenue report',
14783
- description: "Revenue from the brand's OWN Stripe account: gross, refunds, net and succeeded-charge count per day/week/month, new customers in the window, and active subscriptions + MRR where the key can read them. This is the money side of the loop — read an ad, a hook or a launch against real revenue instead of clicks. Default window is the last 30 days; a window with no charges answers a real zero, not a failed read. Read-only, never moves money, free. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector: paste a restricted key from Developers > Restricted keys).",
14802
+ description: "Revenue from the brand's OWN Stripe account: gross, refunds, net and succeeded-charge count per day/week/month, new customers in the window, and active subscriptions + MRR where the key can read them. This is the money side of the loop — read an ad, a hook or a launch against real revenue instead of clicks. Default window is the last 30 days; a window with no charges answers a real zero, not a failed read. Read-only, never moves money, free. Needs Stripe connected (Connectors > Stripe, or connect_connector: paste a restricted key from Developers > Restricted keys).",
14784
14803
  inputSchema: {
14785
14804
  since: z.string().optional().describe('YYYY-MM-DD (default 30 days ago)'),
14786
14805
  until: z.string().optional().describe('YYYY-MM-DD (default today)'),
@@ -14816,104 +14835,104 @@ function buildTools(rawServer, opts = {}, sink = null) {
14816
14835
  // ---------- Stripe, the full surface (2026-09-09): reads, and writes that confirm on a live key ----------
14817
14836
  server.registerTool('list_stripe_subscriptions', {
14818
14837
  title: 'list stripe subscriptions',
14819
- description: "Subscriptions on the brand's OWN Stripe account with each item's price in MAJOR units (19.00 means $19.00), interval and quantity, and the MRR each contributes. With NO status it returns everything that counts toward MRR — active + trialing + past_due — and says so; pass status all, canceled, unpaid, incomplete, incomplete_expired or paused for the rest, or filter by customer or price. Read-only, free. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
14838
+ description: "Subscriptions on the brand's OWN Stripe account with each item's price in MAJOR units (19.00 means $19.00), interval and quantity, and the MRR each contributes. With NO status it returns everything that counts toward MRR — active + trialing + past_due — and says so; pass status all, canceled, unpaid, incomplete, incomplete_expired or paused for the rest, or filter by customer or price. Read-only, free. Needs Stripe connected (Connectors > Stripe, or connect_connector).",
14820
14839
  inputSchema: { status: z.string().optional().describe('filter by status (all, active, trialing, past_due, canceled and others); omit for MRR statuses'), customerId: z.string().optional().describe('Stripe customer id, cus_...'), priceId: z.string().optional().describe('Stripe price id, price_...; only subscriptions on this price'), limit: z.number().optional().describe('rows to return, 1 to 100; default 25'), after: z.string().optional().describe('pagination cursor: the last subscription id from the previous page') },
14821
14840
  outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
14822
14841
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
14823
14842
  }, wrap(async (a) => { const d = await apiGet('/api/stripe/subscriptions', a); return ok(d.count ? `${d.note}\n${d.subscriptions.map(x => `• ${x.id} ${x.status} — ${x.customerEmail || x.customer}: ${x.items.map(i => `${i.quantity}×${i.amount == null ? '?' : i.amount.toFixed(2)} ${i.currency}/${i.interval || 'once'}${i.nickname ? ` (${i.nickname})` : ''}`).join(', ')} · MRR ${x.mrr}${x.cancelAtPeriodEnd ? ' · cancels at period end' : ''}`).join('\n')}` : d.note, d); }));
14824
14843
  server.registerTool('list_stripe_invoices', {
14825
14844
  title: 'list stripe invoices',
14826
- description: "Invoices on the brand's Stripe account, newest first: number, status (draft, open, paid, uncollectible, void), amount due and paid, customer email, dates and the hosted invoice link; filter by status or customer. Read-only, free. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
14845
+ description: "Invoices on the brand's Stripe account, newest first: number, status (draft, open, paid, uncollectible, void), amount due and paid, customer email, dates and the hosted invoice link; filter by status or customer. Read-only, free. Needs Stripe connected (Connectors > Stripe, or connect_connector).",
14827
14846
  inputSchema: { status: z.string().optional().describe('filter to draft, open, paid, uncollectible or void'), customerId: z.string().optional().describe('Stripe customer id, cus_...'), limit: z.number().optional().describe('rows to return, 1 to 100; default 25'), after: z.string().optional().describe('pagination cursor: the last invoice id from the previous page') },
14828
14847
  outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
14829
14848
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
14830
14849
  }, wrap(async (a) => { const d = await apiGet('/api/stripe/invoices', a); return ok(d.count ? `${d.count} invoice(s):\n${d.invoices.map(i => `• ${i.number || i.id} ${i.status} — ${i.amountPaid}/${i.amountDue} ${i.currency} — ${i.customerEmail || i.customer} — ${i.created.slice(0, 10)}${i.hostedInvoiceUrl ? ` ${i.hostedInvoiceUrl}` : ''}`).join('\n')}${d.hasMore ? '\n(more: pass after=' + d.invoices[d.invoices.length - 1].id + ')' : ''}` : d.note, d); }));
14831
14850
  server.registerTool('list_stripe_products', {
14832
14851
  title: 'list stripe products',
14833
- description: "Products on the brand's Stripe account with their active prices (amount, currency, one-time or recurring interval), which is what create_stripe_payment_link and create_stripe_subscription take. active:false lists archived products too. Read-only, free. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
14852
+ description: "Products on the brand's Stripe account with their active prices (amount, currency, one-time or recurring interval), which is what create_stripe_payment_link and create_stripe_subscription take. active:false lists archived products too. Read-only, free. Needs Stripe connected (Connectors > Stripe, or connect_connector).",
14834
14853
  inputSchema: { active: z.boolean().optional().describe('false also lists archived products; omit or true for active products only'), limit: z.number().optional().describe('rows to return, 1 to 100; default 25'), after: z.string().optional().describe('pagination cursor: the last product id from the previous page') },
14835
14854
  outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
14836
14855
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
14837
14856
  }, wrap(async (a) => { const d = await apiGet('/api/stripe/products', a); return ok(d.count ? `${d.count} product(s):\n${d.products.map(p => `• ${p.name} (${p.id})${p.active ? '' : ' · archived'}: ${p.prices.map(x => `${x.id} ${x.amount == null ? '?' : x.amount.toFixed(2)} ${x.currency}${x.interval ? '/' + x.interval : ''}${x.nickname ? ` (${x.nickname})` : ''}`).join(', ') || 'no active price'}`).join('\n')}${d.hasMore ? '\n(more: pass after=' + d.products[d.products.length - 1].id + ')' : ''}` : d.note, d); }));
14838
14857
  server.registerTool('stripe_balance', {
14839
14858
  title: 'stripe balance',
14840
- description: "The brand's Stripe balance (available and pending, per currency) and the most recent payouts with status and arrival date. Read-only, free. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
14859
+ description: "The brand's Stripe balance (available and pending, per currency) and the most recent payouts with status and arrival date. Read-only, free. Needs Stripe connected (Connectors > Stripe, or connect_connector).",
14841
14860
  inputSchema: { limit: z.number().optional().describe('number of recent payouts to show, 1 to 100; default 25') },
14842
14861
  outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
14843
14862
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
14844
14863
  }, wrap(async (a) => { const d = await apiGet('/api/stripe/balance', a); return ok(`${d.note}\n${(d.payouts || []).map(p => `• ${p.arrivalDate} ${p.amount} ${p.currency} ${p.status} (${p.id})`).join('\n')}`, d); }));
14845
14864
  server.registerTool('list_stripe_refunds', {
14846
14865
  title: 'list stripe refunds',
14847
- description: "Refunds on the brand's Stripe account, newest first: amount, status, reason and the charge refunded; filter by charge. Read-only, free. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
14866
+ description: "Refunds on the brand's Stripe account, newest first: amount, status, reason and the charge refunded; filter by charge. Read-only, free. Needs Stripe connected (Connectors > Stripe, or connect_connector).",
14848
14867
  inputSchema: { chargeId: z.string().optional().describe('only refunds of this charge, ch_...'), limit: z.number().optional().describe('rows to return, 1 to 100; default 25'), after: z.string().optional().describe('pagination cursor: the last refund id from the previous page') },
14849
14868
  outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
14850
14869
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
14851
14870
  }, wrap(async (a) => { const d = await apiGet('/api/stripe/refunds', a); return ok(d.count ? `${d.count} refund(s):\n${d.refunds.map(r => `• ${r.created.slice(0, 10)} ${r.amount} ${r.currency} ${r.status}${r.reason ? ` (${r.reason})` : ''} on ${r.charge} (${r.id})`).join('\n')}` : d.note, d); }));
14852
14871
  server.registerTool('list_stripe_coupons', {
14853
14872
  title: 'list stripe coupons',
14854
- description: "Coupons on the brand's Stripe account: percent or amount off, duration, validity and redemptions. Read-only, free. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
14873
+ description: "Coupons on the brand's Stripe account: percent or amount off, duration, validity and redemptions. Read-only, free. Needs Stripe connected (Connectors > Stripe, or connect_connector).",
14855
14874
  inputSchema: { limit: z.number().optional().describe('rows to return, 1 to 100; default 25'), after: z.string().optional().describe('pagination cursor: the last coupon id from the previous page') },
14856
14875
  outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
14857
14876
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
14858
14877
  }, wrap(async (a) => { const d = await apiGet('/api/stripe/coupons', a); return ok(d.count ? `${d.count} coupon(s):\n${d.coupons.map(c => `• ${c.id}${c.name ? ` ${c.name}` : ''}: ${c.percentOff != null ? c.percentOff + '% off' : c.amountOff + ' ' + c.currency + ' off'}, ${c.duration}${c.durationInMonths ? ' ' + c.durationInMonths + ' months' : ''}${c.valid ? '' : ' · no longer valid'} · redeemed ${c.timesRedeemed}${c.maxRedemptions ? '/' + c.maxRedemptions : ''}`).join('\n')}` : d.note, d); }));
14859
14878
  server.registerTool('create_stripe_product', {
14860
14879
  title: 'create stripe product',
14861
- description: "Create a product on the brand's Stripe account together with its default price: name, optional description, `amount` in MAJOR units — 19 or 19.00 both mean $19.00, and a zero-decimal currency like JPY is whole — a 3-letter currency, and an optional recurring interval (day, week, month, year) with intervalCount. Returns both ids, read back. On a LIVE key it shows what it is about to create and needs confirm:true; on a test key it just does it. Idempotent on its arguments. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
14880
+ description: "Create a product on the brand's Stripe account together with its default price: name, optional description, `amount` in MAJOR units — 19 or 19.00 both mean $19.00, and a zero-decimal currency like JPY is whole — a 3-letter currency, and an optional recurring interval (day, week, month, year) with intervalCount. Returns both ids, read back. On a LIVE key it shows what it is about to create and needs confirm:true; on a test key it just does it. Idempotent on its arguments. Needs Stripe connected (Connectors > Stripe, or connect_connector).",
14862
14881
  inputSchema: { name: z.string().describe('product name'), description: z.string().optional().describe('product description shown to customers in Stripe'), amount: z.number().describe('MAJOR units — 19 or 19.00 is $19.00, never 1900'), currency: z.string().describe('3-letter ISO currency code, e.g. usd'), interval: z.string().optional().describe('recurring interval: day, week, month or year; omit for a one-time price'), intervalCount: z.number().optional().describe('intervals between charges, e.g. 3 with month for quarterly'), nickname: z.string().optional().describe('internal label for the default price; not shown to customers'), metadata: z.record(z.any()).optional().describe('key/value metadata stored on the product'), confirm: z.boolean().optional().describe('true on a live key, after the user approves the preview') },
14863
14882
  outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
14864
14883
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
14865
14884
  }, wrap(async (a) => { const d = await apiPost('/api/stripe/product', a); return ok(d.note, d); }));
14866
14885
  server.registerTool('create_stripe_price', {
14867
14886
  title: 'create stripe price',
14868
- description: "Add a price to an existing Stripe product: `amount` in MAJOR units (19 or 19.00 is $19.00, never 1900), currency, optional recurring interval and intervalCount, optional nickname. Read back. LIVE key: confirm:true after showing the preview. Idempotent on its arguments. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
14887
+ description: "Add a price to an existing Stripe product: `amount` in MAJOR units (19 or 19.00 is $19.00, never 1900), currency, optional recurring interval and intervalCount, optional nickname. Read back. LIVE key: confirm:true after showing the preview. Idempotent on its arguments. Needs Stripe connected (Connectors > Stripe, or connect_connector).",
14869
14888
  inputSchema: { productId: z.string().describe('product id, prod_..., from create_stripe_product or list_stripe_products'), amount: z.number().describe('MAJOR units — 19 or 19.00 is $19.00, never 1900'), currency: z.string().describe('3-letter ISO currency code, e.g. usd'), interval: z.string().optional().describe('recurring interval: day, week, month or year; omit for a one-time price'), intervalCount: z.number().optional().describe('intervals between charges, e.g. 3 with month for quarterly'), nickname: z.string().optional().describe('internal label for the price; not shown to customers'), confirm: z.boolean().optional().describe('true on a live key, after the user approves the preview') },
14870
14889
  outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
14871
14890
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
14872
14891
  }, wrap(async (a) => { const d = await apiPost('/api/stripe/price', a); return ok(d.note, d); }));
14873
14892
  server.registerTool('create_stripe_payment_link', {
14874
14893
  title: 'create stripe payment link',
14875
- description: "Create a shareable Stripe Payment Link: for ONE price pass priceId (price_…) with an optional quantity; for several pass lineItems: [{priceId, quantity}]. Optionally redirect to afterCompletionUrl when paid. allowPromotionCodes:true shows the promotion-code box at checkout — needed for any 'use code X' ad. referenceId and utm return a taggedUrl: put THAT URL in the ad, and get_stripe_payment_link_revenue (groupBy reference) then counts that ad's purchases. Returns the URL. LIVE key: confirm:true after showing the preview. Idempotent on its arguments. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
14894
+ description: "Create a shareable Stripe Payment Link: for ONE price pass priceId (price_…) with an optional quantity; for several pass lineItems: [{priceId, quantity}]. Optionally redirect to afterCompletionUrl when paid. allowPromotionCodes:true shows the promotion-code box at checkout — needed for any 'use code X' ad. referenceId and utm return a taggedUrl: put THAT URL in the ad, and get_stripe_payment_link_revenue (groupBy reference) then counts that ad's purchases. Returns the URL. LIVE key: confirm:true after showing the preview. Idempotent on its arguments. Needs Stripe connected (Connectors > Stripe, or connect_connector).",
14876
14895
  inputSchema: { priceId: z.string().optional().describe('one price — the shorthand for a single line item'), quantity: z.number().optional().describe('quantity of priceId, e.g. 2'), lineItems: z.array(z.object({ priceId: z.string().describe('price id, price_...'), quantity: z.number().optional().describe('quantity of this price in the link') })).optional().describe('several prices at once; use instead of priceId'), afterCompletionUrl: z.string().optional().describe('URL the buyer is sent to after paying'), allowPromotionCodes: z.boolean().optional().describe('show the promotion-code box at checkout'), referenceId: z.string().optional().describe('letters, digits, dashes, underscores (up to 200) — tags the returned taggedUrl for one ad; Stripe stores it on each purchase'), utm: z.object({ source: z.string().optional().describe('UTM source on the tagged URL; letters, digits, dashes and underscores, up to 150'), medium: z.string().optional().describe('UTM medium on the tagged URL; letters, digits, dashes and underscores, up to 150'), campaign: z.string().optional().describe('UTM campaign on the tagged URL; letters, digits, dashes and underscores, up to 150'), content: z.string().optional().describe('UTM content on the tagged URL; letters, digits, dashes and underscores, up to 150'), term: z.string().optional().describe('UTM term on the tagged URL; letters, digits, dashes and underscores, up to 150') }).optional().describe('UTM values on the taggedUrl (letters, digits, dashes, underscores, up to 150 each); Stripe passes them to the after-payment redirect only'), metadata: z.record(z.any()).optional().describe('key/value metadata stored on the payment link'), confirm: z.boolean().optional().describe('true on a live key, after the user approves the preview') },
14877
14896
  outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
14878
14897
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
14879
14898
  }, wrap(async (a) => { const d = await apiPost('/api/stripe/payment-link', a); return ok(d.note, d); }));
14880
14899
  server.registerTool('create_stripe_coupon', {
14881
14900
  title: 'create stripe coupon',
14882
- description: "Create a Stripe coupon: exactly one of percentOff (1–100) or amountOff (major units) + currency; duration once (default), repeating (with durationInMonths) or forever; optional name, id and maxRedemptions. A coupon alone has NO code a customer can type: pass `code` (letters, digits, dashes) to also mint the customer-typable promotion code in the same call, with optional codeMaxRedemptions, expiresAt, firstTimeOnly and minimumAmount. Read back. LIVE key: confirm:true after showing the preview. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
14901
+ description: "Create a Stripe coupon: exactly one of percentOff (1–100) or amountOff (major units) + currency; duration once (default), repeating (with durationInMonths) or forever; optional name, id and maxRedemptions. A coupon alone has NO code a customer can type: pass `code` (letters, digits, dashes) to also mint the customer-typable promotion code in the same call, with optional codeMaxRedemptions, expiresAt, firstTimeOnly and minimumAmount. Read back. LIVE key: confirm:true after showing the preview. Needs Stripe connected (Connectors > Stripe, or connect_connector).",
14883
14902
  inputSchema: { name: z.string().optional().describe('display name for the coupon, for your records'), id: z.string().optional().describe('custom coupon id; Stripe generates one if omitted'), percentOff: z.number().optional().describe('percent discount, 1 to 100; use this or amountOff'), amountOff: z.number().optional().describe('fixed discount in major units, e.g. 5 = 5.00; needs currency'), currency: z.string().optional().describe('3-letter currency code; required with amountOff'), duration: z.string().optional().describe('once (default), repeating or forever'), durationInMonths: z.number().optional().describe('months the discount applies, with duration repeating'), maxRedemptions: z.number().optional().describe('uses of the COUPON across every code'), code: z.string().optional().describe('the customer-typable promotion code, e.g. SUMMER20 — letters, digits and dashes only; refused (never altered) otherwise'), codeMaxRedemptions: z.number().optional().describe('uses of this CODE'), expiresAt: z.string().optional().describe('YYYY-MM-DD the code stops working'), firstTimeOnly: z.boolean().optional().describe("the code works only on a customer's first purchase"), minimumAmount: z.number().optional().describe('minimum order in MAJOR units for the code'), minimumAmountCurrency: z.string().optional().describe("currency for the code's minimumAmount, e.g. usd"), confirm: z.boolean().optional().describe('true on a live key, after the user approves the preview') },
14884
14903
  outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
14885
14904
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
14886
14905
  }, wrap(async (a) => { const d = await apiPost('/api/stripe/coupon', a); return ok(d.note, d); }));
14887
14906
  server.registerTool('create_stripe_customer', {
14888
14907
  title: 'create stripe customer',
14889
- description: "Create a Stripe customer by email (optional name, phone, description, metadata). If a customer with that email already exists it is returned instead and nothing is created — pass allowDuplicate:true to create a second one on purpose. LIVE key: confirm:true after showing the preview. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
14908
+ description: "Create a Stripe customer by email (optional name, phone, description, metadata). If a customer with that email already exists it is returned instead and nothing is created — pass allowDuplicate:true to create a second one on purpose. LIVE key: confirm:true after showing the preview. Needs Stripe connected (Connectors > Stripe, or connect_connector).",
14890
14909
  inputSchema: { email: z.string().describe('customer email; the key Stripe matches duplicates on'), name: z.string().optional().describe('customer name'), phone: z.string().optional().describe('customer phone number'), description: z.string().optional().describe('internal note on the customer record'), metadata: z.record(z.any()).optional().describe('key/value metadata stored on the customer'), allowDuplicate: z.boolean().optional().describe('true = create a second customer even if this email already has one'), confirm: z.boolean().optional().describe('true on a live key, after the user approves the preview') },
14891
14910
  outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
14892
14911
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
14893
14912
  }, wrap(async (a) => { const d = await apiPost('/api/stripe/customer', a); return ok(d.note, d); }));
14894
14913
  server.registerTool('create_stripe_subscription', {
14895
14914
  title: 'create stripe subscription',
14896
- description: "Subscribe a Stripe customer (cus_…) to a price (price_…), optional quantity and trialDays. THIS CHARGES THE CUSTOMER'S SAVED PAYMENT METHOD when there is no trial, so it ALWAYS needs confirm:true (test keys included) after you show the user exactly what will be created. Read back; an INCOMPLETE status means no chargeable card is on file. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
14915
+ description: "Subscribe a Stripe customer (cus_…) to a price (price_…), optional quantity and trialDays. THIS CHARGES THE CUSTOMER'S SAVED PAYMENT METHOD when there is no trial, so it ALWAYS needs confirm:true (test keys included) after you show the user exactly what will be created. Read back; an INCOMPLETE status means no chargeable card is on file. Needs Stripe connected (Connectors > Stripe, or connect_connector).",
14897
14916
  inputSchema: { customerId: z.string().describe('customer id, cus_...'), priceId: z.string().describe('recurring price id, price_...'), quantity: z.number().optional().describe('quantity of the price, e.g. number of seats'), trialDays: z.number().optional().describe('free trial length in days before the first charge'), metadata: z.record(z.any()).optional().describe('key/value metadata stored on the subscription'), confirm: z.boolean().optional().describe('true to charge the saved card; always required, test keys included') },
14898
14917
  outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
14899
14918
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
14900
14919
  }, wrap(async (a) => { const d = await apiPost('/api/stripe/subscription', a); return ok(d.note, d); }));
14901
14920
  server.registerTool('cancel_stripe_subscription', {
14902
14921
  title: 'cancel stripe subscription',
14903
- description: "Cancel a Stripe subscription: at the end of the current period by default (the customer keeps access until then), or immediately:true to end it now. ALWAYS needs confirm:true, test keys included. The read-back distinguishes the two — a period-end cancel reports the date it will end and the status it keeps until then, an immediate one reports status canceled. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
14922
+ description: "Cancel a Stripe subscription: at the end of the current period by default (the customer keeps access until then), or immediately:true to end it now. ALWAYS needs confirm:true, test keys included. The read-back distinguishes the two — a period-end cancel reports the date it will end and the status it keeps until then, an immediate one reports status canceled. Needs Stripe connected (Connectors > Stripe, or connect_connector).",
14904
14923
  inputSchema: { subscriptionId: z.string().describe('subscription id, sub_...'), immediately: z.boolean().optional().describe('true = end now; omitted or false = end at the period end'), confirm: z.boolean().optional().describe('true after the user approves this cancellation') },
14905
14924
  outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
14906
14925
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
14907
14926
  }, wrap(async (a) => { const d = await apiPost('/api/stripe/subscription/cancel', a); return ok(d.note, d); }));
14908
14927
  server.registerTool('refund_stripe_charge', {
14909
14928
  title: 'refund stripe charge',
14910
- description: "Refund a Stripe charge (ch_… or a pi_… payment intent): the full refundable amount, or a partial `amount` in major units; optional reason duplicate / fraudulent / requested_by_customer. ALWAYS needs confirm:true, test keys included. Read back. Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).",
14929
+ description: "Refund a Stripe charge (ch_… or a pi_… payment intent): the full refundable amount, or a partial `amount` in major units; optional reason duplicate / fraudulent / requested_by_customer. ALWAYS needs confirm:true, test keys included. Read back. Needs Stripe connected (Connectors > Stripe, or connect_connector).",
14911
14930
  inputSchema: { chargeId: z.string().describe('charge id, ch_..., or payment intent id, pi_...'), amount: z.number().optional().describe('partial refund in major units; omit to refund the full refundable amount'), reason: z.string().optional().describe('duplicate, fraudulent or requested_by_customer'), confirm: z.boolean().optional().describe('true after the user approves this refund') },
14912
14931
  outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
14913
14932
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
14914
14933
  }, wrap(async (a) => { const d = await apiPost('/api/stripe/refund', a); return ok(d.note, d); }));
14915
14934
  // ---------- Stripe promotion codes, payment-link edits and revenue per link (2026-10-27) ----------
14916
- const stripeConnected = 'Needs Stripe connected (Settings > Connectors > Stripe, or connect_connector).';
14935
+ const stripeConnected = 'Needs Stripe connected (Connectors > Stripe, or connect_connector).';
14917
14936
  server.registerTool('list_stripe_promotion_codes', {
14918
14937
  title: 'list stripe promotion codes',
14919
14938
  description: `Promotion codes on the brand's Stripe account — the codes customers TYPE at checkout — with the coupon each applies, active or not, times redeemed / max, expiry, first-purchase-only and minimum-order rules. Filter by active, code or couponId. Read-only, free. ${stripeConnected}`,
@@ -14959,7 +14978,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14959
14978
  // ---------- HubSpot (2026-09-09): the brand's OWN CRM — contacts, an upsert by email, lead forms → contacts, deals ----------
14960
14979
  server.registerTool('list_hubspot_contacts', {
14961
14980
  title: 'List or search HubSpot contacts',
14962
- description: "Contacts from the brand's OWN HubSpot CRM, newest first — email, name, lifecycle stage, lead status, created date, original source — or a search by email / first name / last name (whole-token match, wildcards allowed). Read-only, free. Needs HubSpot connected (Settings > Connectors > HubSpot).",
14981
+ description: "Contacts from the brand's OWN HubSpot CRM, newest first — email, name, lifecycle stage, lead status, created date, original source — or a search by email / first name / last name (whole-token match, wildcards allowed). Read-only, free. Needs HubSpot connected (Connectors > HubSpot).",
14963
14982
  inputSchema: { search: z.string().optional().describe('email or name to search for'), limit: z.number().optional().describe('1–100, default 25'), after: z.string().optional().describe('pagination cursor from the previous page') },
14964
14983
  outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), contacts: z.array(z.any()).optional(), after: z.string().nullable().optional(), note: z.string().optional() },
14965
14984
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -15042,7 +15061,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
15042
15061
  ? ` [${m.event || 'event'}${m.author?.name ? ` by ${m.author.name}` : ''}${m.createdAt ? ` ${icStamp(m.createdAt)}` : ''}]${m.text ? ` ${m.text}` : ''}`
15043
15062
  : ` ${m.kind === 'customer' ? 'CUSTOMER' : m.kind === 'note' ? 'NOTE (internal)' : m.kind === 'bot' ? 'BOT' : 'TEAMMATE'}${m.author?.name ? ` ${m.author.name}` : ''}${m.createdAt ? ` | ${icStamp(m.createdAt)}` : ''}: ${m.text || '(empty)'}`);
15044
15063
  const icWrite = (d) => (d.dryRun ? `${d.note || ''}\n${d.preview || ''}` : `${d.note || ''}${d.posted ? `\nAs Intercom shows it: ${d.posted}` : ''}`);
15045
- const icNeeds = 'Needs Intercom connected (Settings > Connectors > Intercom, or connect_connector).';
15064
+ const icNeeds = 'Needs Intercom connected (Connectors > Intercom, or connect_connector).';
15046
15065
  server.registerTool('list_intercom_conversations', {
15047
15066
  title: 'List Intercom conversations',
15048
15067
  description: "Support conversations from the brand's OWN Intercom inbox, newest activity first: subject, state, whether the customer is waiting, assignee, customer email, channel, tags and the first message. One Intercom workspace can serve several products, so every filter below combines with the others. "
@@ -15194,7 +15213,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
15194
15213
  }));
15195
15214
  server.registerTool('list_microsoft_ads_campaigns', {
15196
15215
  title: 'List Microsoft Advertising accounts / campaigns',
15197
- description: 'Read the brand’s connected Microsoft Advertising (Bing Ads) account(s). Call with NO adAccountId to list the accounts shared with this profile — do this first to pick a target. Call WITH adAccountId to list that account’s campaigns (id, name, status, daily budget, campaign type, and whether the budget is SHARED). Microsoft statuses are Active / Paused — never Google’s ENABLED — and Microsoft also sets BudgetPaused, BudgetAndManualPaused and Suspended on its own, so report the status you read rather than assuming a paused campaign was paused by a person. Read-only, free. Needs Microsoft Advertising connected (Settings > Connectors > Microsoft Advertising).',
15216
+ description: 'Read the brand’s connected Microsoft Advertising (Bing Ads) account(s). Call with NO adAccountId to list the accounts shared with this profile — do this first to pick a target. Call WITH adAccountId to list that account’s campaigns (id, name, status, daily budget, campaign type, and whether the budget is SHARED). Microsoft statuses are Active / Paused — never Google’s ENABLED — and Microsoft also sets BudgetPaused, BudgetAndManualPaused and Suspended on its own, so report the status you read rather than assuming a paused campaign was paused by a person. Read-only, free. Needs Microsoft Advertising connected (Connectors > Microsoft Advertising).',
15198
15217
  inputSchema: {
15199
15218
  ...msAcct,
15200
15219
  },
@@ -15203,9 +15222,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
15203
15222
  }, wrap(async (a) => {
15204
15223
  if (!(a.adAccountId || a.accountId)) {
15205
15224
  const d = await apiGet('/api/microsoft-ads/shared-accounts', {});
15206
- if (!(d.accounts || []).length) return ok('No Microsoft Advertising account is shared with this profile yet. Ask the user to tick the ones it may use in Settings ▸ Connectors ▸ Microsoft Ads ▸ Manage accounts (or call set_connector_accounts), then retry.', d);
15225
+ if (!(d.accounts || []).length) return ok('No Microsoft Advertising account is shared with this profile yet. Ask the user to tick the ones it may use in Connectors ▸ Microsoft Ads ▸ Manage accounts (or call set_connector_accounts), then retry.', d);
15207
15226
  const lines = (d.accounts || []).map(c => `• ${c.name} (${c.accountId})${c.number ? ` — ${c.number}` : ''}${c.selected ? ' [shared with this profile]' : ''}${c.status ? ` · ${c.status}` : ''}`);
15208
- return ok(`${(d.accounts || []).length} Microsoft Advertising account(s) shared with this profile${d.sandbox ? ' (SANDBOX environment)' : ''}:\n${lines.join('\n') || '(none)'}\nPass an adAccountId to see its campaigns. Only accounts ticked in Settings ▸ Connectors can be managed.`, d);
15227
+ return ok(`${(d.accounts || []).length} Microsoft Advertising account(s) shared with this profile${d.sandbox ? ' (SANDBOX environment)' : ''}:\n${lines.join('\n') || '(none)'}\nPass an adAccountId to see its campaigns. Only accounts ticked in Connectors can be managed.`, d);
15209
15228
  }
15210
15229
  const d = await apiGet('/api/microsoft-ads/campaigns', { adAccountId: (a.adAccountId || a.accountId) });
15211
15230
  const lines = (d.campaigns || []).map(c => `• ${c.name} (${c.id}) — ${c.status}${c.dailyBudget != null ? `, ${c.dailyBudget}/day${c.sharedBudget ? ' (SHARED budget — read-only here)' : ''}` : ''}${c.type ? `, ${c.type}` : ''}`);
@@ -16035,7 +16054,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
16035
16054
  }).describe('which products of a product-feed campaign this ad group advertises; omit for every product in the feed');
16036
16055
  server.registerTool('list_openai_ads_campaigns', {
16037
16056
  title: 'List ChatGPT Ads account / campaigns / ad groups / ads',
16038
- description: 'Read the brand’s connected ChatGPT Ads account — the ads that appear below ChatGPT answers. Every campaign, ad group and ad row carries servingIssues, OpenAI’s own list of what is blocking delivery (payment method, brand review, budget spent, ad in review, landing page not crawlable, country policy…) with a plain meaning each: null means OpenAI was not asked, [] means it reports no blocker, which is still not a promise of impressions. Call with NO ids to get the ad account itself (its three names: brandName shown in ads, accountName internal to Ads Manager, legalName; currency, status, brand review and business review state) plus its campaigns; with campaignId to list that campaign’s ad groups; with adGroupId to list that ad group’s ads, including each ad’s REVIEW status, which is what decides whether it can ever show. Statuses here are active / paused / archived. Read-only, free, zero spend risk. Needs ChatGPT Ads connected under Settings > Connectors > ChatGPT Ads in the Hermoso app; one connection covers one ad account.',
16057
+ description: 'Read the brand’s connected ChatGPT Ads account — the ads that appear below ChatGPT answers. Every campaign, ad group and ad row carries servingIssues, OpenAI’s own list of what is blocking delivery (payment method, brand review, budget spent, ad in review, landing page not crawlable, country policy…) with a plain meaning each: null means OpenAI was not asked, [] means it reports no blocker, which is still not a promise of impressions. Call with NO ids to get the ad account itself (its three names: brandName shown in ads, accountName internal to Ads Manager, legalName; currency, status, brand review and business review state) plus its campaigns; with campaignId to list that campaign’s ad groups; with adGroupId to list that ad group’s ads, including each ad’s REVIEW status, which is what decides whether it can ever show. Statuses here are active / paused / archived. Read-only, free, zero spend risk. Needs ChatGPT Ads connected under Connectors > ChatGPT Ads in the Hermoso app; one connection covers one ad account.',
16039
16058
  inputSchema: {
16040
16059
  campaignId: z.string().optional().describe('list this campaign’s ad groups'),
16041
16060
  adGroupId: z.string().optional().describe('list this ad group’s ads'),
@@ -16356,7 +16375,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
16356
16375
  // sentences move with them.
16357
16376
  server.registerTool('list_apple_ads_campaigns', {
16358
16377
  title: 'List Apple Ads campaigns',
16359
- description: 'Read the brand’s connected Apple Ads (Apple Search Ads) campaigns — App Store search ads, on Apple’s Platform API. Each row carries status, the system-computed displayStatus, the daily budget, the bid strategy, the countries and placements it runs in and, when a campaign cannot run, systemStatusReasons stating exactly why. Read-only, free, zero spend risk. Needs Apple Ads connected (Settings > Connectors > Apple Ads, or connect_connector): there is no OAuth — Hermoso generates an EC signing key, the user pastes the public half into Apple Ads > Account Settings > API and pastes back clientId / teamId / keyId. To BUILD on this account, use create_apple_ads_campaign / create_apple_ads_ad_group / add_apple_ads_keywords: everything is created PAUSED and only set_apple_ads_status(confirm:true) can arm spend.',
16378
+ description: 'Read the brand’s connected Apple Ads (Apple Search Ads) campaigns — App Store search ads, on Apple’s Platform API. Each row carries status, the system-computed displayStatus, the daily budget, the bid strategy, the countries and placements it runs in and, when a campaign cannot run, systemStatusReasons stating exactly why. Read-only, free, zero spend risk. Needs Apple Ads connected (Connectors > Apple Ads, or connect_connector): there is no OAuth — Hermoso generates an EC signing key, the user pastes the public half into Apple Ads > Account Settings > API and pastes back clientId / teamId / keyId. To BUILD on this account, use create_apple_ads_campaign / create_apple_ads_ad_group / add_apple_ads_keywords: everything is created PAUSED and only set_apple_ads_status(confirm:true) can arm spend.',
16360
16379
  inputSchema: {
16361
16380
  limit: z.number().optional().describe('page size, default 100, Apple’s max is 1000'),
16362
16381
  offset: z.number().optional().describe('offset pagination'),
@@ -16411,7 +16430,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
16411
16430
  }, wrap(async (a) => { const d = await apiPost('/api/apple-ads/report', a); return ok(`${d.note}\n${JSON.stringify((d.rows || []).slice(0, 40))}`, d); }));
16412
16431
  server.registerTool('list_apple_ads_orgs', {
16413
16432
  title: 'List Apple Ads organizations',
16414
- description: 'The Apple Ads ad accounts these credentials can act as, with the roles held on each, plus the organization’s currency, time zone and payment model. NAMING: Apple’s older API called these “organizations” (campaign groups) and its Platform API calls them AD ACCOUNTS — the id is the same number, and `orgId` and `adAccountId` on each row are equal. One login can cover several: an agency managing multiple clients has one per client. Reading this does NOT switch account: Apple Ads is pinned to the ONE chosen when the connection was made, so an agent can never act as another client’s. To use a different one, pick it in Settings > Connectors > Apple Ads > Manage accounts, with set_connector_accounts, or by reconnecting. A payment model of null is worth reporting: Apple states that without one, campaigns cannot run. Read-only, free.',
16433
+ description: 'The Apple Ads ad accounts these credentials can act as, with the roles held on each, plus the organization’s currency, time zone and payment model. NAMING: Apple’s older API called these “organizations” (campaign groups) and its Platform API calls them AD ACCOUNTS — the id is the same number, and `orgId` and `adAccountId` on each row are equal. One login can cover several: an agency managing multiple clients has one per client. Reading this does NOT switch account: Apple Ads is pinned to the ONE chosen when the connection was made, so an agent can never act as another client’s. To use a different one, pick it in Connectors > Apple Ads > Manage accounts, with set_connector_accounts, or by reconnecting. A payment model of null is worth reporting: Apple states that without one, campaigns cannot run. Read-only, free.',
16415
16434
  inputSchema: {},
16416
16435
  outputSchema: { ok: z.boolean().optional(), active: z.string().nullable().optional(), count: z.number().optional(), orgs: z.array(z.any()).optional(), org: z.any().optional(), note: z.string().optional() },
16417
16436
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -17956,7 +17975,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17956
17975
 
17957
17976
  server.registerTool('list_pinterest_ads_campaigns', {
17958
17977
  title: 'List Pinterest ad accounts / campaigns',
17959
- description: 'Read the brand’s connected Pinterest AD account(s). Call with NO adAccountId to list the ad accounts shared with this profile — do this first to pick a target. Call WITH adAccountId to list that account’s campaigns (id, name, status, objective, budget, and Pinterest’s own summary status). Add campaignId to get that ONE campaign’s whole tree — its AD GROUPS and ADS with their ids, statuses and review status. That is the only way to enumerate them, and it matters: Pinterest has no delete, so an ad group you cannot see is one you cannot even archive. All money is in the AD ACCOUNT’S currency, which is not necessarily dollars. Pinterest’s own list default hides DRAFT and ARCHIVED objects; this asks for all four statuses so nothing is silently missing. Read-only, free. Needs Pinterest connected (Settings > Connectors > Pinterest) and the ad account ticked under Manage accounts (or call list_connector_accounts with provider pinterest_ads, then set_connector_accounts).',
17978
+ description: 'Read the brand’s connected Pinterest AD account(s). Call with NO adAccountId to list the ad accounts shared with this profile — do this first to pick a target. Call WITH adAccountId to list that account’s campaigns (id, name, status, objective, budget, and Pinterest’s own summary status). Add campaignId to get that ONE campaign’s whole tree — its AD GROUPS and ADS with their ids, statuses and review status. That is the only way to enumerate them, and it matters: Pinterest has no delete, so an ad group you cannot see is one you cannot even archive. All money is in the AD ACCOUNT’S currency, which is not necessarily dollars. Pinterest’s own list default hides DRAFT and ARCHIVED objects; this asks for all four statuses so nothing is silently missing. Read-only, free. Needs Pinterest connected (Connectors > Pinterest) and the ad account ticked under Manage accounts (or call list_connector_accounts with provider pinterest_ads, then set_connector_accounts).',
17960
17979
  inputSchema: {
17961
17980
  adAccountId: z.string().optional().describe('Pinterest ad account id — omit to list the ad accounts shared with this profile'),
17962
17981
  campaignId: z.string().optional().describe('one campaign -> its ad groups and ads too (the only way to enumerate them)'),
@@ -17967,9 +17986,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
17967
17986
  }, wrap(async (a) => {
17968
17987
  if (!a.adAccountId) {
17969
17988
  const d = await apiGet('/api/pinterest/shared-ad-accounts', {});
17970
- if (!(d.accounts || []).length) return ok('No Pinterest ad account is shared with this profile yet. Ask the user to tick the ones it may use in Settings ▸ Connectors ▸ Pinterest ▸ Manage accounts (or call set_connector_accounts), then retry.', d);
17989
+ if (!(d.accounts || []).length) return ok('No Pinterest ad account is shared with this profile yet. Ask the user to tick the ones it may use in Connectors ▸ Pinterest ▸ Manage accounts (or call set_connector_accounts), then retry.', d);
17971
17990
  const lines = (d.accounts || []).map(c => `• ${c.name} (${c.adAccountId})${c.currency ? ` — ${c.currency}` : ''}${c.selected ? ' [shared with this profile]' : ''}`);
17972
- return ok(`${(d.accounts || []).length} Pinterest ad account(s) shared with this profile:\n${lines.join('\n') || '(none)'}\nPass an adAccountId to see its campaigns. Only accounts ticked in Settings ▸ Connectors can be managed.`, d);
17991
+ return ok(`${(d.accounts || []).length} Pinterest ad account(s) shared with this profile:\n${lines.join('\n') || '(none)'}\nPass an adAccountId to see its campaigns. Only accounts ticked in Connectors can be managed.`, d);
17973
17992
  }
17974
17993
  const d = await apiGet('/api/pinterest/ads-campaigns', { adAccountId: a.adAccountId, ...(a.campaignId ? { campaignId: a.campaignId } : {}), ...(a.statuses ? { statuses: a.statuses } : {}) });
17975
17994
  const lines = (d.campaigns || []).map(c => `• ${c.name} (${c.id}) — ${c.status}${c.objective ? `, ${c.objective}` : ''}${c.dailyBudget != null ? `, ${c.dailyBudget}/day` : c.lifetimeBudget != null ? `, ${c.lifetimeBudget} lifetime` : ''}${c.summaryStatus ? ` · ${c.summaryStatus}` : ''}`);
@@ -18386,14 +18405,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
18386
18405
  // account the brand was not given, and everything is created PAUSED with no override.
18387
18406
  server.registerTool('list_reddit_ads_campaigns', {
18388
18407
  title: 'List Reddit ad accounts / campaigns',
18389
- description: 'Read the brand’s connected Reddit AD account(s). Call with NO adAccountId to list the ad accounts shared with this profile — do this first to pick a target. Call WITH adAccountId to read that account’s whole tree at once: campaigns, ad groups and ads, each with its configured status and Reddit’s own effective status (the effective one is what says whether it could actually serve — PENDING_APPROVAL, CAMPAIGN_PAUSED, REJECTED and so on). Read-only, free. Needs Reddit Ads connected (Settings > Connectors > Reddit Ads) and the ad account ticked under Manage accounts (or call list_connector_accounts with provider reddit_ads, then set_connector_accounts).',
18408
+ description: 'Read the brand’s connected Reddit AD account(s). Call with NO adAccountId to list the ad accounts shared with this profile — do this first to pick a target. Call WITH adAccountId to read that account’s whole tree at once: campaigns, ad groups and ads, each with its configured status and Reddit’s own effective status (the effective one is what says whether it could actually serve — PENDING_APPROVAL, CAMPAIGN_PAUSED, REJECTED and so on). Read-only, free. Needs Reddit Ads connected (Connectors > Reddit Ads) and the ad account ticked under Manage accounts (or call list_connector_accounts with provider reddit_ads, then set_connector_accounts).',
18390
18409
  inputSchema: { adAccountId: z.string().optional().describe('Reddit ad account id (a2_…) — omit to list the ad accounts shared with this profile') },
18391
18410
  outputSchema: { accounts: z.array(z.any()).optional(), adAccountId: z.string().optional(), name: z.string().optional(), campaigns: z.array(z.any()).optional(), adGroups: z.array(z.any()).optional(), ads: z.array(z.any()).optional(), costCap: z.any().optional() },
18392
18411
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
18393
18412
  }, wrap(async (a) => {
18394
18413
  if (!a.adAccountId) {
18395
18414
  const d = await apiGet('/api/reddit-ads/shared-accounts', {});
18396
- if (!(d.accounts || []).length) return ok('No Reddit ad account is shared with this profile yet. Ask the user to tick the ones it may use in Settings ▸ Connectors ▸ Reddit Ads ▸ Manage accounts (or call set_connector_accounts), then retry.', d);
18415
+ if (!(d.accounts || []).length) return ok('No Reddit ad account is shared with this profile yet. Ask the user to tick the ones it may use in Connectors ▸ Reddit Ads ▸ Manage accounts (or call set_connector_accounts), then retry.', d);
18397
18416
  const lines = (d.accounts || []).map(c => `• ${c.name} (${c.id})${c.currency ? ` — ${c.currency}` : ''}`);
18398
18417
  return ok(`${(d.accounts || []).length} Reddit ad account(s) shared with this profile:\n${lines.join('\n')}\nPass an adAccountId to read its campaigns, ad groups and ads.`, d);
18399
18418
  }
@@ -19413,7 +19432,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
19413
19432
  const TIKTOK_WEB_OPT_EVENTS = ['ON_WEB_DETAIL', 'ON_WEB_SEARCH', 'ON_WEB_ADD_TO_WISHLIST', 'ON_WEB_CART', 'INITIATE_ORDER', 'ADD_BILLING', 'SHOPPING', 'CONSULT', 'DOWNLOAD_START', 'FORM', 'ON_WEB_REGISTER', 'ON_WEB_SUBSCRIBE'];
19414
19433
  server.registerTool('list_tiktok_ads_accounts', {
19415
19434
  title: 'List TikTok advertiser accounts',
19416
- description: 'List the TikTok ADVERTISER accounts this profile can act on — id, name, currency, timezone and status. Every other TikTok Ads tool needs an advertiserId, and this is where it comes from: call this first and let the USER pick when there is more than one. Read-only, free, spends nothing. Needs TikTok ADS connected (Settings > Connectors > TikTok Ads) — that is a DIFFERENT connection from the TikTok posting connector behind post_to_tiktok, so a brand that publishes to TikTok every day may still have nothing here. When nothing is shared and the user\'s TikTok login has no ad account at all, the reply says so: offer to create one with create_tiktok_ads_account.',
19435
+ description: 'List the TikTok ADVERTISER accounts this profile can act on — id, name, currency, timezone and status. Every other TikTok Ads tool needs an advertiserId, and this is where it comes from: call this first and let the USER pick when there is more than one. Read-only, free, spends nothing. Needs TikTok ADS connected (Connectors > TikTok Ads) — that is a DIFFERENT connection from the TikTok posting connector behind post_to_tiktok, so a brand that publishes to TikTok every day may still have nothing here. When nothing is shared and the user\'s TikTok login has no ad account at all, the reply says so: offer to create one with create_tiktok_ads_account.',
19417
19436
  inputSchema: {},
19418
19437
  outputSchema: { advertisers: z.array(z.any()).optional(), count: z.number().optional(), hasOwnAdAccounts: z.boolean().nullable().optional(), note: z.string().optional() },
19419
19438
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -21783,7 +21802,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
21783
21802
  //
21784
21803
  // ⚠ A GRANT IS NOT A TOKEN. TikTok binds permissions at AUTHORIZE time and never retroactively, so every
21785
21804
  // tiktok_ads connection minted before the approval — which is all of them — answers 40001 on these routes until
21786
- // its owner RECONNECTS in Settings ▸ Connectors. Measured 2026-08-20: on our own production connection
21805
+ // its owner RECONNECTS in Connectors. Measured 2026-08-20: on our own production connection
21787
21806
  // /advertiser/info/ answered code 0 in the same second that /store/list/, /account/verification/status/,
21788
21807
  // /payment_portfolio/get/ and /tto/oauth2/info/ each answered "advertiser does not grant you …". That is
21789
21808
  // TikTok's binding rule working, not a defect.
@@ -22245,7 +22264,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
22245
22264
  // account the login can reach, including other clients' — so no agent surface may read it.
22246
22265
  const d = await apiGet('/api/snapchat-ads/shared-accounts', {});
22247
22266
  const list = d.adAccounts || [];
22248
- if (!list.length) return ok('Snapchat Ads is connected but NO ad account is SHARED with this profile yet, so nothing can be built or read. A connection is not a licence to spend from every account on it — one Snapchat login often administers several clients. Ask the user to tick the ones this profile may use in Settings ▸ Connectors ▸ Snapchat Ads ▸ Manage accounts (or use set_connector_accounts).', d);
22267
+ if (!list.length) return ok('Snapchat Ads is connected but NO ad account is SHARED with this profile yet, so nothing can be built or read. A connection is not a licence to spend from every account on it — one Snapchat login often administers several clients. Ask the user to tick the ones this profile may use in Connectors ▸ Snapchat Ads ▸ Manage accounts (or use set_connector_accounts).', d);
22249
22268
  return ok(`${list.length} Snapchat ad account(s):\n${list.map(a => `· ${a.name || '(unnamed)'} — ${a.adAccountId}${a.currency ? `, ${a.currency}` : ''}${a.test ? ' ⚠ TEST account (cannot serve real ads)' : ''} [org ${a.organization || a.organizationId}]`).join('\n')}`, d);
22250
22269
  }));
22251
22270
  server.registerTool('list_snapchat_ads_campaigns', {
@@ -22695,7 +22714,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
22695
22714
  const held = Number(d.held || 0);
22696
22715
  if (!list.length) {
22697
22716
  return ok(held
22698
- ? `NONE of the ${held} LinkedIn Page(s) this account administers is shared with this profile, so nothing can be posted as a Page. Tell the user to tick the Page(s) that belong to this profile under Workspace ▸ Connectors ▸ LinkedIn ▸ Manage accounts (or call list_connector_accounts with provider linkedin, then set_connector_accounts). Do not guess a Page id.`
22717
+ ? `NONE of the ${held} LinkedIn Page(s) this account administers is shared with this profile, so nothing can be posted as a Page. Tell the user to tick the Page(s) that belong to this profile under Connectors ▸ LinkedIn ▸ Manage accounts (or call list_connector_accounts with provider linkedin, then set_connector_accounts). Do not guess a Page id.`
22699
22718
  : 'This LinkedIn connection administers NO company Page. The account needs an admin role on the Page, and Hermoso’s LinkedIn app needs LinkedIn’s organization scopes granted. Do not guess a Page id.', d);
22700
22719
  }
22701
22720
  return ok(`${list.length} LinkedIn Page(s) shared with this profile. Let the USER pick:\n${list.map(o => `• ${o.name || '(unnamed)'} (id ${o.id}) — ${(o.roles || []).join(', ')}`).join('\n')}${held ? `\n(${held} further Page(s) this account administers are NOT shared with this profile and cannot be posted to.)` : ''}`, d);
@@ -22744,7 +22763,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
22744
22763
  server.group('channel_admin');
22745
22764
  server.registerTool('list_linkedin_comments', {
22746
22765
  title: 'Read the comments on a LinkedIn Page post',
22747
- description: "Read the comments people have left on one of your LinkedIn company Page's posts. Community Management surface, and it needs its OWN connection: LinkedIn grants the comment scopes on a different app from the one that posts, so connecting LinkedIn does NOT connect this — the card is “LinkedIn Community Management” in Settings > Connectors. Pass the post's urn, which is what post_to_linkedin_page returns. A post nobody has commented on comes back as an EMPTY LIST with a note, never an error: LinkedIn answers 404 for a post with no social activity, and that means “no comments yet”, not “no such post”. Each comment carries its text, author, time, like and reply counts, and `isOurs` so your Page's own replies are distinguishable from other people's comments; `fromOthers` counts the ones actually awaiting an answer. The connected member must hold an ADMINISTRATOR role on the Page.",
22766
+ description: "Read the comments people have left on one of your LinkedIn company Page's posts. Community Management surface, and it needs its OWN connection: LinkedIn grants the comment scopes on a different app from the one that posts, so connecting LinkedIn does NOT connect this — the card is “LinkedIn Community Management” in Connectors. Pass the post's urn, which is what post_to_linkedin_page returns. A post nobody has commented on comes back as an EMPTY LIST with a note, never an error: LinkedIn answers 404 for a post with no social activity, and that means “no comments yet”, not “no such post”. Each comment carries its text, author, time, like and reply counts, and `isOurs` so your Page's own replies are distinguishable from other people's comments; `fromOthers` counts the ones actually awaiting an answer. The connected member must hold an ADMINISTRATOR role on the Page.",
22748
22767
  inputSchema: {
22749
22768
  postUrn: z.string().describe('the post to read — urn:li:share:…, urn:li:ugcPost:… or urn:li:activity:…'),
22750
22769
  limit: z.number().optional().describe('how many to fetch (default 50; clamped below LinkedIn’s own 200 timeout bound rather than refused)'),
@@ -22850,7 +22869,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
22850
22869
  }, wrap(async (a) => {
22851
22870
  if (!a.adAccountId) {
22852
22871
  const d = await apiGet('/api/linkedin/ads-shared', {});
22853
- if (!(d.accounts || []).length) return ok('No LinkedIn ad account is shared with this profile yet. Ask the user to tick the ones it may use in Settings ▸ Connectors ▸ LinkedIn ▸ Manage accounts (or call set_connector_accounts), then retry.', d);
22872
+ if (!(d.accounts || []).length) return ok('No LinkedIn ad account is shared with this profile yet. Ask the user to tick the ones it may use in Connectors ▸ LinkedIn ▸ Manage accounts (or call set_connector_accounts), then retry.', d);
22854
22873
  const lines = (d.accounts || []).map(c => `• ${c.name || c.id} (${c.id})${c.currency ? ` — ${c.currency}` : ''}${c.test ? ' [TEST account — never serves, no analytics]' : ''}${c.role ? ` · ${c.role}` : ''}`);
22855
22874
  return ok(`${(d.accounts || []).length} LinkedIn ad account(s) shared with this profile:\n${lines.join('\n') || '(none — LinkedIn’s Advertising API product may not be granted on this app, or the member holds no ad-account role)'}\nPass an adAccountId to see its campaigns.`, d);
22856
22875
  }
@@ -23400,7 +23419,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
23400
23419
  server.group('channel_admin');
23401
23420
  server.registerTool('manage_meta_post', {
23402
23421
  title: 'Edit or delete a published post',
23403
- description: 'Edit the text of, or delete, a published post. target:"facebook" -> edit the message and/or Meta\u2019s AI-generated label (action:"edit", message, aiGenerated) OR delete (action:"delete"), and on a Facebook Reel list or invite collaborator Pages (action:"collaborators"); target:"threads" -> delete only (Threads has no edit API); target:"instagram" -> DELETE ONLY — Meta lets you change nothing on a published Instagram post except whether comments are enabled, so a caption cannot be fixed; deleting covers ordinary posts, Stories, Reels and ENTIRE carousel albums (Instagram cannot remove one card out of an album — pass the album’s own media id, from list_instagram_media). Deleting is permanent. FOR INSTAGRAM, CALL IT WITHOUT confirm FIRST: nothing is deleted and you get back the post’s real caption, its likes and comments and how many carousel cards go with it — show the user exactly that, then call again with confirm:true plus confirmName (and confirmChildren for an album) if the refusal asks for them. A post nobody has liked or commented on yet stays a one-call delete. INSTAGRAM DELETE NEEDS A RECONNECT ON AN OLD CONNECTION: the `instagram_manage_contents` permission joined Hermoso’s Meta grant on 2026-08-05, so any Meta connection made before then must be reconnected (Settings > Connectors > Meta) before Instagram will accept a delete. Call the tool rather than pre-refusing — every refusal it can raise names the one thing that fixes it.',
23422
+ description: 'Edit the text of, or delete, a published post. target:"facebook" -> edit the message and/or Meta\u2019s AI-generated label (action:"edit", message, aiGenerated) OR delete (action:"delete"), and on a Facebook Reel list or invite collaborator Pages (action:"collaborators"); target:"threads" -> delete only (Threads has no edit API); target:"instagram" -> DELETE ONLY — Meta lets you change nothing on a published Instagram post except whether comments are enabled, so a caption cannot be fixed; deleting covers ordinary posts, Stories, Reels and ENTIRE carousel albums (Instagram cannot remove one card out of an album — pass the album’s own media id, from list_instagram_media). Deleting is permanent. FOR INSTAGRAM, CALL IT WITHOUT confirm FIRST: nothing is deleted and you get back the post’s real caption, its likes and comments and how many carousel cards go with it — show the user exactly that, then call again with confirm:true plus confirmName (and confirmChildren for an album) if the refusal asks for them. A post nobody has liked or commented on yet stays a one-call delete. INSTAGRAM DELETE NEEDS A RECONNECT ON AN OLD CONNECTION: the `instagram_manage_contents` permission joined Hermoso’s Meta grant on 2026-08-05, so any Meta connection made before then must be reconnected (Connectors > Meta) before Instagram will accept a delete. Call the tool rather than pre-refusing — every refusal it can raise names the one thing that fixes it.',
23404
23423
  inputSchema: {
23405
23424
  ...MANAGE_BRAND,
23406
23425
  postId: z.string().describe('the post id returned by post_to_meta — for Instagram, the media id from list_instagram_media'),
@@ -23431,7 +23450,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
23431
23450
  server.group('files');
23432
23451
  server.registerTool('save_to_drive', {
23433
23452
  title: 'Save file(s) to Google Drive',
23434
- description: 'Save a Hermoso render — or ANY file — into the user’s connected Google Drive. Pass a Hermoso render URL as url (or urls[] for several); for a local/external file, call upload_file first and pass the url it returns. Optional folder (created if new) + name. Returns the Drive file(s) with a webViewLink. Needs Google Drive connected (Settings > Connectors > Google Drive — one connection covers Drive, Sheets and Docs). NOTE: Hermoso uses the drive.file scope, so it reaches ONLY the files it created plus any the user explicitly handed over with the Google file picker in the app — never their whole Drive.',
23453
+ description: 'Save a Hermoso render — or ANY file — into the user’s connected Google Drive. Pass a Hermoso render URL as url (or urls[] for several); for a local/external file, call upload_file first and pass the url it returns. Optional folder (created if new) + name. Returns the Drive file(s) with a webViewLink. Needs Google Drive connected (Connectors > Google Drive — one connection covers Drive, Sheets and Docs). NOTE: Hermoso uses the drive.file scope, so it reaches ONLY the files it created plus any the user explicitly handed over with the Google file picker in the app — never their whole Drive.',
23435
23454
  inputSchema: {
23436
23455
  url: z.string().optional().describe('a single Hermoso render URL to save'),
23437
23456
  urls: z.array(z.string()).optional().describe('several render URLs (up to 20) to save in one call'),
@@ -23570,7 +23589,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
23570
23589
  server.group('files');
23571
23590
  server.registerTool('create_sheet', {
23572
23591
  title: 'Create a Google Sheet',
23573
- description: 'Create a new Google Spreadsheet in the user’s Drive and optionally fill it with rows — e.g. export a swipefile, ad list, or performance report. Pass rows as an array of row arrays (first row = headers). Returns the spreadsheet id + URL. Needs Google Drive connected (Settings > Connectors > Google Drive — one connection covers Drive, Sheets and Docs).',
23592
+ description: 'Create a new Google Spreadsheet in the user’s Drive and optionally fill it with rows — e.g. export a swipefile, ad list, or performance report. Pass rows as an array of row arrays (first row = headers). Returns the spreadsheet id + URL. Needs Google Drive connected (Connectors > Google Drive — one connection covers Drive, Sheets and Docs).',
23574
23593
  inputSchema: {
23575
23594
  title: z.string().optional().describe('spreadsheet title'),
23576
23595
  rows: z.array(z.array(z.union([z.string(), z.number(), z.boolean()]))).optional().describe('rows to write — array of row arrays; first row = headers'),
@@ -23616,7 +23635,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
23616
23635
  server.group('files');
23617
23636
  server.registerTool('export_swipefile_deck', {
23618
23637
  title: 'Swipefile to Google Slides',
23619
- description: 'Turn a SWIPEFILE COLLECTION into a real Google Slides deck — one slide per saved ad, carrying the creative, the brand, the ad copy, the run dates with the run length, and the platform. This is the thing a marketer actually presents to a client or a team. Returns the presentation id + URL. Creates a NEW deck every time: under the drive.file scope Hermoso can only touch files it created, so it cannot add slides to a deck the user already has. A creative whose ad-library link has expired cannot be embedded — Meta signs those URLs with a short expiry — so that slide says so in words and keeps its copy and run dates, and the reply reports how many. Needs Google Drive connected (Settings > Connectors > Google Drive — one connection covers Drive, Sheets, Docs and Slides).',
23638
+ description: 'Turn a SWIPEFILE COLLECTION into a real Google Slides deck — one slide per saved ad, carrying the creative, the brand, the ad copy, the run dates with the run length, and the platform. This is the thing a marketer actually presents to a client or a team. Returns the presentation id + URL. Creates a NEW deck every time: under the drive.file scope Hermoso can only touch files it created, so it cannot add slides to a deck the user already has. A creative whose ad-library link has expired cannot be embedded — Meta signs those URLs with a short expiry — so that slide says so in words and keeps its copy and run dates, and the reply reports how many. Needs Google Drive connected (Connectors > Google Drive — one connection covers Drive, Sheets, Docs and Slides).',
23620
23639
  inputSchema: {
23621
23640
  collection: z.string().optional().describe('the swipefile collection to export, by name or id (default: the first collection)'),
23622
23641
  title: z.string().optional().describe('deck title (default: the collection name)'),
@@ -23635,7 +23654,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
23635
23654
  server.group('files');
23636
23655
  server.registerTool('list_notion_pages', {
23637
23656
  title: 'List Notion pages',
23638
- description: "Find the Notion pages and databases the user shared with Hermoso, by title, newest edited first. Gives the page id that save_to_notion, append_to_notion_page and read_notion_page take, or the database id to save rows into. Read-only, free. Needs Notion connected (Settings > Connectors > Notion).",
23657
+ description: "Find the Notion pages and databases the user shared with Hermoso, by title, newest edited first. Gives the page id that save_to_notion, append_to_notion_page and read_notion_page take, or the database id to save rows into. Read-only, free. Needs Notion connected (Connectors > Notion).",
23639
23658
  inputSchema: { query: z.string().optional().describe('words in the title'), type: z.enum(['page', 'database']).optional().describe('limit results to pages or to databases; omit for both'), limit: z.number().optional().describe('1-100, default 25'), cursor: z.string().optional().describe('from the previous page') },
23640
23659
  outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), results: z.array(z.any()).optional(), nextCursor: z.string().nullable().optional(), note: z.string().optional() },
23641
23660
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -23677,7 +23696,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
23677
23696
  server.group('files');
23678
23697
  server.registerTool('query_notion_database', {
23679
23698
  title: 'Read a Notion database',
23680
- description: "Read the ROWS of a Notion database the user shared with Hermoso — e.g. the team's content calendar: each row's title, id, link and every property as a plain value (status, date, channels, caption, links), plus the database's columns with their types and options. Filter with match {\"Status\": \"Scheduled\"} (typed from the database's own columns) or a raw Notion filter object; sort with sortBy (a column, or created_time / last_edited_time) and direction. Up to 100 rows per call, cursor for more; the answer says when Notion stopped at its 10,000-result limit. Use it to import a calendar and schedule its posts, or to find the row update_notion_row changes. Read-only, free. Needs Notion connected (Settings > Connectors > Notion).",
23699
+ description: "Read the ROWS of a Notion database the user shared with Hermoso — e.g. the team's content calendar: each row's title, id, link and every property as a plain value (status, date, channels, caption, links), plus the database's columns with their types and options. Filter with match {\"Status\": \"Scheduled\"} (typed from the database's own columns) or a raw Notion filter object; sort with sortBy (a column, or created_time / last_edited_time) and direction. Up to 100 rows per call, cursor for more; the answer says when Notion stopped at its 10,000-result limit. Use it to import a calendar and schedule its posts, or to find the row update_notion_row changes. Read-only, free. Needs Notion connected (Connectors > Notion).",
23681
23700
  inputSchema: { databaseId: z.string().optional().describe('database id or link (list_notion_pages type:"database")'), dataSourceId: z.string().optional().describe('data source id from an earlier read, as an alternative to databaseId'), match: z.record(z.any()).optional().describe('{"Column": value} — equality for status/select/checkbox/number/date, contains for text/multi-select'), filter: z.record(z.any()).optional().describe('a raw Notion filter object'), sortBy: z.string().optional().describe('column name to sort by, or created_time or last_edited_time'), direction: z.enum(['ascending', 'descending']).optional().describe('ascending (default) or descending'), limit: z.number().optional().describe('1-100, default 50'), cursor: z.string().optional().describe('nextCursor from the previous page of rows'), archived: z.boolean().optional().describe('read archived rows instead') },
23682
23701
  outputSchema: { ok: z.boolean().optional(), database: z.string().optional(), dataSourceId: z.string().optional(), schema: z.any().optional(), count: z.number().optional(), rows: z.array(z.any()).optional(), nextCursor: z.string().nullable().optional(), incomplete: z.boolean().optional(), note: z.string().optional() },
23683
23702
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -23702,7 +23721,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
23702
23721
  server.group('files');
23703
23722
  server.registerTool('save_to_canva', {
23704
23723
  title: 'Save to Canva',
23705
- description: "Send Hermoso renders to the user's Canva: each is uploaded to their Canva uploads, ready to drag into any design. asDesign:true also asks Canva AI to rebuild a PNG, JPEG or WEBP image as a new design whose text, objects and background are separate editable layers: it uses the account's Canva credits and can redraw details, so only when the user asks for an editable 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 upload, and each design's edit link. Free on Hermoso. Needs Canva connected (Settings > Connectors > Canva).",
23724
+ description: "Send Hermoso renders to the user's Canva: each is uploaded to their Canva uploads, ready to drag into any design. asDesign:true also asks Canva AI to rebuild a PNG, JPEG or WEBP image as a new design whose text, objects and background are separate editable layers: it uses the account's Canva credits and can redraw details, so only when the user asks for an editable 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 upload, and each design's edit link. Free on Hermoso. Needs Canva connected (Connectors > Canva).",
23706
23725
  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().describe('title for the new Canva design or upload'), folderId: z.string().optional().describe('Canva folder id from list_canva_folder or create_canva_folder; omit for the default folder'), asDesign: z.boolean().optional().describe('true asks Canva AI to rebuild an image as an editable layered design (uses Canva credits); default false: the upload only') },
23707
23726
  outputSchema: { ok: z.boolean().optional(), sent: z.number().optional(), failed: z.number().optional(), results: z.array(z.any()).optional(), note: z.string().optional() },
23708
23727
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
@@ -23763,7 +23782,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
23763
23782
  server.group('files');
23764
23783
  server.registerTool('create_doc', {
23765
23784
  title: 'Create a Google Doc',
23766
- description: 'Create a new Google Doc in the user’s Drive with a title + optional body text — e.g. export ad copy, a creative brief, or a report. Returns the document id + URL. Needs Google Drive connected (Settings > Connectors > Google Drive — one connection covers Drive, Sheets and Docs).',
23785
+ description: 'Create a new Google Doc in the user’s Drive with a title + optional body text — e.g. export ad copy, a creative brief, or a report. Returns the document id + URL. Needs Google Drive connected (Connectors > Google Drive — one connection covers Drive, Sheets and Docs).',
23767
23786
  inputSchema: {
23768
23787
  title: z.string().optional().describe('document title'),
23769
23788
  text: z.string().optional().describe('body text to insert'),
@@ -23927,7 +23946,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
23927
23946
  server.group('files');
23928
23947
  server.registerTool('save_to_onedrive', {
23929
23948
  title: 'Save file(s) to OneDrive',
23930
- description: 'Save a Hermoso render — or ANY file — into the user’s connected Microsoft OneDrive. Pass a Hermoso render URL as url (or urls[] for several); for a local/external file, call upload_file first and pass the url it returns. Optional folder (created if new) + name. Returns the OneDrive file(s) with a webViewLink. Needs OneDrive connected (Settings > Connectors > OneDrive).',
23949
+ description: 'Save a Hermoso render — or ANY file — into the user’s connected Microsoft OneDrive. Pass a Hermoso render URL as url (or urls[] for several); for a local/external file, call upload_file first and pass the url it returns. Optional folder (created if new) + name. Returns the OneDrive file(s) with a webViewLink. Needs OneDrive connected (Connectors > OneDrive).',
23931
23950
  inputSchema: {
23932
23951
  url: z.string().optional().describe('a single Hermoso render URL to save'),
23933
23952
  urls: z.array(z.string()).optional().describe('several render URLs (up to 20) to save in one call'),
@@ -24103,7 +24122,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
24103
24122
  };
24104
24123
  server.registerTool('plan_ad', {
24105
24124
  title: 'Plan an ad concept',
24106
- description: 'Plan an ad: turns a brand and a product or brief into an ad concept, with copy variants (headline, primary text, call to action) and either an image prompt or a timed video storyboard, plus the recipe and the model ids to render with. It renders nothing; pass its output to render_ad, generate_image or generate_video. A named length ("a 30 second ad") goes in durationSeconds and the storyboard is written to it; a video with none is not planned: a free question comes back to ask the user (15s is one clip, or 30s, or any 4-180). The user\u2019s own screenshots or pictures to show inside the video go in `inserts`. The brand\u2019s SAVED product photo (get_brand / list_product_photos) is used automatically; never ask the user to upload it again. Uses credits for the planning step.',
24125
+ description: 'Plan an ad: turns a brand and a product or brief into an ad concept, with copy variants (headline, primary text, call to action) and either an image prompt or a timed video storyboard, plus the recipe and the model ids to render with. It renders nothing; pass its output to render_ad, generate_image or generate_video. For a single image ad it is optional: generate_image alone makes one from the saved brand, so skip this step unless the user wants concepts or copy options first. A named length ("a 30 second ad") goes in durationSeconds and the storyboard is written to it; a video with none is not planned: a free question comes back to ask the user (15s is one clip, or 30s, or any 4-180). The user\u2019s own screenshots or pictures to show inside the video go in `inserts`. The brand\u2019s SAVED product photo (get_brand / list_product_photos) is used automatically; never ask the user to upload it again. Uses credits for the planning step.',
24107
24126
  inputSchema: {
24108
24127
  brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name, or a brand profile object {name,domain,category,palette,products,…}. OMIT to use the workspace’s SAVED brand + memory automatically (see get_brand); use draft_brand to onboard a new one'),
24109
24128
  product: z.string().describe('what to advertise + any angle/offer the user specified'),
@@ -24950,6 +24969,20 @@ function buildTools(rawServer, opts = {}, sink = null) {
24950
24969
  const pal = (Array.isArray(b.palette) ? b.palette : []).filter((c) => /^#[0-9a-f]{6}$/i.test(String(c || '')));
24951
24970
  _cfg = { ..._cfg, endCard: { ..._cfg.endCard, ...(!_cfg.endCard.logo && b.logo ? { logo: b.logo } : {}), ...(!_cfg.endCard.domain && b.domain ? { domain: b.domain } : {}) }, ...(!Array.isArray(_cfg.palette) && pal.length ? { palette: pal } : {}) };
24952
24971
  }
24972
+ // THE BRAND'S FONTS AND COLOURS REACH A PRESET TOO (2026-11-13). The web composer sends the saved fonts and palette with
24973
+ // every template; over MCP a preset got neither unless the caller wrote them into config, so a customer who set his
24974
+ // fonts saw them on the web and never on an agent's render. The server sets the designed presets in the brand fonts
24975
+ // (and leaves the chat / Notes look-alikes in their system type); the palette fills only when the caller gave none.
24976
+ // A profile set to "just exploring" is never applied.
24977
+ if (_cfg && typeof _cfg === 'object' && String(_cfg.template || '') !== 'custom') {
24978
+ let b = await readStore('heist.brand.v1').catch(() => null); if (!b || typeof b !== 'object' || Array.isArray(b)) b = {};
24979
+ if (!b.setAside) {
24980
+ const fonts = (Array.isArray(b.fonts) ? b.fonts : []).filter((f) => typeof f === 'string' && f.trim());
24981
+ if (fonts.length) _brand = { ...(_brand || {}), fonts };
24982
+ const pal = (Array.isArray(b.palette) ? b.palette : []).filter((c) => /^#[0-9a-f]{6}$/i.test(String(c || '')));
24983
+ if (!Array.isArray(_cfg.palette) && pal.length) _cfg = { ..._cfg, palette: pal };
24984
+ }
24985
+ }
24953
24986
  const r = await renderJob('templatead', { config: _cfg, ...(_brand ? { brand: _brand } : {}) }, 'MCP template ad');
24954
24987
  if (Array.isArray(r?.raw?.images) && r.raw.images.length) { // carousel: one PNG per slide → list every URL + inline the first slide
24955
24988
  const urls = r.raw.images.map((u) => abs(u));
@@ -25645,7 +25678,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
25645
25678
  if (st && st.status === 'running') return ok(`${said.join('\n')}${said.length ? '\n' : ''}The ${st.label.toLowerCase()} stage is still rendering [job ${st.jobId}]. Call advance_story_film({runId:"${run.id}"}) again in a minute or two to pick it up; nothing more is charged by asking.\n${storyStageLine(run)}`, { run, stillRendering: true, jobId: st.jobId });
25646
25679
  const next = run.next || {};
25647
25680
  if (next.kind === 'create') {
25648
- if (!approved) return ok(`${said.join('\n')}${said.length ? '\n' : ''}Next: the screenplay for "${run.title}" (${run.lengthSeconds}s). ${next.billing}; usually a few credits. Call again with approve:true to write it.\n${storyStageLine(run)}`, { run });
25681
+ if (!approved) return ok(`${said.join('\n')}${said.length ? '\n' : ''}Next: the screenplay for "${run.title}" (${run.lengthSeconds}s). ${next.billing}. Call again with approve:true to write it.\n${storyStageLine(run)}`, { run });
25649
25682
  const c = await apiPost('/api/create', d.screenplayBody || {});
25650
25683
  const creative = c?.creative || c;
25651
25684
  if (!creative?.video_storyboard) return ok(`The planner did not return a screenplay: ${c?.message || c?.error || 'no storyboard'}. Nothing was recorded; call again to retry.`, { run, planner: c });
@@ -26640,24 +26673,30 @@ function memoryNoteVerdict(text) {
26640
26673
  })));
26641
26674
  server.registerTool('update_brand', {
26642
26675
  title: 'Update profile fields',
26643
- description: 'Patch SPECIFIC fields of the active profile (name, domain, sells, summary, category, audience, positioning, voice, style, goal, logos, brand/product name pronunciations, profileType: explore = kept but unused) WITHOUT overwriting the rest — a read-modify-write on the saved brand. Use for “change our voice to playful”, “we sell to dentists now”. To onboard a brand from scratch, use draft_brand. If no brand is saved yet and you only INFERRED one from what the user is making, ask them to confirm it is their brand before saving it (they may be working for a client or just trying things). Only pass the fields you’re changing.',
26676
+ description: 'Patch SPECIFIC fields of the active profile (name, domain, sells, summary, category, audience, positioning, voice, style, goal, fonts, palette, logos, brand/product name pronunciations, profileType: explore = kept but unused) WITHOUT overwriting the rest — a read-modify-write on the saved brand. Use for “change our voice to playful”, “we sell to dentists now”, “our fonts are Benne and Josefin Sans”, “our colours are #3D4C41 and #EEE4DA”. The fonts and palette are what template ads are set in and what every planned ad is told to use, so set them here rather than describing them in style. To onboard a brand from scratch, use draft_brand. If no brand is saved yet and you only INFERRED one from what the user is making, ask them to confirm it is their brand before saving it (they may be working for a client or just trying things). Only pass the fields you’re changing.',
26644
26677
  inputSchema: { ...STORE_BRAND,
26645
26678
  name: z.string().optional().describe('brand name'), domain: z.string().optional().describe('website domain'), sells: z.string().optional().describe('what the brand sells'),
26646
26679
  summary: z.string().optional().describe('one-line description'), category: z.string().optional().describe('product or business category, one line'), audience: z.string().optional().describe('who the brand sells to, one line'),
26647
26680
  positioning: z.string().optional().describe('how the brand is positioned against alternatives, one line'), voice: z.string().optional().describe('brand voice/tone'), style: z.string().optional().describe('visual style — palette, typography, aesthetic'), goal: z.string().optional().describe('current marketing goal'),
26648
26681
  pronounce: z.string().optional().describe('how the brand NAME is said aloud, as a simple respelling with the stressed syllable in capitals (e.g. "KOH-dee-ak"). Videos use it as a delivery note beside the spoken line; set it when a render mispronounced the name.'),
26649
26682
  pronunciations: z.record(z.string()).optional().describe('how PRODUCT names are said aloud, e.g. {"Power Cakes": "POW-er cakes"}. Merged into the saved ones; an empty string removes one.'),
26683
+ fonts: z.union([z.array(z.string()), z.object({ headline: z.string().optional(), body: z.string().optional() })]).optional().describe('the brand fonts, any family name: a list with the HEADLINE font first and the BODY font second (it replaces the saved list), or {headline, body} to change one and keep the other ("" removes one). Template ads load any Google Fonts family; the reply says what each will be set in.'),
26684
+ palette: z.array(z.string()).optional().describe(`the brand colours as six-digit hex codes like "#3D4C41", in order, up to ${PALETTE_MAX}. Replaces the saved palette; [] clears it. A short code (#abc) or one without # is refused, never rewritten.`),
26650
26685
  logo: z.string().optional().describe('logo image: URL or local file path to a PNG, JPEG or WebP (no SVG)'),
26651
26686
  logoLight: z.string().optional().describe('for dark pictures; "none" removes'),
26652
26687
  logoDark: z.string().optional().describe('for light pictures'),
26653
26688
  profileType: z.enum(['website', 'social', 'describe', 'explore']).optional().describe('website, social, describe or explore; explore keeps the profile but stops using it'),
26654
26689
  },
26655
- outputSchema: { ok: z.boolean().optional(), updated: z.array(z.string()).optional(), brand: z.any().optional(), profileType: z.string().optional() },
26690
+ outputSchema: { ok: z.boolean().optional(), updated: z.array(z.string()).optional(), brand: z.any().optional(), profileType: z.string().optional(), fontCheck: z.any().optional().describe('what template ads will set each font in (exact, a similar stand-in, or none)') },
26656
26691
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
26657
26692
  }, wrap(inBrand(async (a) => {
26658
26693
  const allow = ['name', 'domain', 'sells', 'summary', 'category', 'audience', 'positioning', 'voice', 'style', 'goal', 'pronounce'];
26659
26694
  const patch = {}; for (const k of allow) if (a[k] != null && String(a[k]).trim()) patch[k] = String(a[k]).slice(0, 400);
26660
26695
  let brand = await readStore('heist.brand.v1'); if (!brand || typeof brand !== 'object' || Array.isArray(brand)) brand = {};
26696
+ // THE FONTS AND THE PALETTE (2026-11-13): a customer could not change either on any surface. One parser for every door
26697
+ // (./brand-look.mjs): trimmed, otherwise kept exactly as written, and refused by name when unusable — nothing is saved then.
26698
+ if (a.fonts != null) { const p = parseBrandFonts(a.fonts, brand.fonts); if (p.error) return { content: [{ type: 'text', text: `${p.error} Nothing was changed.` }], isError: true }; patch.fonts = p.value; }
26699
+ if (a.palette != null) { const p = parseBrandPalette(a.palette); if (p.error) return { content: [{ type: 'text', text: `${p.error} Nothing was changed.` }], isError: true }; patch.palette = p.value; }
26661
26700
  // PRODUCT-NAME PRONUNCIATIONS merge into the saved map ('' removes one) — the user's correction of a derived hint.
26662
26701
  if (a.pronunciations && typeof a.pronunciations === 'object' && !Array.isArray(a.pronunciations)) {
26663
26702
  const cur = { ...((brand.pronunciations && typeof brand.pronunciations === 'object' && !Array.isArray(brand.pronunciations)) ? brand.pronunciations : {}) };
@@ -26684,7 +26723,17 @@ function memoryNoteVerdict(text) {
26684
26723
  const merged = { ...brand, ...patch };
26685
26724
  await writeStore('heist.brand.v1', merged); // the store PUT preserves server-side brand enrichments (playbook/pronounce)
26686
26725
  const _typeNote = patch.profileType === 'explore' ? ' The profile is now set to just exploring: it stays saved, and nothing you make uses it until profileType is switched back.' : patch.profileType ? ` It is now a ${patch.profileType} profile, used for what you make.` : '';
26687
- return ok(`Updated ${merged.name ? `“${merged.name}”` : 'the brand'}${namedBrand(a) ? ` (the brand you named, ${namedBrand(a)})` : ''} — ${Object.keys(patch).filter((k) => k !== 'setAside').join(', ')}.${_typeNote}`, { ok: true, updated: Object.keys(patch).filter((k) => k !== 'setAside'), brand: merged, ...(patch.profileType ? { profileType: patch.profileType } : {}) });
26726
+ // WHAT A TEMPLATE WILL SET THE FONTS IN, said now rather than discovered on a render. A failed check says nothing.
26727
+ let _fontNote = '', _fontCheck;
26728
+ if (patch.fonts) {
26729
+ _fontNote = patch.fonts.length ? ` Fonts: ${describeBrandFonts(patch.fonts)}.` : ' Fonts cleared.';
26730
+ if (patch.fonts.length) { try { _fontCheck = await apiGet('/api/brand/font-check?fonts=' + encodeURIComponent(JSON.stringify(patch.fonts))); } catch { _fontCheck = undefined; }
26731
+ const miss = (_fontCheck?.fonts || []).filter((f) => f.how !== 'exact');
26732
+ if (_fontCheck && miss.length) _fontNote += ' ' + miss.map((f) => f.how === 'similar' ? `Template ads set ${f.asked} in ${f.as}, the closest font they can load.` : `Template ads cannot load ${f.asked}; they use ${_fontCheck.headline?.used || 'a clean system sans'} instead.`).join(' ');
26733
+ else if (_fontCheck) _fontNote += ' Template ads load both.'; }
26734
+ }
26735
+ const _palNote = patch.palette ? (patch.palette.length ? ` Palette: ${patch.palette.join(', ')}.` : ' Palette cleared.') : '';
26736
+ return ok(`Updated ${merged.name ? `“${merged.name}”` : 'the brand'}${namedBrand(a) ? ` (the brand you named, ${namedBrand(a)})` : ''} — ${Object.keys(patch).filter((k) => k !== 'setAside').join(', ')}.${_typeNote}${_fontNote}${_palNote}`, { ok: true, updated: Object.keys(patch).filter((k) => k !== 'setAside'), brand: merged, ...(patch.profileType ? { profileType: patch.profileType } : {}), ...(_fontCheck ? { fontCheck: _fontCheck } : {}) });
26688
26737
  })));
26689
26738
  server.registerTool('store_get', {
26690
26739
  title: 'Read a workspace store',
@@ -26708,21 +26757,32 @@ function memoryNoteVerdict(text) {
26708
26757
  // so setting it once changes what every subsequent ad, plan and answer is written in.
26709
26758
  server.registerTool('get_settings', {
26710
26759
  title: 'Read app settings',
26711
- description: 'Read this account\'s app settings — the LANGUAGE Hermoso writes ads and copy in (chat replies follow the language the user writes in), the app appearance (theme), and whether the weekly competitor-watch email is on. Same settings as the web app\'s Settings pane. Read-only, free.',
26760
+ description: 'Read this account\'s app settings — the LANGUAGE Hermoso writes ads and copy in (chat replies follow the language the user writes in), the app appearance (theme), and which Hermoso emails are on (tips, product news, credit and plan notices, competitor watch, autopilot reviews, weekly posts, and the all-emails switch). Same settings as the web app\'s Settings pane. Read-only, free.',
26712
26761
  inputSchema: {},
26713
26762
  outputSchema: { language: z.string().optional(), theme: z.string().optional(), notifications: z.any().optional(), privacy: z.any().optional() },
26714
26763
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
26715
26764
  }, wrap(async () => {
26716
26765
  const d = await apiGet('/api/settings');
26717
- return ok(`Language: ${d.language}\nAppearance: ${d.theme}\nWeekly competitor-watch email: ${d.notifications?.watchEmail === false ? 'off' : 'on'}\n${d.privacy?.note || ''}`, d);
26766
+ const em = d.notifications?.emails || {};
26767
+ const emLine = Object.keys(em).length ? `\nEmails: ${Object.entries(em).map(([k, v]) => `${k} ${v === false ? 'off' : 'on'}`).join(', ')}` : '';
26768
+ return ok(`Language: ${d.language}\nAppearance: ${d.theme}\nWeekly competitor-watch email: ${d.notifications?.watchEmail === false ? 'off' : 'on'}${emLine}\n${d.privacy?.note || ''}`, d);
26718
26769
  }));
26719
26770
  server.registerTool('update_settings', {
26720
26771
  title: 'Change app settings',
26721
- description: 'Change this account\'s app settings. language = the language EVERY ad, script and plan is written in from now on (chat replies follow the language the user writes in) (say the language in plain English, e.g. "German", "Japanese", "Brazilian Portuguese") — it applies to renders made over MCP as well as in the app. theme = the app\'s appearance, "dark" or "light". watchEmail = the weekly competitor-watch email on/off. Only pass what you are changing. Account-wide (every profile), and it takes effect on the next call.',
26772
+ description: 'Change this account\'s app settings. language = the language EVERY ad, script and plan is written in from now on (chat replies follow the language the user writes in) (say the language in plain English, e.g. "German", "Japanese", "Brazilian Portuguese") — it applies to renders made over MCP as well as in the app. theme = the app\'s appearance, "dark" or "light". watchEmail = the weekly competitor-watch email on/off. emails = turn Hermoso emails on or off by group, including turning back on what an unsubscribe link turned off. Only pass what you are changing. Account-wide (every profile), and it takes effect on the next call.',
26722
26773
  inputSchema: {
26723
26774
  language: z.string().optional().describe('language for generated ads and copy (chat replies follow the language the user writes in) — e.g. "English", "German", "Japanese"'),
26724
26775
  theme: z.enum(['dark', 'light']).optional().describe('app appearance'),
26725
26776
  watchEmail: z.boolean().optional().describe('weekly competitor-watch email on/off'),
26777
+ emails: z.object({
26778
+ tips: z.boolean().optional().describe('tips and how-tos after signup'),
26779
+ productNews: z.boolean().optional().describe('product news and launches'),
26780
+ notices: z.boolean().optional().describe('low-credit and plan notices'),
26781
+ watch: z.boolean().optional().describe('weekly competitor-watch email'),
26782
+ autopilot: z.boolean().optional().describe('posts ready for review'),
26783
+ weeklyPosts: z.boolean().optional().describe('weekly email about your posts'),
26784
+ all: z.boolean().optional().describe('master switch; off = only sign-in and billing emails arrive'),
26785
+ }).optional().describe('email switches by group; pass only the ones you are changing'),
26726
26786
  },
26727
26787
  outputSchema: { ok: z.boolean().optional(), changed: z.array(z.string()).optional(), language: z.string().optional(), theme: z.string().optional(), notifications: z.any().optional() },
26728
26788
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
@@ -26731,13 +26791,16 @@ function memoryNoteVerdict(text) {
26731
26791
  if (a.language != null) body.language = a.language;
26732
26792
  if (a.theme != null) body.theme = a.theme;
26733
26793
  if (a.watchEmail != null) body.watchEmail = a.watchEmail;
26734
- if (!Object.keys(body).length) return { content: [{ type: 'text', text: 'Pass at least one of: language, theme, watchEmail.' }], isError: true };
26794
+ if (a.emails && Object.keys(a.emails).length) body.emails = a.emails;
26795
+ if (!Object.keys(body).length) return { content: [{ type: 'text', text: 'Pass at least one of: language, theme, watchEmail, emails.' }], isError: true };
26735
26796
  const d = await apiPost('/api/settings', body);
26736
- return ok(`Updated ${(d.changed || []).join(', ')}. Language: ${d.language} · Appearance: ${d.theme} · Weekly competitor-watch email: ${d.notifications?.watchEmail === false ? 'off' : 'on'}.${body.language ? '\nEvery ad, script and answer from here on is written in ' + d.language + '.' : ''}`, d);
26797
+ const em = d.notifications?.emails || {};
26798
+ const emLine = body.emails && Object.keys(em).length ? ` · Emails: ${Object.entries(em).map(([k, v]) => `${k} ${v === false ? 'off' : 'on'}`).join(', ')}` : '';
26799
+ return ok(`Updated ${(d.changed || []).join(', ')}. Language: ${d.language} · Appearance: ${d.theme} · Weekly competitor-watch email: ${d.notifications?.watchEmail === false ? 'off' : 'on'}${emLine}.${body.language ? '\nEvery ad, script and answer from here on is written in ' + d.language + '.' : ''}`, d);
26737
26800
  }));
26738
26801
  server.registerTool('list_connectors', {
26739
26802
  title: 'List connectors',
26740
- description: 'List the third-party accounts connected to this workspace (Meta, Google Ads, Google Drive/Sheets/Docs, YouTube, LinkedIn, OneDrive, Slack, …) — provider, status and the connected account label — PLUS which providers are available to connect. IT ALSO FLAGS A CONNECTION WHOSE PERMISSIONS ARE OUT OF DATE: a provider writes its granted permission set into the token at consent time, so a connection authorized before a permission was approved does not carry it and never will \u2014 those calls are refused by the provider and no retry or wait can change it. Check this FIRST when a connected provider starts refusing things. The fix is a browser: the user reconnects under Workspace > Connectors. Read-only, free.',
26803
+ description: 'List the third-party accounts connected to this workspace (Meta, Google Ads, Google Drive/Sheets/Docs, YouTube, LinkedIn, OneDrive, Slack, …) — provider, status and the connected account label — PLUS which providers are available to connect. IT ALSO FLAGS A CONNECTION WHOSE PERMISSIONS ARE OUT OF DATE: a provider writes its granted permission set into the token at consent time, so a connection authorized before a permission was approved does not carry it and never will \u2014 those calls are refused by the provider and no retry or wait can change it. Check this FIRST when a connected provider starts refusing things. The fix is a browser: the user reconnects under Connectors. Read-only, free.',
26741
26804
  inputSchema: {},
26742
26805
  outputSchema: { connectors: z.array(z.any()).optional(), providers: z.array(z.string()).optional() },
26743
26806
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
@@ -26758,8 +26821,8 @@ function memoryNoteVerdict(text) {
26758
26821
  // (2026-10-07, OpenAI plugin guidelines: restricted data includes "Access credentials and authentication secrets (such
26759
26822
  // as API keys …)"). So there the paste-a-key half points at the app instead of at a tool the host does not list.
26760
26823
  const widget = hostRendersWidgets();
26761
- const emptyWidget = '\nIf this profile should have connected accounts, check WHICH workspace this connection is on: list_brands names every profile on this account plus every one shared with you, and use_brand switches. Otherwise the user connects an account in the Hermoso app under Workspace ▸ Connectors (hand them the connect link below for a sign-in account).';
26762
- const empty = '\nIf this profile should have connected accounts, check WHICH workspace this connection is on: list_brands names every profile on this account plus every one shared with you, and use_brand switches. Otherwise connect one: an account that connects through a sign-in screen needs a browser (hand the user its connect link, below), and a paste-a-key account can be connected right here with connect_connector or in Workspace ▸ Connectors, whichever the user prefers.';
26824
+ const emptyWidget = '\nIf this profile should have connected accounts, check WHICH workspace this connection is on: list_brands names every profile on this account plus every one shared with you, and use_brand switches. Otherwise the user connects an account in the Hermoso app under Connectors (hand them the connect link below for a sign-in account).';
26825
+ const empty = '\nIf this profile should have connected accounts, check WHICH workspace this connection is on: list_brands names every profile on this account plus every one shared with you, and use_brand switches. Otherwise connect one: an account that connects through a sign-in screen needs a browser (hand the user its connect link, below), and a paste-a-key account can be connected right here with connect_connector or in Connectors, whichever the user prefers.';
26763
26826
  // AN AGENT NEVER SEES A PICKER PROVIDER'S RAW `accountLabel` (2026-08-04). It names whoever AUTHORIZED the
26764
26827
  // connection, and on LinkedIn that is a tickable, postable identity the user has almost certainly NOT ticked
26765
26828
  // (config.liMember is off by default) — printing it told Studio a personal profile was postable when it was not.
@@ -26793,12 +26856,12 @@ function memoryNoteVerdict(text) {
26793
26856
  const lines = on.map(c => ` • ${c.provider}${c.agentLabel ? ` — ${c.agentLabel}` : ''} (${c.status || 'active'})${c.grantMode === 'posting' ? ' · POSTING ONLY' : ''}${c.scopeDrift?.status === 'missing' ? ` ⚠ missing ${c.scopeDrift.missing.length} permission(s) — needs a reconnect` : ''}${c.needsReconnect && c.revokedReason ? ` ⚠ RECONNECT NEEDED — ${c.revokedReason}` : ''}${c.deliveryWarning?.why ? ` ⚠ ${String(c.deliveryWarning.badge || 'not delivering').toUpperCase()} — ${c.deliveryWarning.why}` : ''}`);
26794
26857
  const postingNote = posting.length
26795
26858
  ? '\n\n' + posting.map(c => `ℹ ${c.grantModeNote}`).join('\n')
26796
- + '\nAds, catalog, WhatsApp and Business-portfolio tools will refuse on this connection, and that is the user\'s deliberate choice rather than a fault — do not retry them. Widening it is an OAuth consent screen, so the user does it in a browser: Workspace ▸ Connectors ▸ Meta ▸ Reconnect with everything, or open ' + linkFor('meta') + '.'
26859
+ + '\nAds, catalog, WhatsApp and Business-portfolio tools will refuse on this connection, and that is the user\'s deliberate choice rather than a fault — do not retry them. Widening it is an OAuth consent screen, so the user does it in a browser: Connectors ▸ Meta ▸ Reconnect with everything, or open ' + linkFor('meta') + '.'
26797
26860
  : '';
26798
26861
  const gapNote = gap.length
26799
26862
  ? `\n\n⚠ ${gap.length} connection(s) need their permissions updated:\n`
26800
26863
  + gap.map(c => ` • ${c.scopeDriftNote || `${c.provider}: missing ${(c.scopeDrift.missing || []).join(', ')}`}`).join('\n')
26801
- + '\nThis is not something an agent can fix: re-authorizing is an OAuth consent screen, so the user has to do it in a browser — Workspace ▸ Connectors ▸ the connector ▸ Reconnect. Everything the connection already carries keeps working until then.' + gap.map(c => `\n Reconnect ${c.provider}: ${linkFor(c.provider)}`).join('')
26864
+ + '\nThis is not something an agent can fix: re-authorizing is an OAuth consent screen, so the user has to do it in a browser — Connectors ▸ the connector ▸ Reconnect. Everything the connection already carries keeps working until then.' + gap.map(c => `\n Reconnect ${c.provider}: ${linkFor(c.provider)}`).join('')
26802
26865
  : '';
26803
26866
  // A DEAD CONNECTION IS LEFT OUT OF `on` (status revoked), so without this it read as "not connected" (2026-11-12).
26804
26867
  const broken = await brokenConnectorNotes();
@@ -26806,7 +26869,7 @@ function memoryNoteVerdict(text) {
26806
26869
  const safe = { ...d, connectors: (d.connectors || []).map(({ accountLabel, ...c }) => c) };
26807
26870
  if (broken.length) safe.needsAttention = broken.map(({ provider, label, who, reason, steps, connectUrl, postsWaiting, nextPostAt, agentNote }) => ({ provider, label, who, reason, steps, connectUrl, postsWaiting, nextPostAt, note: agentNote }));
26808
26871
  const onBrand = d.brand && typeof d.brand.profileId === 'string' ? `On ${d.brand.name ? `"${d.brand.name}" (${d.brand.profileId})` : `profile ${d.brand.profileId}`}${d.brand.shared ? ', shared with you' : ''}. ` : '';
26809
- return ok(`${onBrand}${on.length} connected:\n${lines.join('\n') || ' (none)'}\nAvailable to connect through a sign-in screen (needs a browser; the link is ${linkFor('<provider>')}): ${(d.providers || []).join(', ') || '(none configured)'}.\nPaste-a-key, ${widget ? 'connected in the Hermoso app under Workspace ▸ Connectors (keys are never pasted into this chat)' : 'connectable from here with connect_connector or in the app'}: ${Object.keys(KEY_CONNECTORS).join(', ')}.${on.length ? '' : (widget ? emptyWidget : empty)}${brokenNote}${gapNote}${waitingNote}${postingNote}`, safe);
26872
+ return ok(`${onBrand}${on.length} connected:\n${lines.join('\n') || ' (none)'}\nAvailable to connect through a sign-in screen (needs a browser; the link is ${linkFor('<provider>')}): ${(d.providers || []).join(', ') || '(none configured)'}.\nPaste-a-key, ${widget ? 'connected in the Hermoso app under Connectors (keys are never pasted into this chat)' : 'connectable from here with connect_connector or in the app'}: ${Object.keys(KEY_CONNECTORS).join(', ')}.${on.length ? '' : (widget ? emptyWidget : empty)}${brokenNote}${gapNote}${waitingNote}${postingNote}`, safe);
26810
26873
  }));
26811
26874
  // ── ALERTS (2026-10-27): Slack / Discord / a signed generic webhook, for renders, scheduled posts, drafts waiting for
26812
26875
  // approval, competitor-watch findings, new leads and low credits. Rules: lib/alert-sinks.mjs; routes /api/alerts*.
@@ -27051,7 +27114,7 @@ function memoryNoteVerdict(text) {
27051
27114
  const p = CONN_ACCOUNT_PICKERS[a.provider];
27052
27115
  if (!p) return { content: [{ type: 'text', text: `list_connector_accounts covers: ${CONN_PICKER_IDS.join(', ')}.` }], isError: true };
27053
27116
  const d = await apiGet(p.read);
27054
- if (p.needsConnect?.(d)) return { content: [{ type: 'text', text: `${p.label} isn’t connected to this workspace yet. Linking it is an OAuth consent screen, so the user has to do it in a browser: Workspace ▸ Connectors ▸ ${p.label}. Then call this again.` }], isError: true };
27117
+ if (p.needsConnect?.(d)) return { content: [{ type: 'text', text: `${p.label} isn’t connected to this workspace yet. Linking it is an OAuth consent screen, so the user has to do it in a browser: Connectors ▸ ${p.label}. Then call this again.` }], isError: true };
27055
27118
  const rows = p.identities(d);
27056
27119
  const sel = [...new Set(rows.filter(r => r.selected).map(r => r.id))];
27057
27120
  const _bad = p.unreadable?.(d) || [];
@@ -27072,7 +27135,7 @@ function memoryNoteVerdict(text) {
27072
27135
  if (!p.write) return { content: [{ type: 'text', text: `${p.label} accounts are not shared by ticking — every account connected to this profile is usable as it is. Add another by connecting again in the app; remove one with disconnect_connector({provider:'${a.provider}', account:'@handle', confirm:true}).` }], isError: true };
27073
27136
  const ids = (a.accountIds || []).map(x => String(x).trim()).filter(Boolean);
27074
27137
  const before = await apiGet(p.read); // also the source LinkedIn needs to tell its personal profile from a Page
27075
- if (p.needsConnect?.(before)) return { content: [{ type: 'text', text: `${p.label} isn’t connected to this workspace yet — there is nothing to scope. The user has to link it in a browser first: Workspace ▸ Connectors ▸ ${p.label}.` }], isError: true };
27138
+ if (p.needsConnect?.(before)) return { content: [{ type: 'text', text: `${p.label} isn’t connected to this workspace yet — there is nothing to scope. The user has to link it in a browser first: Connectors ▸ ${p.label}.` }], isError: true };
27076
27139
  const [path, body] = p.write(ids, before);
27077
27140
  const _wr = (await apiPost(path, body)) || {};
27078
27141
  // READ BACK. The server keeps only the ids the live connection can actually reach, so what was asked for and what
@@ -27115,7 +27178,7 @@ function memoryNoteVerdict(text) {
27115
27178
  // is moot now that nothing revokes — the hazard is closed rather than guarded.
27116
27179
  const _one = await apiPost(`/api/connectors/${encodeURIComponent(provider)}/disconnect`, a.account ? { account: String(a.account) } : {});
27117
27180
  if (_one?.data?.removedAccount) return ok(`Removed ${provider} account ${_one.data.removedAccount} from this brand — ${_one.data.remaining} account(s) remain connected.`, { ok: true, provider, disconnected: false, removedAccount: _one.data.removedAccount, remaining: _one.data.remaining });
27118
- return ok(`Disconnected ${provider}${hit.agentLabel ? ` (${hit.agentLabel})` : ''} from this brand.${hit.removalNote ? ` ${hit.removalNote}` : ''} ${_isKey ? 'Reconnect it with connect_connector, or in the app under Workspace ▸ Connectors.' : `Reconnect from the app: Workspace ▸ Connectors ▸ ${provider}, or hand the user ${_link}.`}`, { ok: true, provider, disconnected: true, revokesAtProvider: hit.revokesAtProvider || null });
27181
+ return ok(`Disconnected ${provider}${hit.agentLabel ? ` (${hit.agentLabel})` : ''} from this brand.${hit.removalNote ? ` ${hit.removalNote}` : ''} ${_isKey ? 'Reconnect it with connect_connector, or in the app under Connectors.' : `Reconnect from the app: Connectors ▸ ${provider}, or hand the user ${_link}.`}`, { ok: true, provider, disconnected: true, revokesAtProvider: hit.revokesAtProvider || null });
27119
27182
  }));
27120
27183
  // ── CONNECT A PASTE-A-KEY ACCOUNT FROM HERE (2026-09-12) ─────────────────────────────────────────────────────────────
27121
27184
  // The why and the derived set are at KEY_CONNECTORS. The key goes to the SAME route the app's paste box uses, so the
@@ -27127,7 +27190,7 @@ function memoryNoteVerdict(text) {
27127
27190
  const keyConnectRedact = (text, secrets) => { let t = String(text ?? ''); for (const s of secrets) if (s && s.length >= 4) t = t.split(s).join('[redacted]'); return t; };
27128
27191
  server.registerTool('connect_connector', {
27129
27192
  title: 'Connect a paste-a-key account',
27130
- description: `Connect an account that links with a KEY rather than a sign-in screen, from here, with no browser: ${Object.values(KEY_CONNECTORS).map(s => s.label).join(', ')}. Pass provider and fields in that provider's own field names (listed below, * = required). The key is checked live with the provider before anything is saved, exactly as the app's Connectors page checks it, and the reply is read back from the saved connection. OFFER BOTH WAYS AND LET THE USER CHOOSE: a key pasted into this chat stays in this conversation's history, while pasting it in the app (Workspace > Connectors, or the one-click link https://app.hermoso.ai/?connect=<provider>) keeps it out of the chat. Hermoso never repeats a submitted key back. An account that connects through the provider's own sign-in screen (OAuth) cannot be connected here: this answers with the link to hand the user instead. Apple Ads with no key material first generates a signing key pair and returns the public key to register with Apple plus a setupToken to send back. Fields: ${Object.entries(KEY_CONNECTORS).map(([id, s]) => `${id} {${keyConnectFieldList(s)}}`).join(' · ')}.`,
27193
+ description: `Connect an account that links with a KEY rather than a sign-in screen, from here, with no browser: ${Object.values(KEY_CONNECTORS).map(s => s.label).join(', ')}. Pass provider and fields in that provider's own field names (listed below, * = required). The key is checked live with the provider before anything is saved, exactly as the app's Connectors page checks it, and the reply is read back from the saved connection. OFFER BOTH WAYS AND LET THE USER CHOOSE: a key pasted into this chat stays in this conversation's history, while pasting it in the app (Connectors, or the one-click link https://app.hermoso.ai/?connect=<provider>) keeps it out of the chat. Hermoso never repeats a submitted key back. An account that connects through the provider's own sign-in screen (OAuth) cannot be connected here: this answers with the link to hand the user instead. Apple Ads with no key material first generates a signing key pair and returns the public key to register with Apple plus a setupToken to send back. Fields: ${Object.entries(KEY_CONNECTORS).map(([id, s]) => `${id} {${keyConnectFieldList(s)}}`).join(' · ')}.`,
27131
27194
  inputSchema: {
27132
27195
  provider: z.string().describe(`the connector id: ${Object.keys(KEY_CONNECTORS).join(', ')}`),
27133
27196
  fields: z.record(z.string()).optional().describe('that provider\'s own field names and values, e.g. {"apiKey":"…"}; the names for each provider are in the description'),
@@ -27268,7 +27331,7 @@ function memoryNoteVerdict(text) {
27268
27331
  catch (e) { if (e?.status === 404 || e?.status === 403) throw e; }
27269
27332
  return ok(pv?.message
27270
27333
  ? `${pv.message} Confirm with the user, then call again with confirm:true.`
27271
- : `This will remove ${a.email || '(no email)'} from this profile and disconnect any connections they made on it. I could not read which ones just now — check Settings ▸ Connectors before confirming. Confirm with the user, then call again with confirm:true.`,
27334
+ : `This will remove ${a.email || '(no email)'} from this profile and disconnect any connections they made on it. I could not read which ones just now — check Connectors before confirming. Confirm with the user, then call again with confirm:true.`,
27272
27335
  { ok: false, ...(pv ? { connectionsDisconnected: pv.connectionsDisconnected || [], scheduledPostsNotGoingOut: pv.scheduledPostsNotGoingOut || 0 } : {}) });
27273
27336
  }
27274
27337
  // The route 404s BY NAME when the address is not a member (a typo, a stale address, the wrong brand) — that error
@@ -28126,7 +28189,7 @@ function memoryNoteVerdict(text) {
28126
28189
  server.group('workspace');
28127
28190
  server.registerTool('get_brand', {
28128
28191
  title: 'Get saved profile',
28129
- description: 'What Hermoso ALREADY KNOWS for the active profile: the same saved profile (products, logos, palette, positioning) + learned memory the web Studio uses. Call it when you need to know whether a brand is on file: if hasBrand is true you can omit brand everywhere; if false, onboard with draft_brand. Not a required first step before a render: the create tools read the saved brand by themselves. 0 credits.',
28192
+ description: 'What Hermoso ALREADY KNOWS for the active profile: the same saved profile (products, logos, palette, fonts, positioning) + learned memory the web Studio uses. Call it when you need to know whether a brand is on file: if hasBrand is true you can omit brand everywhere; if false, onboard with draft_brand. Not a required first step before a render: the create tools read the saved brand by themselves. 0 credits.',
28130
28193
  inputSchema: { ...STORE_BRAND },
28131
28194
  outputSchema: {
28132
28195
  hasBrand: z.boolean().optional().describe('whether brand details are saved for this profile'),
@@ -28160,6 +28223,7 @@ function memoryNoteVerdict(text) {
28160
28223
  if (_s(b.logo)) lines.push(`Logo: ${abs(_s(b.logo))}`);
28161
28224
  const pal = (Array.isArray(b.palette) ? b.palette : []).map((c) => _s(typeof c === 'string' ? c : c?.hex || c?.color)).filter(Boolean);
28162
28225
  if (pal.length) lines.push(`Palette: ${pal.slice(0, 8).join(', ')}`);
28226
+ const _fonts = describeBrandFonts(b.fonts); if (_fonts) lines.push(`Fonts: ${_fonts}`);
28163
28227
  const prods = (Array.isArray(b.products) ? b.products : []).map((x) => _s(typeof x === 'string' ? x : x?.title || x?.name)).filter(Boolean);
28164
28228
  if (prods.length) lines.push(`Products: ${prods.slice(0, 12).join(', ')}`);
28165
28229
  return lines.length ? '\n' + lines.join('\n') : '';
@@ -28176,7 +28240,7 @@ function memoryNoteVerdict(text) {
28176
28240
 
28177
28241
  server.registerTool('draft_brand', {
28178
28242
  title: 'Draft profile',
28179
- description: 'Fill in a profile (a business brand or a creator) from a website domain, a free-text description, or a social handle: its name, products, logo and summary, returned as a {name, products, logo, …} object and saved as the brand profile when none is saved yet. Pass the object to plan_ad / generate. IMPORTANT: a domain can resolve to a DIFFERENT company than intended (e.g. bala.com is an engineering firm, not the Bala fitness brand at shopbala.com). Before spending any credits on research or renders, VERIFY the returned `name` (and `summary`) match the brand the user meant; if it looks wrong, re-draft with the correct domain or a description (pass save:false until confirmed) — this tool cannot ask the user, so the caller owns that check.',
28243
+ description: 'Fill in a profile (a business brand or a creator) from a website domain, a free-text description, or a social handle: its name, products, logo and summary, returned as a {name, products, logo, …} object and saved as the brand profile when none is saved yet. On an account with NO saved brand one call does it all: it saves the brand and reads its product photos (no second call with confirmed:true). Check the business it returns against what you know about the user and go straight on to the ad unless you have a real reason to think it is a different business. Next, for an image ad call generate_image directly (plan_ad is for a video or for concepts to choose from). A domain can resolve to a DIFFERENT company than intended (e.g. bala.com is an engineering firm, not the Bala fitness brand at shopbala.com): if the returned `name` or `summary` is clearly not the business the user named, re-draft with the correct domain or a description.',
28180
28244
  inputSchema: {
28181
28245
  domain: z.string().optional().describe('a website to scrape, or a social link (profile, YouTube channel or one post) to draft from the account it names'),
28182
28246
  description: z.string().optional().describe('a free-text brand description (no website)'),
@@ -28206,8 +28270,17 @@ function memoryNoteVerdict(text) {
28206
28270
  // of a brand nobody wanted. This tool cannot ask, and it read the photos in the SAME call that returns the name the
28207
28271
  // caller is told to verify: the same premature charge. So the site photo read runs only on confirmed:true. A confirmed
28208
28272
  // call asks the draft to `resume`, so a second call within 30 minutes is answered from the first draft, not run again.
28209
- const _photosConfirmed = confirmed === true;
28210
- const d = await apiPost('/api/brand/draft', _photosConfirmed && a.domain ? { ...a, resume: true } : a);
28273
+ // A FIRST BRAND NEEDS NO "IS THIS YOU?" TURN (2026-11-13, agent-first-run QA, owner): ChatGPT stopped to ask "Is Brightland your
28274
+ // correct brand?" before saving a brand that replaced nothing, and Claude made a second confirmed:true call that took two
28275
+ // minutes. The photo review that the yes guarded is free inside the first-brand onboarding window (/api/site/images
28276
+ // markOnbWindow), so on an account with NO saved brand the default save also reads the photos in this same call, and the
28277
+ // read starts BESIDE the draft, as the web onboarding does. Replacing a saved profile keeps every gate it had.
28278
+ let _cur0 = null;
28279
+ if (save !== false && save !== true && confirmed !== true) _cur0 = await apiGet('/api/brand/current').catch(() => null);
28280
+ const _firstBrand = !!(_cur0 && _cur0.hasBrand === false);
28281
+ const _photosConfirmed = confirmed === true || _firstBrand;
28282
+ const _earlyPhotos = _firstBrand && a.domain && !a.socialHandle ? apiGet('/api/site/images', { url: a.domain }).catch(() => null) : null; // read beside the draft; used only if the draft keeps it (a product brand, same site)
28283
+ const d = await apiPost('/api/brand/draft', confirmed === true && a.domain ? { ...a, resume: true } : a);
28211
28284
  const p = d.profile || d;
28212
28285
  // ALWAYS TRY THE WEBSITE (2026-07-28). /api/brand/draft returns the PROFILE only — it never fetched a single
28213
28286
  // product photo, so an MCP-onboarded brand was structurally photo-less even with a perfectly good domain, and every
@@ -28228,7 +28301,8 @@ function memoryNoteVerdict(text) {
28228
28301
  if (_photosConfirmed && p && p.domain && p.physical_product !== false && !_creatorNoProduct) {
28229
28302
  _photosRead = true;
28230
28303
  try {
28231
- const site = await apiGet('/api/site/images', { url: p.domain });
28304
+ const _sameSite = (x, y) => String(x || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/[/?#].*$/, '') === String(y || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/[/?#].*$/, '');
28305
+ const site = (_earlyPhotos && _sameSite(a.domain, p.domain) ? await _earlyPhotos : null) || await apiGet('/api/site/images', { url: p.domain });
28232
28306
  const imgs = [...(site?.images || [])].filter(Boolean);
28233
28307
  if (imgs.length) { p.productImages = imgs.slice(0, 12); if (!p.product) p.product = imgs[0]; }
28234
28308
  // THE PRODUCTS' OWN NAMES beside their photos (2026-09-29): the shop's title, else the name the photo review gave
@@ -28247,7 +28321,7 @@ function memoryNoteVerdict(text) {
28247
28321
  let saved = false, keptNewer = null;
28248
28322
  if (save !== false) {
28249
28323
  try {
28250
- const cur = save === true ? null : await apiGet('/api/brand/current').catch(() => null);
28324
+ const cur = save === true ? null : (_cur0 || await apiGet('/api/brand/current').catch(() => null));
28251
28325
  const _host = (x) => String(x || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/[/?#].*$/, '');
28252
28326
  const _photoFields = ['productImages', 'product', 'productTitles', 'products', 'logo'].filter((f) => p[f] != null && !(Array.isArray(p[f]) && !p[f].length));
28253
28327
  if (save !== true && _photosRead && cur?.hasBrand && cur.brand && _host(cur.brand.domain) && _host(cur.brand.domain) === _host(p.domain) && _photoFields.length) {
@@ -28283,7 +28357,17 @@ function memoryNoteVerdict(text) {
28283
28357
  // repeat draft, a failure or a second agent never runs it again in a loop. Best-effort: never delays or fails the draft.
28284
28358
  if (saved && p && p.name) { const _kind = a.socialHandle ? 'creator' : (p.physical_product === false ? 'service' : 'product'); void apiPost('/api/studio/recommend', { brand: p, kind: _kind, context: '' }).catch(() => {}); }
28285
28359
  const _photoNote = _photosHeld ? ` Product photos NOT read yet: the website's photo review is paid, so it waits for the user's yes. Ask them whether ${p.name || 'this'} (${p.domain}) is their brand; if it is, call draft_brand again with domain "${p.domain}" and confirmed:true (the draft is reused, not billed again) to read and save the photos. If it is not, nothing was spent on photos.` : '';
28286
- return ok(`Drafted brand: ${p.name || '—'}${p.category ? ' · ' + p.category : ''}.${saved ? (keptNewer ? ` Saved as the workspace brand, EXCEPT ${keptNewer.join(', ')}: a newer edit to those is already stored and was kept.` : ' Saved as the workspace brand — plan_ad/create now use it automatically.') : ' Pass this object to plan_ad.'}${_photoNote}`, { ...p, ...(_photosHeld ? { photosHeld: true } : {}) });
28360
+ // WHAT TO DO NEXT, IN AS FEW CALLS AS THE ASK NEEDS (2026-11-13): "set up my brand and make my first image ad" took three
28361
+ // drafts and a 6-credit plan_ad before the image. An image ad is ONE generate_image call on the saved brand; plan_ad is for a
28362
+ // video or when the user wants concepts to choose from. A first brand saved here was this account's only one, so a wrong
28363
+ // business is fixed by drafting the right one with save:true, with nothing of the user's to lose.
28364
+ const _nPhotos = Array.isArray(p.productImages) ? p.productImages.length : 0;
28365
+ // THE AGENT CONFIRMS THE BUSINESS ITSELF (owner, 2026-11-13: "Can't the agent confirm if its the right business, they already
28366
+ // likely know about their user."): the reply names what was found, and the agent checks it against what it knows.
28367
+ const _found = `${p.name || 'unnamed'}${p.domain ? ` (${p.domain})` : ''}${p.summary ? `: ${String(p.summary).replace(/\s+/g, ' ').trim().slice(0, 160)}` : ''}`;
28368
+ const _firstSaved = saved && _firstBrand ? ` Found ${_found}. Saved as this account's first brand${_nPhotos ? ` with ${_nPhotos} product photo${_nPhotos === 1 ? '' : 's'}` : ''}. Check it against what you already know about the user: if it fits, or nothing suggests otherwise, go straight on to the ad. Only if you have a real reason to think it is a different business, tell the user and draft the right one with save:true.` : '';
28369
+ const _next = saved ? ' Next: for an image ad call generate_image directly (the saved brand and its photos ride automatically; no plan_ad needed); for a video ad, plan_ad then render_ad.' : '';
28370
+ return ok(`Drafted brand: ${p.name || 'unnamed'}${p.category ? ' · ' + p.category : ''}.${saved ? (keptNewer ? ` Saved as the workspace brand, EXCEPT ${keptNewer.join(', ')}: a newer edit to those is already stored and was kept.` : ' Saved as the workspace brand: plan_ad/create now use it automatically.') : ' Pass this object to plan_ad.'}${_firstSaved}${_photoNote}${_next}`, { ...p, ...(_photosHeld ? { photosHeld: true } : {}), ...(saved && _firstBrand ? { firstBrand: true } : {}) });
28287
28371
  }));
28288
28372
 
28289
28373
  // ---------- assets ----------