hermoso 0.1.233 → 0.1.234

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@ scripts. Research the ads already winning in a market, generate finished image &
5
5
  composited in, copy + CTA included), publish them to your own social channels, and build & manage the ad
6
6
  campaigns behind them — all over [MCP](https://modelcontextprotocol.io) tools, a CLI, or installable Claude skills.
7
7
 
8
- **827 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
8
+ **828 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
9
9
  catalog with exact per-render credit costs plus the full capability map.
10
10
 
11
11
  **What it connects to.** Ad platforms: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads,
@@ -171,7 +171,7 @@ block entirely if you signed in above; it is there for CI, where the process can
171
171
 
172
172
  Then ask your agent: *“Generate an image ad with Hermoso.”*
173
173
 
174
- ### What the 827 tools cover
174
+ ### What the 828 tools cover
175
175
 
176
176
  **Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
177
177
  Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
package/mcp/client.mjs CHANGED
@@ -106,7 +106,8 @@ async function unwrap(res) {
106
106
  // `_viaApi` MARKS AN ERROR THAT ALREADY REACHED THE SERVER, so route() has already recorded it in the error
107
107
  // ledger with the tool name off x-hermoso-tool. wrap() reports ONLY the errors that lack this marker — a local
108
108
  // throw, a schema rejection, a socket reset — which is what stops the twins double-counting every 4xx.
109
- throw Object.assign(new Error(msg), { status: res.status, _viaApi: true, ...(body?.connector ? { connector: body.connector } : {}) });
109
+ // `connectUrl` rides a not-connected 401 with the brand already in it, so a hint never rebuilds the link.
110
+ throw Object.assign(new Error(msg), { status: res.status, _viaApi: true, ...(body?.connector ? { connector: body.connector } : {}), ...(typeof body?.connectUrl === 'string' ? { connectUrl: body.connectUrl } : {}) });
110
111
  }
111
112
  return body && Object.prototype.hasOwnProperty.call(body, 'data') ? body.data : body;
112
113
  }
package/mcp/tools.mjs CHANGED
@@ -120,6 +120,31 @@ const okVideo = async (text, r) => {
120
120
  // tools/agent-independence-check.mjs — a hand-written number in a prompt is how a roster goes stale silently.
121
121
  export const INDEPENDENCE = 'INDEPENDENT AREAS, NOT A PIPELINE — research, creation, publishing/scheduling and ads management each work ON THEIR OWN, and NO tool requires that you used another one first: publish or schedule media the user already has and generate nothing here (upload_file turns any local or external file into a URL the publish, schedule and ad-build tools accept), build and read campaigns on their OWN ad accounts with their OWN creative across all eleven ad platforms, research competitors with no brand drafted and no channel connected, or generate a file with nothing connected at all and simply hand back the URL. Use one area, several, or all of it together — never tell a user they have to start somewhere else first.';
122
122
 
123
+ // ── PASTE-A-KEY CONNECTORS AN AGENT MAY CONNECT ITSELF (2026-09-12) ──────────────────────────────────────────────────
124
+ // Dave: "AI agents should be able to connect key based accounts, we should offer both and its up to users what they
125
+ // prefer." An OAuth account needs its provider's consent screen, which only a browser can show. A paste-a-key account
126
+ // needs a value the user already holds, and the app's own route checks that value live with the vendor before it saves
127
+ // anything. So connect_connector posts to the SAME route the Connectors page posts to (server validation, workspace
128
+ // resolution and the saved-row read-back all unchanged), and the web app stays the alternative: the user chooses.
129
+ // THE SET IS DERIVED, NOT LISTED: tools/connect-connector-check.mjs reads every literal POST /api/connectors/<slug>
130
+ // route in server.js whose handler upserts kind 'apikey' or 'webhook', and fails unless this table names exactly that
131
+ // set, with the field names the route itself reads. Flags: r = required, s = secret (never echoed back, and redacted
132
+ // from any text that returns). Apple Ads' required set depends on which key path is used, so its handler checks it.
133
+ export const KEY_CONNECTORS = {
134
+ stripe: { label: 'Stripe', route: 'stripe', fields: { apiKey: 'rs' }, how: 'a secret (sk_) or restricted (rk_) key from Stripe ▸ Developers ▸ API keys' },
135
+ openai_ads: { label: 'ChatGPT Ads', route: 'openai-ads', fields: { apiKey: 'rs' }, how: 'an Advertiser API key from ChatGPT Ads Manager ▸ Settings ▸ API keys ▸ Create' },
136
+ apple_ads: { label: 'Apple Ads', route: 'apple-ads', fields: { clientId: '', teamId: '', keyId: '', privateKey: 's', setupToken: 's', orgId: '' }, how: 'clientId, teamId and keyId (Apple Ads ▸ Account Settings ▸ API shows all three once a public key is saved there), plus privateKey for a key already registered with Apple, or setupToken from a first call with no fields, which generates the key pair' },
137
+ bluesky: { label: 'Bluesky', route: 'bluesky', fields: { identifier: 'r', appPassword: 'rs', pds: '' }, how: 'the handle and an APP password from Bluesky ▸ Settings ▸ Privacy and Security ▸ App Passwords (pds only for a self-hosted server)' },
138
+ telegram: { label: 'Telegram', route: 'telegram', fields: { token: 'rs' }, how: 'the bot token from @BotFather ▸ /mybots ▸ your bot ▸ API Token' },
139
+ bing_webmaster: { label: 'Bing Webmaster Tools', route: 'bing-webmaster', fields: { apiKey: 'rs' }, how: 'the API key from Bing Webmaster Tools ▸ Settings ▸ API access ▸ Generate' },
140
+ posthog: { label: 'PostHog', route: 'posthog', fields: { apiKey: 'rs', region: '', host: '', projectId: '' }, how: 'a PERSONAL API key (starts phx_) from PostHog ▸ Settings ▸ Personal API keys; region us or eu; projectId when the key can see several projects' },
141
+ mixpanel: { label: 'Mixpanel', route: 'mixpanel', fields: { username: 'r', secret: 'rs', projectId: 'r', region: '', workspaceId: '' }, how: 'a SERVICE ACCOUNT username and secret from Mixpanel ▸ Organization Settings ▸ Service Accounts, plus the numeric projectId from Project Settings' },
142
+ amplitude: { label: 'Amplitude', route: 'amplitude', fields: { apiKey: 'rs', secretKey: 'rs', region: '', host: '' }, how: 'the project API key and secret key from Amplitude ▸ Settings ▸ Projects ▸ your project ▸ General' },
143
+ slack: { label: 'Slack', route: 'slack', fields: { webhookUrl: 'rs' }, how: 'an incoming-webhook URL (it starts https://hooks.slack.com/) for render notifications' },
144
+ discord: { label: 'Discord', route: 'discord', fields: { webhookUrl: 'rs' }, how: 'a channel webhook URL (Channel ▸ Edit ▸ Integrations ▸ Webhooks) for render notifications' },
145
+ webhook: { label: 'Webhook', route: 'webhook', fields: { webhookUrl: 'rs' }, how: 'a public https:// URL that should receive a JSON POST for each render (a Zapier, Make or n8n catch hook)' },
146
+ };
147
+
123
148
  // X ADS: THE ENTRY BELOW USED TO BE A DENIAL, AND IT WAS FALSE (fixed 2026-08-10). It read "X ADS ARE NOT
124
149
  // AVAILABLE: … Hermoso cannot create or manage X ad campaigns, so say that plainly instead of offering it" — an
125
150
  // authoritative refusal of 17 VERIFIED-LIVE tools, and post_to_x carried a second copy ("X ADS are a separate
@@ -164,7 +189,7 @@ export const CAPABILITY_MAP = [
164
189
  'B) CREATE — finished, 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 brand workspaces — an agency runs every client through here; each has its own brand, 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) · clone_static / recast_motion / reframe_video / upscale_video / dub_video / change_voice / finish_video / fix_beat / stitch_video · plan_variations + score_ad (fan out + rank).',
165
190
  '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.',
166
191
  '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, plan and answer is written in — set it once and every render obeys it, over MCP as well as in the app — plus app appearance and the weekly competitor-watch email) · list_team / invite_member / remove_member / set_role (who else can work in this brand).',
167
- '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 brand 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) · disconnect_connector (revoke and drop a connection; confirm-gated because RECONNECTING NEEDS A BROWSER and no agent can do it) · 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 a new account is the one thing that is not headless — it is an OAuth consent screen, so send the user to Workspace ▸ Connectors in the app. META: list_meta_pages · instagram_insights (ACCOUNT-level Instagram performance — views, reach, accounts engaged, interactions, saves, profile link taps — plus the audience DEMOGRAPHICS by age / city / country / gender) · list_instagram_media (the brand’s own recent Instagram posts, and where the media id every other Instagram tool needs comes from) · search_instagram_audio (licensed music and original sounds an Instagram Reel may use, by keyword or trending) · list_instagram_collab_invites then respond_instagram_collab_invite (collab-post invitations waiting on the account; accept or decline one, read back from Instagram) · list_instagram_collab_media (posts this account co-authors) · like_instagram (like a post or comment as the connected account) · post_to_meta (Facebook / Instagram / Threads) · list_meta_posts (the Page’s / Instagram account’s OWN existing posts with their ids — THIS is where the postId every other Meta read needs comes from; without it an agent that did not itself just publish has no way to name a post) · list_meta_ads + meta_insights (read existing campaigns/ad sets/ads + spend/CTR/CPC, with breakdowns by age / gender / placement / country) · preview_meta_ad (Meta renders the REAL ad per placement — a link the user can look at, valid 24h) · list_meta_pixels + create_meta_pixel (the pixel a conversion-optimised campaign REQUIRES — Meta will not let a build optimise for conversions without one, and until these existed a caller had no way to discover the id they had to pass) · estimate_meta_reach (how many people a targeting spec reaches, BEFORE a budget is committed) · list_meta_audiences / create_meta_audience (website-pixel retargeting, Page + Instagram engagement audiences, and lookalikes — creating one spends nothing) · list_meta_conversations / read_meta_conversation / reply_to_meta_message (MESSENGER AND INSTAGRAM DMs — the brand’s direct-message threads and a reply to someone who wrote first. Meta only permits a reply within 24 HOURS of the person acting, and read_meta_conversation says whether that window is open BEFORE anything is drafted; Hermoso sends replies only, never a proactive message or a message tag) · subscribe_meta_webhooks / meta_webhook_status / unsubscribe_meta_webhooks / list_meta_webhook_events (REAL-TIME EVENTS — have Meta PUSH new comments, mentions, lead-form submissions and inbound DMs to Hermoso instead of polling for them. Every other inbox read asks an edge “anything new?”; this is the only way to be TOLD, and it is how a lead arrives the moment it is submitted rather than when somebody thinks to look. An empty feed is ambiguous — check meta_webhook_status first, because an unsubscribed Page is silent and looks exactly like a quiet one) · instagram_collaborators (who ACCEPTED a Collab invite on an Instagram post — publishing only SENDS the invite, so this is the only way to know whether the post is actually live on the other account too) · list_instagram_shopping_catalogs / search_instagram_shopping_products / manage_instagram_product_tags (INSTAGRAM SHOPPING — make a post SHOPPABLE. Check eligibility and the account’s taggable catalogs, find the product ids, then pass productTags to post_to_meta so tapping the picture opens the product’s price sheet inside Instagram. Tagging needs an APPROVED Instagram Shop, so check FIRST — otherwise it fails after the media is already uploaded — and note that a tag whose product is not “approved” is stored and shown to nobody. Meta publishes no way to REMOVE a tag) · create_meta_catalog / update_meta_catalog / meta_catalog_blast_radius / delete_meta_catalog (BUILD AND RETIRE A CATALOG — create one on a named business portfolio, rename or re-point it, and, before ever proposing a delete, read meta_catalog_blast_radius: a catalog delete is PERMANENT with no archive and no undo, its product sets go with it, and any ad set still bound to one keeps spending with nothing to show) · list_meta_partnership_creators / manage_meta_partnership_creator (PARTNERSHIP ADS — the creators whose content this brand 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) · 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 — defaults to UNLISTED, i.e. link-only and ad-ready; set public to put it on the channel, or private for eyes-only) · 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 — the step that finishes the default publish flow; 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 + 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_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 brand is in, the TikTok business id, the scopes the grant carries and any MISSING from it — TikTok binds scopes at authorize time and never retroactively, so only a re-authorization picks up a new one — plus the exact URL to send the user to, because authorizing is the one step that needs a browser) · list_tiktok_comments + list_tiktok_comment_replies (the comments on the brand’s OWN posts, hidden ones included — TikTok’s answer to list_meta_comments and list_youtube_comments) · comment_on_tiktok_video · reply_to_tiktok_comment · moderate_tiktok_comment (LIKE / UNLIKE / HIDE / UNHIDE / DELETE — you can only DELETE a comment this account wrote, so HIDE is the tool for a stranger’s, and TikTok warns UNHIDE may not take effect when its own moderation is what hid it) · upload_tiktok_comment_image (a new comment will not take a raw image URL; a reply will) · set_tiktok_post_ad_authorization (THIS IS WHERE A SPARK ADS AUTHORIZATION CODE COMES FROM for the brand’s OWN post — previously a human had to copy one out of the TikTok app; hand the code to authorize_tiktok_ads_spark_post) · get_tiktok_post_ad_authorization · extend_tiktok_post_ad_authorization (the days are ADDED to what is left, not set as an absolute) · delete_tiktok_post_ad_authorization. BRAND MONITORING AND AUDIENCE, on that same account authorization (these need permissions added on 2026-08-20, so a brand that authorized before then holds a grant that predates them and has to authorize once more; tiktok_account_status names exactly which are missing, and the remedy is always to authorize the TikTok ACCOUNT again rather than to touch the advertiser connection, which is a separate grant and is unaffected): list_tiktok_mentions (public posts whose caption @-mentions the brand, TikTok’s answer to x_mentions and list_threads_mentions) · list_tiktok_mention_comments (comments whose text mentions it) · get_tiktok_mention (one mention in full, for the mentions webhook, and TikTok only keeps that data 48 hours) · tiktok_mention_top_terms (the top 20 keywords and top 20 hashtags inside those mentions) · list_tiktok_brand_hashtags + manage_tiktok_brand_hashtags + list_tiktok_brand_hashtag_posts (the hashtags TikTok counts as this brand’s, up to 50, and the posts carrying them; a new one is not counted for 24 hours and cannot be removed for 7 days) · tiktok_account_insights (follower demographics by age, gender, country and city plus the daily performance series, needing a BUSINESS account with 100+ followers, and capped at 60 days rather than the 90 the mention tools cover) · tiktok_category_benchmark (the same numbers averaged across an industry, so ‘are we ahead of our category’ is answerable). ALL OF THIS IS ORGANIC LISTENING ON THE BRAND’S OWN ACCOUNT, not ad research: for competitors’ ads use the ad-library research tools instead. TIKTOK ADS (a SEPARATE connection from the TikTok posting connector above — Settings ▸ Connectors ▸ TikTok Ads; a brand that posts to TikTok every day may still have no ad account here, so never read one as the other): list_tiktok_ads_accounts (the ADVERTISER accounts this brand 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_creative (THE STEP THAT TURNS A RENDER INTO AN AD — put a finished Hermoso video on the ad account and it hands back the videoId AND the coverImageId create_tiktok_ads_ad needs; there is no other source for either) · create_tiktok_ads_campaign → create_tiktok_ads_ad_group → create_tiktok_ads_ad (the tree) · set_tiktok_ads_budget · set_tiktok_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_tiktok_ads_object (removal on TikTok is a STATUS, not a verb — the same route as set_tiktok_ads_status) · create_tiktok_smart_campaign → create_tiktok_smart_ad_group → create_tiktok_smart_ad (Smart+, TikTok’s Performance Max — born PAUSED) · list_tiktok_smart_campaigns · set_tiktok_smart_status (the Smart+ money switch, confirm-gated) · tiktok_bid_protection (the ad-credit compensation TikTok pays when a Smart+ object misses its bid) · list_tiktok_ads_lead_forms + list_tiktok_ads_lead_fields + download_tiktok_ads_leads + manage_tiktok_ads_test_lead (LEAD ADS — an Instant Form is built in TikTok Ads Manager and NO API creates one, so list them to find the id a LEAD_GENERATION ad group needs. The lead REGION is required with no default: it selects which of three separate lead stores you read, and leaving it out is a THIRD value rather than “all”, so an advertiser who omits it downloads an empty file and wrongly concludes there are no leads) · list_tiktok_ads_audiences + create_tiktok_ads_audience + create_tiktok_ads_lookalike_audience + apply_tiktok_ads_audience + update_tiktok_ads_audience + delete_tiktok_ads_audience + tiktok_ads_audience_overlap (CUSTOM AUDIENCES and lookalikes — TikTok targeting is otherwise interests-and-geo only. A freshly created audience reports itself invalid for up to 48 hours BY DESIGN, so that is not a failure to retry) · list_tiktok_ads_business_centers + list_tiktok_ads_catalogs + create_tiktok_ads_catalog + list_tiktok_ads_catalog_products + list_tiktok_ads_catalog_sets + manage_tiktok_ads_catalog_feed + tiktok_ads_catalog_diagnostics (DPA / PRODUCT CATALOGS, the Shopify lane — a catalog is keyed on a BUSINESS CENTER id, NOT an advertiser id, so list the Business Centers first or every call refuses) · list_tiktok_ads_apps + list_tiktok_ads_app_events (the registered apps an APP_INSTALL campaign needs — nothing else can produce an app id) · tiktok_ads_rf_inventory_estimate + create_tiktok_ads_rf_ad_group (REACH & FREQUENCY — a RESERVATION, so it is confirm-gated like a status change rather than born paused, and it needs a per-ad-account allowlist plus a signed branding contract that no endpoint reports. Always price it with the estimate first: TikTok silently books its own maximum rather than refusing an out-of-range value) · send_tiktok_ads_events (SERVER-SIDE conversion events — there is a vendor-sanctioned test code for exercising it without entering the advertiser’s real reporting), and its offline/crm sources take the event-set ids the two tools below mint) · list_tiktok_ads_offline_event_sets + manage_tiktok_ads_offline_event_set + send_tiktok_ads_offline_events (REAL-WORLD CONVERSIONS — an in-store purchase, a phone booking, a signed contract, reported so TikTok can attribute them to the ads that caused them. The timestamp is an ISO-8601 STRING here and a Unix NUMBER on send_tiktok_ads_events; a wrong-shaped one is accepted by TikTok and attributed to nothing. There is NO test code on this pair, so everything sent is a real permanent conversion — rehearse through send_tiktok_ads_events with eventSource “offline” and a testEventCode instead. Reporting also needs the connected user to be an ADMIN or OPERATOR of the advertiser, which managing the event SETS does not) · list_tiktok_ads_crm_event_sets + create_tiktok_ads_crm_event_set (LEAD-LIFECYCLE events — sending “this lead qualified / closed” back is what makes a LEAD_GENERATION campaign optimise toward leads that convert rather than form fills. TikTok publishes create and list and nothing else, so one of these is PERMANENT) · list_tiktok_tto_accounts + list_tiktok_creator_labels + discover_tiktok_creators + tiktok_creator_leaderboard + check_tiktok_creator_status + list_tiktok_tto_brand_profiles + create_tiktok_tto_brand_profile + list_tiktok_tto_campaigns + create_tiktok_tto_campaign + update_tiktok_tto_campaign + link_tiktok_tto_video + list_tiktok_tto_link_requests + tiktok_tto_campaign_report + request_tiktok_tto_spark_authorization + get_tiktok_tto_spark_authorization + manage_tiktok_tto_anchor (TIKTOK ONE / CREATOR MARKETPLACE: INFLUENCER MARKETING, and the only place in Hermoso that does it: find creators by audience size, engagement, price and who their followers actually are, check whether they have joined TikTok One, invite them to a campaign with an invite link, ask them to tag a video to it, and read every metric SPLIT ORGANIC VERSUS PAID. Its account id is a THIRD id space; not an advertiser id and not a Business Center id; so start at list_tiktok_tto_accounts. It rides this same connection with nothing extra to apply for. IT ALSO CLOSES THE SPARK ADS LOOP: request_tiktok_tto_spark_authorization asks a creator directly and get_tiktok_tto_spark_authorization returns the code authorize_tiktok_ads_spark_post takes, which is otherwise obtainable only by the creator pasting one out of the TikTok app. Two things put a notification in a real person’s inbox; a campaign invitation and a video-linking request; and a repeated linking request is a REMINDER that TikTok caps at two, so read list_tiktok_tto_link_requests before re-sending anything) · list_tiktok_ads_stores + list_tiktok_ads_store_products (TIKTOK SHOPS: what a Shopping Ads or GMV Max campaign sells from; the store list is keyed on an ad account and the product list on a BUSINESS CENTER, which each store row names) · tiktok_ads_verification_status + list_tiktok_ads_verification_documents + submit_tiktok_ads_verification (BUSINESS VERIFICATION: an unverified account hits limits that get diagnosed as something else, so it is worth reading during onboarding. Hermoso never handles a verification DOCUMENT: submitting sends account details plus the ids of images the user uploaded in TikTok Ads Manager, and the legal name and document number can never be changed afterwards, so it is confirm-gated) · list_tiktok_ads_payment_portfolios + list_tiktok_ads_payment_portfolio_links (HOW THE AD ACCOUNTS ARE FUNDED: read-only, because "why did delivery stop" is often a funding answer, and because deciding where a customer’s money sits is not ours to do) · create_tiktok_ads_rule + list_tiktok_ads_rules + update_tiktok_ads_rule + bind_tiktok_ads_rule + set_tiktok_ads_rule_status + tiktok_ads_rule_results (AUTOMATED RULES — standing instructions TikTok runs on the account unattended. THE SECOND SPEND SWITCH ON THIS PLATFORM and gated in TWO CLASSES: a rule that can only pause, decrease or email needs confirm:true, while one that can TURN_ON an object or RAISE a budget or bid needs confirm:true AND confirmScope echoing the token list_tiktok_ads_rules prints, computed from the rule as TikTok STORES it. Every rule is created TURNED OFF and read back to prove it, because TikTok publishes no way to create one in the off position. TikTok emails rule notifications to the DEVELOPER address on the app rather than to the advertiser, so tiktok_ads_rule_results is the only place a customer sees what a rule did — and TikTok itself says this endpoint is for direct advertisers and may refuse a platform-managed account entirely). · list_tiktok_ads_comments + tiktok_ads_comment_thread + moderate_tiktok_ads_comment + reply_to_tiktok_ads_comment + delete_tiktok_ads_comment (COMMENT MODERATION on your own TikTok ads — the platform where the comment section IS the ad, and until now the one platform Hermoso could not moderate. HIDE is the moderation verb and works on anyone’s comment and is reversible; DELETE only ever removes a comment your OWN identity posted, which TikTok reports per comment as canDelete. Comments are scoped to an AD GROUP and to nothing else, and the time window may span at most 30 DAYS, so an empty answer means “none in these 30 days” rather than “none ever”) · list_tiktok_ads_blocked_words + manage_tiktok_ads_blocked_words (a standing 500-word filter that auto-hides any comment containing one of these across EVERY ad on the account — nothing else in Hermoso does this, and removing a word republishes every comment it had hidden) · tiktok_ads_diagnosis (TikTok’s own issues-and-suggestions verdict on your ad groups — creative, bid/budget with its full estimated-delivery tables, and a pixel that has gone quiet. It covers ACTIVE ad groups only and omits any it has nothing to say about, so an empty answer is not a clean bill of health) · get_tiktok_ads_brand_safety + set_tiktok_ads_brand_safety (what content the ads may appear next to. Two things to say out loud: TikTok applies this to Smart+ campaigns and explicitly NOT to the regular campaigns create_tiktok_ads_campaign builds, and coverAllObjectives is a ONE-WAY DOOR TikTok cannot set back). TWO THINGS HERE ARE UNLIKE EVERY OTHER AD PLATFORM: TikTok creates objects ENABLED by default, so Hermoso forces every campaign, ad group and ad PAUSED with no override and nothing serves until set_tiktok_ads_status(confirm:true); and TikTok’s QPS is 1, so every call is serialized and a tree build or a bulk read is SLOW BY DESIGN — a throttle is not a broken connection. SNAPCHAT ADS (the tenth ad platform — Settings ▸ Connectors ▸ Snapchat Ads; a SEPARATE connection from Snapchat posting): list_snapchat_ads_accounts (the organizations and AD ACCOUNTS this brand can act on — every other Snapchat tool needs an adAccountId and this is where it comes from) · list_snapchat_ads_campaigns (the whole tree — campaigns, ad squads and ads) · snapchat_ads_report (impressions, spend, swipes and video quartiles at any level) · search_snapchat_ads_targeting (resolve country / region / interest / language ids — an ad squad cannot be created without at least one country) · upload_snapchat_ads_creative (put a finished render on the ad account as MEDIA and then as the CREATIVE an ad points at — Snapchat has no upload-from-URL, so Hermoso streams the bytes) · create_snapchat_ads_campaign → create_snapchat_ads_ad_squad → create_snapchat_ads_ad (the tree, every tier born PAUSED) · set_snapchat_ads_budget · set_snapchat_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_snapchat_ads_object (a REAL delete verb here, unlike TikTok — irreversible, so offer PAUSED first). THREE THINGS TO SAY OUT LOUD ON THIS PLATFORM: money is MICRO-CURRENCY (1,000,000 = one unit), so quote plain amounts and let Hermoso convert, and never pass both units — under-converting fails loudly while double-converting asks for a budget a million times too large; the creative HEADLINE is capped at 34 characters and brandName at 32, far shorter than Meta or Google, and over-long copy is refused rather than truncated; and a Snapchat ad points at a CREATIVE, never at a media id. SNAPCHAT POSTING (Stories / Spotlights on a Public Profile) IS BUILT BUT NOT YET REACHABLE — Snap’s Public Profile API is allowlist-only and Hermoso has not been allowlisted, so the connector is deliberately not offered; say that plainly rather than looking for a tool. LINKEDIN: post_to_linkedin (publish a finished post to the connected LinkedIn PROFILE) · list_linkedin_pages (the company Pages this connection administers — call this first and let the USER pick, never guess a Page) · post_to_linkedin_page (publish as a company PAGE rather than a person — this is the one most brands actually want) · manage_linkedin_post (edit the copy of a published post, or delete it) \u00b7 list_linkedin_comments / reply_to_linkedin_comment / delete_linkedin_comment (moderate the comments on your Page\u2019s posts \u2014 a SEPARATE LinkedIn authorization grants these, see Connectors) · linkedin_page_analytics (ORGANIC Page performance — followers, follower gains, Page views, and post impressions/clicks/engagement, for the Page total or per post; this is the free organic read, NOT linkedin_ads_report). LINKEDIN ADS (full three-tier management): list_linkedin_ads_campaigns (ad accounts, then a chosen account’s campaign groups, campaigns and — with campaignId — the CREATIVES under them) · linkedin_ads_report (impressions, clicks, cost, conversions, leads) · search_linkedin_ads_targeting (resolve locations / titles / industries / seniorities / company sizes to the URNs LinkedIn demands — never invent one) · linkedin_audience_count (HOW MANY members that targeting actually reaches, before a budget is committed — and a returned 0 means fewer than 300 people, LinkedIn’s privacy floor and also its campaign minimum, never an empty audience) · linkedin_bid_pricing (LinkedIn’s own suggested bid and daily-budget range for that audience — quote it instead of guessing what LinkedIn costs) · create_linkedin_ads_campaign_group → create_linkedin_ads_campaign → create_linkedin_ads_creative (the tree, every tier born DRAFT) · set_linkedin_ads_budget / set_linkedin_ads_status / delete_linkedin_ads_object (budgets, activate/pause at any tier, delete — every spend change confirm-gated). LINKEDIN LEAD SYNC: list_linkedin_lead_forms · list_linkedin_leads / get_linkedin_lead (the LEADS its forms collected, answers named by field — PERSONAL DATA: show, never republish) · subscribe_linkedin_leads / list_linkedin_lead_events / list_linkedin_lead_subscriptions / delete_linkedin_lead_subscription (real-time push to Hermoso, optional forwardTo relay to a CRM; LinkedIn validates ONLY Hermoso’s own webhook). LinkedIn is a THREE-tier platform and the third tier is the one people forget: a campaign with no creative shows nothing, and all three tiers must be ACTIVE before a single impression is served. REDDIT ADS: list_reddit_ads_campaigns / reddit_ads_report (read the account tree + performance) · list_reddit_ads_profiles + list_reddit_ads_posts / create_reddit_ads_post / update_reddit_ads_post (the CREATIVE — a Reddit ad promotes a post) · create_reddit_ads_campaign / update_reddit_ads_campaign · create_reddit_ads_ad_group / update_reddit_ads_ad_group · create_reddit_ads_ad / update_reddit_ads_ad · set_reddit_ads_status (the ONLY switch that arms real spend, confirm-gated) · delete_reddit_ads_object (remove a campaign, ad group or ad — Reddit has no delete verb, removal is a status, and it refuses to delete anything touched in the last 3 hours) · delete_reddit_ads_saved_audience · search_reddit_ads_targeting / reddit_ads_forecast / reddit_ads_bid_suggestion (free planning) · list_reddit_ads_pixels + send_reddit_ads_conversions (conversion tracking — Reddit now requires a pixel on every ad group) · list_reddit_ads_audiences / create_reddit_ads_audience / update_reddit_ads_audience_users / delete_reddit_ads_audience (retargeting lists) · list_reddit_ads_saved_audiences / create_reddit_ads_saved_audience / update_reddit_ads_saved_audience · list_reddit_ads_lead_forms / create_reddit_ads_lead_form · reddit_ads_history (who changed what, when). TELEGRAM: post_to_telegram (publish to a channel, group or chat as the brand’s own bot — text up to 4096 characters, but only 1024 once any photo or video is attached; one image, one video, or an album of 2–10 in which photos and videos may be mixed. chatId IS ALWAYS REQUIRED and is never guessed: the Bot API publishes NO method that lists the chats a bot belongs to, so pass the public channel’s @username or the numeric id) · list_telegram_chats (chats that MESSAGED the bot in the last 24 hours — a shortcut for finding an id, NOT a roster, and a chat missing from it can still be posted to) · list_telegram_dms (what those chats actually SAID, newest per chat — free, and a rolling 24-hour window rather than an inbox: the Bot API has no history endpoint at all) · delete_telegram_message (confirm-gated; Telegram refuses once a message is more than 48 hours old). BLUESKY: post_to_bluesky (publish as the connected account — text up to 300 characters AND, separately, 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; either up to 4 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 brand 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 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 brand can act on, WITH the permission level held on each — read it before attempting a write) · list_x_ads_funding_instruments (a campaign cannot be created without one) · list_x_ads_campaigns / list_x_ads_line_items / list_x_ads_promoted_tweets / list_x_ads_targeting (the whole tree as it stands) · x_ads_report (impressions, clicks, spend and engagements at any level) · x_ads_geo_search / x_ads_targeting_search (resolve places and targeting values to the ids X demands — never invent one) · create_x_ads_campaign → create_x_ads_line_item → create_x_ads_promoted_tweet (the tree, every tier born PAUSED with no override; A CAMPAIGN ALONE CANNOT SERVE ON X — it needs a line item and a promoted post underneath it, and the read-back says so rather than letting you call it a finished ad) · add_x_ads_targeting · update_x_ads_campaign / update_x_ads_line_item (throttle or raise spend on a running campaign without rebuilding it) · set_x_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_x_ads_object. PINTEREST — POSTING AND ADS ARE TWO SEPARATE CONNECTIONS on the same Pinterest login (Pinterest keeps ads access behind different permissions), so a brand can hold either without the other and connecting one does not connect the other; if an ads call says Pinterest Ads is not connected, that is the card to send them to, NOT the Pinterest posting one. ADS: pinterest_ads_async_report (the DEEP paid report — 914 days back where the quick one stops at 90, and three times the metric columns; generated asynchronously, so pass the returned token back rather than re-submitting) · pinterest_targeting_analytics (WHICH audience segment delivered — by keyword, interest, age, gender, location, placement) · pinterest_audience_insights (WHO the audience is: interest affinities plus demographics, the input to a creative brief rather than a performance report) · pinterest_analytics (ORGANIC performance — impressions, saves, Pin clicks, outbound clicks, for the account, the TOP PINS, the top video Pins, or one Pin; Pinterest keeps 90 days and publishes no board-level analytics at all) · create_pinterest_board (make a board — a NEW Pinterest account has none and a Pin needs one) · list_pinterest_boards (the user must pick a board — never choose one for them) · post_to_pinterest (create an image or video Pin on a chosen board, with a title, description and destination link) · list_pinterest_pins (the Pins on a board with their ids — where the pinId every Pin tool needs comes from, and it flags any Pin an ad is promoting) · update_pinterest_pin (retitle, re-describe, fix a dead link, move it — Pinterest keeps this endpoint in a limited BETA, so it may be refused outright and save_pinterest_pin is the generally-available way onto another board; a Pin’s picture can never be swapped by anyone) · save_pinterest_pin (copy a Pin onto another board) · delete_pinterest_pin (confirm-gated, and it says whether an ad is promoting the Pin first) · update_pinterest_board (rename, re-describe, or hide it — SECRET hides every Pin on the board, reversibly) · delete_pinterest_board (the heaviest one here: the board AND every Pin on it, confirm-gated with the Pin count echoed back — offer hiding it instead). GOOGLE ADS (full management): list_google_ads_campaigns (list accounts, then a customer’s campaigns + spend/CTR/CPC/conversions) · google_ads_report (any GAQL breakdown — ad groups, keywords, search terms, geo) · create_google_ads_campaign (paused) · set_google_ads_budget / set_google_ads_status (change budget, enable/pause — every spend change confirm-gated) · delete_google_ads_object (remove a campaign, ad group, ad, KEYWORD, asset LINK or conversion action — Google has no delete verb, `remove` is the terminal state and it cannot be undone; call it unconfirmed first to see the spend and the tree that go with it) · upload_google_ads_asset (add an image render or a YouTube video to the ad account’s asset library) · create_google_ads_performance_max_campaign (Google’s cross-surface campaign type, RETAIL INCLUDED — pass merchantCenterId to make it a Shopping-feed Performance Max advertising the WHOLE Merchant Center feed under one root listing group, and feedLabel to narrow it to a single feed; only PARTITIONING that feed by brand/category/custom label is refused by name) · add_google_ads_assets (sitelinks, callouts and structured snippets, CREATED AND ATTACHED — an asset that is not attached shows nothing) · list_google_ads_conversion_actions + create_google_ads_conversion_action (what Google counts as a result — MAXIMIZE_CONVERSIONS, TARGET_CPA, TARGET_ROAS and every Performance Max campaign are undeliverable without one, and Hermoso refuses to build them on an account that has none) · google_ads_keyword_ideas (Keyword Planner — real monthly search volume, competition and top-of-page bids; use it before choosing keywords) · google_ads_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_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 BRAND, 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 brand and any other one is refused by name; an empty list means nothing is ticked yet, which set_connector_accounts or Settings ▸ Connectors ▸ Google Analytics ▸ Manage accounts fixes) · analytics_report (what happened — sessions, users, revenue, conversions and engagement broken down by channel, source/medium, campaign, landing page, country, device or date, i.e. the read that says whether the traffic an ad bought actually did anything) · analytics_realtime (who is on the site right now, ~30 minutes — a DIFFERENT metric set that rejects `sessions` outright, never a shortcut for analytics_report) · list_analytics_definitions (what the property already measures: its key events and its own custom dimensions, and the check to run before creating either) · create_analytics_key_event (mark an event GA4 already collects as a KEY EVENT — the 2024 rename of a conversion, and what makes it importable into Google Ads; marking an event the site never fires creates one that can never fire) · create_analytics_custom_dimension (register an event parameter the site already sends so reports can break down by it — say out loud first that a GA4 custom dimension CANNOT be deleted, only archived, and a property is capped at 50 event-scoped ones, so a typo permanently burns a slot) · list_analytics_data_streams (the streams on a property and the measurement ID (G-...) each one carries, which is what a gtag or GTM install needs and what nobody can find in the GA4 UI when asked) · get_analytics_stream_setup (the finished gtag <script> block to paste into the site — the last mile list_analytics_data_streams stops short of — plus whether enhanced measurement is really collecting scrolls, outbound clicks, site search, video, downloads and form interactions, and whether redaction is stripping campaign parameters out of recorded URLs. Web streams only. Read the master switch before believing a toggle: with enhanced measurement off for the stream, every toggle is inert whatever it says) · list_analytics_metadata (every dimension and metric this property can be asked for, including its own custom ones, which is what stops analytics_report guessing a field name) · check_analytics_compatibility (whether a dimension and metric can appear in the same report before spending a call finding out they cannot) · create_analytics_custom_metric + archive_analytics_custom_metric · archive_analytics_custom_dimension · delete_analytics_key_event (all one-way in the same sense as their create twins: archiving is not deleting and there is no un-archive) · list_analytics_google_ads_links + link_google_ads_to_analytics + unlink_google_ads_from_analytics (the join that makes a GA4 audience usable in Google Ads and a GA4 key event importable as a conversion — without it a perfectly good audience simply never appears in the ads account, with no error anywhere) · list_analytics_audiences + create_analytics_audience + archive_analytics_audience (GA4 remarketing audiences, the input to Google Ads remarketing. Archiving is one-way) · manage_analytics_measurement_protocol_secret (mint the API secret that lets the customer’s OWN SERVER send events straight into GA4, the Google twin of the conversions APIs already here for Reddit, Snapchat and OpenAI Ads. Say out loud that there is NO rotation anywhere in the API, so replacing a secret means create the new one, move every sender across, then delete the old one) · manage_analytics_channel_group (HOW GA4 BUCKETS TRAFFIC — the answer to “why is my campaign showing as Unassigned”, and the one number an ad studio is judged on. Read the Default channel group’s rules before diagnosing anything, then author your own group whose channels catch the campaigns Hermoso publishes. The rule fields are the eachScope… names, NOT the sessionSource / medium dimensions reports use, and GA4 stops at the first rule that matches so order decides everything) · manage_analytics_calculated_metric (the derived number a marketer actually reports — cost per purchase, revenue per session — built from metrics GA4 already collects and then available to analytics_report under its own permanent API name. The id is permanent, and a formula naming a metric the property does not collect is created happily and flagged invalid, so read that flag back). MICROSOFT ADVERTISING / BING ADS (full management, mirroring Google): list_microsoft_ads_campaigns (list the shared ad accounts, then a chosen account’s campaigns + budgets) · microsoft_ads_geo_search (resolve country / region / city names to the Microsoft location ids a campaign needs — call it when an ask is ambiguous and let the USER pick) · microsoft_ads_report (impressions, clicks, CTR, average CPC, spend, conversions — generated asynchronously, so it may come back pending and must be called again) · create_microsoft_ads_campaign (campaign → ad group → responsive search ad → keywords, always Paused; with no locations[] it is created serving WORLDWIDE, Microsoft’s own default, and the read-back warns loudly — relay that before anyone activates it) · create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (fill in an existing account) · set_microsoft_ads_budget / set_microsoft_ads_status (change budget, activate/pause — every spend change confirm-gated; Microsoft statuses are Active/Paused, never Deleted) · delete_microsoft_ads_object (a REAL delete — campaign, ad group, ad or keyword — permanent, with no undelete; call it unconfirmed first to see what goes with it) · microsoft_ads_keyword_ideas (Microsoft’s Keyword Planner — real search volume, competition and suggested bids, with NO planning-tier gate, unlike Google’s) · microsoft_ads_traffic_estimates (what those keywords would deliver at a named bid — a range, never one number) · microsoft_ads_budget_opportunities (where Microsoft says a budget is capping delivery, and what raising it is forecast to buy) · microsoft_ads_auction_insights (who ELSE is bidding on the same auctions — rival domains with their impression share, overlap and outranking share; shares of YOUR auctions, never a measure of a competitor’s whole account) · microsoft_ads_bulk_download (export the account as ONE bulk file — the only way to read ~185 Microsoft record types Hermoso cannot otherwise touch: sitelinks, callouts, structured snippets, labels, shared negative keyword lists, bid strategies, audiences, experiments, seasonality adjustments, conversion goals, asset groups, feeds) · microsoft_ads_bulk_upload (apply an edited bulk file — hundreds of objects in one request. IT IS GATED HARDER THAN ANYTHING ELSE ON THIS CONNECTOR, because a bulk file carries a Status column and can turn campaigns ON without ever touching set_microsoft_ads_status: confirm:true alone is refused, and you must first call it unconfirmed to get the row-by-row list of what it would ACTIVATE and DELETE, show that to the user, then echo both counts back as confirmActivations/confirmDeletions — or pass pauseInstead:true to land the file with every activation written as Paused) · list_microsoft_ads_conversion_goals (what the account counts as a conversion, and which goals are OFFLINE ones) · send_microsoft_ads_offline_conversions (close the loop: phone sales, in-store purchases and late-closing leads fed back so smart bidding stops optimising against website conversions alone — pass PLAIN emails and E.164 phones, hashing happens server-side to Microsoft’s own published spec) · list_microsoft_ads_audiences (the account’s Customer Match lists with their current sizes; a fresh list reads 0 for up to 48 hours and Microsoft will not use one under 300 people, so never call that a failed upload) · create_microsoft_ads_customer_list then apply_microsoft_ads_customer_list (build a Customer Match audience from PLAIN email addresses, normalized and SHA-256 hashed server-side to Microsoft’s own published spec so no plaintext ever leaves us; the user must be shown Microsoft’s Customer Match terms and agree first) · microsoft_ads_recommendations (what Microsoft ITSELF suggests changing, each one priced by Microsoft: budget raises carrying the current and recommended daily amount, new and broadened keywords, negative keywords it wants removed, and ads it has written. Every one INCREASES what the account buys, which is what they are for, so none is a free win and an empty list means Microsoft has no advice rather than that the account is optimal) · apply_microsoft_ads_recommendations (act on them, gated exactly like the bulk upload: confirm:true alone is REFUSED, so call it unconfirmed first to get every recommendation named with what it changes and Microsoft’s own cost estimate, show that to the user, then echo confirmCount and confirmCostIncrease back. Both are recomputed from a fresh read, and there is no undo) · dismiss_microsoft_ads_recommendations (take advice off the list. It cannot spend, so it needs no confirmation at all, and it is the right answer to “make it stop suggesting that” rather than applying something to clear it) · microsoft_ads_auto_apply (THE READ THAT ANSWERS “is Microsoft changing this account while nobody is looking?”, per type. An inherited account can already be opted in with nobody at the brand having done it) · set_microsoft_ads_auto_apply (turn that standing permission on or off. Switching any type ON is the strongest consent anywhere in Hermoso: Microsoft then writes and publishes its own ads under the brand’s name, deletes negative keywords so the account buys more searches, and changes conversion goals, unattended and indefinitely, with NOTHING to preview beforehand. So confirm:true is not enough and every type must be named in confirmTypes. Switching it OFF is never gated). GOOGLE BUSINESS PROFILE (the local-SEO channel — the listing panel on Google Search and Maps, which for a local business is where the demand actually is, and there is no delete): list_business_locations (the listings the connected Google account manages — call this first and let the USER pick when there is more than one; a Post on the wrong storefront is a public mistake) · post_to_google_business (publish a Post to the listing — text, ONE PHOTO and a call-to-action button; Google’s Posts API takes no video, so pass a still. EVENT and OFFER posts both require a title and a start date, and on an OFFER Google ignores the button link) · list_google_business_posts (what is showing right now, with each Post’s state) · delete_google_business_post (take one down — immediate and public, so confirm first) · list_google_business_reviews (the reviews on the listing, and which ones have NO reply yet — for a local business the highest-leverage surface there is) · reply_to_google_business_review (answer one publicly as the business; it is an UPSERT, so it replaces any existing reply) · list_google_business_questions + answer_google_business_question (the public Q&A on the listing) · google_business_search_keywords (the actual search terms people typed to find the listing — free local keyword data; low-volume terms are SUPPRESSED and come back as "fewer than N", never as zero) · google_business_insights (Search + Maps impressions, calls, website clicks, direction requests, messages, bookings — listing-level; Google discontinued per-Post insights in 2023 with no replacement, so never promise per-Post numbers) · get_business_location (everything the listing actually says — name, address, phone, website, categories, description, hours, service area — as the merchant set it; the answer to “what does our Google listing say?”) · update_business_location (change any of that — hours, phone, website, description, categories, even the name or address. It edits the live panel on Search and Maps with no draft and no undo, so call it WITHOUT confirm first: nothing is written, Google validates the payload, and you get the current value of every field you are about to change to show the user) · google_business_account (whose Business Profile account the listing is on, and whether the connected Google account’s role can edit it at all). Google gates this API behind a per-project access request and the default quota is zero, so the connection can be live and calls still refused — the error says so. CHATGPT ADS (ads under ChatGPT answers, via OpenAI’s Advertiser API — full management): list_openai_ads_campaigns (the ad account, then its campaigns, ad groups and ads with each ad’s review state) · openai_ads_report (impressions, clicks, spend, CTR, CPC, CPM at account / campaign / ad group / ad scope — run this first, it validates the key with zero spend risk) · openai_ads_geo_search (location ids) · list_openai_ads_audiences + create_openai_ads_audience (custom audiences — geo and these are the only list-based targeting this platform has; target them with customAudienceIds / excludedCustomAudienceIds on a campaign) · create_openai_ads_campaign (campaign → ad group → ad in one call, always PAUSED) · create_openai_ads_ad_group / create_openai_ads_ad (fill in an existing campaign) · update_openai_ads_object (rename, re-budget, rewrite context hints or the ad copy) · set_openai_ads_budget / set_openai_ads_status (change budget, activate, pause) · delete_openai_ads_object (ARCHIVE — this API has no delete and OpenAI say archiving is not reversible, so offer pausing first). TWO RULES THIS CHANNEL DOES NOT SHARE WITH THE OTHERS: it is connected by PASTING an Advertiser API key (no OAuth, no manager account, one key = one ad account), and it has exactly ONE creative format — a text plus image card, title 50 characters, body 100. There is NO VIDEO on ChatGPT Ads, so never offer a video ad here. GOOGLE DRIVE — ONE connection covering Drive, Sheets and Docs (full CRUD over the files Hermoso created there, plus any file the user hands over with the Google file picker in the app): save_to_drive · list_drive_files / get_drive_file · update_drive_file (rename/move/trash) · delete_drive_file · create_drive_folder. GOOGLE SHEETS (part of the Google Drive connection — export data to a spreadsheet the app creates, or read one the user picked; drive.file, no verification): create_sheet · append_to_sheet · read_sheet. GOOGLE DOCS (part of the Google Drive connection — export copy/brief/report as a doc, or read one the user picked; drive.file, no verification): create_doc · append_to_doc. GOOGLE SLIDES (part of the Google Drive connection — turn a swipefile collection into a real presentation, one slide per saved ad with the creative, brand, copy, run dates and platform; drive.file, no verification, no new scope): export_swipefile_deck — it CREATES a deck each time and cannot append to one the user already has, and a creative whose ad-library link has expired is reported rather than silently dropped. ONEDRIVE (full CRUD over the user’s Microsoft OneDrive): convert_onedrive_file (Microsoft converts a file server-side to PDF or JPG — ~130 formats including PowerPoint and Word decks, PSD, Illustrator, Sketch, 3D, video, iPhone HEIC and raw camera files; JPG needs both width and height) · save_to_onedrive · list_onedrive_files / get_onedrive_file · update_onedrive_file (rename/move) · delete_onedrive_file · create_onedrive_folder. Use these standalone — Hermoso is a full posting/ads/file-storage control surface, not only an ad generator.',
192
+ '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 brand 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 (revoke and drop a connection; confirm-gated because reconnecting a sign-in account needs a browser) · leave_connector (on a connector several teammates can each contribute their OWN account to, remove just YOURS — teammates’ accounts keep working and nothing is revoked at the provider). LINKING an account that connects through a provider sign-in screen (OAuth) is the one step that is not headless: hand the user its connect link, https://app.hermoso.ai/?connect=<provider>, or send them to Workspace ▸ Connectors in the app. META: list_meta_pages · instagram_insights (ACCOUNT-level Instagram performance — views, reach, accounts engaged, interactions, saves, profile link taps — plus the audience DEMOGRAPHICS by age / city / country / gender) · list_instagram_media (the brand’s own recent Instagram posts, and where the media id every other Instagram tool needs comes from) · search_instagram_audio (licensed music and original sounds an Instagram Reel may use, by keyword or trending) · list_instagram_collab_invites then respond_instagram_collab_invite (collab-post invitations waiting on the account; accept or decline one, read back from Instagram) · list_instagram_collab_media (posts this account co-authors) · like_instagram (like a post or comment as the connected account) · post_to_meta (Facebook / Instagram / Threads) · list_meta_posts (the Page’s / Instagram account’s OWN existing posts with their ids — THIS is where the postId every other Meta read needs comes from; without it an agent that did not itself just publish has no way to name a post) · list_meta_ads + meta_insights (read existing campaigns/ad sets/ads + spend/CTR/CPC, with breakdowns by age / gender / placement / country) · preview_meta_ad (Meta renders the REAL ad per placement — a link the user can look at, valid 24h) · list_meta_pixels + create_meta_pixel (the pixel a conversion-optimised campaign REQUIRES — Meta will not let a build optimise for conversions without one, and until these existed a caller had no way to discover the id they had to pass) · estimate_meta_reach (how many people a targeting spec reaches, BEFORE a budget is committed) · list_meta_audiences / create_meta_audience (website-pixel retargeting, Page + Instagram engagement audiences, and lookalikes — creating one spends nothing) · list_meta_conversations / read_meta_conversation / reply_to_meta_message (MESSENGER AND INSTAGRAM DMs — the brand’s direct-message threads and a reply to someone who wrote first. Meta only permits a reply within 24 HOURS of the person acting, and read_meta_conversation says whether that window is open BEFORE anything is drafted; Hermoso sends replies only, never a proactive message or a message tag) · subscribe_meta_webhooks / meta_webhook_status / unsubscribe_meta_webhooks / list_meta_webhook_events (REAL-TIME EVENTS — have Meta PUSH new comments, mentions, lead-form submissions and inbound DMs to Hermoso instead of polling for them. Every other inbox read asks an edge “anything new?”; this is the only way to be TOLD, and it is how a lead arrives the moment it is submitted rather than when somebody thinks to look. An empty feed is ambiguous — check meta_webhook_status first, because an unsubscribed Page is silent and looks exactly like a quiet one) · instagram_collaborators (who ACCEPTED a Collab invite on an Instagram post — publishing only SENDS the invite, so this is the only way to know whether the post is actually live on the other account too) · list_instagram_shopping_catalogs / search_instagram_shopping_products / manage_instagram_product_tags (INSTAGRAM SHOPPING — make a post SHOPPABLE. Check eligibility and the account’s taggable catalogs, find the product ids, then pass productTags to post_to_meta so tapping the picture opens the product’s price sheet inside Instagram. Tagging needs an APPROVED Instagram Shop, so check FIRST — otherwise it fails after the media is already uploaded — and note that a tag whose product is not “approved” is stored and shown to nobody. Meta publishes no way to REMOVE a tag) · create_meta_catalog / update_meta_catalog / meta_catalog_blast_radius / delete_meta_catalog (BUILD AND RETIRE A CATALOG — create one on a named business portfolio, rename or re-point it, and, before ever proposing a delete, read meta_catalog_blast_radius: a catalog delete is PERMANENT with no archive and no undo, its product sets go with it, and any ad set still bound to one keeps spending with nothing to show) · list_meta_partnership_creators / manage_meta_partnership_creator (PARTNERSHIP ADS — the creators whose content this brand 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) · 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 — defaults to UNLISTED, i.e. link-only and ad-ready; set public to put it on the channel, or private for eyes-only) · 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 — the step that finishes the default publish flow; 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 + 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_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 brand is in, the TikTok business id, the scopes the grant carries and any MISSING from it — TikTok binds scopes at authorize time and never retroactively, so only a re-authorization picks up a new one — plus the exact URL to send the user to, because authorizing is the one step that needs a browser) · list_tiktok_comments + list_tiktok_comment_replies (the comments on the brand’s OWN posts, hidden ones included — TikTok’s answer to list_meta_comments and list_youtube_comments) · comment_on_tiktok_video · reply_to_tiktok_comment · moderate_tiktok_comment (LIKE / UNLIKE / HIDE / UNHIDE / DELETE — you can only DELETE a comment this account wrote, so HIDE is the tool for a stranger’s, and TikTok warns UNHIDE may not take effect when its own moderation is what hid it) · upload_tiktok_comment_image (a new comment will not take a raw image URL; a reply will) · set_tiktok_post_ad_authorization (THIS IS WHERE A SPARK ADS AUTHORIZATION CODE COMES FROM for the brand’s OWN post — previously a human had to copy one out of the TikTok app; hand the code to authorize_tiktok_ads_spark_post) · get_tiktok_post_ad_authorization · extend_tiktok_post_ad_authorization (the days are ADDED to what is left, not set as an absolute) · delete_tiktok_post_ad_authorization. BRAND MONITORING AND AUDIENCE, on that same account authorization (these need permissions added on 2026-08-20, so a brand that authorized before then holds a grant that predates them and has to authorize once more; tiktok_account_status names exactly which are missing, and the remedy is always to authorize the TikTok ACCOUNT again rather than to touch the advertiser connection, which is a separate grant and is unaffected): list_tiktok_mentions (public posts whose caption @-mentions the brand, TikTok’s answer to x_mentions and list_threads_mentions) · list_tiktok_mention_comments (comments whose text mentions it) · get_tiktok_mention (one mention in full, for the mentions webhook, and TikTok only keeps that data 48 hours) · tiktok_mention_top_terms (the top 20 keywords and top 20 hashtags inside those mentions) · list_tiktok_brand_hashtags + manage_tiktok_brand_hashtags + list_tiktok_brand_hashtag_posts (the hashtags TikTok counts as this brand’s, up to 50, and the posts carrying them; a new one is not counted for 24 hours and cannot be removed for 7 days) · tiktok_account_insights (follower demographics by age, gender, country and city plus the daily performance series, needing a BUSINESS account with 100+ followers, and capped at 60 days rather than the 90 the mention tools cover) · tiktok_category_benchmark (the same numbers averaged across an industry, so ‘are we ahead of our category’ is answerable). ALL OF THIS IS ORGANIC LISTENING ON THE BRAND’S OWN ACCOUNT, not ad research: for competitors’ ads use the ad-library research tools instead. TIKTOK ADS (a SEPARATE connection from the TikTok posting connector above — Settings ▸ Connectors ▸ TikTok Ads; a brand that posts to TikTok every day may still have no ad account here, so never read one as the other): list_tiktok_ads_accounts (the ADVERTISER accounts this brand 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_creative (THE STEP THAT TURNS A RENDER INTO AN AD — put a finished Hermoso video on the ad account and it hands back the videoId AND the coverImageId create_tiktok_ads_ad needs; there is no other source for either) · create_tiktok_ads_campaign → create_tiktok_ads_ad_group → create_tiktok_ads_ad (the tree) · set_tiktok_ads_budget · set_tiktok_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_tiktok_ads_object (removal on TikTok is a STATUS, not a verb — the same route as set_tiktok_ads_status) · create_tiktok_smart_campaign → create_tiktok_smart_ad_group → create_tiktok_smart_ad (Smart+, TikTok’s Performance Max — born PAUSED) · list_tiktok_smart_campaigns · set_tiktok_smart_status (the Smart+ money switch, confirm-gated) · tiktok_bid_protection (the ad-credit compensation TikTok pays when a Smart+ object misses its bid) · list_tiktok_ads_lead_forms + list_tiktok_ads_lead_fields + download_tiktok_ads_leads + manage_tiktok_ads_test_lead (LEAD ADS — an Instant Form is built in TikTok Ads Manager and NO API creates one, so list them to find the id a LEAD_GENERATION ad group needs. The lead REGION is required with no default: it selects which of three separate lead stores you read, and leaving it out is a THIRD value rather than “all”, so an advertiser who omits it downloads an empty file and wrongly concludes there are no leads) · list_tiktok_ads_audiences + create_tiktok_ads_audience + create_tiktok_ads_lookalike_audience + apply_tiktok_ads_audience + update_tiktok_ads_audience + delete_tiktok_ads_audience + tiktok_ads_audience_overlap (CUSTOM AUDIENCES and lookalikes — TikTok targeting is otherwise interests-and-geo only. A freshly created audience reports itself invalid for up to 48 hours BY DESIGN, so that is not a failure to retry) · list_tiktok_ads_business_centers + list_tiktok_ads_catalogs + create_tiktok_ads_catalog + list_tiktok_ads_catalog_products + list_tiktok_ads_catalog_sets + manage_tiktok_ads_catalog_feed + tiktok_ads_catalog_diagnostics (DPA / PRODUCT CATALOGS, the Shopify lane — a catalog is keyed on a BUSINESS CENTER id, NOT an advertiser id, so list the Business Centers first or every call refuses) · list_tiktok_ads_apps + list_tiktok_ads_app_events (the registered apps an APP_INSTALL campaign needs — nothing else can produce an app id) · tiktok_ads_rf_inventory_estimate + create_tiktok_ads_rf_ad_group (REACH & FREQUENCY — a RESERVATION, so it is confirm-gated like a status change rather than born paused, and it needs a per-ad-account allowlist plus a signed branding contract that no endpoint reports. Always price it with the estimate first: TikTok silently books its own maximum rather than refusing an out-of-range value) · send_tiktok_ads_events (SERVER-SIDE conversion events — there is a vendor-sanctioned test code for exercising it without entering the advertiser’s real reporting), and its offline/crm sources take the event-set ids the two tools below mint) · list_tiktok_ads_offline_event_sets + manage_tiktok_ads_offline_event_set + send_tiktok_ads_offline_events (REAL-WORLD CONVERSIONS — an in-store purchase, a phone booking, a signed contract, reported so TikTok can attribute them to the ads that caused them. The timestamp is an ISO-8601 STRING here and a Unix NUMBER on send_tiktok_ads_events; a wrong-shaped one is accepted by TikTok and attributed to nothing. There is NO test code on this pair, so everything sent is a real permanent conversion — rehearse through send_tiktok_ads_events with eventSource “offline” and a testEventCode instead. Reporting also needs the connected user to be an ADMIN or OPERATOR of the advertiser, which managing the event SETS does not) · list_tiktok_ads_crm_event_sets + create_tiktok_ads_crm_event_set (LEAD-LIFECYCLE events — sending “this lead qualified / closed” back is what makes a LEAD_GENERATION campaign optimise toward leads that convert rather than form fills. TikTok publishes create and list and nothing else, so one of these is PERMANENT) · list_tiktok_tto_accounts + list_tiktok_creator_labels + discover_tiktok_creators + tiktok_creator_leaderboard + check_tiktok_creator_status + list_tiktok_tto_brand_profiles + create_tiktok_tto_brand_profile + list_tiktok_tto_campaigns + create_tiktok_tto_campaign + update_tiktok_tto_campaign + link_tiktok_tto_video + list_tiktok_tto_link_requests + tiktok_tto_campaign_report + request_tiktok_tto_spark_authorization + get_tiktok_tto_spark_authorization + manage_tiktok_tto_anchor (TIKTOK ONE / CREATOR MARKETPLACE: INFLUENCER MARKETING, and the only place in Hermoso that does it: find creators by audience size, engagement, price and who their followers actually are, check whether they have joined TikTok One, invite them to a campaign with an invite link, ask them to tag a video to it, and read every metric SPLIT ORGANIC VERSUS PAID. Its account id is a THIRD id space; not an advertiser id and not a Business Center id; so start at list_tiktok_tto_accounts. It rides this same connection with nothing extra to apply for. IT ALSO CLOSES THE SPARK ADS LOOP: request_tiktok_tto_spark_authorization asks a creator directly and get_tiktok_tto_spark_authorization returns the code authorize_tiktok_ads_spark_post takes, which is otherwise obtainable only by the creator pasting one out of the TikTok app. Two things put a notification in a real person’s inbox; a campaign invitation and a video-linking request; and a repeated linking request is a REMINDER that TikTok caps at two, so read list_tiktok_tto_link_requests before re-sending anything) · list_tiktok_ads_stores + list_tiktok_ads_store_products (TIKTOK SHOPS: what a Shopping Ads or GMV Max campaign sells from; the store list is keyed on an ad account and the product list on a BUSINESS CENTER, which each store row names) · tiktok_ads_verification_status + list_tiktok_ads_verification_documents + submit_tiktok_ads_verification (BUSINESS VERIFICATION: an unverified account hits limits that get diagnosed as something else, so it is worth reading during onboarding. Hermoso never handles a verification DOCUMENT: submitting sends account details plus the ids of images the user uploaded in TikTok Ads Manager, and the legal name and document number can never be changed afterwards, so it is confirm-gated) · list_tiktok_ads_payment_portfolios + list_tiktok_ads_payment_portfolio_links (HOW THE AD ACCOUNTS ARE FUNDED: read-only, because "why did delivery stop" is often a funding answer, and because deciding where a customer’s money sits is not ours to do) · create_tiktok_ads_rule + list_tiktok_ads_rules + update_tiktok_ads_rule + bind_tiktok_ads_rule + set_tiktok_ads_rule_status + tiktok_ads_rule_results (AUTOMATED RULES — standing instructions TikTok runs on the account unattended. THE SECOND SPEND SWITCH ON THIS PLATFORM and gated in TWO CLASSES: a rule that can only pause, decrease or email needs confirm:true, while one that can TURN_ON an object or RAISE a budget or bid needs confirm:true AND confirmScope echoing the token list_tiktok_ads_rules prints, computed from the rule as TikTok STORES it. Every rule is created TURNED OFF and read back to prove it, because TikTok publishes no way to create one in the off position. TikTok emails rule notifications to the DEVELOPER address on the app rather than to the advertiser, so tiktok_ads_rule_results is the only place a customer sees what a rule did — and TikTok itself says this endpoint is for direct advertisers and may refuse a platform-managed account entirely). · list_tiktok_ads_comments + tiktok_ads_comment_thread + moderate_tiktok_ads_comment + reply_to_tiktok_ads_comment + delete_tiktok_ads_comment (COMMENT MODERATION on your own TikTok ads — the platform where the comment section IS the ad, and until now the one platform Hermoso could not moderate. HIDE is the moderation verb and works on anyone’s comment and is reversible; DELETE only ever removes a comment your OWN identity posted, which TikTok reports per comment as canDelete. Comments are scoped to an AD GROUP and to nothing else, and the time window may span at most 30 DAYS, so an empty answer means “none in these 30 days” rather than “none ever”) · list_tiktok_ads_blocked_words + manage_tiktok_ads_blocked_words (a standing 500-word filter that auto-hides any comment containing one of these across EVERY ad on the account — nothing else in Hermoso does this, and removing a word republishes every comment it had hidden) · tiktok_ads_diagnosis (TikTok’s own issues-and-suggestions verdict on your ad groups — creative, bid/budget with its full estimated-delivery tables, and a pixel that has gone quiet. It covers ACTIVE ad groups only and omits any it has nothing to say about, so an empty answer is not a clean bill of health) · get_tiktok_ads_brand_safety + set_tiktok_ads_brand_safety (what content the ads may appear next to. Two things to say out loud: TikTok applies this to Smart+ campaigns and explicitly NOT to the regular campaigns create_tiktok_ads_campaign builds, and coverAllObjectives is a ONE-WAY DOOR TikTok cannot set back). TWO THINGS HERE ARE UNLIKE EVERY OTHER AD PLATFORM: TikTok creates objects ENABLED by default, so Hermoso forces every campaign, ad group and ad PAUSED with no override and nothing serves until set_tiktok_ads_status(confirm:true); and TikTok’s QPS is 1, so every call is serialized and a tree build or a bulk read is SLOW BY DESIGN — a throttle is not a broken connection. SNAPCHAT ADS (the tenth ad platform — Settings ▸ Connectors ▸ Snapchat Ads; a SEPARATE connection from Snapchat posting): list_snapchat_ads_accounts (the organizations and AD ACCOUNTS this brand can act on — every other Snapchat tool needs an adAccountId and this is where it comes from) · list_snapchat_ads_campaigns (the whole tree — campaigns, ad squads and ads) · snapchat_ads_report (impressions, spend, swipes and video quartiles at any level) · search_snapchat_ads_targeting (resolve country / region / interest / language ids — an ad squad cannot be created without at least one country) · upload_snapchat_ads_creative (put a finished render on the ad account as MEDIA and then as the CREATIVE an ad points at — Snapchat has no upload-from-URL, so Hermoso streams the bytes) · create_snapchat_ads_campaign → create_snapchat_ads_ad_squad → create_snapchat_ads_ad (the tree, every tier born PAUSED) · set_snapchat_ads_budget · set_snapchat_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_snapchat_ads_object (a REAL delete verb here, unlike TikTok — irreversible, so offer PAUSED first). THREE THINGS TO SAY OUT LOUD ON THIS PLATFORM: money is MICRO-CURRENCY (1,000,000 = one unit), so quote plain amounts and let Hermoso convert, and never pass both units — under-converting fails loudly while double-converting asks for a budget a million times too large; the creative HEADLINE is capped at 34 characters and brandName at 32, far shorter than Meta or Google, and over-long copy is refused rather than truncated; and a Snapchat ad points at a CREATIVE, never at a media id. SNAPCHAT POSTING (Stories / Spotlights on a Public Profile) IS BUILT BUT NOT YET REACHABLE — Snap’s Public Profile API is allowlist-only and Hermoso has not been allowlisted, so the connector is deliberately not offered; say that plainly rather than looking for a tool. LINKEDIN: post_to_linkedin (publish a finished post to the connected LinkedIn PROFILE) · list_linkedin_pages (the company Pages this connection administers — call this first and let the USER pick, never guess a Page) · post_to_linkedin_page (publish as a company PAGE rather than a person — this is the one most brands actually want) · manage_linkedin_post (edit the copy of a published post, or delete it) \u00b7 list_linkedin_comments / reply_to_linkedin_comment / delete_linkedin_comment (moderate the comments on your Page\u2019s posts \u2014 a SEPARATE LinkedIn authorization grants these, see Connectors) · linkedin_page_analytics (ORGANIC Page performance — followers, follower gains, Page views, and post impressions/clicks/engagement, for the Page total or per post; this is the free organic read, NOT linkedin_ads_report). LINKEDIN ADS (full three-tier management): list_linkedin_ads_campaigns (ad accounts, then a chosen account’s campaign groups, campaigns and — with campaignId — the CREATIVES under them) · linkedin_ads_report (impressions, clicks, cost, conversions, leads) · search_linkedin_ads_targeting (resolve locations / titles / industries / seniorities / company sizes to the URNs LinkedIn demands — never invent one) · linkedin_audience_count (HOW MANY members that targeting actually reaches, before a budget is committed — and a returned 0 means fewer than 300 people, LinkedIn’s privacy floor and also its campaign minimum, never an empty audience) · linkedin_bid_pricing (LinkedIn’s own suggested bid and daily-budget range for that audience — quote it instead of guessing what LinkedIn costs) · create_linkedin_ads_campaign_group → create_linkedin_ads_campaign → create_linkedin_ads_creative (the tree, every tier born DRAFT) · set_linkedin_ads_budget / set_linkedin_ads_status / delete_linkedin_ads_object (budgets, activate/pause at any tier, delete — every spend change confirm-gated). LINKEDIN LEAD SYNC: list_linkedin_lead_forms · list_linkedin_leads / get_linkedin_lead (the LEADS its forms collected, answers named by field — PERSONAL DATA: show, never republish) · subscribe_linkedin_leads / list_linkedin_lead_events / list_linkedin_lead_subscriptions / delete_linkedin_lead_subscription (real-time push to Hermoso, optional forwardTo relay to a CRM; LinkedIn validates ONLY Hermoso’s own webhook). LinkedIn is a THREE-tier platform and the third tier is the one people forget: a campaign with no creative shows nothing, and all three tiers must be ACTIVE before a single impression is served. REDDIT ADS: list_reddit_ads_campaigns / reddit_ads_report (read the account tree + performance) · list_reddit_ads_profiles + list_reddit_ads_posts / create_reddit_ads_post / update_reddit_ads_post (the CREATIVE — a Reddit ad promotes a post) · create_reddit_ads_campaign / update_reddit_ads_campaign · create_reddit_ads_ad_group / update_reddit_ads_ad_group · create_reddit_ads_ad / update_reddit_ads_ad · set_reddit_ads_status (the ONLY switch that arms real spend, confirm-gated) · delete_reddit_ads_object (remove a campaign, ad group or ad — Reddit has no delete verb, removal is a status, and it refuses to delete anything touched in the last 3 hours) · delete_reddit_ads_saved_audience · search_reddit_ads_targeting / reddit_ads_forecast / reddit_ads_bid_suggestion (free planning) · list_reddit_ads_pixels + send_reddit_ads_conversions (conversion tracking — Reddit now requires a pixel on every ad group) · list_reddit_ads_audiences / create_reddit_ads_audience / update_reddit_ads_audience_users / delete_reddit_ads_audience (retargeting lists) · list_reddit_ads_saved_audiences / create_reddit_ads_saved_audience / update_reddit_ads_saved_audience · list_reddit_ads_lead_forms / create_reddit_ads_lead_form · reddit_ads_history (who changed what, when). TELEGRAM: post_to_telegram (publish to a channel, group or chat as the brand’s own bot — text up to 4096 characters, but only 1024 once any photo or video is attached; one image, one video, or an album of 2–10 in which photos and videos may be mixed. chatId IS ALWAYS REQUIRED and is never guessed: the Bot API publishes NO method that lists the chats a bot belongs to, so pass the public channel’s @username or the numeric id) · list_telegram_chats (chats that MESSAGED the bot in the last 24 hours — a shortcut for finding an id, NOT a roster, and a chat missing from it can still be posted to) · list_telegram_dms (what those chats actually SAID, newest per chat — free, and a rolling 24-hour window rather than an inbox: the Bot API has no history endpoint at all) · delete_telegram_message (confirm-gated; Telegram refuses once a message is more than 48 hours old). BLUESKY: post_to_bluesky (publish as the connected account — text up to 300 characters AND, separately, 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; either up to 4 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 brand 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 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 brand can act on, WITH the permission level held on each — read it before attempting a write) · list_x_ads_funding_instruments (a campaign cannot be created without one) · list_x_ads_campaigns / list_x_ads_line_items / list_x_ads_promoted_tweets / list_x_ads_targeting (the whole tree as it stands) · x_ads_report (impressions, clicks, spend and engagements at any level) · x_ads_geo_search / x_ads_targeting_search (resolve places and targeting values to the ids X demands — never invent one) · create_x_ads_campaign → create_x_ads_line_item → create_x_ads_promoted_tweet (the tree, every tier born PAUSED with no override; A CAMPAIGN ALONE CANNOT SERVE ON X — it needs a line item and a promoted post underneath it, and the read-back says so rather than letting you call it a finished ad) · add_x_ads_targeting · update_x_ads_campaign / update_x_ads_line_item (throttle or raise spend on a running campaign without rebuilding it) · set_x_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_x_ads_object. PINTEREST — POSTING AND ADS ARE TWO SEPARATE CONNECTIONS on the same Pinterest login (Pinterest keeps ads access behind different permissions), so a brand can hold either without the other and connecting one does not connect the other; if an ads call says Pinterest Ads is not connected, that is the card to send them to, NOT the Pinterest posting one. ADS: pinterest_ads_async_report (the DEEP paid report — 914 days back where the quick one stops at 90, and three times the metric columns; generated asynchronously, so pass the returned token back rather than re-submitting) · pinterest_targeting_analytics (WHICH audience segment delivered — by keyword, interest, age, gender, location, placement) · pinterest_audience_insights (WHO the audience is: interest affinities plus demographics, the input to a creative brief rather than a performance report) · pinterest_analytics (ORGANIC performance — impressions, saves, Pin clicks, outbound clicks, for the account, the TOP PINS, the top video Pins, or one Pin; Pinterest keeps 90 days and publishes no board-level analytics at all) · create_pinterest_board (make a board — a NEW Pinterest account has none and a Pin needs one) · list_pinterest_boards (the user must pick a board — never choose one for them) · post_to_pinterest (create an image or video Pin on a chosen board, with a title, description and destination link) · list_pinterest_pins (the Pins on a board with their ids — where the pinId every Pin tool needs comes from, and it flags any Pin an ad is promoting) · update_pinterest_pin (retitle, re-describe, fix a dead link, move it — Pinterest keeps this endpoint in a limited BETA, so it may be refused outright and save_pinterest_pin is the generally-available way onto another board; a Pin’s picture can never be swapped by anyone) · save_pinterest_pin (copy a Pin onto another board) · delete_pinterest_pin (confirm-gated, and it says whether an ad is promoting the Pin first) · update_pinterest_board (rename, re-describe, or hide it — SECRET hides every Pin on the board, reversibly) · delete_pinterest_board (the heaviest one here: the board AND every Pin on it, confirm-gated with the Pin count echoed back — offer hiding it instead). GOOGLE ADS (full management): list_google_ads_campaigns (list accounts, then a customer’s campaigns + spend/CTR/CPC/conversions) · google_ads_report (any GAQL breakdown — ad groups, keywords, search terms, geo) · create_google_ads_campaign (paused) · set_google_ads_budget / set_google_ads_status (change budget, enable/pause — every spend change confirm-gated) · delete_google_ads_object (remove a campaign, ad group, ad, KEYWORD, asset LINK or conversion action — Google has no delete verb, `remove` is the terminal state and it cannot be undone; call it unconfirmed first to see the spend and the tree that go with it) · upload_google_ads_asset (add an image render or a YouTube video to the ad account’s asset library) · create_google_ads_performance_max_campaign (Google’s cross-surface campaign type, RETAIL INCLUDED — pass merchantCenterId to make it a Shopping-feed Performance Max advertising the WHOLE Merchant Center feed under one root listing group, and feedLabel to narrow it to a single feed; only PARTITIONING that feed by brand/category/custom label is refused by name) · add_google_ads_assets (sitelinks, callouts and structured snippets, CREATED AND ATTACHED — an asset that is not attached shows nothing) · list_google_ads_conversion_actions + create_google_ads_conversion_action (what Google counts as a result — MAXIMIZE_CONVERSIONS, TARGET_CPA, TARGET_ROAS and every Performance Max campaign are undeliverable without one, and Hermoso refuses to build them on an account that has none) · google_ads_keyword_ideas (Keyword Planner — real monthly search volume, competition and top-of-page bids; use it before choosing keywords) · google_ads_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_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 BRAND, 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 brand and any other one is refused by name; an empty list means nothing is ticked yet, which set_connector_accounts or Settings ▸ Connectors ▸ Google Analytics ▸ Manage accounts fixes) · analytics_report (what happened — sessions, users, revenue, conversions and engagement broken down by channel, source/medium, campaign, landing page, country, device or date, i.e. the read that says whether the traffic an ad bought actually did anything) · analytics_realtime (who is on the site right now, ~30 minutes — a DIFFERENT metric set that rejects `sessions` outright, never a shortcut for analytics_report) · list_analytics_definitions (what the property already measures: its key events and its own custom dimensions, and the check to run before creating either) · create_analytics_key_event (mark an event GA4 already collects as a KEY EVENT — the 2024 rename of a conversion, and what makes it importable into Google Ads; marking an event the site never fires creates one that can never fire) · create_analytics_custom_dimension (register an event parameter the site already sends so reports can break down by it — say out loud first that a GA4 custom dimension CANNOT be deleted, only archived, and a property is capped at 50 event-scoped ones, so a typo permanently burns a slot) · list_analytics_data_streams (the streams on a property and the measurement ID (G-...) each one carries, which is what a gtag or GTM install needs and what nobody can find in the GA4 UI when asked) · get_analytics_stream_setup (the finished gtag <script> block to paste into the site — the last mile list_analytics_data_streams stops short of — plus whether enhanced measurement is really collecting scrolls, outbound clicks, site search, video, downloads and form interactions, and whether redaction is stripping campaign parameters out of recorded URLs. Web streams only. Read the master switch before believing a toggle: with enhanced measurement off for the stream, every toggle is inert whatever it says) · list_analytics_metadata (every dimension and metric this property can be asked for, including its own custom ones, which is what stops analytics_report guessing a field name) · check_analytics_compatibility (whether a dimension and metric can appear in the same report before spending a call finding out they cannot) · create_analytics_custom_metric + archive_analytics_custom_metric · archive_analytics_custom_dimension · delete_analytics_key_event (all one-way in the same sense as their create twins: archiving is not deleting and there is no un-archive) · list_analytics_google_ads_links + link_google_ads_to_analytics + unlink_google_ads_from_analytics (the join that makes a GA4 audience usable in Google Ads and a GA4 key event importable as a conversion — without it a perfectly good audience simply never appears in the ads account, with no error anywhere) · list_analytics_audiences + create_analytics_audience + archive_analytics_audience (GA4 remarketing audiences, the input to Google Ads remarketing. Archiving is one-way) · manage_analytics_measurement_protocol_secret (mint the API secret that lets the customer’s OWN SERVER send events straight into GA4, the Google twin of the conversions APIs already here for Reddit, Snapchat and OpenAI Ads. Say out loud that there is NO rotation anywhere in the API, so replacing a secret means create the new one, move every sender across, then delete the old one) · manage_analytics_channel_group (HOW GA4 BUCKETS TRAFFIC — the answer to “why is my campaign showing as Unassigned”, and the one number an ad studio is judged on. Read the Default channel group’s rules before diagnosing anything, then author your own group whose channels catch the campaigns Hermoso publishes. The rule fields are the eachScope… names, NOT the sessionSource / medium dimensions reports use, and GA4 stops at the first rule that matches so order decides everything) · manage_analytics_calculated_metric (the derived number a marketer actually reports — cost per purchase, revenue per session — built from metrics GA4 already collects and then available to analytics_report under its own permanent API name. The id is permanent, and a formula naming a metric the property does not collect is created happily and flagged invalid, so read that flag back). MICROSOFT ADVERTISING / BING ADS (full management, mirroring Google): list_microsoft_ads_campaigns (list the shared ad accounts, then a chosen account’s campaigns + budgets) · microsoft_ads_geo_search (resolve country / region / city names to the Microsoft location ids a campaign needs — call it when an ask is ambiguous and let the USER pick) · microsoft_ads_report (impressions, clicks, CTR, average CPC, spend, conversions — generated asynchronously, so it may come back pending and must be called again) · create_microsoft_ads_campaign (campaign → ad group → responsive search ad → keywords, always Paused; with no locations[] it is created serving WORLDWIDE, Microsoft’s own default, and the read-back warns loudly — relay that before anyone activates it) · create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (fill in an existing account) · set_microsoft_ads_budget / set_microsoft_ads_status (change budget, activate/pause — every spend change confirm-gated; Microsoft statuses are Active/Paused, never Deleted) · delete_microsoft_ads_object (a REAL delete — campaign, ad group, ad or keyword — permanent, with no undelete; call it unconfirmed first to see what goes with it) · microsoft_ads_keyword_ideas (Microsoft’s Keyword Planner — real search volume, competition and suggested bids, with NO planning-tier gate, unlike Google’s) · microsoft_ads_traffic_estimates (what those keywords would deliver at a named bid — a range, never one number) · microsoft_ads_budget_opportunities (where Microsoft says a budget is capping delivery, and what raising it is forecast to buy) · microsoft_ads_auction_insights (who ELSE is bidding on the same auctions — rival domains with their impression share, overlap and outranking share; shares of YOUR auctions, never a measure of a competitor’s whole account) · microsoft_ads_bulk_download (export the account as ONE bulk file — the only way to read ~185 Microsoft record types Hermoso cannot otherwise touch: sitelinks, callouts, structured snippets, labels, shared negative keyword lists, bid strategies, audiences, experiments, seasonality adjustments, conversion goals, asset groups, feeds) · microsoft_ads_bulk_upload (apply an edited bulk file — hundreds of objects in one request. IT IS GATED HARDER THAN ANYTHING ELSE ON THIS CONNECTOR, because a bulk file carries a Status column and can turn campaigns ON without ever touching set_microsoft_ads_status: confirm:true alone is refused, and you must first call it unconfirmed to get the row-by-row list of what it would ACTIVATE and DELETE, show that to the user, then echo both counts back as confirmActivations/confirmDeletions — or pass pauseInstead:true to land the file with every activation written as Paused) · list_microsoft_ads_conversion_goals (what the account counts as a conversion, and which goals are OFFLINE ones) · send_microsoft_ads_offline_conversions (close the loop: phone sales, in-store purchases and late-closing leads fed back so smart bidding stops optimising against website conversions alone — pass PLAIN emails and E.164 phones, hashing happens server-side to Microsoft’s own published spec) · list_microsoft_ads_audiences (the account’s Customer Match lists with their current sizes; a fresh list reads 0 for up to 48 hours and Microsoft will not use one under 300 people, so never call that a failed upload) · create_microsoft_ads_customer_list then apply_microsoft_ads_customer_list (build a Customer Match audience from PLAIN email addresses, normalized and SHA-256 hashed server-side to Microsoft’s own published spec so no plaintext ever leaves us; the user must be shown Microsoft’s Customer Match terms and agree first) · microsoft_ads_recommendations (what Microsoft ITSELF suggests changing, each one priced by Microsoft: budget raises carrying the current and recommended daily amount, new and broadened keywords, negative keywords it wants removed, and ads it has written. Every one INCREASES what the account buys, which is what they are for, so none is a free win and an empty list means Microsoft has no advice rather than that the account is optimal) · apply_microsoft_ads_recommendations (act on them, gated exactly like the bulk upload: confirm:true alone is REFUSED, so call it unconfirmed first to get every recommendation named with what it changes and Microsoft’s own cost estimate, show that to the user, then echo confirmCount and confirmCostIncrease back. Both are recomputed from a fresh read, and there is no undo) · dismiss_microsoft_ads_recommendations (take advice off the list. It cannot spend, so it needs no confirmation at all, and it is the right answer to “make it stop suggesting that” rather than applying something to clear it) · microsoft_ads_auto_apply (THE READ THAT ANSWERS “is Microsoft changing this account while nobody is looking?”, per type. An inherited account can already be opted in with nobody at the brand having done it) · set_microsoft_ads_auto_apply (turn that standing permission on or off. Switching any type ON is the strongest consent anywhere in Hermoso: Microsoft then writes and publishes its own ads under the brand’s name, deletes negative keywords so the account buys more searches, and changes conversion goals, unattended and indefinitely, with NOTHING to preview beforehand. So confirm:true is not enough and every type must be named in confirmTypes. Switching it OFF is never gated). GOOGLE BUSINESS PROFILE (the local-SEO channel — the listing panel on Google Search and Maps, which for a local business is where the demand actually is, and there is no delete): list_business_locations (the listings the connected Google account manages — call this first and let the USER pick when there is more than one; a Post on the wrong storefront is a public mistake) · post_to_google_business (publish a Post to the listing — text, ONE PHOTO and a call-to-action button; Google’s Posts API takes no video, so pass a still. EVENT and OFFER posts both require a title and a start date, and on an OFFER Google ignores the button link) · list_google_business_posts (what is showing right now, with each Post’s state) · delete_google_business_post (take one down — immediate and public, so confirm first) · list_google_business_reviews (the reviews on the listing, and which ones have NO reply yet — for a local business the highest-leverage surface there is) · reply_to_google_business_review (answer one publicly as the business; it is an UPSERT, so it replaces any existing reply) · list_google_business_questions + answer_google_business_question (the public Q&A on the listing) · google_business_search_keywords (the actual search terms people typed to find the listing — free local keyword data; low-volume terms are SUPPRESSED and come back as "fewer than N", never as zero) · google_business_insights (Search + Maps impressions, calls, website clicks, direction requests, messages, bookings — listing-level; Google discontinued per-Post insights in 2023 with no replacement, so never promise per-Post numbers) · get_business_location (everything the listing actually says — name, address, phone, website, categories, description, hours, service area — as the merchant set it; the answer to “what does our Google listing say?”) · update_business_location (change any of that — hours, phone, website, description, categories, even the name or address. It edits the live panel on Search and Maps with no draft and no undo, so call it WITHOUT confirm first: nothing is written, Google validates the payload, and you get the current value of every field you are about to change to show the user) · google_business_account (whose Business Profile account the listing is on, and whether the connected Google account’s role can edit it at all). Google gates this API behind a per-project access request and the default quota is zero, so the connection can be live and calls still refused — the error says so. CHATGPT ADS (ads under ChatGPT answers, via OpenAI’s Advertiser API — full management): list_openai_ads_campaigns (the ad account, then its campaigns, ad groups and ads with each ad’s review state) · openai_ads_report (impressions, clicks, spend, CTR, CPC, CPM at account / campaign / ad group / ad scope — run this first, it validates the key with zero spend risk) · openai_ads_geo_search (location ids) · list_openai_ads_audiences + create_openai_ads_audience (custom audiences — geo and these are the only list-based targeting this platform has; target them with customAudienceIds / excludedCustomAudienceIds on a campaign) · create_openai_ads_campaign (campaign → ad group → ad in one call, always PAUSED) · create_openai_ads_ad_group / create_openai_ads_ad (fill in an existing campaign) · update_openai_ads_object (rename, re-budget, rewrite context hints or the ad copy) · set_openai_ads_budget / set_openai_ads_status (change budget, activate, pause) · delete_openai_ads_object (ARCHIVE — this API has no delete and OpenAI say archiving is not reversible, so offer pausing first). TWO RULES THIS CHANNEL DOES NOT SHARE WITH THE OTHERS: it is connected by PASTING an Advertiser API key (no OAuth, no manager account, one key = one ad account), and it has exactly ONE creative format — a text plus image card, title 50 characters, body 100. There is NO VIDEO on ChatGPT Ads, so never offer a video ad here. GOOGLE DRIVE — ONE connection covering Drive, Sheets and Docs (full CRUD over the files Hermoso created there, plus any file the user hands over with the Google file picker in the app): save_to_drive · list_drive_files / get_drive_file · update_drive_file (rename/move/trash) · delete_drive_file · create_drive_folder. GOOGLE SHEETS (part of the Google Drive connection — export data to a spreadsheet the app creates, or read one the user picked; drive.file, no verification): create_sheet · append_to_sheet · read_sheet. GOOGLE DOCS (part of the Google Drive connection — export copy/brief/report as a doc, or read one the user picked; drive.file, no verification): create_doc · append_to_doc. GOOGLE SLIDES (part of the Google Drive connection — turn a swipefile collection into a real presentation, one slide per saved ad with the creative, brand, copy, run dates and platform; drive.file, no verification, no new scope): export_swipefile_deck — it CREATES a deck each time and cannot append to one the user already has, and a creative whose ad-library link has expired is reported rather than silently dropped. ONEDRIVE (full CRUD over the user’s Microsoft OneDrive): convert_onedrive_file (Microsoft converts a file server-side to PDF or JPG — ~130 formats including PowerPoint and Word decks, PSD, Illustrator, Sketch, 3D, video, iPhone HEIC and raw camera files; JPG needs both width and height) · save_to_onedrive · list_onedrive_files / get_onedrive_file · update_onedrive_file (rename/move) · delete_onedrive_file · create_onedrive_folder. Use these standalone — Hermoso is a full posting/ads/file-storage control surface, not only an ad generator.',
168
193
  'F) YOUR ROSTER STARTS SLIM, AND YOU CAN WIDEN IT YOURSELF — paid-campaign management (`ads`) is NOT loaded by default. On a host that cannot reload its tool list (claude.ai, ChatGPT) use find_tools + call_tool, which reach every tool without a reload. It is by far the largest group — roughly two thirds of the schema weight, and most sessions never touch it. THE MOMENT the user asks to build, budget, target, report on or change a campaign on Meta, Google Ads, LinkedIn, Reddit, Microsoft, Pinterest, X, TikTok, Snapchat, ChatGPT Ads or Apple Search Ads, call enable_tools({groups:[\'ads\']}) — it is free and instant, the tools appear immediately, and you then proceed normally. Do NOT tell the user a campaign cannot be built here; turn the group on. THE SAME APPLIES TO `channel_admin`: you can PUBLISH and SCHEDULE to every connected channel out of the box, but reading a channel back \u2014 its insights, comments, DMs, product catalogs, message templates, webhooks, or editing/deleting an already-published post \u2014 lives in that group. The moment the user asks to read, moderate, reply to, measure or clean up what is ALREADY on a channel, call enable_tools({groups:[\'channel_admin\']}). Never say Hermoso cannot read comments, answer a DM or pull a channel\u2019s numbers. Other groups: research, create, channels, files, workspace, or \'all\'.',
169
194
  ].join('\n');
170
195
 
@@ -334,7 +359,10 @@ const CONNECT_LEGACY_PROVIDERS = [
334
359
  [/pinterest/i, 'pinterest'], [/\bx\b|twitter/i, 'x'],
335
360
  [/meta|facebook|instagram/i, 'meta'], [/linkedin/i, 'linkedin'],
336
361
  ];
337
- const CONNECT_LINK_HINT = (provider) => `\nHand your human this one-click connect link: https://app.hermoso.ai/?connect=${provider} — it opens Hermoso, signs them in if needed, and starts the connection. Then retry.`;
362
+ // The link is the one the server put on the 401 (`connectUrl`, which carries the brand the request resolved to) when it
363
+ // is a well-formed app deep link; only a server that predates it gets the bare link rebuilt here.
364
+ 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.`;
365
+ 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.` : '';
338
366
  const CONNECT_ASK_HINT = `\nAsk your human to connect it at https://app.hermoso.ai (Settings ▸ Connectors), then retry.`;
339
367
  // PURE, and module-scope on purpose: tools/mcp-not-connected-gate-check.mjs lifts and RUNS this exact function over
340
368
  // a table of real error shapes, so the classification is tested rather than read. Returns the sentence to APPEND, or
@@ -342,7 +370,9 @@ const CONNECT_ASK_HINT = `\nAsk your human to connect it at https://app.hermoso.
342
370
  function notConnectedHint(e, msg) {
343
371
  const status = Number(e?.status) || 0;
344
372
  const marker = e?.connector ? String(e.connector) : '';
345
- if (status === 401) return marker ? CONNECT_LINK_HINT(marker) : ''; // the ONE shape; a bare 401 is a session
373
+ // The ONE shape; a bare 401 is a session. The link is never printed twice (a headless reader's message already carries
374
+ // it when the server's copy belt added it), and a paste-a-key connector is also offered its headless route.
375
+ if (status === 401) return marker ? ((/\?connect=[a-z_]/.test(String(msg || '')) ? '' : CONNECT_LINK_HINT(marker, e?.connectUrl)) + KEY_CONNECT_HINT(marker, msg)) : '';
346
376
  if (marker) return ''; // connected already — a sharing / ownership / provider refusal that merely names its connector
347
377
  if (status === 501) return ''; // a provider key WE have not set; no user connection can supply it
348
378
  if (!NOT_CONNECTED_LEGACY_RE.test(String(msg || ''))) return '';
@@ -2036,7 +2066,7 @@ export const holdReasonText = (name, why, ctx = null) => {
2036
2066
  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.${reddit}`;
2037
2067
  }
2038
2068
  if (why === 'host_policy') return `${name} is not offered on this host (the host's own commerce policy). Use the Hermoso app or another client for it.`;
2039
- if (why === 'not_connected') return `${name} needs the "${toolProvider(name)}" connection and this workspace has not made it. Connect it under Settings ▸ Connectors in the Hermoso app, then call again.`;
2069
+ if (why === 'not_connected') return `${name} needs the "${toolProvider(name)}" connection and this workspace has not made it. Connect it under Settings ▸ Connectors in the Hermoso app${Object.prototype.hasOwnProperty.call(KEY_CONNECTORS, toolProvider(name)) ? ', or right here with connect_connector if the user prefers' : ''}, then call again.`;
2040
2070
  if (why === 'directory') return `${name} is outside what this Claude directory connection may run. Use the Hermoso app, or connect the unscoped server URL.`;
2041
2071
  return null;
2042
2072
  };
@@ -2145,7 +2175,7 @@ const makeEnableToolsHandler = (ctx) => async ({ groups }) => {
2145
2175
  // an account that has not connected the platform yet, because they could only answer 401. Say that, and say where
2146
2176
  // the one-click fix is — the failure this sentence prevents is an agent telling a user we cannot run their ads.
2147
2177
  const held = heldBack
2148
- ? ` ${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>, or list_connectors to see what is linked) and they appear.`
2178
+ ? ` ${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.`
2149
2179
  : '';
2150
2180
  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'
2151
2181
  + ' with `?tools=all` on the server URL, or run the `hermoso` CLI, which reaches every tool with no roster at all.';
@@ -2674,7 +2704,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
2674
2704
  if (why) reportDeadEnd(why, n, holdReasonText(n, why, ctx));
2675
2705
  if (why === 'not_offered') return { content: [{ type: 'text', text: holdReasonText(n, why, ctx) }], isError: true };
2676
2706
  if (why === 'host_policy') return { content: [{ type: 'text', text: `${n} is not offered on this host (the host's own commerce policy). Use the Hermoso app or another client for it.` }], isError: true };
2677
- if (why === 'not_connected') return { content: [{ type: 'text', text: `${n} needs the "${toolProvider(n)}" connection and this workspace has not made it. Connect it under Settings ▸ Connectors in the Hermoso app, then call again.` }], isError: true };
2707
+ if (why === 'not_connected') return { content: [{ type: 'text', text: `${n} needs the "${toolProvider(n)}" connection and this workspace has not made it. Connect it under Settings ▸ Connectors in the Hermoso app${Object.prototype.hasOwnProperty.call(KEY_CONNECTORS, toolProvider(n)) ? ', or right here with connect_connector if the user prefers' : ''}, then call again.` }], isError: true };
2678
2708
  if (why === 'directory') return { content: [{ type: 'text', text: `${n} is outside what this Claude directory connection may run. Use the Hermoso app, or connect the unscoped server URL.` }], isError: true };
2679
2709
  let input = args && typeof args === 'object' ? args : {};
2680
2710
  if (h.inputSchema) {
@@ -2723,7 +2753,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
2723
2753
  server.group('channels');
2724
2754
  server.registerTool('post_to_bluesky', {
2725
2755
  title: 'Post to Bluesky',
2726
- description: "Publish a post to Bluesky as the connected account. Text up to 300 characters — Bluesky ALSO caps a post at 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; Hermoso checks both before spending the round trip and says which limit and by how much. MEDIA: either up to 4 images (imageUrls + altText) OR one MP4 video (videoUrl + videoAlt), never both — a Bluesky post record carries a single embed and images and video are two different embed types. Video is MP4 only, up to 300MB at Bluesky's end (Hermoso can fetch up to 150MB from a URL), with optional WebVTT caption tracks; the aspect ratio is measured from the file. Bluesky requires a CONFIRMED EMAIL on the account before it will process any video — if it is unconfirmed you get a refusal saying so, and reconnecting will not help. Links in the text are made clickable automatically. LINK CARDS: Bluesky does NOT scrape links, so a URL posted bare renders as plain blue text — the client composing the post has to build the card. Hermoso builds one AUTOMATICALLY when the post has a URL and NO media: it fetches the page, uses its title/description and uploads its image as the card thumbnail. Pass linkCard:false to suppress it, or linkCard:{uri,title,description,thumbUrl} to control it (give both title and description and the page is not fetched at all). A POST CARRIES ONE EMBED, so a card and images/video cannot both ride: if you pass linkCard explicitly ALONGSIDE media the call is REFUSED by name rather than silently dropping one, and if the URL was merely in the text the MEDIA WINS and the reply says the card was skipped (the link stays clickable either way). Returns the post's public bsky.app URL. Connect at Settings ▸ Connectors ▸ Bluesky with a handle and an APP PASSWORD.",
2756
+ description: "Publish a post to Bluesky as the connected account. Text up to 300 characters — Bluesky ALSO caps a post at 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; Hermoso checks both before spending the round trip and says which limit and by how much. MEDIA: either up to 4 images (imageUrls + altText) OR one MP4 video (videoUrl + videoAlt), never both — a Bluesky post record carries a single embed and images and video are two different embed types. Video is MP4 only, up to 300MB at Bluesky's end (Hermoso can fetch up to 150MB from a URL), with optional WebVTT caption tracks; the aspect ratio is measured from the file. Bluesky requires a CONFIRMED EMAIL on the account before it will process any video — if it is unconfirmed you get a refusal saying so, and reconnecting will not help. Links in the text are made clickable automatically. LINK CARDS: Bluesky does NOT scrape links, so a URL posted bare renders as plain blue text — the client composing the post has to build the card. Hermoso builds one AUTOMATICALLY when the post has a URL and NO media: it fetches the page, uses its title/description and uploads its image as the card thumbnail. Pass linkCard:false to suppress it, or linkCard:{uri,title,description,thumbUrl} to control it (give both title and description and the page is not fetched at all). A POST CARRIES ONE EMBED, so a card and images/video cannot both ride: if you pass linkCard explicitly ALONGSIDE media the call is REFUSED by name rather than silently dropping one, and if the URL was merely in the text the MEDIA WINS and the reply says the card was skipped (the link stays clickable either way). Returns the post's public bsky.app URL. Connect at Settings ▸ Connectors ▸ Bluesky, or here with connect_connector, with a handle and an APP PASSWORD.",
2727
2757
  inputSchema: {
2728
2758
  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 brand has more than one bluesky account connected (several and none named is refused by name, never guessed); omit when there is one."),
2729
2759
  text: z.string().describe('The post, up to 300 characters / 3000 UTF-8 bytes.'),
@@ -2749,7 +2779,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
2749
2779
  server.group('channel_admin');
2750
2780
  server.registerTool('delete_bluesky_post', {
2751
2781
  title: 'Delete a post from the connected Bluesky account',
2752
- 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).",
2782
+ 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).",
2753
2783
  inputSchema: {
2754
2784
  uri: z.string().describe("the post's AT-URI (at://did:plc:…/app.bsky.feed.post/…) as post_to_bluesky returned it, or just its record key (3mtc4n3fibn2x)"),
2755
2785
  confirm: z.boolean().optional().describe('REQUIRED true — deletion is permanent and cannot be undone'),
@@ -2769,8 +2799,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
2769
2799
 
2770
2800
  // ── TELEGRAM (2026-08-19) ─────────────────────────────────────────────────────────────────────────────────────
2771
2801
  // A PASTE-A-CREDENTIAL CONNECTOR (@BotFather issues the token; Telegram has no OAuth app for bots), so there is
2772
- // no consent screen to send anyone to and connecting is the one Telegram thing that is NOT headless only because
2773
- // the token has to be created in the Telegram app.
2802
+ // no consent screen to send anyone to. The token has to be created in the Telegram app, and connecting it is headless:
2803
+ // connect_connector posts it to the same route the app's paste box uses.
2774
2804
  //
2775
2805
  // THE ONE THING EVERY SURFACE MUST SAY THE SAME WAY: there is NO roster of chats. The Bot API publishes no
2776
2806
  // method that lists the chats a bot belongs to — getChat, getChatAdministrators, getChatMemberCount and
@@ -2780,7 +2810,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
2780
2810
  server.group('channels');
2781
2811
  server.registerTool('post_to_telegram', {
2782
2812
  title: 'Post to Telegram',
2783
- description: "Publish to a Telegram channel, group or chat as the brand's own bot. WHICH CHAT IS ALWAYS REQUIRED AND IS NEVER GUESSED: pass chatId as the public channel's @username (e.g. @hermosoai) or its numeric id (a group is negative; a supergroup or channel starts with -100). There is no default and there cannot be one — the Telegram Bot API publishes no method that lists the chats a bot belongs to, so Hermoso genuinely cannot know them. list_telegram_chats reports the chats that have MESSAGED the bot in the last 24 hours, which is a shortcut for finding an id and is NOT a roster: a chat missing from it can still be posted to. TEXT: up to 4096 characters on a text-only message, but only 1024 the moment ANY photo or video is attached — a caption is not a message, and Hermoso refuses the over-long one before spending the round trip and says which budget applied. MEDIA: one image (imageUrl), one video (videoUrl), or an ALBUM of 2–10 (imageUrls, in order) in which photos and videos may be MIXED — pass a videoUrl alongside imageUrls and it joins the album as one more item. Hermoso uploads the bytes rather than handing Telegram a link, which is what buys the larger ceilings: 10MB per photo and 50MB per video, where a link would be 5MB and 20MB. THE BOT MUST BE IN THE CHAT — an administrator with Post Messages for a channel, an unrestricted member for a group; if it is not, Telegram refuses and the error says to add it rather than to reconnect. Returns the message id and, for a PUBLIC chat, its t.me link — a private group has no public web link, so `url` comes back null rather than as a link that would 404 for whoever you hand it to. 0 credits. Connect at Settings ▸ Connectors ▸ Telegram by pasting a bot token from @BotFather.",
2813
+ description: "Publish to a Telegram channel, group or chat as the brand's own bot. WHICH CHAT IS ALWAYS REQUIRED AND IS NEVER GUESSED: pass chatId as the public channel's @username (e.g. @hermosoai) or its numeric id (a group is negative; a supergroup or channel starts with -100). There is no default and there cannot be one — the Telegram Bot API publishes no method that lists the chats a bot belongs to, so Hermoso genuinely cannot know them. list_telegram_chats reports the chats that have MESSAGED the bot in the last 24 hours, which is a shortcut for finding an id and is NOT a roster: a chat missing from it can still be posted to. TEXT: up to 4096 characters on a text-only message, but only 1024 the moment ANY photo or video is attached — a caption is not a message, and Hermoso refuses the over-long one before spending the round trip and says which budget applied. MEDIA: one image (imageUrl), one video (videoUrl), or an ALBUM of 2–10 (imageUrls, in order) in which photos and videos may be MIXED — pass a videoUrl alongside imageUrls and it joins the album as one more item. Hermoso uploads the bytes rather than handing Telegram a link, which is what buys the larger ceilings: 10MB per photo and 50MB per video, where a link would be 5MB and 20MB. THE BOT MUST BE IN THE CHAT — an administrator with Post Messages for a channel, an unrestricted member for a group; if it is not, Telegram refuses and the error says to add it rather than to reconnect. Returns the message id and, for a PUBLIC chat, its t.me link — a private group has no public web link, so `url` comes back null rather than as a link that would 404 for whoever you hand it to. 0 credits. Connect at Settings ▸ Connectors ▸ Telegram, or here with connect_connector, by pasting a bot token from @BotFather.",
2784
2814
  inputSchema: {
2785
2815
  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 brand has more than one telegram account connected (several and none named is refused by name, never guessed); omit when there is one."),
2786
2816
  chatId: z.string().describe("REQUIRED — the destination: a public channel's @username, or the numeric chat id. Never guessed; ask the user, or use list_telegram_chats."),
@@ -2824,7 +2854,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
2824
2854
  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. "
2825
2855
  + "⚠ 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'. "
2826
2856
  + "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. "
2827
- + "Free — no vendor charge and no credits. Needs Telegram connected (Settings ▸ Connectors ▸ Telegram).",
2857
+ + "Free — no vendor charge and no credits. Needs Telegram connected (Settings ▸ Connectors ▸ Telegram, or connect_connector).",
2828
2858
  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.') },
2829
2859
  outputSchema: { messages: z.array(z.any()).optional(), count: z.number().optional(), webhookSet: z.boolean().optional(), windowHours: z.number().optional(), note: z.string().optional() },
2830
2860
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
@@ -2844,7 +2874,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
2844
2874
  // and nothing to echo back. A confirmText gate would be asking the caller to echo something we invented.
2845
2875
  server.registerTool('delete_telegram_message', {
2846
2876
  title: 'Delete a Telegram message',
2847
- 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).",
2877
+ 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).",
2848
2878
  inputSchema: {
2849
2879
  chatId: z.string().describe("the chat the message is in — the same @username or numeric id it was posted with"),
2850
2880
  messageId: z.number().describe('the message id post_to_telegram returned (also the number at the end of a t.me link)'),
@@ -4294,25 +4324,68 @@ function buildTools(rawServer, opts = {}, sink = null) {
4294
4324
  }));
4295
4325
  server.registerTool('list_scheduled', {
4296
4326
  title: 'List scheduled and past posts',
4297
- description: 'Show what is queued to post and what already went out. Each fired item reports PER-CHANNEL outcomes, so you can see that (say) Instagram published and TikTok failed on the same item rather than a single misleading verdict. Past items are rebuilt from published-post records, one row per channel; they are not job runs (use list_jobs for those). Read-only, 0 credits.',
4298
- inputSchema: {},
4327
+ description: 'Show what is queued to post and what already went out. Each fired item reports PER-CHANNEL outcomes, so you can see that (say) Instagram published and TikTok failed on the same item rather than a single misleading verdict. Past items are rebuilt from published-post records, one row per channel; they are not job runs (use list_jobs for those). THE LIST IS COMPACT so it fits in one reply: the next 25 queued and the last 15 fired, captions shortened. Pass `id` for ONE post in full (every caption and setting, which you need before reschedule_post replaces a caption map), `channel` to filter, or `upcoming` / `fired` for more rows. Read-only, 0 credits.',
4328
+ inputSchema: {
4329
+ id: z.string().optional().describe('one post id from this list: returns that post in full, every caption and setting included'),
4330
+ channel: z.string().optional().describe('only posts that include this channel, e.g. "pinterest" or "x"'),
4331
+ upcoming: z.number().optional().describe('how many queued posts to list, soonest first (default 25, max 200)'),
4332
+ fired: z.number().optional().describe('how many already-fired posts to list, most recent last (default 15, max 200)'),
4333
+ },
4299
4334
  outputSchema: {
4300
4335
  scheduled: z.array(z.object({ id: z.string().optional(), at: z.string().nullable().optional(), channels: z.array(z.string()).optional(), message: z.string().optional(), status: z.string().optional() })).optional(),
4301
4336
  history: z.array(z.object({ id: z.string().optional(), at: z.string().nullable().optional(), channels: z.array(z.string()).optional(), status: z.string().optional(), results: z.array(z.object({ channel: z.string().optional(), ok: z.boolean().optional(), id: z.string().nullable().optional(), url: z.string().nullable().optional(), error: z.string().optional() })).nullable().optional(), error: z.string().nullable().optional() })).optional(),
4302
4337
  },
4303
4338
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
4304
- }, wrap(async () => {
4339
+ }, wrap(async ({ id, channel, upcoming, fired } = {}) => {
4305
4340
  const d = await apiGet('/api/schedule', {});
4306
- const q = (d.scheduled || []).length, h = (d.history || []).length;
4341
+ // ONE POST IN FULL (2026-09-13). The compact list below shortens captions, and reschedule_post's `captions`
4342
+ // replaces the WHOLE per-channel map, so an agent changing one caption needs the full row first.
4343
+ if (id) {
4344
+ const wantId = String(id).trim();
4345
+ const hitQ = (d.scheduled || []).filter(r => r && r.id === wantId), hitH = (d.history || []).filter(r => r && r.id === wantId);
4346
+ if (!hitQ.length && !hitH.length) return { content: [{ type: 'text', text: `No queued or fired post with id ${wantId} on this brand. Call list_scheduled without an id to see the ids.` }], isError: true };
4347
+ const r0 = hitQ[0] || hitH[0];
4348
+ return ok(`${wantId}: ${r0.status || 'unknown'} at ${r0.at || 'no time'} to ${(r0.channels || []).join(', ') || 'no channel'}${hitH.length > 1 ? ` (${hitH.length} fired rows, one per channel)` : ''}. The full post is in the structured result.`, { scheduled: hitQ, history: hitH });
4349
+ }
4350
+ // COMPACT BY DEFAULT (2026-09-13). This returned the whole calendar, every caption and setting on every row: 540,011
4351
+ // characters on our own brand (55 queued at ~4.7KB each, 366 fired), which claude.ai refused to hand the agent at
4352
+ // all ("exceeds maximum allowed tokens"). A schedule an agent cannot read is a dead end, so the list is a projection
4353
+ // and the full row is one `id` away. The totals and the failure line still count EVERYTHING, so a shorter list can
4354
+ // never hide a post that did not publish.
4355
+ const want = String(channel || '').trim().toLowerCase();
4356
+ const hasCh = (r) => !want || (Array.isArray(r?.channels) && r.channels.some(c => String(c).toLowerCase() === want)) || (Array.isArray(r?.results) && r.results.some(x => x && String(x.channel || '').toLowerCase() === want));
4357
+ const clamp = (v, dflt) => { const k = Math.floor(Number(v)); return Number.isFinite(k) && k >= 0 ? Math.min(k, 200) : dflt; };
4358
+ const CAP = 160;
4359
+ const brief = (r) => {
4360
+ const msg = typeof r.message === 'string' ? r.message.replace(/\s+/g, ' ').trim() : '';
4361
+ const slides = Array.isArray(r.imageUrls) ? r.imageUrls.length : 0;
4362
+ return {
4363
+ id: r.id, at: r.at, status: r.status, channels: r.channels,
4364
+ ...(msg ? { message: msg.length > CAP ? `${msg.slice(0, CAP)}…` : msg } : {}),
4365
+ ...(msg.length > CAP ? { messageChars: msg.length } : {}),
4366
+ ...(r.xArticle && r.xArticle.title ? { xArticle: { title: r.xArticle.title } } : {}),
4367
+ media: r.videoUrl ? 'video' : slides > 1 ? `carousel of ${slides}` : (r.imageUrl || slides) ? 'image' : 'none',
4368
+ ...((r.videoUrl || r.imageUrl) ? { mediaUrl: r.videoUrl || r.imageUrl } : {}),
4369
+ ...(Array.isArray(r.results) ? { results: r.results.map(x => ({ channel: x?.channel, ok: x?.ok, ...(x?.id ? { id: String(x.id) } : {}), ...(x?.url ? { url: x.url } : {}), ...(x?.error ? { error: String(x.error).slice(0, 300) } : {}) })) } : {}),
4370
+ ...(r.error ? { error: String(r.error).slice(0, 300) } : {}),
4371
+ };
4372
+ };
4373
+ const qAll = (d.scheduled || []).filter(hasCh), hAll = (d.history || []).filter(hasCh);
4374
+ const q = qAll.length, h = hAll.length;
4375
+ const qShow = qAll.slice(0, clamp(upcoming, 25));
4376
+ const nH = clamp(fired, 15), hShow = nH ? hAll.slice(-nH) : [];
4307
4377
  // Same honesty rule as get_job: a fired item that published nothing must not be counted as simply "fired". Name
4308
4378
  // the broken ones in the sentence — an agent that only reads the summary line would otherwise report a calendar
4309
4379
  // full of failures as a calendar that ran.
4310
- const bad = (d.history || []).filter(r => r.status === 'error' || (Array.isArray(r.results) && r.results.some(x => x && x.ok === false)));
4380
+ const bad = hAll.filter(r => r.status === 'error' || (Array.isArray(r.results) && r.results.some(x => x && x.ok === false)));
4311
4381
  const badLine = bad.length ? ` ${bad.length} of those did NOT fully publish: ${bad.slice(0, 5).map(r => `${r.id} (${(Array.isArray(r.results) ? r.results.filter(x => x && x.ok === false).map(x => `${x.channel}: ${x.error || 'failed'}`).join('; ') : '') || r.error || 'failed'})`).join(' · ')}.` : '';
4312
4382
  // A QUEUED X ARTICLE IS NAMED IN THE SENTENCE: in a channel list it reads exactly like an ordinary X post, and
4313
4383
  // its title is what tells an agent which queued item is the long-form one.
4314
- const arts = (d.scheduled || []).filter(r => r && r.xArticle && r.xArticle.title);
4315
- return ok(`${q} post${q === 1 ? '' : 's'} queued, ${h} already fired.${badLine}${arts.length ? ` X Article${arts.length === 1 ? '' : 's'} queued: ${arts.slice(0, 5).map(r => `${r.id} "${r.xArticle.title}" at ${r.at}`).join('; ')}.` : ''}`, d);
4384
+ const arts = qAll.filter(r => r && r.xArticle && r.xArticle.title);
4385
+ const partial = qShow.length < q || hShow.length < h;
4386
+ const { scheduled: _allQ, history: _allH, ...rest } = d;
4387
+ return ok(`${want ? `${want}: ` : ''}${q} post${q === 1 ? '' : 's'} queued, ${h} already fired.${badLine}${arts.length ? ` X Article${arts.length === 1 ? '' : 's'} queued: ${arts.slice(0, 5).map(r => `${r.id} "${r.xArticle.title}" at ${r.at}`).join('; ')}.` : ''}${partial ? ` Showing the next ${qShow.length} queued and the last ${hShow.length} fired; pass upcoming or fired for more, or id for one post in full.` : ''}`,
4388
+ { ...rest, scheduled: qShow.map(brief), history: hShow.map(brief), queuedTotal: q, firedTotal: h });
4316
4389
  }));
4317
4390
  // RESCHEDULE. `PATCH /api/schedule/:id` has existed since drag-to-reschedule shipped and NO agent surface wrapped
4318
4391
  // it, so the only move an agent had was cancel + retype — which is exactly how a calendar loses a caption, and the
@@ -5055,7 +5128,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5055
5128
  // Google login manages; it is the owner-only PICKER route, reached through list_connector_accounts alone.
5056
5129
  const d = await apiGet('/api/google-business/shared-locations');
5057
5130
  if (!d.shared) return ok('No Google Business listing is shared with this brand yet, so there is nothing to post to. The user chooses which listings belong to this brand: Workspace ▸ Connectors ▸ Google Business Profile ▸ Manage accounts, or list_connector_accounts then set_connector_accounts. Do not name or guess one.', d);
5058
- if (!d.count) return ok(`The ${d.shared === 1 ? 'listing' : `${d.shared} listings`} shared with this brand ${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.`, d);
5131
+ if (!d.count) return ok(`The ${d.shared === 1 ? 'listing' : `${d.shared} listings`} shared with this brand ${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);
5059
5132
  return ok(`${d.count} listing${d.count === 1 ? '' : 's'} shared with this brand: ${(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);
5060
5133
  }));
5061
5134
  server.registerTool('post_to_google_business', {
@@ -5851,7 +5924,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5851
5924
  }));
5852
5925
  server.registerTool('bluesky_account', {
5853
5926
  title: 'Read a Bluesky account',
5854
- 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 \u25b8 Connectors \u25b8 Bluesky).',
5927
+ 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 \u25b8 Connectors \u25b8 Bluesky, or connect_connector).',
5855
5928
  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') },
5856
5929
  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() },
5857
5930
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -7861,7 +7934,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
7861
7934
  // "nothing ticked" and "the ticked ones vanished" are different problems with different fixes — a single
7862
7935
  // sentence covering both sends half of the users to the wrong place.
7863
7936
  return ok(d.shared
7864
- ? `The ${d.shared} propert${d.shared === 1 ? 'y' : 'ies'} shared with this brand ${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.`
7937
+ ? `The ${d.shared} propert${d.shared === 1 ? 'y' : 'ies'} shared with this brand ${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).`
7865
7938
  : 'No GA4 property is shared with this brand yet, so there is nothing to read. Ask the user to pick which properties belong to this brand under Settings ▸ Connectors ▸ Google Analytics ▸ Manage accounts (or call set_connector_accounts with provider "google_analytics"). Do not name or guess one.', d);
7866
7939
  }));
7867
7940
  server.registerTool('analytics_report', {
@@ -8263,7 +8336,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
8263
8336
  // and list_tag_manager_tags answers the second one explicitly.
8264
8337
  server.registerTool('list_tag_manager_containers', {
8265
8338
  title: 'List the Tag Manager containers shared with this brand',
8266
- description: "The Google Tag Manager containers SHARED WITH THIS BRAND, 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 brand 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. Never name or guess a container. Read-only, free. Read-only, 0 credits. Needs Google Tag Manager connected.",
8339
+ description: "The Google Tag Manager containers SHARED WITH THIS BRAND, 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 brand 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.",
8267
8340
  inputSchema: {},
8268
8341
  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() },
8269
8342
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -8273,7 +8346,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
8273
8346
  if (rows.length) return ok(`${rows.length} Tag Manager container(s) shared with this brand:\n${rows.join('\n')}`, d);
8274
8347
  // "nothing ticked" and "the ticked ones vanished" are different problems with different fixes.
8275
8348
  return ok(d.shared
8276
- ? `The ${d.shared} container${d.shared === 1 ? '' : 's'} shared with this brand ${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.`
8349
+ ? `The ${d.shared} container${d.shared === 1 ? '' : 's'} shared with this brand ${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).`
8277
8350
  : 'No Tag Manager container is shared with this brand yet, so there is nothing to read. Ask the user to pick which containers belong to this brand 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);
8278
8351
  }));
8279
8352
  server.registerTool('list_tag_manager_tags', {
@@ -8435,7 +8508,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
8435
8508
  if (rows.length) return ok(`${rows.length} Search Console propert(ies) shared with this brand:\n${rows.join('\n')}`, d);
8436
8509
  // "nothing ticked" and "the ticked ones vanished" are different problems with different fixes.
8437
8510
  return ok(d.shared
8438
- ? `The ${d.shared} propert${d.shared === 1 ? 'y' : 'ies'} shared with this brand ${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.`
8511
+ ? `The ${d.shared} propert${d.shared === 1 ? 'y' : 'ies'} shared with this brand ${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).`
8439
8512
  : 'No Search Console property is shared with this brand yet, so there is nothing to read. Ask the user to pick which properties belong to this brand 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);
8440
8513
  }));
8441
8514
  server.registerTool('search_console_performance', {
@@ -8531,7 +8604,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
8531
8604
  }));
8532
8605
  server.registerTool('add_search_console_site', {
8533
8606
  title: 'Add a property to Search Console',
8534
- 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 brand, so someone has to tick it under Settings ▸ Connectors ▸ Google Search Console ▸ Manage 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.',
8607
+ 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 brand, 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.',
8535
8608
  inputSchema: {
8536
8609
  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.'),
8537
8610
  },
@@ -8575,8 +8648,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
8575
8648
  const rows = (d.sites || []).map(s => `• ${s.siteUrl}${s.isVerified ? '' : ' (NOT VERIFIED in Bing — every call on it will be refused)'}`);
8576
8649
  if (rows.length) return ok(`${rows.length} Bing Webmaster site(s) shared with this brand:\n${rows.join('\n')}`, d);
8577
8650
  return ok(d.shared
8578
- ? `The ${d.shared} site(s) shared with this brand are no longer visible to the connected Bing account. Ask the user to re-pick under Settings ▸ Connectors ▸ Bing Webmaster Tools ▸ Manage accounts.`
8579
- : 'No Bing Webmaster site is shared with this brand yet. Ask the user to pick which of their verified sites belong to this brand under Settings ▸ Connectors ▸ Bing Webmaster Tools ▸ Manage accounts. Do not name or guess one.', d);
8651
+ ? `The ${d.shared} site(s) shared with this brand 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).`
8652
+ : 'No Bing Webmaster site is shared with this brand yet. Ask the user to pick which of their verified sites belong to this brand 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);
8580
8653
  }));
8581
8654
  server.registerTool('bing_webmaster_traffic', {
8582
8655
  title: 'Bing clicks and impressions',
@@ -8773,7 +8846,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
8773
8846
  }));
8774
8847
  server.registerTool('add_bing_webmaster_site', {
8775
8848
  title: 'Add a site to the Bing Webmaster account',
8776
- description: 'Add a site to the connected Bing Webmaster account. 🚨 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 brand until the user ticks it under Settings ▸ Connectors ▸ Bing Webmaster Tools ▸ Manage accounts. 0 credits.',
8849
+ description: 'Add a site to the connected Bing Webmaster account. 🚨 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 brand 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.',
8777
8850
  inputSchema: { siteUrl: z.string().describe('the site with its scheme, e.g. "https://example.com"') },
8778
8851
  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() },
8779
8852
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
@@ -8839,7 +8912,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
8839
8912
  // tool copy SAYS SO — an agent that has not been told the rule will cheerfully write the loop.
8840
8913
  server.registerTool('list_posthog_projects', {
8841
8914
  title: 'List the PostHog projects this key can see',
8842
- description: 'The PostHog projects the connected personal API key can see, and which ONE this brand 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 brand to a different project, the user reconnects PostHog under Settings ▸ Connectors ▸ PostHog with that project id. Read-only, 0 credits.',
8915
+ description: 'The PostHog projects the connected personal API key can see, and which ONE this brand 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 brand to a different project, the user reconnects PostHog under Settings ▸ Connectors ▸ PostHog, or with connect_connector, with that project id. Read-only, 0 credits.',
8843
8916
  inputSchema: {},
8844
8917
  outputSchema: { projects: z.array(z.any()).optional(), count: z.number().optional(), active: z.string().optional(), note: z.string().optional() },
8845
8918
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -9212,7 +9285,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
9212
9285
  // ---------- Stripe (2026-09-09): the brand's OWN revenue, read-only via a restricted key ----------
9213
9286
  server.registerTool('stripe_report', {
9214
9287
  title: 'Stripe revenue report',
9215
- 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: paste a restricted key from Developers ▸ Restricted keys).",
9288
+ 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).",
9216
9289
  inputSchema: {
9217
9290
  since: z.string().optional().describe('YYYY-MM-DD (default 30 days ago)'),
9218
9291
  until: z.string().optional().describe('YYYY-MM-DD (default today)'),
@@ -9248,98 +9321,98 @@ function buildTools(rawServer, opts = {}, sink = null) {
9248
9321
  // ---------- Stripe, the full surface (2026-09-09): reads, and writes that confirm on a live key ----------
9249
9322
  server.registerTool('list_stripe_subscriptions', {
9250
9323
  title: 'list stripe subscriptions',
9251
- 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).",
9324
+ 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).",
9252
9325
  inputSchema: { status: z.string().optional(), customerId: z.string().optional(), priceId: z.string().optional(), limit: z.number().optional(), after: z.string().optional() },
9253
9326
  outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
9254
9327
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
9255
9328
  }, 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); }));
9256
9329
  server.registerTool('list_stripe_invoices', {
9257
9330
  title: 'list stripe invoices',
9258
- 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).",
9331
+ 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).",
9259
9332
  inputSchema: { status: z.string().optional(), customerId: z.string().optional(), limit: z.number().optional(), after: z.string().optional() },
9260
9333
  outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
9261
9334
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
9262
9335
  }, 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); }));
9263
9336
  server.registerTool('list_stripe_products', {
9264
9337
  title: 'list stripe products',
9265
- 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).",
9338
+ 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).",
9266
9339
  inputSchema: { active: z.boolean().optional(), limit: z.number().optional(), after: z.string().optional() },
9267
9340
  outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
9268
9341
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
9269
9342
  }, 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); }));
9270
9343
  server.registerTool('stripe_balance', {
9271
9344
  title: 'stripe balance',
9272
- 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).",
9345
+ 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).",
9273
9346
  inputSchema: { limit: z.number().optional() },
9274
9347
  outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
9275
9348
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
9276
9349
  }, 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); }));
9277
9350
  server.registerTool('list_stripe_refunds', {
9278
9351
  title: 'list stripe refunds',
9279
- 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).",
9352
+ 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).",
9280
9353
  inputSchema: { chargeId: z.string().optional(), limit: z.number().optional(), after: z.string().optional() },
9281
9354
  outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
9282
9355
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
9283
9356
  }, 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); }));
9284
9357
  server.registerTool('list_stripe_coupons', {
9285
9358
  title: 'list stripe coupons',
9286
- 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).",
9359
+ 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).",
9287
9360
  inputSchema: { limit: z.number().optional(), after: z.string().optional() },
9288
9361
  outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
9289
9362
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
9290
9363
  }, 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); }));
9291
9364
  server.registerTool('create_stripe_product', {
9292
9365
  title: 'create stripe product',
9293
- 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).",
9366
+ 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).",
9294
9367
  inputSchema: { name: z.string(), description: z.string().optional(), amount: z.number().describe('MAJOR units — 19 or 19.00 is $19.00, never 1900'), currency: z.string(), interval: z.string().optional(), intervalCount: z.number().optional(), nickname: z.string().optional(), metadata: z.record(z.any()).optional(), confirm: z.boolean().optional() },
9295
9368
  outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
9296
9369
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
9297
9370
  }, wrap(async (a) => { const d = await apiPost('/api/stripe/product', a); return ok(d.note, d); }));
9298
9371
  server.registerTool('create_stripe_price', {
9299
9372
  title: 'create stripe price',
9300
- 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).",
9373
+ 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).",
9301
9374
  inputSchema: { productId: z.string(), amount: z.number().describe('MAJOR units — 19 or 19.00 is $19.00, never 1900'), currency: z.string(), interval: z.string().optional(), intervalCount: z.number().optional(), nickname: z.string().optional(), confirm: z.boolean().optional() },
9302
9375
  outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
9303
9376
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
9304
9377
  }, wrap(async (a) => { const d = await apiPost('/api/stripe/price', a); return ok(d.note, d); }));
9305
9378
  server.registerTool('create_stripe_payment_link', {
9306
9379
  title: 'create stripe payment link',
9307
- 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. Returns the URL. LIVE key: confirm:true after showing the preview. Idempotent on its arguments. Needs Stripe connected (Settings ▸ Connectors ▸ Stripe).",
9380
+ 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. Returns the URL. LIVE key: confirm:true after showing the preview. Idempotent on its arguments. Needs Stripe connected (Settings ▸ Connectors ▸ Stripe, or connect_connector).",
9308
9381
  inputSchema: { priceId: z.string().optional().describe('one price — the shorthand for a single line item'), quantity: z.number().optional(), lineItems: z.array(z.object({ priceId: z.string(), quantity: z.number().optional() })).optional().describe('several prices at once; use instead of priceId'), afterCompletionUrl: z.string().optional(), metadata: z.record(z.any()).optional(), confirm: z.boolean().optional() },
9309
9382
  outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
9310
9383
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
9311
9384
  }, wrap(async (a) => { const d = await apiPost('/api/stripe/payment-link', a); return ok(d.note, d); }));
9312
9385
  server.registerTool('create_stripe_coupon', {
9313
9386
  title: 'create stripe coupon',
9314
- 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. Read back. LIVE key: confirm:true after showing the preview. Needs Stripe connected (Settings ▸ Connectors ▸ Stripe).",
9387
+ 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. Read back. LIVE key: confirm:true after showing the preview. Needs Stripe connected (Settings ▸ Connectors ▸ Stripe, or connect_connector).",
9315
9388
  inputSchema: { name: z.string().optional(), id: z.string().optional(), percentOff: z.number().optional(), amountOff: z.number().optional(), currency: z.string().optional(), duration: z.string().optional(), durationInMonths: z.number().optional(), maxRedemptions: z.number().optional(), confirm: z.boolean().optional() },
9316
9389
  outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
9317
9390
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
9318
9391
  }, wrap(async (a) => { const d = await apiPost('/api/stripe/coupon', a); return ok(d.note, d); }));
9319
9392
  server.registerTool('create_stripe_customer', {
9320
9393
  title: 'create stripe customer',
9321
- 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).",
9394
+ 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).",
9322
9395
  inputSchema: { email: z.string(), name: z.string().optional(), phone: z.string().optional(), description: z.string().optional(), metadata: z.record(z.any()).optional(), allowDuplicate: z.boolean().optional(), confirm: z.boolean().optional() },
9323
9396
  outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
9324
9397
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
9325
9398
  }, wrap(async (a) => { const d = await apiPost('/api/stripe/customer', a); return ok(d.note, d); }));
9326
9399
  server.registerTool('create_stripe_subscription', {
9327
9400
  title: 'create stripe subscription',
9328
- 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).",
9401
+ 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).",
9329
9402
  inputSchema: { customerId: z.string(), priceId: z.string(), quantity: z.number().optional(), trialDays: z.number().optional(), metadata: z.record(z.any()).optional(), confirm: z.boolean().optional() },
9330
9403
  outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
9331
9404
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
9332
9405
  }, wrap(async (a) => { const d = await apiPost('/api/stripe/subscription', a); return ok(d.note, d); }));
9333
9406
  server.registerTool('cancel_stripe_subscription', {
9334
9407
  title: 'cancel stripe subscription',
9335
- 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).",
9408
+ 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).",
9336
9409
  inputSchema: { subscriptionId: z.string(), immediately: z.boolean().optional(), confirm: z.boolean().optional() },
9337
9410
  outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
9338
9411
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
9339
9412
  }, wrap(async (a) => { const d = await apiPost('/api/stripe/subscription/cancel', a); return ok(d.note, d); }));
9340
9413
  server.registerTool('refund_stripe_charge', {
9341
9414
  title: 'refund stripe charge',
9342
- 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).",
9415
+ 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).",
9343
9416
  inputSchema: { chargeId: z.string(), amount: z.number().optional(), reason: z.string().optional(), confirm: z.boolean().optional() },
9344
9417
  outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
9345
9418
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
@@ -9818,7 +9891,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
9818
9891
  });
9819
9892
  server.registerTool('list_openai_ads_campaigns', {
9820
9893
  title: 'List ChatGPT Ads account / campaigns / ad groups / ads',
9821
- description: 'Read the brand’s connected ChatGPT Ads account — the ads that appear below ChatGPT answers. Call with NO ids to get the ad account itself (name, currency, status, 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 (Settings ▸ Connectors ▸ ChatGPT Ads): the user pastes an Advertiser API key from ChatGPT Ads Manager ▸ Settings — there is no OAuth and no manager account, and one key is scoped to one ad account.',
9894
+ description: 'Read the brand’s connected ChatGPT Ads account — the ads that appear below ChatGPT answers. Call with NO ids to get the ad account itself (name, currency, status, 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 (Settings ▸ Connectors ▸ ChatGPT Ads, or connect_connector): the user pastes an Advertiser API key from ChatGPT Ads Manager ▸ Settings — there is no OAuth and no manager account, and one key is scoped to one ad account.',
9822
9895
  inputSchema: {
9823
9896
  campaignId: z.string().optional().describe('list this campaign’s ad groups'),
9824
9897
  adGroupId: z.string().optional().describe('list this ad group’s ads'),
@@ -10098,7 +10171,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
10098
10171
  // sentences move with them.
10099
10172
  server.registerTool('list_apple_ads_campaigns', {
10100
10173
  title: 'List Apple Ads campaigns',
10101
- 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): 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.',
10174
+ 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.',
10102
10175
  inputSchema: {
10103
10176
  limit: z.number().optional().describe('page size, default 100, Apple’s max is 1000'),
10104
10177
  offset: z.number().optional().describe('offset pagination'),
@@ -11158,9 +11231,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
11158
11231
  description: 'ATTRIBUTED CONVERSIONS for ChatGPT Ads — the number the whole pixel + conversion-event setup exists to produce, and the one openai_ads_report structurally cannot give you (that endpoint family carries impressions, clicks, spend, CTR, CPC and CPM and no conversions at all). Pass entityIds — the campaign / ad group / ad ids to report on — with a matching level; the default window is the last 30 days. NEVER ADD conversions AND viewThroughConversions TOGETHER: OpenAI states that "conversions is always equal to click_through_conversions" and that view-through is "a separate, supplemental metric" NOT added to that total, and that view-through is reporting-only because CPA, post-click CVR, bidding, billing and conversion optimization all remain click-through-based. NO ROWS means no attributed conversion was recorded, not that data is missing — say exactly that, and check that an event setting exists (list_openai_ads_conversion_events) and that its pixel snippet is actually live on the site. RECEIVED EVENTS: pass recentEvents:true (no ids needed) to read a recent SAMPLE of the events OpenAI actually received on the pixel — type, event time, receive time, API channel and the event id — which answers "did OpenAI get the signup at all?" when a conversion is missing. It is a sample, not a complete log, and receiving an event is not the same as attributing it. Read-only, free.',
11159
11232
  inputSchema: {
11160
11233
  level: z.enum(['ad_account', 'campaign', 'ad_group', 'ad']).optional().describe('inferred from which id you pass — default ad_account'),
11161
- entityIds: z.array(z.string()).optional().describe('the ids to report on — REQUIRED (ChatGPT Ads has no "everything" mode)'),
11234
+ entityIds: z.array(z.string()).optional().describe('the campaign, ad group or ad ids to report on, required below the account level; omit for level ad_account, which sums every campaign'),
11162
11235
  campaignId: z.string().optional(), adGroupId: z.string().optional(), adId: z.string().optional(),
11163
11236
  since: z.string().optional().describe('YYYY-MM-DD'), until: z.string().optional().describe('YYYY-MM-DD'),
11237
+ granularity: z.enum(['none', 'daily']).optional().describe('daily = one row per day per entity, plus a byDate total; default none = one total per entity'),
11238
+ breakdown: z.enum(['device', 'country']).optional().describe('split each row by device or by country'),
11164
11239
  recentEvents: z.boolean().optional().describe('true = list a recent sample of the events OpenAI RECEIVED on the pixel instead of attributed totals'),
11165
11240
  pixelId: z.string().optional().describe('with recentEvents: the pixel_id to read; omit to use the account\'s first pixel'),
11166
11241
  limit: z.number().optional().describe('with recentEvents: how many events, 1-50 (default 50)'),
@@ -11170,7 +11245,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
11170
11245
  }, wrap(async (a) => {
11171
11246
  const d = await apiPost('/api/openai-ads/conversions', a);
11172
11247
  if (d.mode === 'recentEvents') return ok(`${d.note}\n${(d.events || []).map(e => `• ${e.receivedAt || '?'} ${e.eventType}${e.customEventName ? `/${e.customEventName}` : ''} via ${e.apiChannel || '?'} id=${e.eventId ?? '—'}${e.hasOppref ? ' (ad click)' : ''}`).join('\n')}`, d);
11173
- return ok(`${d.note}\n${(d.rows || []).map(r => `• ${r.entityId} — ${r.conversions ?? 0} conversion(s), ${r.viewThroughConversions ?? 0} view-through`).join('\n')}`, d);
11248
+ const daily = Array.isArray(d.byDate) && d.byDate.length ? `\nBy day: ${d.byDate.map(x => `${x.date} ${x.conversions}`).join(' · ')}` : '';
11249
+ return ok(`${d.note}${daily}\n${(d.rows || []).slice(0, 200).map(r => `• ${r.date ? `${r.date} ` : ''}${r.entityId}${r.country ? ` ${r.country}` : ''}${r.device ? ` ${r.device}` : ''} — ${r.conversions ?? 0} conversion(s), ${r.viewThroughConversions ?? 0} view-through`).join('\n')}`, d);
11174
11250
  }));
11175
11251
  server.registerTool('preview_openai_ads_ad', {
11176
11252
  title: 'Preview a ChatGPT Ads ad',
@@ -11409,7 +11485,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
11409
11485
 
11410
11486
  server.registerTool('list_pinterest_ads_campaigns', {
11411
11487
  title: 'List Pinterest ad accounts / campaigns',
11412
- description: 'Read the brand’s connected Pinterest AD account(s). Call with NO adAccountId to list the ad accounts shared with this brand — 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.',
11488
+ description: 'Read the brand’s connected Pinterest AD account(s). Call with NO adAccountId to list the ad accounts shared with this brand — 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).',
11413
11489
  inputSchema: {
11414
11490
  adAccountId: z.string().optional().describe('Pinterest ad account id — omit to list the ad accounts shared with this brand'),
11415
11491
  campaignId: z.string().optional().describe('one campaign → its ad groups and ads too (the only way to enumerate them)'),
@@ -11628,7 +11704,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
11628
11704
  // account the brand was not given, and everything is created PAUSED with no override.
11629
11705
  server.registerTool('list_reddit_ads_campaigns', {
11630
11706
  title: 'List Reddit ad accounts / campaigns',
11631
- description: 'Read the brand’s connected Reddit AD account(s). Call with NO adAccountId to list the ad accounts shared with this brand — 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.',
11707
+ description: 'Read the brand’s connected Reddit AD account(s). Call with NO adAccountId to list the ad accounts shared with this brand — 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).',
11632
11708
  inputSchema: { adAccountId: z.string().optional().describe('Reddit ad account id (a2_…) — omit to list the ad accounts shared with this brand') },
11633
11709
  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() },
11634
11710
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -14721,7 +14797,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14721
14797
  const held = Number(d.held || 0);
14722
14798
  if (!list.length) {
14723
14799
  return ok(held
14724
- ? `NONE of the ${held} LinkedIn Page(s) this account administers is shared with this brand, so nothing can be posted as a Page. Tell the user to tick the Page(s) that belong to this brand under Workspace ▸ Connectors ▸ LinkedIn ▸ Manage accounts. Do not guess a Page id.`
14800
+ ? `NONE of the ${held} LinkedIn Page(s) this account administers is shared with this brand, so nothing can be posted as a Page. Tell the user to tick the Page(s) that belong to this brand under Workspace ▸ Connectors ▸ LinkedIn ▸ Manage accounts (or call list_connector_accounts with provider linkedin, then set_connector_accounts). Do not guess a Page id.`
14725
14801
  : '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);
14726
14802
  }
14727
14803
  return ok(`${list.length} LinkedIn Page(s) shared with this brand. 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 brand and cannot be posted to.)` : ''}`, d);
@@ -14817,7 +14893,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14817
14893
  // which is how "we have no data on that post" becomes a confident "it got no engagement".
14818
14894
  server.registerTool('linkedin_page_analytics', {
14819
14895
  title: 'Organic performance of a LinkedIn company Page',
14820
- description: 'ORGANIC performance for one of the brand’s LinkedIn COMPANY PAGES: total followers, followers gained (organic vs paid) across the window, Page views (all / unique / desktop / mobile), and the impressions, unique impressions, clicks, likes, comments, shares and engagement rate of the Page’s posts. This is what answers “is our LinkedIn actually working” and “did that post land”. It is NOT linkedin_ads_report — that covers PAID campaigns; LinkedIn excludes sponsored activity from these figures entirely. Pass postUrns (the urn:li:share:… / urn:li:ugcPost:… that post_to_linkedin_page returned) for PER-POST numbers; LinkedIn forbids a date range together with named posts, so that switches to lifetime-per-post. Only Pages the user ticked in Manage accounts are readable — a Page the account merely administers is refused, by design. LinkedIn keeps 12 months, follower figures run about 2 days behind, and it OMITS posts with no recorded activity rather than returning zeros: report an absent post or an unavailable section as MISSING data, never as zero. Read-only, 0 credits. Needs LinkedIn connected with the organization scopes.',
14896
+ description: 'ORGANIC performance for one of the brand’s LinkedIn COMPANY PAGES: total followers, followers gained (organic vs paid) across the window, Page views (all / unique / desktop / mobile), and the impressions, unique impressions, clicks, likes, comments, shares and engagement rate of the Page’s posts. This is what answers “is our LinkedIn actually working” and “did that post land”. It is NOT linkedin_ads_report — that covers PAID campaigns; LinkedIn excludes sponsored activity from these figures entirely. Pass postUrns (the urn:li:share:… / urn:li:ugcPost:… that post_to_linkedin_page returned) for PER-POST numbers; LinkedIn forbids a date range together with named posts, so that switches to lifetime-per-post. Only Pages the user ticked in Manage accounts (or call list_connector_accounts with provider linkedin, then set_connector_accounts) are readable — a Page the account merely administers is refused, by design. LinkedIn keeps 12 months, follower figures run about 2 days behind, and it OMITS posts with no recorded activity rather than returning zeros: report an absent post or an unavailable section as MISSING data, never as zero. Read-only, 0 credits. Needs LinkedIn connected with the organization scopes.',
14821
14897
  inputSchema: {
14822
14898
  organizationId: z.string().optional().describe('numeric Page id from list_linkedin_pages — omit only when exactly one Page is shared with this brand'),
14823
14899
  startDate: z.string().optional().describe('YYYY-MM-DD, default 28 days ago (LinkedIn keeps 12 months)'),
@@ -16931,13 +17007,15 @@ function memoryNoteVerdict(text) {
16931
17007
  }));
16932
17008
  server.registerTool('list_connectors', {
16933
17009
  title: 'List connectors',
16934
- 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 \u25b8 Connectors. Read-only, free.',
17010
+ 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 \u25b8 Connectors. A paste-a-key account needs no browser at all: connect_connector connects it from here if the user prefers that to the app. Read-only, free.',
16935
17011
  inputSchema: {},
16936
17012
  outputSchema: { connectors: z.array(z.any()).optional(), providers: z.array(z.string()).optional() },
16937
17013
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
16938
17014
  }, wrap(async () => {
16939
17015
  const d = await apiGet('/api/connectors');
16940
17016
  const on = (d.connectors || []).filter(c => c && c.status !== 'revoked');
17017
+ // The server's link for THIS brand with a {provider} slot; an older server gets the bare link.
17018
+ const linkFor = (p) => ((typeof d.connectLink === 'string' && d.connectLink.includes('{provider}')) ? d.connectLink : 'https://app.hermoso.ai/?connect={provider}').replace('{provider}', p);
16941
17019
  // "0 connected" HAS TWO CAUSES AND ONLY ONE OF THEM IS "nothing is connected". The other is that this connection
16942
17020
  // is pointed at a different WORKSPACE than the one the user is looking at in the app — which is what a teammate
16943
17021
  // hits, because connectors are per-brand and a brand shared with them lives on somebody else's account. That
@@ -16946,7 +17024,7 @@ function memoryNoteVerdict(text) {
16946
17024
  // …and a THIRD cause, which used to be indistinguishable from the first: the caller's ACCESS to the shared
16947
17025
  // workspace was revoked. The route now 403s that case by name instead of quietly scoping the read to the
16948
17026
  // caller's own empty account (R6-6), so it surfaces as an error through wrap() rather than as "0 connected".
16949
- const empty = '\nIf this brand should have connected accounts, check WHICH workspace this connection is on: list_brands names every brand on this account plus every one shared with you, and use_brand switches. Otherwise connect one in the app — Workspace ▸ Connectors (linking needs a browser).';
17027
+ const empty = '\nIf this brand should have connected accounts, check WHICH workspace this connection is on: list_brands names every brand 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.';
16950
17028
  // AN AGENT NEVER SEES A PICKER PROVIDER'S RAW `accountLabel` (2026-08-04). It names whoever AUTHORIZED the
16951
17029
  // connection, and on LinkedIn that is a tickable, postable identity the user has almost certainly NOT ticked
16952
17030
  // (config.liMember is off by default) — printing it told Studio a personal profile was postable when it was not.
@@ -16975,15 +17053,15 @@ function memoryNoteVerdict(text) {
16975
17053
  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` : ''}`);
16976
17054
  const postingNote = posting.length
16977
17055
  ? '\n\n' + posting.map(c => `ℹ ${c.grantModeNote}`).join('\n')
16978
- + '\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.'
17056
+ + '\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') + '.'
16979
17057
  : '';
16980
17058
  const gapNote = gap.length
16981
17059
  ? `\n\n⚠ ${gap.length} connection(s) need their permissions updated:\n`
16982
17060
  + gap.map(c => ` • ${c.scopeDriftNote || `${c.provider}: missing ${(c.scopeDrift.missing || []).join(', ')}`}`).join('\n')
16983
- + '\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.'
17061
+ + '\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('')
16984
17062
  : '';
16985
17063
  const safe = { ...d, connectors: (d.connectors || []).map(({ accountLabel, ...c }) => c) };
16986
- return ok(`${on.length} connected:\n${lines.join('\n') || ' (none)'}\nAvailable to connect: ${(d.providers || []).join(', ') || '(none configured)'}.${on.length ? '' : empty}${gapNote}${postingNote}`, safe);
17064
+ return ok(`${on.length} connected:\n${lines.join('\n') || ' (none)'}\nAvailable to connect through a sign-in screen (needs a browser; the link is ${linkFor('<provider>')}): ${(d.providers || []).join(', ') || '(none configured)'}.\nPaste-a-key, connectable from here with connect_connector or in the app: ${Object.keys(KEY_CONNECTORS).join(', ')}.${on.length ? '' : empty}${gapNote}${postingNote}`, safe);
16987
17065
  }));
16988
17066
  // ── CONNECTOR WRITES. Connecting needs a browser (OAuth consent) and is correctly NOT headless — but the other two
16989
17067
  // halves of connector management are, and were web-only: DISCONNECTING, and choosing WHICH accounts a brand may
@@ -17204,7 +17282,7 @@ function memoryNoteVerdict(text) {
17204
17282
  description: 'Disconnect a third-party account from this workspace (Meta, Google Ads, Google Drive/Sheets/Docs, YouTube, TikTok, LinkedIn, X, Reddit, Pinterest, Google Business, Microsoft Advertising/OneDrive, Slack, …). This always drops the stored credentials, so every tool for that provider stops working immediately and posts/campaigns already published are NOT affected. WHETHER IT ALSO REVOKES THE GRANT AT THE PROVIDER DEPENDS ON THE PROVIDER — a few (Threads, Microsoft) publish no revocation endpoint, so the authorisation stays in place until the user removes it in that provider\'s own settings. The unconfirmed call reports which it is for this provider (list_connectors also carries it as revokesAtProvider) — relay that verbatim rather than promising a revoke. RECONNECTING NEEDS A BROWSER (the provider\'s consent screen) — an agent cannot undo this. Name the provider to the user, then call with confirm:true. Use list_connectors for the exact provider ids.',
17205
17283
  inputSchema: {
17206
17284
  provider: z.string().describe('provider id exactly as list_connectors reports it, e.g. "meta", "google_ads", "youtube", "linkedin"'),
17207
- confirm: z.boolean().optional().describe('REQUIRED true — reconnecting needs the user\'s browser'),
17285
+ confirm: z.boolean().optional().describe('REQUIRED true — reconnecting a sign-in account afterwards needs the user\'s browser; a paste-a-key account is reconnected with connect_connector'),
17208
17286
  account: z.string().optional().describe('on a channel with several connected accounts (TikTok, X, YouTube, Threads, Bluesky, Telegram, Reddit, Pinterest): remove ONLY this account (@handle or id from list_connector_accounts) and keep the others'),
17209
17287
  },
17210
17288
  outputSchema: { ok: z.boolean().optional(), provider: z.string().optional(), disconnected: z.boolean().optional(), revokesAtProvider: z.string().nullable().optional() },
@@ -17212,8 +17290,11 @@ function memoryNoteVerdict(text) {
17212
17290
  }, wrap(async (a) => {
17213
17291
  const provider = String(a.provider || '').trim().toLowerCase();
17214
17292
  if (!provider) return { content: [{ type: 'text', text: 'Name the provider to disconnect (see list_connectors).' }], isError: true };
17215
- const live = ((await apiGet('/api/connectors')).connectors || []).filter(c => c && c.status !== 'revoked');
17293
+ const _cd = await apiGet('/api/connectors');
17294
+ const live = (_cd.connectors || []).filter(c => c && c.status !== 'revoked');
17216
17295
  const hit = live.find(c => c.provider === provider);
17296
+ const _isKey = Object.prototype.hasOwnProperty.call(KEY_CONNECTORS, provider);
17297
+ const _link = ((typeof _cd.connectLink === 'string' && _cd.connectLink.includes('{provider}')) ? _cd.connectLink : 'https://app.hermoso.ai/?connect={provider}').replace('{provider}', provider);
17217
17298
  if (!hit) return { content: [{ type: 'text', text: `Nothing connected for "${provider}". Connected: ${live.map(c => c.provider).join(', ') || '(none)'}.` }], isError: true };
17218
17299
  // agentLabel, never accountLabel — the confirm sentence is model-visible text like any other (2026-08-04).
17219
17300
  // THE REVOKE CLAIM IS THE SERVER'S, AND SINCE 2026-08-13 IT IS THE SAME FOR EVERY PROVIDER: a disconnect
@@ -17223,13 +17304,84 @@ function memoryNoteVerdict(text) {
17223
17304
  // disconnectClaim() and names where the user can really revoke us. It is printed UNCONDITIONALLY on both the
17224
17305
  // confirm prompt and the result, because "this is brand-scoped" is a capability limit that stays true forever
17225
17306
  // rather than a per-provider quirk — the connector-card rule about desc-vs-setup copy, applied to an agent.
17226
- if (a.confirm !== true) return ok(`This disconnects ${hit.provider}${hit.agentLabel ? ` (${hit.agentLabel})` : ''} from this workspace: the stored credentials are deleted and every ${hit.provider} tool stops working until someone reconnects it IN A BROWSER — I can't do that step. ${hit.removalNote || ''} Already-published posts and running campaigns are untouched. Confirm with the user, then call again with confirm:true.`, { ok: false, provider, revokesAtProvider: hit.revokesAtProvider || null });
17307
+ if (a.confirm !== true) return ok(`This disconnects ${hit.provider}${hit.agentLabel ? ` (${hit.agentLabel})` : ''} from this workspace: the stored credentials are deleted and every ${hit.provider} tool stops working until ${_isKey ? `it is connected again (connect_connector with the key, or ${_link} in a browser)` : `someone reconnects it IN A BROWSER (${_link}) — I can't do that step`}. ${hit.removalNote || ''} Already-published posts and running campaigns are untouched. Confirm with the user, then call again with confirm:true.`, { ok: false, provider, revokesAtProvider: hit.revokesAtProvider || null });
17227
17308
  // Straight through the app's own disconnect route → Connectors.remove(). The shared-grant guard that used to
17228
17309
  // matter here (all seven google_* connectors ride ONE OAuth client id, so a naive revoke killed the siblings)
17229
17310
  // is moot now that nothing revokes — the hazard is closed rather than guarded.
17230
17311
  const _one = await apiPost(`/api/connectors/${encodeURIComponent(provider)}/disconnect`, a.account ? { account: String(a.account) } : {});
17231
17312
  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 });
17232
- return ok(`Disconnected ${provider}${hit.agentLabel ? ` (${hit.agentLabel})` : ''} from this brand.${hit.removalNote ? ` ${hit.removalNote}` : ''} Reconnect from the app: Workspace ▸ Connectors ▸ ${provider}.`, { ok: true, provider, disconnected: true, revokesAtProvider: hit.revokesAtProvider || null });
17313
+ 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 });
17314
+ }));
17315
+ // ── CONNECT A PASTE-A-KEY ACCOUNT FROM HERE (2026-09-12) ─────────────────────────────────────────────────────────────
17316
+ // 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
17317
+ // server's own validation (a live check with the vendor), workspace resolution and saved row are unchanged, and the
17318
+ // reply is the READ-BACK of the saved connection. A submitted value is never echoed: every secret field is redacted
17319
+ // from anything that comes back, the server's own error text included. An OAuth provider is refused with its one-click
17320
+ // link, a field the route does not read is refused by name, and in every refusal nothing is sent.
17321
+ const keyConnectFieldList = (spec) => Object.entries(spec.fields).map(([n, f]) => `${n}${f.includes('r') ? '*' : ''}`).join(', ');
17322
+ 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; };
17323
+ server.registerTool('connect_connector', {
17324
+ title: 'Connect a paste-a-key account',
17325
+ 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(' · ')}.`,
17326
+ inputSchema: {
17327
+ provider: z.string().describe(`the connector id: ${Object.keys(KEY_CONNECTORS).join(', ')}`),
17328
+ 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'),
17329
+ },
17330
+ outputSchema: { ok: z.boolean().optional(), provider: z.string().optional(), connected: z.boolean().optional(), label: z.string().nullable().optional(), status: z.string().nullable().optional(), warning: z.string().optional(), step: z.string().optional(), publicKey: z.string().optional(), setupToken: z.string().optional(), expiresInHours: z.number().optional() },
17331
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
17332
+ }, wrap(async (a) => {
17333
+ const refuse = (text) => ({ content: [{ type: 'text', text }], isError: true });
17334
+ const provider = String(a.provider || '').trim().toLowerCase();
17335
+ const ids = Object.keys(KEY_CONNECTORS).join(', ');
17336
+ const spec = Object.prototype.hasOwnProperty.call(KEY_CONNECTORS, provider) ? KEY_CONNECTORS[provider] : null;
17337
+ if (!spec) {
17338
+ if (!/^[a-z_]{1,40}$/.test(provider)) return refuse(`Name the connector to connect. Paste-a-key connectors this can connect: ${ids}.`);
17339
+ // Not a paste-a-key connector. The server's one answer (the same one the app's link handler reads) says whether it
17340
+ // is on offer and under which id; a read that fails still hands over the link, because the app refuses by name.
17341
+ let target;
17342
+ try { target = (await apiGet('/api/auth/config', { connect: provider })).connect; } catch {}
17343
+ if (target === null) return refuse(`"${provider}" is not a connection Hermoso offers here, so nothing was sent. Paste-a-key connectors this can connect: ${ids}. list_connectors shows the accounts that connect through a sign-in screen.`);
17344
+ const p = (target && target.provider) || provider;
17345
+ let url = `https://app.hermoso.ai/?connect=${p}`;
17346
+ try { const tpl = (await apiGet('/api/connectors')).connectLink; if (typeof tpl === 'string' && tpl.includes('{provider}')) url = tpl.replace('{provider}', p); } catch {}
17347
+ return refuse(`${(target && target.label) || p} connects through its own sign-in screen (OAuth), which needs a browser, so it cannot be connected from a chat and nothing was sent. Hand your human this one-click link: ${url} (it opens Hermoso${url.includes('&brand=') ? ' on this brand' : ''}, signs them in if needed, and starts the connection), then retry.`);
17348
+ }
17349
+ const given = (a.fields && typeof a.fields === 'object' && !Array.isArray(a.fields)) ? a.fields : {};
17350
+ const unknown = Object.keys(given).filter(n => !Object.prototype.hasOwnProperty.call(spec.fields, n));
17351
+ if (unknown.length) return refuse(`${spec.label} does not take ${unknown.map(n => `"${n}"`).join(', ')}, so nothing was sent. Its fields are ${keyConnectFieldList(spec)} (* = required): ${spec.how}.`);
17352
+ const vals = {};
17353
+ for (const [n, v] of Object.entries(given)) {
17354
+ if (v == null) continue;
17355
+ if (typeof v !== 'string') return refuse(`${spec.label} field "${n}" must be text, so nothing was sent.`);
17356
+ if (v.trim()) vals[n] = v.trim();
17357
+ }
17358
+ const secrets = Object.entries(vals).filter(([n]) => spec.fields[n].includes('s')).map(([, v]) => v);
17359
+ if (provider === 'apple_ads') {
17360
+ const idNames = ['clientId', 'teamId', 'keyId'];
17361
+ if (!vals.privateKey && !vals.setupToken) {
17362
+ if (idNames.some(k => vals[k])) return refuse('Apple Ads needs the key those ids belong to, so nothing was sent: pass privateKey (a key already registered with Apple) or setupToken (call connect_connector for apple_ads with no fields first, which generates one).');
17363
+ const kp = await apiPost('/api/connectors/apple-ads/keypair', {});
17364
+ return ok(`Apple Ads, step 1 of 2. Hermoso generated a signing key pair and sealed the private half. Have the user save this PUBLIC key in Apple Ads (${kp.settingsUrl || 'Account Settings ▸ API'}); Apple then shows the clientId, teamId and keyId:\n\n${kp.publicKey}\n\nThen call connect_connector again with provider "apple_ads" and the fields setupToken, clientId, teamId and keyId. setupToken (valid ${kp.expiresInHours || 24} hours and only for this Hermoso account, so treat it like a password):\n${kp.setupToken}${kp.roleNote ? `\n${kp.roleNote}` : ''}`, { ok: true, provider, connected: false, step: 'keypair', publicKey: kp.publicKey, setupToken: kp.setupToken, ...(kp.expiresInHours != null ? { expiresInHours: kp.expiresInHours } : {}) });
17365
+ }
17366
+ const missing = idNames.filter(k => !vals[k]);
17367
+ if (missing.length) return refuse(`Apple Ads needs ${missing.join(', ')}, so nothing was sent: Apple shows all three above the public key once it is saved (Account Settings ▸ API).`);
17368
+ } else {
17369
+ const missing = Object.entries(spec.fields).filter(([n, f]) => f.includes('r') && !vals[n]).map(([n]) => n);
17370
+ if (missing.length) return refuse(`${spec.label} needs ${missing.join(', ')}, so nothing was sent: ${spec.how}.`);
17371
+ }
17372
+ let d;
17373
+ try { d = await apiPost(`/api/connectors/${spec.route}`, vals); }
17374
+ catch (e) { throw Object.assign(new Error(keyConnectRedact(e?.message || e, secrets)), { status: e?.status, _viaApi: e?._viaApi, ...(e?.connector ? { connector: e.connector } : {}) }); }
17375
+ let row = null, readOk = true;
17376
+ try { row = ((await apiGet('/api/connectors')).connectors || []).find(c => c && c.provider === provider && c.status !== 'revoked') || null; } catch { readOk = false; }
17377
+ const warning = keyConnectRedact(typeof d?.warning === 'string' ? d.warning : '', secrets);
17378
+ const label = row && row.agentLabel ? keyConnectRedact(row.agentLabel, secrets) : '';
17379
+ const head = row
17380
+ ? `Connected ${spec.label}${label ? ` (${label})` : ''} to this workspace, read back from the saved connection (status ${row.status || 'active'}).`
17381
+ : readOk
17382
+ ? `${spec.label} accepted the credential, but the read-back lists no ${provider} connection on this workspace. Check which brand this connection is on with list_brands, then list_connectors.`
17383
+ : `${spec.label} accepted the credential, but the read-back could not run. Call list_connectors to confirm it is connected.`;
17384
+ return ok(keyConnectRedact(`${head}${warning ? `\nNote: ${warning}` : ''}\nThe key is stored encrypted and Hermoso will not show it again; it does stay in this chat's history.`, secrets), { ok: true, provider, connected: !!row, label: label || null, status: row ? (row.status || 'active') : null, ...(warning ? { warning } : {}) });
17233
17385
  }));
17234
17386
  // ── REMOVE ONLY WHAT I CONTRIBUTED (2026-08-11) ────────────────────────────────────────────────────────────────
17235
17387
  // The web gained a "Remove my account" control the same day, and a WEB-ONLY capability is a defect
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.233",
3
+ "version": "0.1.234",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
- "description": "AI ad studio and marketing MCP server with 827 tools. Research the ads already running in any market, generate finished image, video and UGC avatar ads, publish and schedule them to your own channels, build and manage the ad campaigns behind them, and read what they achieved. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
5
+ "description": "AI ad studio and marketing MCP server with 828 tools. Research the ads already running in any market, generate finished image, video and UGC avatar ads, publish and schedule them to your own channels, build and manage the ad campaigns behind them, and read what they achieved. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "hermoso": "bin/hermoso.mjs"