hermoso 0.1.533 → 0.1.535
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -8
- package/mcp/brand-look.mjs +120 -0
- package/mcp/client.mjs +1 -1
- package/mcp/http.mjs +37 -2
- package/mcp/openai-extensions.mjs +1 -1
- package/mcp/roster-scope.mjs +1 -1
- package/mcp/tool-hints.mjs +1 -1
- package/mcp/tools.mjs +330 -175
- package/package.json +2 -2
- package/skills/hermoso-marketing/SKILL.md +1 -1
package/mcp/tools.mjs
CHANGED
|
@@ -44,8 +44,9 @@ import { registerLibraryApp, installEntrypointIcons, chatgptExtOn, EXT_PREFS_KEY
|
|
|
44
44
|
import { STATIC_BATCH, normalizeRatios, renderList } from './static-batch.mjs';
|
|
45
45
|
import { findClientLocalMedia, clientLocalMediaError, MEDIA_PARAM_NOTE } from './media-ref.mjs'; // ONE rule: a media reference is a URL, never a path on the caller's machine (2026-11-05)
|
|
46
46
|
import { dataUriPayload } from './upload-payload.mjs'; // a dataUri whose payload is not base64 is refused by what it holds, never decoded to noise (2026-11-04)
|
|
47
|
+
import { parseBrandFonts, parseBrandPalette, describeBrandFonts, PALETTE_MAX } from './brand-look.mjs'; // the brand's fonts + palette: the one parser every door uses (2026-11-13)
|
|
47
48
|
import { joinQueuedImageNote } from './queued-image-note.mjs'; // the queued generate_image note says the logo's corner once (journey QA 2026-11-03)
|
|
48
|
-
import { CHATGPT_ROSTER_FAMILIES } from './chatgpt-roster.mjs'; // the ranked ChatGPT families, GENERATED by tools/chatgpt-roster-rank.mjs
|
|
49
|
+
import { CHATGPT_ROSTER_FAMILIES, CHATGPT_ROSTER_LEFT_OUT } from './chatgpt-roster.mjs'; // the ranked ChatGPT families, GENERATED by tools/chatgpt-roster-rank.mjs
|
|
49
50
|
import { directoryNeutral, paramFallback, makeDirectoryNeutral, directoryToolNames } from './directory-neutral.mjs'; // directory scope: no instructions about other tools or model behaviour in any description (2026-11-06)
|
|
50
51
|
|
|
51
52
|
// AD MONEY IS IN THE AD ACCOUNT'S CURRENCY, NEVER "$" (2026-11-06). A CAD ad set's 20/day was printed "$20/day" and
|
|
@@ -298,7 +299,7 @@ export const CAPABILITY_MAP = [
|
|
|
298
299
|
'B) CREATE: on-brand image & video ads (real product composited in, copy + CTA baked). draft_brand / get_brand / update_brand (patch single fields without re-onboarding) / use_brand · list_brands / create_brand / delete_brand (one account holds MANY profiles: brands, clients or creators, so an agency runs every client through here; each has its own brand details, memory, swipefile, Library and connectors, and create_brand → draft_brand onboards a new one end to end) · plan_ad (concept + copy) → render_ad (the Studio quality pipeline) or generate_image / generate_video / generate_avatar (UGC creators + lip-sync) · list_creators / save_creator / delete_creator (the workspace’s REUSABLE CAST — saved creators with their portrait urls, so the SAME person stars in every ad; list them before ever generating a new one, then cast one into the ad with render_ad’s `creator`, which also skips the character-portrait render and so costs LESS than casting a stranger) · make_template_ad (native HTML ad formats) · make_motion_film (motion graphics: kinetic typography, logo intros, launch films; no video model) · clone_static / recast_motion / reframe_video / insert_scene / upscale_video / dub_video / change_voice / finish_video / fix_beat / hook_variants / stitch_video · plan_variations + score_ad (fan out + rank).',
|
|
299
300
|
'C) RAW MODEL PLAYGROUND — direct access to the full catalog (30+ image / video / voice / writing models, each with the exact per-render credit cost shown above), no ad framing: generate_image / generate_video (useBrand:false) for plain prompt-only renders, generate_voice for raw text-to-speech against any voice engine, and generate_text for the writing models (Claude / Gemini / GPT / Llama / DeepSeek…) — all against ANY catalog id.',
|
|
300
301
|
'D) ACCOUNT — hermoso_credits (balance) · billing_status (plan + your billing role) · buy_credits (one-click top-up on the saved card, or a first-purchase checkout link) · upgrade_plan / set_auto_reload (admin) · list_jobs / get_job (track async renders) · get_settings / update_settings (the LANGUAGE every ad, script and plan is written in (chat replies follow the language the user writes in) — set it once and every render obeys it, over MCP as well as in the app — plus app appearance and the weekly competitor-watch email) · list_alert_settings / set_alert_events / send_test_alert (where ALERTS go: Slack, Discord or a signed webhook, for renders, posts, approvals, competitor findings, leads and low credits) · list_team / invite_member / remove_member / set_role (who else can work in this profile).',
|
|
301
|
-
'E) PUBLISH & MANAGE YOUR CHANNELS — post, run ads, and organize files on the user’s OWN connected accounts (Settings ▸ Connectors), all driven over this MCP. Bring ANY file in with upload_file (desktop/external media, not just Hermoso renders). MANAGING THE CONNECTIONS THEMSELVES: list_connectors (what is linked, and what could be) · list_connector_accounts then set_connector_accounts (WHICH Facebook Pages / Instagram / Meta ad accounts / Google Ads customers / LinkedIn company Pages and ad accounts / Pinterest ad accounts / Microsoft Advertising accounts / Reddit ad accounts / Google Business listings / Google Analytics properties this profile may post to, spend from and read — one person often administers or has access to several belonging to different clients, only the chosen ones are usable anywhere, and an empty choice shares nothing) · connect_connector (connect a PASTE-A-KEY account from here: ' + Object.values(KEY_CONNECTORS).map((s) => s.label).join(', ') + '; offer it beside the Connectors page in the app and let the user choose, because a key pasted into a chat stays in its history) · disconnect_connector (drop a connection from this brand, without revoking access at the platform; confirm-gated because reconnecting a sign-in account needs a browser) · leave_connector (on a connector several teammates can each contribute their OWN account to, remove just YOURS — teammates’ accounts keep working and nothing is revoked at the provider). LINKING an account that connects through a provider sign-in screen (OAuth) is the one step that is not headless: hand the user its connect link, https://app.hermoso.ai/?connect=<provider>, or send them to Workspace ▸ Connectors in the app. META: list_meta_pages · instagram_insights (ACCOUNT-level Instagram performance — views, reach, accounts engaged, interactions, saves, profile link taps — plus the audience DEMOGRAPHICS by age / city / country / gender) · list_meta_stories (the Stories live right now on Instagram and Facebook Pages) / get_meta_story (one story id: live details, or that it expired) / instagram_story_insights (a Story’s reach, views, replies, link clicks and navigation, kept readable after its 24 hours end) / list_instagram_media (the brand’s own recent Instagram posts, and where the media id every other Instagram tool needs comes from) · list_instagram_dm_automations / save_instagram_dm_automation / test_instagram_dm_automation / delete_instagram_dm_automation (INSTAGRAM DM AUTOMATIONS — a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply; test shows what a sample would send without sending) · list_dm_automations / save_dm_automation / test_dm_automation / delete_dm_automation (THE SAME ON EVERY CHANNEL — Instagram, Facebook Page comments and ads + Messenger, and X: a keyword mention or reply gets a public reply, a DM keyword a DM; X is billed in credits per delivered event and per send) · search_instagram_audio (licensed music and original sounds an Instagram Reel may use, by keyword or trending) · list_instagram_collab_invites then respond_instagram_collab_invite (collab-post invitations waiting on the account; accept or decline one, read back from Instagram) · list_instagram_collab_media (posts this account co-authors) · like_instagram (like a post or comment as the connected account) · post_to_meta (Facebook / Instagram / Threads) · list_meta_posts (the Page’s / Instagram account’s OWN existing posts with their ids — THIS is where the postId every other Meta read needs comes from; without it an agent that did not itself just publish has no way to name a post) · list_meta_ads + meta_insights (read existing campaigns/ad sets/ads + spend/CTR/CPC, with breakdowns by age / gender / placement / country) · preview_meta_ad (Meta renders the REAL ad per placement — a link the user can look at, valid 24h) · meta_catalog_suggestions (which catalog Meta would show under a new ad) · list_meta_pixels + create_meta_pixel (the pixel a conversion-optimised campaign REQUIRES — Meta will not let a build optimise for conversions without one, and until these existed a caller had no way to discover the id they had to pass) · estimate_meta_reach (how many people a targeting spec reaches, BEFORE a budget is committed) · list_meta_audiences / create_meta_audience (website-pixel retargeting, Page + Instagram engagement audiences, and lookalikes — creating one spends nothing) · list_meta_conversations / read_meta_conversation / reply_to_meta_message (MESSENGER AND INSTAGRAM DMs — the brand’s direct-message threads and a reply to someone who wrote first. Meta only permits a reply within 24 HOURS of the person acting, and read_meta_conversation says whether that window is open BEFORE anything is drafted; Hermoso sends replies only, never a proactive message or a message tag) · subscribe_meta_webhooks / meta_webhook_status / unsubscribe_meta_webhooks / list_meta_webhook_events (REAL-TIME EVENTS — have Meta PUSH new comments, mentions, lead-form submissions and inbound DMs to Hermoso instead of polling for them. Every other inbox read asks an edge “anything new?”; this is the only way to be TOLD, and it is how a lead arrives the moment it is submitted rather than when somebody thinks to look. An empty feed is ambiguous — check meta_webhook_status first, because an unsubscribed Page is silent and looks exactly like a quiet one) · instagram_collaborators (who ACCEPTED a Collab invite on an Instagram post — publishing only SENDS the invite, so this is the only way to know whether the post is actually live on the other account too) · list_instagram_shopping_catalogs / search_instagram_shopping_products / manage_instagram_product_tags (INSTAGRAM SHOPPING — make a post SHOPPABLE. Check eligibility and the account’s taggable catalogs, find the product ids, then pass productTags to post_to_meta so tapping the picture opens the product’s price sheet inside Instagram. Tagging needs an APPROVED Instagram Shop, so check FIRST — otherwise it fails after the media is already uploaded — and note that a tag whose product is not “approved” is stored and shown to nobody. Meta publishes no way to REMOVE a tag) · create_meta_catalog / update_meta_catalog / meta_catalog_blast_radius / delete_meta_catalog (BUILD AND RETIRE A CATALOG — create one on a named business portfolio, rename or re-point it, and, before ever proposing a delete, read meta_catalog_blast_radius: a catalog delete is PERMANENT with no archive and no undo, its product sets go with it, and any ad set still bound to one keeps spending with nothing to show) · list_meta_partnership_creators / manage_meta_partnership_creator (PARTNERSHIP ADS — the creators whose content this profile may run as an advert, and who may tag this brand as a paid partner. Two separate lists, neither implying the other, and neither defaults on; adding is a REQUEST the creator must accept, and an ad naming a creator who is only PENDING fails for a reason nothing in the error says) · list_meta_catalogs / list_meta_product_sets / list_meta_catalog_products (PRODUCT CATALOGS — the merchant’s own Meta catalogs, the product SETS inside each and the products themselves with Meta’s review status. A catalog is the input to Advantage+ catalog ads, the highest-performing ecommerce format on Meta: pass productCatalogId to create_meta_campaign and productSetId to create_meta_adset / create_meta_ad, and Meta builds every impression from the product’s own image, name and price — no render needed. An empty list is a fact about which business portfolio this login administers, NEVER about whether the merchant has a catalog) · create_meta_campaign / create_meta_ad / upload_meta_asset (build) · upload_meta_playable / list_meta_playables (PLAYABLE ads: the advertiser’s own interactive HTML game, run through create_meta_ad’s playableAssetId in an App Installs ad set) · list_meta_lead_forms / create_meta_lead_form (INSTANT LEAD FORMS — the form a lead ad opens INSIDE Facebook/Instagram instead of sending the click to a website; pass the id as create_meta_ad(objective:\"OUTCOME_LEADS\", leadFormId:…) and read the submissions with read_meta_leads) · update_meta_object / delete_meta_object / set_meta_campaign_status (edit, delete, activate — every spend + delete is confirm-gated) · delete_meta_audience (remove a custom audience or lookalike — its blast radius is the PEOPLE in it and the lookalikes built from it, which Meta refuses to delete around) · manage_meta_post (edit or delete a published post). THREADS (a separate connection from Meta, on its own API): post_to_meta(target:"threads") publishes · list_threads_posts · threads_insights · list_threads_replies / reply_to_thread / hide_thread_reply · list_threads_mentions · search_threads_keyword · repost_thread (amplify a customer’s post or one of your own to the brand’s profile — the Threads retweet, and there is NO documented un-repost) · delete_thread (confirm-gated; Threads has no EDIT at all, so delete-and-repost is the only correction) · threads_publishing_limit (how much of the rolling-24h quota is left — 250 posts, 1,000 replies, 100 DELETIONS, 500 location searches; check it before a bulk clean-up, because a quota refusal otherwise reads as a broken connection). SCHEDULING (one content calendar across every channel): schedule_post (queue a post for a future time to one or MORE channels at once — Facebook / Instagram / Threads / TikTok / YouTube / LinkedIn / X / Pinterest / Bluesky / Telegram (ten; Google Business Profile is accepted but held back on Google API access) — with per-channel captions; Hermoso publishes it at that time, nothing has to stay open — it goes LIVE PUBLICLY by default, and only stages as draft/unlisted/private if the user asks, and an impossible channel+visibility pair, an over-length caption or media the channel cannot carry is REFUSED while you are still there rather than failing hours later) · list_scheduled (what is queued and what already fired, with PER-CHANNEL outcomes) · reschedule_post (move a queued post to a new time, or change its caption, media, channels or target Page/board — send only what changes) · cancel_scheduled (pull a queued post before it goes out). POST PERFORMANCE (the loop that closes research → publish → learn — Hermoso records the HOOK and SUBJECT of everything it publishes, because those exist only at the moment of publishing and can never be recovered from a post id afterwards): list_published_posts (everything this brand has published across every channel, with the hook it was written to and its measured engagement) · post_performance (which HOOKS and SUBJECTS are getting traction — engagement rates compared WITHIN a channel and NEVER summed across them, with a verdict suppressed below 5 measured posts and the reason stated) · collect_post_metrics (pull fresh numbers ~24h and ~7d after each publish; a metric a channel cannot report is recorded ABSENT with its reason and never as zero, and X is skipped unless asked because it bills per call) · backfill_posts (import a channel’s past posts so the analysis has history — dry-run and cost-quoted first, and an imported post never votes on a hook unless it matched a Hermoso creation). YOUTUBE (publish, measure AND manage): post_to_youtube (publish a finished video to the brand’s channel — PUBLIC by default; unlisted (link-only, the ad-ready setting) or private only when the user asks) · list_youtube_videos (the channel’s OWN uploads with their video ids — call this to resolve “my latest video” yourself instead of asking the user for a link; it is where the videoId every other YouTube tool needs comes from, and it sees unlisted/private uploads a public search cannot) · update_youtube_video (retitle/re-describe/re-tag, and FLIP AN UNLISTED UPLOAD PUBLIC; confirm before going public) · delete_youtube_video (take one down for good — irreversible, so the unconfirmed call reports the video’s real title, privacy, views and comments first; use update_youtube_video(privacy:"private") when they only want it out of sight) · set_youtube_thumbnail (put a Hermoso thumbnail on an uploaded video — the biggest single lever on click-through, and YouTube otherwise picks a frame at random; needs a phone-verified channel) · update_youtube_channel (brand the CHANNEL ITSELF — banner art, description, keywords, country, the trailer non-subscribers see; everything else here brands the videos, this brands the page they sit on. It MERGES with the current settings, and it reports any field YouTube accepted but silently ignored, channel title above all) · set_youtube_watermark (the subscribe badge overlaid on EVERY video on the channel, including ones uploaded later — one square image brands the whole channel at once; the API publishes no way to read it back, so it reports accepted rather than confirmed) · list_youtube_video_stats (views, likes and comments for up to 50 videos IN ONE CALL, which is how to answer "how are my last twenty uploads doing" without one youtube_video_insights per video. It carries NO titles, because VideoStatsSnippet publishes only publishTime, so join on videoId with list_youtube_videos for names. YouTube calls this endpoint "intentionally not atomic", so a short answer is normal: the missing ids are named, and a missing id is never zero views) · youtube_video_insights (per-VIDEO views, watch time, average view PERCENTAGE/retention, likes, comments, shares, subscribers gained — the numbers that say whether a hook held; youtube_channel only gives channel-wide totals) · youtube_channel_report (the same numbers BROKEN DOWN — traffic source (search vs browse vs suggested vs shorts feed), the actual search terms, country/city, device, age+gender, subscribed vs not, and the audience-RETENTION curve showing exactly where viewers left) · list_youtube_comments + list_youtube_comment_replies + reply_to_youtube_comment (read viewer questions and objections in their own words, and answer as the channel) · moderate_youtube_comment (hide, reject, spam-report or delete an abusive comment — reject is reversible, delete is not) · list_youtube_playlists + manage_youtube_playlist + manage_youtube_playlist_items (organise the channel: create playlists, add/remove/re-order videos in them) · manage_youtube_playlist_image (a custom cover on a playlist — make_thumbnail renders the artwork, this is the call that puts it on. YouTube answers every failure here as an HTTP 500 whose real reason is buried inside it, and the tool unpacks that; if it comes back refused, check channel verification first) · manage_youtube_channel_section (the SHELVES ON THE CHANNEL HOMEPAGE — put a chosen playlist or a featured channel above YouTube’s own default layout, and re-order them. Every write is PUBLIC IMMEDIATELY, a delete has no undo, and YouTube’s own section list LAGS a write by a few seconds in both directions, so never treat a list taken straight afterwards as proof either way) · list_youtube_captions + manage_youtube_caption (real subtitle TRACKS — what YouTube indexes the video by and what a viewer toggles on, which is NOT the same as captions burned into the picture; downloading one is also the quickest way to get an existing video’s script back) · list_youtube_categories (which categoryId post_to_youtube will accept in a given country) · youtube_bulk_report (THE ONLY PLACE YOUTUBE PUBLISHES THUMBNAIL IMPRESSIONS AND THUMBNAIL CTR — a different, SCHEDULED API: the first call starts a job and returns nothing, then YouTube writes one file per day, the first within 48 hours, plus a 30-day backfill. It also carries per-card and per-end-screen metrics and an uncapped list of the search terms people arrived on) · list_youtube_report_jobs (whether that thumbnail history is already accumulating, and since when — check before promising a number) · delete_youtube_report_job (stop one; the job IS the history, so deleting it throws the accumulated files away) · youtube_channel (read title + subscriber/view/video counts for reporting). TIKTOK: post_to_tiktok (post a finished video — or a PHOTO POST, TikTok’s photo/slideshow format of 1 to 35 images where a single image is just a one-slide post —LIVE to the profile, or into TikTok drafts to review in the app) · tiktok_creator_info (the creator’s REAL privacy options — read them and let the user choose before any direct post) · tiktok_post_status (follow a post_to_tiktok that came back pending: done, failed or still processing) · tiktok_account (bio, verified status, follower/following/likes/video counts) · list_tiktok_videos (their own posts with views/likes/comments/shares — either the most recent, or specific videoIds read directly however old they are). ⚠️ TIKTOK HAS NO DELETE AND NO EDIT: its API publishes no way to remove a posted video or change its caption, privacy, cover or comment/duet/stitch settings — every one of those is fixed at publish time and there is no delete scope in TikTok’s scope catalogue at all. If the user wants a TikTok taken down or changed, say plainly that it has to be done in the TikTok app rather than hunting for a tool. TIKTOK ACCOUNT AUTHORIZATION (a SECOND, separate consent on the SAME TikTok app the TikTok Ads connection uses — holding one does NOT give you the other, so a brand fully connected for ads can still be unauthorized here, and that is a real third state rather than a broken session): tiktok_account_status (which state this profile is in, the TikTok business id, the scopes the grant carries and any MISSING from it — TikTok binds scopes at authorize time and never retroactively, so only a re-authorization picks up a new one — plus the exact URL to send the user to, because authorizing is the one step that needs a browser) · list_tiktok_comments + list_tiktok_comment_replies (the comments on the brand’s OWN posts, hidden ones included — TikTok’s answer to list_meta_comments and list_youtube_comments) · comment_on_tiktok_video · reply_to_tiktok_comment · moderate_tiktok_comment (LIKE / UNLIKE / HIDE / UNHIDE / DELETE — you can only DELETE a comment this account wrote, so HIDE is the tool for a stranger’s, and TikTok warns UNHIDE may not take effect when its own moderation is what hid it) · upload_tiktok_comment_image (a new comment will not take a raw image URL; a reply will) · set_tiktok_post_ad_authorization (THIS IS WHERE A SPARK ADS AUTHORIZATION CODE COMES FROM for the brand’s OWN post — previously a human had to copy one out of the TikTok app; hand the code to authorize_tiktok_ads_spark_post) · get_tiktok_post_ad_authorization · extend_tiktok_post_ad_authorization (the days are ADDED to what is left, not set as an absolute) · delete_tiktok_post_ad_authorization. BRAND MONITORING AND AUDIENCE, on that same account authorization (these need permissions added on 2026-08-20, so a brand that authorized before then holds a grant that predates them and has to authorize once more; tiktok_account_status names exactly which are missing, and the remedy is always to authorize the TikTok ACCOUNT again rather than to touch the advertiser connection, which is a separate grant and is unaffected): list_tiktok_mentions (public posts whose caption @-mentions the brand, TikTok’s answer to x_mentions and list_threads_mentions) · list_tiktok_mention_comments (comments whose text mentions it) · get_tiktok_mention (one mention in full, for the mentions webhook, and TikTok only keeps that data 48 hours) · tiktok_mention_top_terms (the top 20 keywords and top 20 hashtags inside those mentions) · list_tiktok_brand_hashtags + manage_tiktok_brand_hashtags + list_tiktok_brand_hashtag_posts (the hashtags TikTok counts as this brand’s, up to 50, and the posts carrying them; a new one is not counted for 24 hours and cannot be removed for 7 days) · tiktok_account_insights (follower demographics by age, gender, country and city plus the daily performance series, needing a BUSINESS account with 100+ followers, and capped at 60 days rather than the 90 the mention tools cover) · tiktok_category_benchmark (the same numbers averaged across an industry, so ‘are we ahead of our category’ is answerable). ALL OF THIS IS ORGANIC LISTENING ON THE BRAND’S OWN ACCOUNT, not ad research: for competitors’ ads use the ad-library research tools instead. TIKTOK POST INSIGHTS, also on the TikTok account authorization: tiktok_video_insights (per-POST analytics for the brand’s own posts — views, reach, average watch time, the per-second RETENTION curve, traffic sources, followers gained) · tiktok_spark_recommendations (TikTok rates the account’s organic posts HIGH / MEDIUM / LOW as Spark Ads candidates, optionally for an objective) · tiktok_hashtag_suggestions (10 hashtag ideas for a caption keyword, with each tag’s post count). TIKTOK ADS (a SEPARATE connection from the TikTok posting connector above — Settings ▸ Connectors ▸ TikTok Ads; a brand that posts to TikTok every day may still have no ad account here, so never read one as the other): list_tiktok_ads_accounts (the ADVERTISER accounts this profile can act on — every other TikTok Ads tool needs an advertiserId and this is where it comes from) · list_tiktok_ads_pixels + create_tiktok_ads_pixel + list_tiktok_ads_custom_conversions + tiktok_ads_pixel_stats (CONVERSION TRACKING — a conversion-optimised ad group dies at creation with "Please select a pixel" without one, so discover the pixel and its events BEFORE building the tree; note TikTok publishes no way to DELETE a pixel, so one you create is permanent) · list_tiktok_ads_campaigns (the whole tree — campaigns, ad groups and ads with their statuses) · tiktok_ads_report (impressions, clicks, spend, CTR, CPC, conversions and video views at any level) · list_tiktok_ads_identities (the TikTok accounts an ad may post AS — MANDATORY, with NO default: call it and let the USER pick, because the ad runs publicly under whichever account is named) · search_tiktok_ads_targeting (resolve location / interest / hashtag / language ids — an ad group cannot be created without location ids, and a guessed id targets the wrong people) · list_tiktok_ads_identity_posts (the ORGANIC posts an identity has already published — where a Spark Ad’s post id comes from) · list_tiktok_ads_spark_posts (the posts authorised for Spark Ads, i.e. promoting an organic post instead of uploading a new video) · authorize_tiktok_ads_spark_post + unbind_tiktok_ads_spark_post (add a creator’s post to that authorised set with the code they generated in the TikTok app, or release it again) · upload_tiktok_ads_playable / list_tiktok_ads_playables / delete_tiktok_ads_playable (PLAYABLE ads: the advertiser’s own interactive HTML zip, run through create_tiktok_smart_ad’s playableUrls) · upload_tiktok_ads_creative (THE STEP THAT TURNS A RENDER INTO AN AD — put a finished Hermoso video on the ad account and it hands back the videoId AND the coverImageId create_tiktok_ads_ad needs; there is no other source for either) · create_tiktok_ads_campaign → create_tiktok_ads_ad_group → create_tiktok_ads_ad (the tree) · set_tiktok_ads_budget · set_tiktok_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_tiktok_ads_object (removal on TikTok is a STATUS, not a verb — the same route as set_tiktok_ads_status) · update_tiktok_ads_ad (EDIT a live ad in place — ad text, call-to-action, landing URL, UTMs, name, video and cover, carousel sound or Instant Form — instead of deleting and rebuilding it, which throws away its review history and delivery learning) · preview_tiktok_ads_ad (see an ad as it renders on TikTok, or preview a video BEFORE any ad is built, with TikTok’s own fix-it tips per placement) · tiktok_ads_review_info (TikTok’s review verdict per ad — approved, approved for only part of its targeting, or rejected — with its REASONS and suggested fix) · prereview_tiktok_ads_creative (the same review verdict BEFORE any ad exists, per video, image, ad text or landing page, for one country) · list_tiktok_ads_music (the ad-cleared sound a CAROUSEL ad needs) · tiktok_ads_creative_fatigue (TikTok’s own verdict on whether a creative is WEARING OUT, per day, with spend and cost per conversion — the signal to swap in a fresh variation) · tiktok_ads_video_retention (per-SECOND ad video performance: where viewers swipe away, the hook read for the next variation) · tiktok_ads_trending_hashtags + tiktok_trends (TikTok’s OWN first-party trend data: top hashtags by country, industry and period; trending pre-cleared Commercial Music Library sounds, the top videos on a sound, top search terms) · create_tiktok_smart_campaign → create_tiktok_smart_ad_group → create_tiktok_smart_ad (Smart+, TikTok’s Performance Max — born PAUSED) · list_tiktok_smart_campaigns · set_tiktok_smart_status (the Smart+ money switch, confirm-gated) · manage_tiktok_ads_negative_keywords (searches an ad must not show for: the whole ad account, a campaign or an ad group) · tiktok_bid_protection (the ad-credit compensation TikTok pays when a Smart+ object misses its bid) · preview_tiktok_smart_ad (see a Smart+ ad as it renders on TikTok, existing or not yet created, with its disclaimer and add-on card) · tiktok_lead_ads_terms (read and accept TikTok’s Lead Ads terms, which a lead-generation ad needs first) · list_tiktok_ads_lead_forms + list_tiktok_ads_lead_fields + download_tiktok_ads_leads + manage_tiktok_ads_test_lead (LEAD ADS — an Instant Form is built in TikTok Ads Manager and NO API creates one, so list them to find the id a LEAD_GENERATION ad group needs. The lead REGION is required with no default: it selects which of three separate lead stores you read, and leaving it out is a THIRD value rather than “all”, so an advertiser who omits it downloads an empty file and wrongly concludes there are no leads) · list_tiktok_ads_audiences + create_tiktok_ads_audience + create_tiktok_ads_lookalike_audience + apply_tiktok_ads_audience + update_tiktok_ads_audience + delete_tiktok_ads_audience + tiktok_ads_audience_overlap (CUSTOM AUDIENCES and lookalikes — TikTok targeting is otherwise interests-and-geo only. A freshly created audience reports itself invalid for up to 48 hours BY DESIGN, so that is not a failure to retry) · list_tiktok_ads_business_centers + list_tiktok_ads_catalogs + create_tiktok_ads_catalog + list_tiktok_ads_catalog_products + list_tiktok_ads_catalog_sets + manage_tiktok_ads_catalog_feed + tiktok_ads_catalog_diagnostics (DPA / PRODUCT CATALOGS, the Shopify lane — a catalog is keyed on a BUSINESS CENTER id, NOT an advertiser id, so list the Business Centers first or every call refuses) · create_tiktok_ads_account (CREATE A NEW TIKTOK AD ACCOUNT for a business that has none, in a Business Center the user’s own login administers and in their company name; two steps, call without confirm first, and it can never be deleted) · create_tiktok_ads_business_center (when that login administers NO Business Center, create one first in the user’s company name; same two steps, every field asked of the user, never guessed) · list_tiktok_ads_apps + list_tiktok_ads_app_events (the registered apps an APP_INSTALL campaign needs — nothing else can produce an app id) · tiktok_ads_rf_inventory_estimate + create_tiktok_ads_rf_ad_group (REACH & FREQUENCY — a RESERVATION, so it is confirm-gated like a status change rather than born paused, and it needs a per-ad-account allowlist plus a signed branding contract that no endpoint reports. Always price it with the estimate first: TikTok silently books its own maximum rather than refusing an out-of-range value) · send_tiktok_ads_events (SERVER-SIDE conversion events — there is a vendor-sanctioned test code for exercising it without entering the advertiser’s real reporting), and its offline/crm sources take the event-set ids the two tools below mint) · list_tiktok_ads_offline_event_sets + manage_tiktok_ads_offline_event_set + send_tiktok_ads_offline_events (REAL-WORLD CONVERSIONS — an in-store purchase, a phone booking, a signed contract, reported so TikTok can attribute them to the ads that caused them. The timestamp is an ISO-8601 STRING here and a Unix NUMBER on send_tiktok_ads_events; a wrong-shaped one is accepted by TikTok and attributed to nothing. There is NO test code on this pair, so everything sent is a real permanent conversion — rehearse through send_tiktok_ads_events with eventSource “offline” and a testEventCode instead. Reporting also needs the connected user to be an ADMIN or OPERATOR of the advertiser, which managing the event SETS does not) · list_tiktok_ads_crm_event_sets + create_tiktok_ads_crm_event_set (LEAD-LIFECYCLE events — sending “this lead qualified / closed” back is what makes a LEAD_GENERATION campaign optimise toward leads that convert rather than form fills. TikTok publishes create and list and nothing else, so one of these is PERMANENT) · list_tiktok_tto_accounts + list_tiktok_creator_labels + discover_tiktok_creators + tiktok_creator_leaderboard + check_tiktok_creator_status + list_tiktok_tto_brand_profiles + create_tiktok_tto_brand_profile + list_tiktok_tto_campaigns + create_tiktok_tto_campaign + update_tiktok_tto_campaign + link_tiktok_tto_video + list_tiktok_tto_link_requests + tiktok_tto_campaign_report + request_tiktok_tto_spark_authorization + get_tiktok_tto_spark_authorization + manage_tiktok_tto_anchor (TIKTOK ONE / CREATOR MARKETPLACE: INFLUENCER MARKETING, and the only place in Hermoso that does it: find creators by audience size, engagement, price and who their followers actually are, check whether they have joined TikTok One, invite them to a campaign with an invite link, ask them to tag a video to it, and read every metric SPLIT ORGANIC VERSUS PAID. Its account id is a THIRD id space; not an advertiser id and not a Business Center id; so start at list_tiktok_tto_accounts. It rides this same connection with nothing extra to apply for. IT ALSO CLOSES THE SPARK ADS LOOP: request_tiktok_tto_spark_authorization asks a creator directly and get_tiktok_tto_spark_authorization returns the code authorize_tiktok_ads_spark_post takes, which is otherwise obtainable only by the creator pasting one out of the TikTok app. Two things put a notification in a real person’s inbox; a campaign invitation and a video-linking request; and a repeated linking request is a REMINDER that TikTok caps at two, so read list_tiktok_tto_link_requests before re-sending anything) · list_tiktok_ads_stores + list_tiktok_ads_store_products (TIKTOK SHOPS: what a Shopping Ads or GMV Max campaign sells from; the store list is keyed on an ad account and the product list on a BUSINESS CENTER, which each store row names) · tiktok_ads_verification_status + list_tiktok_ads_verification_documents + submit_tiktok_ads_verification (BUSINESS VERIFICATION: an unverified account hits limits that get diagnosed as something else, so it is worth reading during onboarding. Hermoso never handles a verification DOCUMENT: submitting sends account details plus the ids of images the user uploaded in TikTok Ads Manager, and the legal name and document number can never be changed afterwards, so it is confirm-gated) · list_tiktok_ads_payment_portfolios + list_tiktok_ads_payment_portfolio_links (HOW THE AD ACCOUNTS ARE FUNDED: read-only, because "why did delivery stop" is often a funding answer, and because deciding where a customer’s money sits is not ours to do) · create_tiktok_ads_rule + list_tiktok_ads_rules + update_tiktok_ads_rule + bind_tiktok_ads_rule + set_tiktok_ads_rule_status + tiktok_ads_rule_results (AUTOMATED RULES — standing instructions TikTok runs on the account unattended. THE SECOND SPEND SWITCH ON THIS PLATFORM and gated in TWO CLASSES: a rule that can only pause, decrease or email needs confirm:true, while one that can TURN_ON an object or RAISE a budget or bid needs confirm:true AND confirmScope echoing the token list_tiktok_ads_rules prints, computed from the rule as TikTok STORES it. Every rule is created TURNED OFF and read back to prove it, because TikTok publishes no way to create one in the off position. TikTok emails rule notifications to the DEVELOPER address on the app rather than to the advertiser, so tiktok_ads_rule_results is the only place a customer sees what a rule did — and TikTok itself says this endpoint is for direct advertisers and may refuse a platform-managed account entirely). · list_tiktok_ads_comments + tiktok_ads_comment_thread + moderate_tiktok_ads_comment + reply_to_tiktok_ads_comment + delete_tiktok_ads_comment (COMMENT MODERATION on your own TikTok ads — the platform where the comment section IS the ad. HIDE is the moderation verb and works on anyone’s comment and is reversible; DELETE only ever removes a comment your OWN identity posted, which TikTok reports per comment as canDelete. Comments are scoped to an AD GROUP and to nothing else, and the time window may span at most 30 DAYS, so an empty answer means “none in these 30 days” rather than “none ever”) · list_tiktok_ads_blocked_words + manage_tiktok_ads_blocked_words (a standing 500-word filter that auto-hides any comment containing one of these across EVERY ad on the account — nothing else in Hermoso does this, and removing a word republishes every comment it had hidden) · tiktok_ads_diagnosis (TikTok’s own issues-and-suggestions verdict on your ad groups — creative, bid/budget with its full estimated-delivery tables, and a pixel that has gone quiet. It covers ACTIVE ad groups only and omits any it has nothing to say about, so an empty answer is not a clean bill of health) · get_tiktok_ads_brand_safety + set_tiktok_ads_brand_safety (what content the ads may appear next to. Two things to say out loud: TikTok applies this to Smart+ campaigns and explicitly NOT to the regular campaigns create_tiktok_ads_campaign builds, and coverAllObjectives is a ONE-WAY DOOR TikTok cannot set back). TWO THINGS HERE ARE UNLIKE EVERY OTHER AD PLATFORM: TikTok creates objects ENABLED by default, so Hermoso forces every campaign, ad group and ad PAUSED with no override and nothing serves until set_tiktok_ads_status(confirm:true); and TikTok’s QPS is 1, so every call is serialized and a tree build or a bulk read is SLOW BY DESIGN — a throttle is not a broken connection. SNAPCHAT ADS (the tenth ad platform — Settings ▸ Connectors ▸ Snapchat Ads): list_snapchat_ads_accounts (the organizations and AD ACCOUNTS this profile can act on — every other Snapchat tool needs an adAccountId and this is where it comes from) · list_snapchat_ads_campaigns (the whole tree — campaigns, ad squads and ads) · snapchat_ads_report (impressions, spend, swipes and video quartiles at any level) · search_snapchat_ads_targeting (resolve country / region / interest / language ids — an ad squad cannot be created without at least one country) · upload_snapchat_ads_creative (put a finished render on the ad account as MEDIA and then as the CREATIVE an ad points at — Snapchat has no upload-from-URL, so Hermoso streams the bytes) · create_snapchat_ads_campaign → create_snapchat_ads_ad_squad → create_snapchat_ads_ad (the tree, every tier born PAUSED) · set_snapchat_ads_budget · set_snapchat_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_snapchat_ads_object (a REAL delete verb here, unlike TikTok — irreversible, so offer PAUSED first). THREE THINGS TO SAY OUT LOUD ON THIS PLATFORM: money is MICRO-CURRENCY (1,000,000 = one unit), so quote plain amounts and let Hermoso convert, and never pass both units — under-converting fails loudly while double-converting asks for a budget a million times too large; the creative HEADLINE is capped at 34 characters and brandName at 32, far shorter than Meta or Google, and over-long copy is refused rather than truncated; and a Snapchat ad points at a CREATIVE, never at a media id. SNAPCHAT POSTING (Stories / Spotlights on a Public Profile) IS BUILT BUT NOT YET REACHABLE — Snap’s Public Profile API is allowlist-only and Hermoso has not been allowlisted, so the connector is deliberately not offered; say that plainly rather than looking for a tool. LINKEDIN: post_to_linkedin (publish a finished post to the connected LinkedIn PROFILE) · list_linkedin_pages (the company Pages this connection administers — call this first and let the USER pick, never guess a Page) · post_to_linkedin_page (publish as a company PAGE rather than a person — this is the one most brands actually want) · manage_linkedin_post (edit the copy of a published post, or delete it) \u00b7 list_linkedin_comments / reply_to_linkedin_comment / delete_linkedin_comment (moderate the comments on your Page\u2019s posts \u2014 a SEPARATE LinkedIn authorization grants these, see Connectors) · linkedin_page_analytics (ORGANIC Page performance — followers, follower gains, Page views, and post impressions/clicks/engagement, for the Page total or per post; this is the free organic read, NOT linkedin_ads_report). LINKEDIN ADS (full three-tier management): list_linkedin_ads_campaigns (ad accounts, then a chosen account’s campaign groups, campaigns and — with campaignId — the CREATIVES under them) · linkedin_ads_report (impressions, clicks, cost, conversions, leads) · search_linkedin_ads_targeting (resolve locations / titles / industries / seniorities / company sizes to the URNs LinkedIn demands — never invent one) · linkedin_audience_count (HOW MANY members that targeting actually reaches, before a budget is committed — and a returned 0 means fewer than 300 people, LinkedIn’s privacy floor and also its campaign minimum, never an empty audience) · BOOST AN EXISTING LINKEDIN POST (a personal post, or a Company Page post with organization; an opt-in on the LinkedIn connection whose refusal carries the enable link): linkedin_boost_status (can it be boosted / is it already) · boost_linkedin_post (free preview without confirm; confirm:true creates it and LAUNCHES spend, there is no paused state) · end_linkedin_boost · linkedin_boost_analytics · linkedin_bid_pricing (LinkedIn’s own suggested bid and daily-budget range for that audience — quote it instead of guessing what LinkedIn costs) · create_linkedin_ads_campaign_group → create_linkedin_ads_campaign → create_linkedin_ads_creative (the tree, every tier born DRAFT) · set_linkedin_ads_budget / set_linkedin_ads_status / delete_linkedin_ads_object (budgets, activate/pause at any tier, delete — every spend change confirm-gated). LINKEDIN LEAD SYNC: list_linkedin_lead_forms · list_linkedin_leads / get_linkedin_lead (the LEADS its forms collected, answers named by field — PERSONAL DATA: show, never republish) · subscribe_linkedin_leads / list_linkedin_lead_events / list_linkedin_lead_subscriptions / delete_linkedin_lead_subscription (real-time push to Hermoso, optional forwardTo relay to a CRM; LinkedIn validates ONLY Hermoso’s own webhook). LinkedIn is a THREE-tier platform and the third tier is the one people forget: a campaign with no creative shows nothing, and all three tiers must be ACTIVE before a single impression is served. REDDIT ADS: list_reddit_ads_campaigns / reddit_ads_report (read the account tree + performance) · list_reddit_ads_profiles + list_reddit_ads_posts / create_reddit_ads_post / update_reddit_ads_post (the CREATIVE — a Reddit ad promotes a post) · create_reddit_ads_campaign / update_reddit_ads_campaign · create_reddit_ads_ad_group / update_reddit_ads_ad_group · create_reddit_ads_ad / update_reddit_ads_ad · create_reddit_ads_max_campaign / get_reddit_ads_max_template / update_reddit_ads_max_template (a Reddit MAX campaign: automated campaign, ad group and a template ad Reddit generates ads from, built from creative-library assets, all PAUSED) · set_reddit_ads_status (the ONLY switch that arms real spend, confirm-gated) · delete_reddit_ads_object (remove a campaign, ad group or ad — Reddit has no delete verb, removal is a status, and it refuses to delete anything touched in the last 3 hours) · delete_reddit_ads_saved_audience · search_reddit_ads_targeting / reddit_ads_forecast / reddit_ads_bid_suggestion (free planning) · list_reddit_ads_pixels + send_reddit_ads_conversions (conversion tracking — Reddit now requires a pixel on every ad group) · list_reddit_ads_audiences / create_reddit_ads_audience / update_reddit_ads_audience_users / delete_reddit_ads_audience (retargeting lists) · list_reddit_ads_saved_audiences / create_reddit_ads_saved_audience / update_reddit_ads_saved_audience · list_reddit_ads_lead_forms (read only: Reddit retired creating lead forms) · reddit_ads_history (who changed what, when). TELEGRAM: post_to_telegram (publish to a channel, group or chat as the brand’s own bot — text up to 4096 characters, but only 1024 once any photo or video is attached; one image, one video, or an album of 2–10 in which photos and videos may be mixed. chatId IS ALWAYS REQUIRED and is never guessed: the Bot API publishes NO method that lists the chats a bot belongs to, so pass the public channel’s @username or the numeric id) · list_telegram_chats (chats that MESSAGED the bot in the last 24 hours — a shortcut for finding an id, NOT a roster, and a chat missing from it can still be posted to) · list_telegram_dms (what those chats actually SAID, newest per chat — free, and a rolling 24-hour window rather than an inbox: the Bot API has no history endpoint at all) · delete_telegram_message (confirm-gated; Telegram refuses once a message is more than 48 hours old). WORDPRESS: post_to_wordpress (a blog post on the brand’s own self-hosted site — publish, draft, pending review, private, or scheduled natively with a future date; the first image is featured, the rest inline, a video embedded; a title is required) · update_wordpress_post · delete_wordpress_post (trash, or permanent; confirm-gated) · list_wordpress_posts · get_wordpress_post · list_wordpress_terms / create_wordpress_term. WordPress has no per-post views. DISCORD + SLACK: post_to_discord / post_to_slack (an ANNOUNCEMENT through the brand’s announcement webhook, never its alert one — Discord 2000 characters with files up to 20 MB attached, larger as a link, mentions off unless asked; Slack 40,000 characters with images inline and a video as a link) WHOP: post_to_whop (a FORUM post — title, Markdown, images and video, pin, members-only or public, paywall, poll — or a CHAT message, into the brand’s whop as the Hermoso app it installed) · list_whop_destinations · list_whop_products · edit_whop_post · get_whop_post · delete_whop_post (chat messages; Whop’s API cannot delete a forum post). A whop is connected from Hermoso (Connectors ▸ Whop) or by pressing Open Hermoso in the whop’s dashboard; nothing is billed through Whop. · edit_discord_message / delete_discord_message / get_discord_message (its reactions). A Slack webhook post cannot be edited or deleted. BLUESKY: post_to_bluesky (publish as the connected account — text up to 300 characters AND, separately, 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; either up to 10 images OR one MP4 video, never both, because a Bluesky post record carries exactly one embed; links are made clickable automatically) · delete_bluesky_post (PERMANENTLY remove one of the account’s own posts — no trash and no undelete. Call it WITHOUT confirm first: it deletes nothing and reports the post’s real text and live like/repost/reply/quote counts, and once the post has any engagement it also wants confirmText echoing its text. Takes the AT-URI or just the record key from the bsky.app link) · list_bluesky_convos / read_bluesky_dm / send_bluesky_dm / mark_bluesky_convo_read (the account’s DIRECT MESSAGES — free, 1000 characters each, text only, and they need a PRIVILEGED app password: an ordinary one posts fine and cannot chat). Replies, mentions AND direct messages all arrive in list_inbox and are answered with reply_to_inbox_item. X / TWITTER: post_to_x (publish a post — text, an image or a video render WITH alt text, a POLL, a reply, or a whole thread, and optionally restrict who may reply; X is the ONE channel that bills per API request, a post carrying a LINK costs roughly 13× one without, and each profile has a rolling 24-hour ceiling on X spend that refuses a request whole rather than publishing half of it) · delete_x_post (remove one) · x_post_metrics (the PUBLIC counts — impressions, likes, reposts, replies, quotes, bookmarks) · x_post_insights (the ADVERTISER numbers for your own posts — link clicks, profile visits, video views and completion quartiles, up to 25 posts at once; this is what says whether a creative worked, and x_post_metrics cannot tell you, but it only sees the LAST 28 HOURS) · x_post_insights_historical (the same advertiser numbers over ANY date range — the one to use for anything older than yesterday) · x_mentions (who is talking to the brand, in their own words — the read half of the reply loop, and a source of real customer language for ad copy) · list_x_dms (the brand’s X DIRECT MESSAGES, grouped into conversations, saying which are waiting on a reply — billed per message returned, and X keeps only 30 days) · send_x_dm (reply privately to one named person; never a broadcast) · x_chat_settings / update_x_chat_settings (the X account’s DM privacy: who may message the brand, the request filter, read receipts and calls). X IS THE ONE CONNECTOR THAT COSTS CREDITS PER CALL — X charges us per API request, so posting, deleting, reading metrics, reading insights and pulling mentions each bill the user, a post CONTAINING A LINK costs 13× one without, and insights and mentions are billed PER POST RETURNED. Say so before posting a thread or pulling a big page of mentions, and prefer one post over five when the content allows. X ADS (the PAID half — a SEPARATE connection from the organic tools above: its own product on its own host with OAuth 1.0a signing, and X grants API access PER AD ACCOUNT rather than per app, so the customer adds Hermoso’s X user at business.x.com → Account access before anything here resolves): list_x_ads_accounts (the ad accounts this profile can act on, WITH the permission level held on each — read it before attempting a write) · list_x_ads_funding_instruments (a campaign cannot be created without one) · list_x_ads_campaigns / list_x_ads_line_items / list_x_ads_promoted_tweets / list_x_ads_targeting (the whole tree as it stands) · x_ads_report (impressions, clicks, spend and engagements at any level) · x_ads_geo_search / x_ads_targeting_search (resolve places and targeting values to the ids X demands — never invent one) · create_x_ads_campaign → create_x_ads_line_item → create_x_ads_promoted_tweet (the tree, every tier born PAUSED with no override; A CAMPAIGN ALONE CANNOT SERVE ON X — it needs a line item and a promoted post underneath it, and the read-back says so rather than letting you call it a finished ad) · add_x_ads_targeting · update_x_ads_campaign / update_x_ads_line_item (throttle or raise spend on a running campaign without rebuilding it) · set_x_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_x_ads_object. PINTEREST — POSTING AND ADS ARE TWO SEPARATE CONNECTIONS on the same Pinterest login (Pinterest keeps ads access behind different permissions), so a brand can hold either without the other and connecting one does not connect the other; if an ads call says Pinterest Ads is not connected, that is the card to send them to, NOT the Pinterest posting one. ADS: pinterest_ads_async_report (the DEEP paid report — 914 days back where the quick one stops at 90, and three times the metric columns; generated asynchronously, so pass the returned token back rather than re-submitting) · pinterest_targeting_analytics (WHICH audience segment delivered — by keyword, interest, age, gender, location, placement) · pinterest_audience_insights (WHO the audience is: interest affinities plus demographics, the input to a creative brief rather than a performance report) · pinterest_analytics (ORGANIC performance — impressions, saves, Pin clicks, outbound clicks, for the account, the TOP PINS, the top video Pins, or one Pin; Pinterest keeps 90 days and publishes no board-level analytics at all) · create_pinterest_board (make a board — a NEW Pinterest account has none and a Pin needs one) · list_pinterest_boards (the user must pick a board — never choose one for them) · post_to_pinterest (create an image or video Pin on a chosen board, with a title, description and destination link) · list_pinterest_pins (the Pins on a board with their ids — where the pinId every Pin tool needs comes from, and it flags any Pin an ad is promoting) · update_pinterest_pin (retitle, re-describe, fix a dead link, move it — Pinterest keeps this endpoint in a limited BETA, so it may be refused outright and save_pinterest_pin is the generally-available way onto another board; a Pin’s picture can never be swapped by anyone) · save_pinterest_pin (copy a Pin onto another board) · delete_pinterest_pin (confirm-gated, and it says whether an ad is promoting the Pin first) · update_pinterest_board (rename, re-describe, or hide it — SECRET hides every Pin on the board, reversibly) · delete_pinterest_board (the heaviest one here: the board AND every Pin on it, confirm-gated with the Pin count echoed back — offer hiding it instead). GOOGLE ADS (full management): list_google_ads_campaigns (list accounts, then a customer’s campaigns + spend/CTR/CPC/conversions) · google_ads_report (any GAQL breakdown — ad groups, keywords, search terms, geo) · create_google_ads_campaign (paused) · set_google_ads_budget / set_google_ads_status (change budget, enable/pause — every spend change confirm-gated) · delete_google_ads_object (remove a campaign, ad group, ad, KEYWORD, asset LINK or conversion action — Google has no delete verb, `remove` is the terminal state and it cannot be undone; call it unconfirmed first to see the spend and the tree that go with it) · upload_google_ads_asset (add an image render or a YouTube video to the ad account’s asset library) · create_google_ads_performance_max_campaign (Google’s cross-surface campaign type, RETAIL INCLUDED — pass merchantCenterId to make it a Shopping-feed Performance Max advertising the WHOLE Merchant Center feed under one root listing group, and feedLabel to narrow it to a single feed; only PARTITIONING that feed by brand/category/custom label is refused by name) · add_google_ads_assets (sitelinks, callouts, structured snippets, call, promotion, price, lead-form, business name and logo assets, CREATED AND ATTACHED — an asset that is not attached shows nothing) · manage_google_ads_assets (list, edit, pause or detach them) · set_google_ads_tracking (UTMs / tracking template on a campaign, ad group or ad; new campaigns get standard UTMs by default) · manage_google_ads_audience + set_google_ads_audiences (remarketing lists, custom segments, affinity / in-market interests, demographics, Performance Max signals and search themes) · upload_google_ads_video (a render straight into Google Ads, no YouTube channel needed) · create_google_ads_demand_gen_campaign (YouTube Shorts / in-feed / in-stream video ads, born PAUSED) · list_google_ads_conversion_actions + create_google_ads_conversion_action (what Google counts as a result — MAXIMIZE_CONVERSIONS, TARGET_CPA, TARGET_ROAS and every Performance Max campaign are undeliverable without one, and Hermoso refuses to build them on an account that has none) · google_ads_keyword_ideas (Keyword Planner — real monthly search volume, competition and top-of-page bids; use it before choosing keywords) · google_ads_recommendations + manage_google_ads_recommendations (Google’s own advice and optimization score; apply / dismiss / auto-apply, spend-type ones only after the user’s yes) · preview_google_ads_ad (a shareable no-login preview link for a Performance Max asset group or YouTube video ad, for client approval) · google_ads_policy_issues (WHY Google disapproved or limited an ad, asset or keyword: the policy topic and the evidence) · google_ads_change_history (WHAT CHANGED ON THE ACCOUNT AND WHEN — the answer to “performance fell off a cliff on Tuesday, what happened?”. Its default source is field-level and reaches 30 days; the other source reaches 90 and is the ONLY one that sees Google Ads Editor and criterion edits, so check both before telling anyone nothing changed). GOOGLE MERCHANT CENTER (the product feed behind every Shopping ad and every free listing, on the SAME connection as Google Ads): register_merchant_developer (the ONE-TIME link between Hermoso’s Google Cloud project and the merchant’s account. Google refuses every other Merchant call until it is done, so run this first when calls are being refused) · list_merchant_accounts (which Merchant Centers this login can reach, and where the merchantCenterId every other tool needs comes from) · list_merchant_products (the feed itself, with each product’s disapprovals) · list_merchant_issues (account-level problems, the answer to "why is nothing showing at all") · merchant_issue_help + trigger_merchant_issue_action (Google’s OWN remediation steps for a problem, and the button that fires one. Several of those actions are one-shot in Google’s own words, so firing one is confirm-gated) · list_merchant_data_sources + create_merchant_data_source + delete_merchant_data_source (feeds. A product write only lands in an API-input feed, and most accounts have none until one is made, so check before writing) · upsert_merchant_product + update_merchant_product + delete_merchant_product (write the feed) · list_merchant_inventory + set_merchant_inventory (the per-STORE and per-REGION price, stock level and availability override on one product, which is what stops a Shopping ad advertising something the nearest store has sold out of. The write MERGES, because Google’s insert replaces the whole entry, and Google takes up to 30 minutes to reflect it on the product) · list_merchant_promotions + create_merchant_promotion (sale and discount badges on a listing. Google validates them asynchronously, so created is never the same as approved) · manage_merchant_notifications (Google POSTs to a URL THE MERCHANT RUNS the moment a product is disapproved, instead of someone having to poll) · merchant_account_status (WHY THE ACCOUNT IS OR IS NOT SERVING — the first thing to run when Shopping ads or free listings show nothing, and the one read that does not believe the program state: an account can report both programs ENABLED and serve in ZERO countries, because a region counts as active only where every requirement is met. It names Google’s own unmet requirements, then the settings that explain them: homepage claimed or not, business address, phone and support contact, active shipping services, return policies, terms accepted) · manage_merchant_settings (FIXES what merchant_account_status finds: shipping services merged by name so unmentioned ones are kept, return policies, business address and support contact, homepage claim, programs on or off; overwrites and removals ask first) · manage_merchant_conversion_source (WHERE MERCHANT CENTER GETS ITS CONVERSION DATA FROM, which is what free-listing and Shopping performance reporting is built on — a merchant with no conversion source sees clicks and no outcomes. Either a Google tag destination, whose MC-… id comes back only on the create and is the id the Google tag has to send conversions to, or a link to a GA4 property, which is IMMUTABLE and needs the connected Google account to be an admin there. A delete is an ARCHIVE and undelete restores it until the expiry Google reports) · merchant_quota (whether the account is simply out of daily API quota or out of product slots, which looks identical to a broken integration and is not. Google resets it at MIDDAY UTC) · merchant_report (the reports Google computes for free, including competitive visibility, best sellers and price competitiveness). MICROSOFT MERCHANT CENTER (the same job on Microsoft’s side, on the Microsoft Advertising connection): list_microsoft_merchant_stores · list_microsoft_merchant_products · upsert_microsoft_merchant_product · delete_microsoft_merchant_product · list_microsoft_merchant_issues · list_microsoft_merchant_catalogs + manage_microsoft_merchant_catalog. GOOGLE ANALYTICS (GA4 — the brand’s OWN site data, and a SEPARATE connection from Google Ads: a brand that spends on Ads every day may have no Analytics access at all, so never read one as the other): list_analytics_properties (call this FIRST — every other Analytics tool needs a NUMERIC property id, and what users actually know is the “G-XXXXXXX” Measurement ID from their tracking snippet, which no endpoint accepts; resolve it from this list rather than sending them hunting. It lists the properties SHARED WITH THIS PROFILE, not everything the Google account can see — Analytics access is handed out freely and one login often has Viewer on many clients’ properties, so the user ticks which belong to this profile and any other one is refused by name; an empty list means nothing is ticked yet, which set_connector_accounts or Settings ▸ Connectors ▸ Google Analytics ▸ Manage accounts fixes) · analytics_report (what happened — sessions, users, revenue, conversions and engagement broken down by channel, source/medium, campaign, landing page, country, device or date, i.e. the read that says whether the traffic an ad bought actually did anything) · analytics_realtime (who is on the site right now, ~30 minutes — a DIFFERENT metric set that rejects `sessions` outright, never a shortcut for analytics_report) · list_analytics_definitions (what the property already measures: its key events and its own custom dimensions, and the check to run before creating either) · create_analytics_key_event (mark an event GA4 already collects as a KEY EVENT — the 2024 rename of a conversion, and what makes it importable into Google Ads; marking an event the site never fires creates one that can never fire) · create_analytics_custom_dimension (register an event parameter the site already sends so reports can break down by it — say out loud first that a GA4 custom dimension CANNOT be deleted, only archived, and a property is capped at 50 event-scoped ones, so a typo permanently burns a slot) · list_analytics_data_streams (the streams on a property and the measurement ID (G-...) each one carries, which is what a gtag or GTM install needs and what nobody can find in the GA4 UI when asked) · get_analytics_stream_setup (the finished gtag <script> block to paste into the site — the last mile list_analytics_data_streams stops short of — plus whether enhanced measurement is really collecting scrolls, outbound clicks, site search, video, downloads and form interactions, and whether redaction is stripping campaign parameters out of recorded URLs. Web streams only. Read the master switch before believing a toggle: with enhanced measurement off for the stream, every toggle is inert whatever it says) · list_analytics_metadata (every dimension and metric this property can be asked for, including its own custom ones, which is what stops analytics_report guessing a field name) · check_analytics_compatibility (whether a dimension and metric can appear in the same report before spending a call finding out they cannot) · create_analytics_custom_metric + archive_analytics_custom_metric · archive_analytics_custom_dimension · delete_analytics_key_event (all one-way in the same sense as their create twins: archiving is not deleting and there is no un-archive) · list_analytics_google_ads_links + link_google_ads_to_analytics + unlink_google_ads_from_analytics (the join that makes a GA4 audience usable in Google Ads and a GA4 key event importable as a conversion — without it a perfectly good audience simply never appears in the ads account, with no error anywhere) · list_analytics_audiences + create_analytics_audience + archive_analytics_audience (GA4 remarketing audiences, the input to Google Ads remarketing. Archiving is one-way) · manage_analytics_measurement_protocol_secret (mint the API secret that lets the customer’s OWN SERVER send events straight into GA4, the Google twin of the conversions APIs already here for Reddit, Snapchat and OpenAI Ads. Say out loud that there is NO rotation anywhere in the API, so replacing a secret means create the new one, move every sender across, then delete the old one) · manage_analytics_channel_group (HOW GA4 BUCKETS TRAFFIC — the answer to “why is my campaign showing as Unassigned”, and the one number an ad studio is judged on. Read the Default channel group’s rules before diagnosing anything, then author your own group whose channels catch the campaigns Hermoso publishes. The rule fields are the eachScope… names, NOT the sessionSource / medium dimensions reports use, and GA4 stops at the first rule that matches so order decides everything) · manage_analytics_calculated_metric (the derived number a marketer actually reports — cost per purchase, revenue per session — built from metrics GA4 already collects and then available to analytics_report under its own permanent API name. The id is permanent, and a formula naming a metric the property does not collect is created happily and flagged invalid, so read that flag back) · manage_analytics_annotations (mark a launch or a post on the customer’s own GA4 charts, so the traffic it moved shows where they look) · analytics_data_retention (how long GA4 keeps event data — the 2-month default caps every exploration; lowering it deletes data, so it is confirm-gated). MICROSOFT ADVERTISING / BING ADS (full management, mirroring Google): list_microsoft_ads_campaigns (list the shared ad accounts, then a chosen account’s campaigns + budgets) · microsoft_ads_geo_search (resolve country / region / city names to the Microsoft location ids a campaign needs — call it when an ask is ambiguous and let the USER pick) · microsoft_ads_report (impressions, clicks, CTR, average CPC, spend, conversions — generated asynchronously, so it may come back pending and must be called again) · create_microsoft_ads_campaign (campaign → ad group → responsive search ad → keywords, always Paused; with no locations[] it is created serving WORLDWIDE, Microsoft’s own default, and the read-back warns loudly — relay that before anyone activates it) · create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (fill in an existing account) · set_microsoft_ads_budget / set_microsoft_ads_status (change budget, activate/pause — every spend change confirm-gated; Microsoft statuses are Active/Paused, never Deleted) · search_microsoft_ads_profiles / list_microsoft_ads_profile_targeting / set_microsoft_ads_profile_targeting (LinkedIn profile targeting: company, industry, job function, seniority and job title bid adjustments on a Search, Shopping or DSA campaign; confirm-gated on an ACTIVE campaign) · delete_microsoft_ads_object (a REAL delete — campaign, ad group, ad or keyword — permanent, with no undelete; call it unconfirmed first to see what goes with it) · set_microsoft_ads_bidding (change a campaign’s BID STRATEGY — EnhancedCpc, MaxClicks, MaxConversions with an optional target CPA, MaxConversionValue with an optional target ROAS, TargetImpressionShare — read back) · list_microsoft_ads_negative_keywords + add_microsoft_ads_negative_keywords + remove_microsoft_ads_negative_keywords + manage_microsoft_ads_negative_keyword_list (NEGATIVE keywords: the searches a campaign or ad group must NOT pay for, Exact or Phrase only, and SHARED negative lists reused across campaigns; removing one on a LIVE campaign lets those searches trigger paid ads again, so it is confirm-gated) · list_microsoft_ads_uet_tags + create_microsoft_ads_uet_tag (the UET site tag and its install snippet — conversion goals, remarketing and conversion bidding all need one) · create_microsoft_ads_conversion_goal (Url / Event / OfflineConversion / Duration / PagesViewedPerVisit goals, read back) · list_microsoft_ads_audience_targeting + set_microsoft_ads_audience_targeting (put an audience — a customer list, remarketing, in-market — to work on a campaign or ad group, or EXCLUDE it, with a bid adjustment) · create_microsoft_ads_image_ad (turn a render into an IMAGE ad on the Microsoft Audience Network or a multimedia ad on Search; the picture is cropped to the 1.91:1 and 1:1 sizes Microsoft requires) · upload_microsoft_ads_video (put a render on the account as a video asset, or check one still processing; Microsoft refuses clips under 6 seconds) · microsoft_ads_keyword_ideas (Microsoft’s Keyword Planner — real search volume, competition and suggested bids, with NO planning-tier gate, unlike Google’s) · microsoft_ads_traffic_estimates (what those keywords would deliver at a named bid — a range, never one number) · microsoft_ads_budget_opportunities (where Microsoft says a budget is capping delivery, and what raising it is forecast to buy) · microsoft_ads_auction_insights (who ELSE is bidding on the same auctions — rival domains with their impression share, overlap and outranking share; shares of YOUR auctions, never a measure of a competitor’s whole account) · microsoft_ads_bulk_download (export the account as ONE bulk file — the only way to read ~185 Microsoft record types Hermoso cannot otherwise touch: sitelinks, callouts, structured snippets, labels, shared negative keyword lists, bid strategies, audiences, experiments, seasonality adjustments, conversion goals, asset groups, feeds) · microsoft_ads_bulk_upload (apply an edited bulk file — hundreds of objects in one request. IT IS GATED HARDER THAN ANYTHING ELSE ON THIS CONNECTOR, because a bulk file carries a Status column and can turn campaigns ON without ever touching set_microsoft_ads_status: confirm:true alone is refused, and you must first call it unconfirmed to get the row-by-row list of what it would ACTIVATE and DELETE, show that to the user, then echo both counts back as confirmActivations/confirmDeletions — or pass pauseInstead:true to land the file with every activation written as Paused) · list_microsoft_ads_conversion_goals (what the account counts as a conversion, and which goals are OFFLINE ones) · send_microsoft_ads_offline_conversions (close the loop: phone sales, in-store purchases and late-closing leads fed back so smart bidding stops optimising against website conversions alone — pass PLAIN emails and E.164 phones, hashing happens server-side to Microsoft’s own published spec) · microsoft_ads_editorial_reasons (WHY an ad or keyword was disapproved: the code, the word Microsoft objected to, where, and the appeal status) · list_microsoft_ads_audiences (the account’s Customer Match lists with their current sizes; a fresh list reads 0 for up to 48 hours and Microsoft will not use one under 300 people, so never call that a failed upload) · create_microsoft_ads_customer_list then apply_microsoft_ads_customer_list (build a Customer Match audience from PLAIN email addresses, normalized and SHA-256 hashed server-side to Microsoft’s own published spec so no plaintext ever leaves us; the user must be shown Microsoft’s Customer Match terms and agree first) · microsoft_ads_recommendations (what Microsoft ITSELF suggests changing, each one priced by Microsoft: budget raises carrying the current and recommended daily amount, new and broadened keywords, negative keywords it wants removed, and ads it has written. Every one INCREASES what the account buys, which is what they are for, so none is a free win and an empty list means Microsoft has no advice rather than that the account is optimal) · apply_microsoft_ads_recommendations (act on them, gated exactly like the bulk upload: confirm:true alone is REFUSED, so call it unconfirmed first to get every recommendation named with what it changes and Microsoft’s own cost estimate, show that to the user, then echo confirmCount and confirmCostIncrease back. Both are recomputed from a fresh read, and there is no undo) · dismiss_microsoft_ads_recommendations (take advice off the list. It cannot spend, so it needs no confirmation at all, and it is the right answer to “make it stop suggesting that” rather than applying something to clear it) · microsoft_ads_auto_apply (THE READ THAT ANSWERS “is Microsoft changing this account while nobody is looking?”, per type. An inherited account can already be opted in with nobody at the brand having done it) · set_microsoft_ads_auto_apply (turn that standing permission on or off. Switching any type ON is the strongest consent anywhere in Hermoso: Microsoft then writes and publishes its own ads under the brand’s name, deletes negative keywords so the account buys more searches, and changes conversion goals, unattended and indefinitely, with NOTHING to preview beforehand. So confirm:true is not enough and every type must be named in confirmTypes. Switching it OFF is never gated) · get_microsoft_ads_account_properties / set_microsoft_ads_account_properties (account-level settings: tracking template, final URL suffix, MSCLKID auto-tagging, view-through conversions, business attributes, AI asset enhancement opt-out and the AI-detected content watermark policy, read back after every change). GOOGLE BUSINESS PROFILE (the local-SEO channel — the listing panel on Google Search and Maps, which for a local business is where the demand actually is, and there is no delete): list_business_locations (the listings the connected Google account manages — call this first and let the USER pick when there is more than one; a Post on the wrong storefront is a public mistake) · post_to_google_business (publish a Post to the listing — text, ONE PHOTO and a call-to-action button; Google’s Posts API takes no video, so pass a still. EVENT and OFFER posts both require a title and a start date, and on an OFFER Google ignores the button link) · list_google_business_posts (what is showing right now, with each Post’s state) · delete_google_business_post (take one down — immediate and public, so confirm first) · list_google_business_reviews (the reviews on the listing, and which ones have NO reply yet — for a local business the highest-leverage surface there is) · reply_to_google_business_review (answer one publicly as the business; it is an UPSERT, so it replaces any existing reply) · list_google_business_questions + answer_google_business_question (the public Q&A on the listing) · google_business_search_keywords (the actual search terms people typed to find the listing — free local keyword data; low-volume terms are SUPPRESSED and come back as "fewer than N", never as zero) · google_business_insights (Search + Maps impressions, calls, website clicks, direction requests, messages, bookings — listing-level; Google discontinued per-Post insights in 2023 with no replacement, so never promise per-Post numbers) · get_business_location (everything the listing actually says — name, address, phone, website, categories, description, hours, service area — as the merchant set it; the answer to “what does our Google listing say?”) · update_business_location (change any of that — hours, phone, website, description, categories, even the name or address. It edits the live panel on Search and Maps with no draft and no undo, so call it WITHOUT confirm first: nothing is written, Google validates the payload, and you get the current value of every field you are about to change to show the user) · google_business_account (whose Business Profile account the listing is on, and whether the connected Google account’s role can edit it at all). Google gates this API behind a per-project access request and the default quota is zero, so the connection can be live and calls still refused — the error says so. CHATGPT ADS (ads under ChatGPT answers, via OpenAI’s Advertiser API — full management): list_openai_ads_campaigns (the ad account, then its campaigns, ad groups and ads with each ad’s review state) · openai_ads_report (impressions, clicks, spend, CTR, CPC, CPM at account / campaign / ad group / ad scope — run this first, it validates the key with zero spend risk) · openai_ads_geo_search (location ids) · list_openai_ads_audiences + create_openai_ads_audience (custom audiences — geo and these are the only list-based targeting this platform has; target them with customAudienceIds / excludedCustomAudienceIds on a campaign) · create_openai_ads_campaign (campaign → ad group → ad in one call, always PAUSED) · create_openai_ads_ad_group / create_openai_ads_ad (fill in an existing campaign) · update_openai_ads_object (rename, re-budget, rewrite context hints or the ad copy) · set_openai_ads_budget / set_openai_ads_status (change budget, activate, pause) · delete_openai_ads_object (ARCHIVE — this API has no delete and OpenAI say archiving is not reversible, so offer pausing first). TWO RULES THIS CHANNEL DOES NOT SHARE WITH THE OTHERS: it is connected by PASTING an Advertiser API key (no OAuth, no manager account, one key = one ad account), and it has exactly ONE creative format — a text plus image card, title 50 characters, body 100. There is NO VIDEO on ChatGPT Ads, so never offer a video ad here. GOOGLE DRIVE — ONE connection covering Drive, Sheets and Docs (full CRUD over the files Hermoso created there, plus any file the user hands over with the Google file picker in the app): save_to_drive · list_drive_files / get_drive_file · update_drive_file (rename/move/trash) · delete_drive_file · create_drive_folder. GOOGLE SHEETS (part of the Google Drive connection — export data to a spreadsheet the app creates, or read one the user picked; drive.file, no verification): create_sheet · append_to_sheet · read_sheet. GOOGLE DOCS (part of the Google Drive connection — export copy/brief/report as a doc, or read one the user picked; drive.file, no verification): create_doc · append_to_doc. GOOGLE SLIDES (part of the Google Drive connection — turn a swipefile collection into a real presentation, one slide per saved ad with the creative, brand, copy, run dates and platform; drive.file, no verification, no new scope): export_swipefile_deck — it CREATES a deck each time and cannot append to one the user already has, and a creative whose ad-library link has expired is reported rather than silently dropped. ONEDRIVE (full CRUD over the user’s Microsoft OneDrive): convert_onedrive_file (Microsoft converts a file server-side to PDF or JPG — ~130 formats including PowerPoint and Word decks, PSD, Illustrator, Sketch, 3D, video, iPhone HEIC and raw camera files; JPG needs both width and height) · save_to_onedrive · list_onedrive_files / get_onedrive_file · update_onedrive_file (rename/move) · delete_onedrive_file · create_onedrive_folder · share_onedrive_file (a view/edit link for a client) · onedrive_file_permissions (who can open it; revoke). INTERCOM (the brand’s own support inbox, nothing billed): list_intercom_conversations (filter by state, assignee, team, tag, channel, customer, dates, priority, unread, or app = the product the customer wrote from) · read_intercom_conversation · reply_to_intercom_conversation / add_intercom_note / manage_intercom_conversation (each a preview until confirm:true) · tag_intercom_conversation · list_intercom_tags · search_intercom_contacts · list_intercom_teammates · intercom_customer_language (customers’ own words for ad copy). MAILCHIMP (same shape): create_mailchimp_template · create_mailchimp_campaign · send_mailchimp_campaign (confirm:true + the count) · mailchimp_report and more. KLAVIYO (the brand’s own email, nothing billed): create_klaviyo_template (an ad + on-brand copy → an email) · create_klaviyo_campaign (a draft) · send_klaviyo_campaign (needs confirm:true + the recipient count) · klaviyo_report and more. VERCEL (the brand’s own sites, nothing billed): list_vercel_projects · list_vercel_deployments · list_vercel_domains · vercel_web_analytics (page views, visitors, top pages, referrers, countries) · vercel_event_analytics (custom-event conversions by campaign) · deploy_vercel_page (a static landing page, confirm-gated; preview by default) · delete_vercel_deployment. Use these standalone — Hermoso is a full posting/ads/file-storage control surface, not only an ad generator.',
|
|
302
|
+
'E) PUBLISH & MANAGE YOUR CHANNELS — post, run ads, and organize files on the user’s OWN connected accounts (Connectors), all driven over this MCP. Bring ANY file in with upload_file (desktop/external media, not just Hermoso renders). MANAGING THE CONNECTIONS THEMSELVES: list_connectors (what is linked, and what could be) · list_connector_accounts then set_connector_accounts (WHICH Facebook Pages / Instagram / Meta ad accounts / Google Ads customers / LinkedIn company Pages and ad accounts / Pinterest ad accounts / Microsoft Advertising accounts / Reddit ad accounts / Google Business listings / Google Analytics properties this profile may post to, spend from and read — one person often administers or has access to several belonging to different clients, only the chosen ones are usable anywhere, and an empty choice shares nothing) · connect_connector (connect a PASTE-A-KEY account from here: ' + Object.values(KEY_CONNECTORS).map((s) => s.label).join(', ') + '; offer it beside the Connectors page in the app and let the user choose, because a key pasted into a chat stays in its history) · disconnect_connector (drop a connection from this brand, without revoking access at the platform; confirm-gated because reconnecting a sign-in account needs a browser) · leave_connector (on a connector several teammates can each contribute their OWN account to, remove just YOURS — teammates’ accounts keep working and nothing is revoked at the provider). LINKING an account that connects through a provider sign-in screen (OAuth) is the one step that is not headless: hand the user its connect link, https://app.hermoso.ai/?connect=<provider>, or send them to Connectors in the app. META: list_meta_pages · instagram_insights (ACCOUNT-level Instagram performance — views, reach, accounts engaged, interactions, saves, profile link taps — plus the audience DEMOGRAPHICS by age / city / country / gender) · list_meta_stories (the Stories live right now on Instagram and Facebook Pages) / get_meta_story (one story id: live details, or that it expired) / instagram_story_insights (a Story’s reach, views, replies, link clicks and navigation, kept readable after its 24 hours end) / list_instagram_media (the brand’s own recent Instagram posts, and where the media id every other Instagram tool needs comes from) · list_instagram_dm_automations / save_instagram_dm_automation / test_instagram_dm_automation / delete_instagram_dm_automation (INSTAGRAM DM AUTOMATIONS — a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply; test shows what a sample would send without sending) · list_dm_automations / save_dm_automation / test_dm_automation / delete_dm_automation (THE SAME ON EVERY CHANNEL — Instagram, Facebook Page comments and ads + Messenger, and X: a keyword mention or reply gets a public reply, a DM keyword a DM; X is billed in credits per delivered event and per send) · search_instagram_audio (licensed music and original sounds an Instagram Reel may use, by keyword or trending) · list_instagram_collab_invites then respond_instagram_collab_invite (collab-post invitations waiting on the account; accept or decline one, read back from Instagram) · list_instagram_collab_media (posts this account co-authors) · like_instagram (like a post or comment as the connected account) · post_to_meta (Facebook / Instagram / Threads) · list_meta_posts (the Page’s / Instagram account’s OWN existing posts with their ids — THIS is where the postId every other Meta read needs comes from; without it an agent that did not itself just publish has no way to name a post) · list_meta_ads + meta_insights (read existing campaigns/ad sets/ads + spend/CTR/CPC, with breakdowns by age / gender / placement / country) · preview_meta_ad (Meta renders the REAL ad per placement — a link the user can look at, valid 24h) · meta_catalog_suggestions (which catalog Meta would show under a new ad) · create_meta_ad_rule + list_meta_ad_rules + update_meta_ad_rule + set_meta_ad_rule_status + run_meta_ad_rule + meta_ad_rule_history + set_meta_ad_rule_limits + delete_meta_ad_rule, and their Google Ads twins create_google_ads_rule + list_google_ads_rules + update_google_ads_rule + set_google_ads_rule_status + run_google_ads_rule + google_ads_rule_history + undo_google_ads_rule_action + set_google_ads_rule_limits + delete_google_ads_rule (AUTOMATED RULES — standing instructions that pause, re-budget or re-bid with nobody watching. Meta runs its own rules; Google Ads has no rules API, so Hermoso runs those on its own schedule. Every rule is born OFF. Switching one on needs confirm:true when it can only pause, decrease or notify, and confirm:true PLUS confirmScope (the scopeToken the list tool prints, computed from the rule as stored) when it can enable anything or raise a budget or bid; per-account limits cap how far any rule may move a budget or bid in a day) · list_meta_pixels + create_meta_pixel (the pixel a conversion-optimised campaign REQUIRES — Meta will not let a build optimise for conversions without one, and until these existed a caller had no way to discover the id they had to pass) · estimate_meta_reach (how many people a targeting spec reaches, BEFORE a budget is committed) · list_meta_audiences / create_meta_audience (website-pixel retargeting, Page + Instagram engagement audiences, and lookalikes — creating one spends nothing) · list_meta_conversations / read_meta_conversation / reply_to_meta_message (MESSENGER AND INSTAGRAM DMs — the brand’s direct-message threads and a reply to someone who wrote first. Meta only permits a reply within 24 HOURS of the person acting, and read_meta_conversation says whether that window is open BEFORE anything is drafted; Hermoso sends replies only, never a proactive message or a message tag) · subscribe_meta_webhooks / meta_webhook_status / unsubscribe_meta_webhooks / list_meta_webhook_events (REAL-TIME EVENTS — have Meta PUSH new comments, mentions, lead-form submissions and inbound DMs to Hermoso instead of polling for them. Every other inbox read asks an edge “anything new?”; this is the only way to be TOLD, and it is how a lead arrives the moment it is submitted rather than when somebody thinks to look. An empty feed is ambiguous — check meta_webhook_status first, because an unsubscribed Page is silent and looks exactly like a quiet one) · instagram_collaborators (who ACCEPTED a Collab invite on an Instagram post — publishing only SENDS the invite, so this is the only way to know whether the post is actually live on the other account too) · list_instagram_shopping_catalogs / search_instagram_shopping_products / manage_instagram_product_tags (INSTAGRAM SHOPPING — make a post SHOPPABLE. Check eligibility and the account’s taggable catalogs, find the product ids, then pass productTags to post_to_meta so tapping the picture opens the product’s price sheet inside Instagram. Tagging needs an APPROVED Instagram Shop, so check FIRST — otherwise it fails after the media is already uploaded — and note that a tag whose product is not “approved” is stored and shown to nobody. Meta publishes no way to REMOVE a tag) · create_meta_catalog / update_meta_catalog / meta_catalog_blast_radius / delete_meta_catalog (BUILD AND RETIRE A CATALOG — create one on a named business portfolio, rename or re-point it, and, before ever proposing a delete, read meta_catalog_blast_radius: a catalog delete is PERMANENT with no archive and no undo, its product sets go with it, and any ad set still bound to one keeps spending with nothing to show) · list_meta_partnership_creators / manage_meta_partnership_creator (PARTNERSHIP ADS — the creators whose content this profile may run as an advert, and who may tag this brand as a paid partner. Two separate lists, neither implying the other, and neither defaults on; adding is a REQUEST the creator must accept, and an ad naming a creator who is only PENDING fails for a reason nothing in the error says) · list_meta_catalogs / list_meta_product_sets / list_meta_catalog_products (PRODUCT CATALOGS — the merchant’s own Meta catalogs, the product SETS inside each and the products themselves with Meta’s review status. A catalog is the input to Advantage+ catalog ads, the highest-performing ecommerce format on Meta: pass productCatalogId to create_meta_campaign and productSetId to create_meta_adset / create_meta_ad, and Meta builds every impression from the product’s own image, name and price — no render needed. An empty list is a fact about which business portfolio this login administers, NEVER about whether the merchant has a catalog) · create_meta_campaign / create_meta_ad / upload_meta_asset (build) · upload_meta_playable / list_meta_playables (PLAYABLE ads: the advertiser’s own interactive HTML game, run through create_meta_ad’s playableAssetId in an App Installs ad set) · list_meta_lead_forms / create_meta_lead_form (INSTANT LEAD FORMS — the form a lead ad opens INSIDE Facebook/Instagram instead of sending the click to a website; pass the id as create_meta_ad(objective:\"OUTCOME_LEADS\", leadFormId:…) and read the submissions with read_meta_leads) · update_meta_object / delete_meta_object / set_meta_campaign_status (edit, delete, activate — every spend + delete is confirm-gated) · delete_meta_audience (remove a custom audience or lookalike — its blast radius is the PEOPLE in it and the lookalikes built from it, which Meta refuses to delete around) · manage_meta_post (edit or delete a published post). THREADS (a separate connection from Meta, on its own API): post_to_meta(target:"threads") publishes · list_threads_posts · threads_insights · list_threads_replies / reply_to_thread / hide_thread_reply · list_threads_mentions · search_threads_keyword · repost_thread (amplify a customer’s post or one of your own to the brand’s profile — the Threads retweet, and there is NO documented un-repost) · delete_thread (confirm-gated; Threads has no EDIT at all, so delete-and-repost is the only correction) · threads_publishing_limit (how much of the rolling-24h quota is left — 250 posts, 1,000 replies, 100 DELETIONS, 500 location searches; check it before a bulk clean-up, because a quota refusal otherwise reads as a broken connection). SCHEDULING (one content calendar across every channel): schedule_post (queue a post for a future time to one or MORE channels at once — Facebook / Instagram / Threads / TikTok / YouTube / LinkedIn / X / Pinterest / Bluesky / Telegram (ten; Google Business Profile is accepted but held back on Google API access) — with per-channel captions; Hermoso publishes it at that time, nothing has to stay open — it goes LIVE PUBLICLY by default, and only stages as draft/unlisted/private if the user asks, and an impossible channel+visibility pair, an over-length caption or media the channel cannot carry is REFUSED while you are still there rather than failing hours later) · list_scheduled (what is queued and what already fired, with PER-CHANNEL outcomes) · reschedule_post (move a queued post to a new time, or change its caption, media, channels or target Page/board — send only what changes) · cancel_scheduled (pull a queued post before it goes out). POST PERFORMANCE (the loop that closes research → publish → learn — Hermoso records the HOOK and SUBJECT of everything it publishes, because those exist only at the moment of publishing and can never be recovered from a post id afterwards): list_published_posts (everything this brand has published across every channel, with the hook it was written to and its measured engagement) · post_performance (which HOOKS and SUBJECTS are getting traction — engagement rates compared WITHIN a channel and NEVER summed across them, with a verdict suppressed below 5 measured posts and the reason stated) · collect_post_metrics (pull fresh numbers ~24h and ~7d after each publish; a metric a channel cannot report is recorded ABSENT with its reason and never as zero, and X is skipped unless asked because it bills per call) · backfill_posts (import a channel’s past posts so the analysis has history — dry-run and cost-quoted first, and an imported post never votes on a hook unless it matched a Hermoso creation). YOUTUBE (publish, measure AND manage): post_to_youtube (publish a finished video to the brand’s channel — PUBLIC by default; unlisted (link-only, the ad-ready setting) or private only when the user asks) · list_youtube_videos (the channel’s OWN uploads with their video ids — call this to resolve “my latest video” yourself instead of asking the user for a link; it is where the videoId every other YouTube tool needs comes from, and it sees unlisted/private uploads a public search cannot) · update_youtube_video (retitle/re-describe/re-tag, and FLIP AN UNLISTED UPLOAD PUBLIC; confirm before going public) · delete_youtube_video (take one down for good — irreversible, so the unconfirmed call reports the video’s real title, privacy, views and comments first; use update_youtube_video(privacy:"private") when they only want it out of sight) · set_youtube_thumbnail (put a Hermoso thumbnail on an uploaded video — the biggest single lever on click-through, and YouTube otherwise picks a frame at random; needs a phone-verified channel) · update_youtube_channel (brand the CHANNEL ITSELF — banner art, description, keywords, country, the trailer non-subscribers see; everything else here brands the videos, this brands the page they sit on. It MERGES with the current settings, and it reports any field YouTube accepted but silently ignored, channel title above all) · set_youtube_watermark (the subscribe badge overlaid on EVERY video on the channel, including ones uploaded later — one square image brands the whole channel at once; the API publishes no way to read it back, so it reports accepted rather than confirmed) · list_youtube_video_stats (views, likes and comments for up to 50 videos IN ONE CALL, which is how to answer "how are my last twenty uploads doing" without one youtube_video_insights per video. It carries NO titles, because VideoStatsSnippet publishes only publishTime, so join on videoId with list_youtube_videos for names. YouTube calls this endpoint "intentionally not atomic", so a short answer is normal: the missing ids are named, and a missing id is never zero views) · youtube_video_insights (per-VIDEO views, watch time, average view PERCENTAGE/retention, likes, comments, shares, subscribers gained — the numbers that say whether a hook held; youtube_channel only gives channel-wide totals) · youtube_channel_report (the same numbers BROKEN DOWN — traffic source (search vs browse vs suggested vs shorts feed), the actual search terms, country/city, device, age+gender, subscribed vs not, and the audience-RETENTION curve showing exactly where viewers left) · list_youtube_comments + list_youtube_comment_replies + reply_to_youtube_comment (read viewer questions and objections in their own words, and answer as the channel) · moderate_youtube_comment (hide, reject, spam-report or delete an abusive comment — reject is reversible, delete is not) · list_youtube_playlists + manage_youtube_playlist + manage_youtube_playlist_items (organise the channel: create playlists, add/remove/re-order videos in them) · manage_youtube_playlist_image (a custom cover on a playlist — make_thumbnail renders the artwork, this is the call that puts it on. YouTube answers every failure here as an HTTP 500 whose real reason is buried inside it, and the tool unpacks that; if it comes back refused, check channel verification first) · manage_youtube_channel_section (the SHELVES ON THE CHANNEL HOMEPAGE — put a chosen playlist or a featured channel above YouTube’s own default layout, and re-order them. Every write is PUBLIC IMMEDIATELY, a delete has no undo, and YouTube’s own section list LAGS a write by a few seconds in both directions, so never treat a list taken straight afterwards as proof either way) · list_youtube_captions + manage_youtube_caption (real subtitle TRACKS — what YouTube indexes the video by and what a viewer toggles on, which is NOT the same as captions burned into the picture; downloading one is also the quickest way to get an existing video’s script back) · list_youtube_categories (which categoryId post_to_youtube will accept in a given country) · youtube_bulk_report (THE ONLY PLACE YOUTUBE PUBLISHES THUMBNAIL IMPRESSIONS AND THUMBNAIL CTR — a different, SCHEDULED API: the first call starts a job and returns nothing, then YouTube writes one file per day, the first within 48 hours, plus a 30-day backfill. It also carries per-card and per-end-screen metrics and an uncapped list of the search terms people arrived on) · list_youtube_report_jobs (whether that thumbnail history is already accumulating, and since when — check before promising a number) · delete_youtube_report_job (stop one; the job IS the history, so deleting it throws the accumulated files away) · youtube_channel (read title + subscriber/view/video counts for reporting). TIKTOK: post_to_tiktok (post a finished video — or a PHOTO POST, TikTok’s photo/slideshow format of 1 to 35 images where a single image is just a one-slide post —LIVE to the profile, or into TikTok drafts to review in the app) · tiktok_creator_info (the creator’s REAL privacy options — read them and let the user choose before any direct post) · tiktok_post_status (follow a post_to_tiktok that came back pending: done, failed or still processing) · tiktok_account (bio, verified status, follower/following/likes/video counts) · list_tiktok_videos (their own posts with views/likes/comments/shares — either the most recent, or specific videoIds read directly however old they are). ⚠️ TIKTOK HAS NO DELETE AND NO EDIT: its API publishes no way to remove a posted video or change its caption, privacy, cover or comment/duet/stitch settings — every one of those is fixed at publish time and there is no delete scope in TikTok’s scope catalogue at all. If the user wants a TikTok taken down or changed, say plainly that it has to be done in the TikTok app rather than hunting for a tool. TIKTOK ACCOUNT AUTHORIZATION (a SECOND, separate consent on the SAME TikTok app the TikTok Ads connection uses — holding one does NOT give you the other, so a brand fully connected for ads can still be unauthorized here, and that is a real third state rather than a broken session): tiktok_account_status (which state this profile is in, the TikTok business id, the scopes the grant carries and any MISSING from it — TikTok binds scopes at authorize time and never retroactively, so only a re-authorization picks up a new one — plus the exact URL to send the user to, because authorizing is the one step that needs a browser) · list_tiktok_comments + list_tiktok_comment_replies (the comments on the brand’s OWN posts, hidden ones included — TikTok’s answer to list_meta_comments and list_youtube_comments) · comment_on_tiktok_video · reply_to_tiktok_comment · moderate_tiktok_comment (LIKE / UNLIKE / HIDE / UNHIDE / DELETE — you can only DELETE a comment this account wrote, so HIDE is the tool for a stranger’s, and TikTok warns UNHIDE may not take effect when its own moderation is what hid it) · upload_tiktok_comment_image (a new comment will not take a raw image URL; a reply will) · set_tiktok_post_ad_authorization (THIS IS WHERE A SPARK ADS AUTHORIZATION CODE COMES FROM for the brand’s OWN post — previously a human had to copy one out of the TikTok app; hand the code to authorize_tiktok_ads_spark_post) · get_tiktok_post_ad_authorization · extend_tiktok_post_ad_authorization (the days are ADDED to what is left, not set as an absolute) · delete_tiktok_post_ad_authorization. BRAND MONITORING AND AUDIENCE, on that same account authorization (these need permissions added on 2026-08-20, so a brand that authorized before then holds a grant that predates them and has to authorize once more; tiktok_account_status names exactly which are missing, and the remedy is always to authorize the TikTok ACCOUNT again rather than to touch the advertiser connection, which is a separate grant and is unaffected): list_tiktok_mentions (public posts whose caption @-mentions the brand, TikTok’s answer to x_mentions and list_threads_mentions) · list_tiktok_mention_comments (comments whose text mentions it) · get_tiktok_mention (one mention in full, for the mentions webhook, and TikTok only keeps that data 48 hours) · tiktok_mention_top_terms (the top 20 keywords and top 20 hashtags inside those mentions) · list_tiktok_brand_hashtags + manage_tiktok_brand_hashtags + list_tiktok_brand_hashtag_posts (the hashtags TikTok counts as this brand’s, up to 50, and the posts carrying them; a new one is not counted for 24 hours and cannot be removed for 7 days) · tiktok_account_insights (follower demographics by age, gender, country and city plus the daily performance series, needing a BUSINESS account with 100+ followers, and capped at 60 days rather than the 90 the mention tools cover) · tiktok_category_benchmark (the same numbers averaged across an industry, so ‘are we ahead of our category’ is answerable). ALL OF THIS IS ORGANIC LISTENING ON THE BRAND’S OWN ACCOUNT, not ad research: for competitors’ ads use the ad-library research tools instead. TIKTOK POST INSIGHTS, also on the TikTok account authorization: tiktok_video_insights (per-POST analytics for the brand’s own posts — views, reach, average watch time, the per-second RETENTION curve, traffic sources, followers gained) · tiktok_spark_recommendations (TikTok rates the account’s organic posts HIGH / MEDIUM / LOW as Spark Ads candidates, optionally for an objective) · tiktok_hashtag_suggestions (10 hashtag ideas for a caption keyword, with each tag’s post count). TIKTOK ADS (a SEPARATE connection from the TikTok posting connector above — Connectors ▸ TikTok Ads; a brand that posts to TikTok every day may still have no ad account here, so never read one as the other): list_tiktok_ads_accounts (the ADVERTISER accounts this profile can act on — every other TikTok Ads tool needs an advertiserId and this is where it comes from) · list_tiktok_ads_pixels + create_tiktok_ads_pixel + list_tiktok_ads_custom_conversions + tiktok_ads_pixel_stats (CONVERSION TRACKING — a conversion-optimised ad group dies at creation with "Please select a pixel" without one, so discover the pixel and its events BEFORE building the tree; note TikTok publishes no way to DELETE a pixel, so one you create is permanent) · list_tiktok_ads_campaigns (the whole tree — campaigns, ad groups and ads with their statuses) · tiktok_ads_report (impressions, clicks, spend, CTR, CPC, conversions and video views at any level) · list_tiktok_ads_identities (the TikTok accounts an ad may post AS — MANDATORY, with NO default: call it and let the USER pick, because the ad runs publicly under whichever account is named) · search_tiktok_ads_targeting (resolve location / interest / hashtag / language ids — an ad group cannot be created without location ids, and a guessed id targets the wrong people) · list_tiktok_ads_identity_posts (the ORGANIC posts an identity has already published — where a Spark Ad’s post id comes from) · list_tiktok_ads_spark_posts (the posts authorised for Spark Ads, i.e. promoting an organic post instead of uploading a new video) · authorize_tiktok_ads_spark_post + unbind_tiktok_ads_spark_post (add a creator’s post to that authorised set with the code they generated in the TikTok app, or release it again) · upload_tiktok_ads_playable / list_tiktok_ads_playables / delete_tiktok_ads_playable (PLAYABLE ads: the advertiser’s own interactive HTML zip, run through create_tiktok_smart_ad’s playableUrls) · upload_tiktok_ads_creative (THE STEP THAT TURNS A RENDER INTO AN AD — put a finished Hermoso video on the ad account and it hands back the videoId AND the coverImageId create_tiktok_ads_ad needs; there is no other source for either) · create_tiktok_ads_campaign → create_tiktok_ads_ad_group → create_tiktok_ads_ad (the tree) · set_tiktok_ads_budget · set_tiktok_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_tiktok_ads_object (removal on TikTok is a STATUS, not a verb — the same route as set_tiktok_ads_status) · update_tiktok_ads_ad (EDIT a live ad in place — ad text, call-to-action, landing URL, UTMs, name, video and cover, carousel sound or Instant Form — instead of deleting and rebuilding it, which throws away its review history and delivery learning) · preview_tiktok_ads_ad (see an ad as it renders on TikTok, or preview a video BEFORE any ad is built, with TikTok’s own fix-it tips per placement) · tiktok_ads_review_info (TikTok’s review verdict per ad — approved, approved for only part of its targeting, or rejected — with its REASONS and suggested fix) · prereview_tiktok_ads_creative (the same review verdict BEFORE any ad exists, per video, image, ad text or landing page, for one country) · list_tiktok_ads_music (the ad-cleared sound a CAROUSEL ad needs) · tiktok_ads_creative_fatigue (TikTok’s own verdict on whether a creative is WEARING OUT, per day, with spend and cost per conversion — the signal to swap in a fresh variation) · tiktok_ads_video_retention (per-SECOND ad video performance: where viewers swipe away, the hook read for the next variation) · tiktok_ads_trending_hashtags + tiktok_trends (TikTok’s OWN first-party trend data: top hashtags by country, industry and period; trending pre-cleared Commercial Music Library sounds, the top videos on a sound, top search terms) · create_tiktok_smart_campaign → create_tiktok_smart_ad_group → create_tiktok_smart_ad (Smart+, TikTok’s Performance Max — born PAUSED) · list_tiktok_smart_campaigns · set_tiktok_smart_status (the Smart+ money switch, confirm-gated) · manage_tiktok_ads_negative_keywords (searches an ad must not show for: the whole ad account, a campaign or an ad group) · tiktok_bid_protection (the ad-credit compensation TikTok pays when a Smart+ object misses its bid) · preview_tiktok_smart_ad (see a Smart+ ad as it renders on TikTok, existing or not yet created, with its disclaimer and add-on card) · tiktok_lead_ads_terms (read and accept TikTok’s Lead Ads terms, which a lead-generation ad needs first) · list_tiktok_ads_lead_forms + list_tiktok_ads_lead_fields + download_tiktok_ads_leads + manage_tiktok_ads_test_lead (LEAD ADS — an Instant Form is built in TikTok Ads Manager and NO API creates one, so list them to find the id a LEAD_GENERATION ad group needs. The lead REGION is required with no default: it selects which of three separate lead stores you read, and leaving it out is a THIRD value rather than “all”, so an advertiser who omits it downloads an empty file and wrongly concludes there are no leads) · list_tiktok_ads_audiences + create_tiktok_ads_audience + create_tiktok_ads_lookalike_audience + apply_tiktok_ads_audience + update_tiktok_ads_audience + delete_tiktok_ads_audience + tiktok_ads_audience_overlap (CUSTOM AUDIENCES and lookalikes — TikTok targeting is otherwise interests-and-geo only. A freshly created audience reports itself invalid for up to 48 hours BY DESIGN, so that is not a failure to retry) · list_tiktok_ads_business_centers + list_tiktok_ads_catalogs + create_tiktok_ads_catalog + list_tiktok_ads_catalog_products + list_tiktok_ads_catalog_sets + manage_tiktok_ads_catalog_feed + tiktok_ads_catalog_diagnostics (DPA / PRODUCT CATALOGS, the Shopify lane — a catalog is keyed on a BUSINESS CENTER id, NOT an advertiser id, so list the Business Centers first or every call refuses) · create_tiktok_ads_account (CREATE A NEW TIKTOK AD ACCOUNT for a business that has none, in a Business Center the user’s own login administers and in their company name; two steps, call without confirm first, and it can never be deleted) · create_tiktok_ads_business_center (when that login administers NO Business Center, create one first in the user’s company name; same two steps, every field asked of the user, never guessed) · list_tiktok_ads_apps + list_tiktok_ads_app_events (the registered apps an APP_INSTALL campaign needs — nothing else can produce an app id) · tiktok_ads_rf_inventory_estimate + create_tiktok_ads_rf_ad_group (REACH & FREQUENCY — a RESERVATION, so it is confirm-gated like a status change rather than born paused, and it needs a per-ad-account allowlist plus a signed branding contract that no endpoint reports. Always price it with the estimate first: TikTok silently books its own maximum rather than refusing an out-of-range value) · send_tiktok_ads_events (SERVER-SIDE conversion events — there is a vendor-sanctioned test code for exercising it without entering the advertiser’s real reporting), and its offline/crm sources take the event-set ids the two tools below mint) · list_tiktok_ads_offline_event_sets + manage_tiktok_ads_offline_event_set + send_tiktok_ads_offline_events (REAL-WORLD CONVERSIONS — an in-store purchase, a phone booking, a signed contract, reported so TikTok can attribute them to the ads that caused them. The timestamp is an ISO-8601 STRING here and a Unix NUMBER on send_tiktok_ads_events; a wrong-shaped one is accepted by TikTok and attributed to nothing. There is NO test code on this pair, so everything sent is a real permanent conversion — rehearse through send_tiktok_ads_events with eventSource “offline” and a testEventCode instead. Reporting also needs the connected user to be an ADMIN or OPERATOR of the advertiser, which managing the event SETS does not) · list_tiktok_ads_crm_event_sets + create_tiktok_ads_crm_event_set (LEAD-LIFECYCLE events — sending “this lead qualified / closed” back is what makes a LEAD_GENERATION campaign optimise toward leads that convert rather than form fills. TikTok publishes create and list and nothing else, so one of these is PERMANENT) · list_tiktok_tto_accounts + list_tiktok_creator_labels + discover_tiktok_creators + tiktok_creator_leaderboard + check_tiktok_creator_status + list_tiktok_tto_brand_profiles + create_tiktok_tto_brand_profile + list_tiktok_tto_campaigns + create_tiktok_tto_campaign + update_tiktok_tto_campaign + link_tiktok_tto_video + list_tiktok_tto_link_requests + tiktok_tto_campaign_report + request_tiktok_tto_spark_authorization + get_tiktok_tto_spark_authorization + manage_tiktok_tto_anchor (TIKTOK ONE / CREATOR MARKETPLACE: INFLUENCER MARKETING, and the only place in Hermoso that does it: find creators by audience size, engagement, price and who their followers actually are, check whether they have joined TikTok One, invite them to a campaign with an invite link, ask them to tag a video to it, and read every metric SPLIT ORGANIC VERSUS PAID. Its account id is a THIRD id space; not an advertiser id and not a Business Center id; so start at list_tiktok_tto_accounts. It rides this same connection with nothing extra to apply for. IT ALSO CLOSES THE SPARK ADS LOOP: request_tiktok_tto_spark_authorization asks a creator directly and get_tiktok_tto_spark_authorization returns the code authorize_tiktok_ads_spark_post takes, which is otherwise obtainable only by the creator pasting one out of the TikTok app. Two things put a notification in a real person’s inbox; a campaign invitation and a video-linking request; and a repeated linking request is a REMINDER that TikTok caps at two, so read list_tiktok_tto_link_requests before re-sending anything) · list_tiktok_ads_stores + list_tiktok_ads_store_products (TIKTOK SHOPS: what a Shopping Ads or GMV Max campaign sells from; the store list is keyed on an ad account and the product list on a BUSINESS CENTER, which each store row names) · tiktok_ads_verification_status + list_tiktok_ads_verification_documents + submit_tiktok_ads_verification (BUSINESS VERIFICATION: an unverified account hits limits that get diagnosed as something else, so it is worth reading during onboarding. Hermoso never handles a verification DOCUMENT: submitting sends account details plus the ids of images the user uploaded in TikTok Ads Manager, and the legal name and document number can never be changed afterwards, so it is confirm-gated) · list_tiktok_ads_payment_portfolios + list_tiktok_ads_payment_portfolio_links (HOW THE AD ACCOUNTS ARE FUNDED: read-only, because "why did delivery stop" is often a funding answer, and because deciding where a customer’s money sits is not ours to do) · create_tiktok_ads_rule + list_tiktok_ads_rules + update_tiktok_ads_rule + bind_tiktok_ads_rule + set_tiktok_ads_rule_status + tiktok_ads_rule_results (AUTOMATED RULES — standing instructions TikTok runs on the account unattended. THE SECOND SPEND SWITCH ON THIS PLATFORM and gated in TWO CLASSES: a rule that can only pause, decrease or email needs confirm:true, while one that can TURN_ON an object or RAISE a budget or bid needs confirm:true AND confirmScope echoing the token list_tiktok_ads_rules prints, computed from the rule as TikTok STORES it. Every rule is created TURNED OFF and read back to prove it, because TikTok publishes no way to create one in the off position. TikTok emails rule notifications to the DEVELOPER address on the app rather than to the advertiser, so tiktok_ads_rule_results is the only place a customer sees what a rule did — and TikTok itself says this endpoint is for direct advertisers and may refuse a platform-managed account entirely). · list_tiktok_ads_comments + tiktok_ads_comment_thread + moderate_tiktok_ads_comment + reply_to_tiktok_ads_comment + delete_tiktok_ads_comment (COMMENT MODERATION on your own TikTok ads — the platform where the comment section IS the ad. HIDE is the moderation verb and works on anyone’s comment and is reversible; DELETE only ever removes a comment your OWN identity posted, which TikTok reports per comment as canDelete. Comments are scoped to an AD GROUP and to nothing else, and the time window may span at most 30 DAYS, so an empty answer means “none in these 30 days” rather than “none ever”) · list_tiktok_ads_blocked_words + manage_tiktok_ads_blocked_words (a standing 500-word filter that auto-hides any comment containing one of these across EVERY ad on the account — nothing else in Hermoso does this, and removing a word republishes every comment it had hidden) · tiktok_ads_diagnosis (TikTok’s own issues-and-suggestions verdict on your ad groups — creative, bid/budget with its full estimated-delivery tables, and a pixel that has gone quiet. It covers ACTIVE ad groups only and omits any it has nothing to say about, so an empty answer is not a clean bill of health) · get_tiktok_ads_brand_safety + set_tiktok_ads_brand_safety (what content the ads may appear next to. Two things to say out loud: TikTok applies this to Smart+ campaigns and explicitly NOT to the regular campaigns create_tiktok_ads_campaign builds, and coverAllObjectives is a ONE-WAY DOOR TikTok cannot set back). TWO THINGS HERE ARE UNLIKE EVERY OTHER AD PLATFORM: TikTok creates objects ENABLED by default, so Hermoso forces every campaign, ad group and ad PAUSED with no override and nothing serves until set_tiktok_ads_status(confirm:true); and TikTok’s QPS is 1, so every call is serialized and a tree build or a bulk read is SLOW BY DESIGN — a throttle is not a broken connection. SNAPCHAT ADS (the tenth ad platform — Connectors ▸ Snapchat Ads): list_snapchat_ads_accounts (the organizations and AD ACCOUNTS this profile can act on — every other Snapchat tool needs an adAccountId and this is where it comes from) · list_snapchat_ads_campaigns (the whole tree — campaigns, ad squads and ads) · snapchat_ads_report (impressions, spend, swipes and video quartiles at any level) · search_snapchat_ads_targeting (resolve country / region / interest / language ids — an ad squad cannot be created without at least one country) · upload_snapchat_ads_creative (put a finished render on the ad account as MEDIA and then as the CREATIVE an ad points at — Snapchat has no upload-from-URL, so Hermoso streams the bytes) · create_snapchat_ads_campaign → create_snapchat_ads_ad_squad → create_snapchat_ads_ad (the tree, every tier born PAUSED) · set_snapchat_ads_budget · set_snapchat_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_snapchat_ads_object (a REAL delete verb here, unlike TikTok — irreversible, so offer PAUSED first). THREE THINGS TO SAY OUT LOUD ON THIS PLATFORM: money is MICRO-CURRENCY (1,000,000 = one unit), so quote plain amounts and let Hermoso convert, and never pass both units — under-converting fails loudly while double-converting asks for a budget a million times too large; the creative HEADLINE is capped at 34 characters and brandName at 32, far shorter than Meta or Google, and over-long copy is refused rather than truncated; and a Snapchat ad points at a CREATIVE, never at a media id. SNAPCHAT POSTING (Stories / Spotlights on a Public Profile) IS BUILT BUT NOT YET REACHABLE — Snap’s Public Profile API is allowlist-only and Hermoso has not been allowlisted, so the connector is deliberately not offered; say that plainly rather than looking for a tool. LINKEDIN: post_to_linkedin (publish a finished post to the connected LinkedIn PROFILE) · list_linkedin_pages (the company Pages this connection administers — call this first and let the USER pick, never guess a Page) · post_to_linkedin_page (publish as a company PAGE rather than a person — this is the one most brands actually want) · manage_linkedin_post (edit the copy of a published post, or delete it) \u00b7 list_linkedin_comments / reply_to_linkedin_comment / delete_linkedin_comment (moderate the comments on your Page\u2019s posts \u2014 a SEPARATE LinkedIn authorization grants these, see Connectors) · linkedin_page_analytics (ORGANIC Page performance — followers, follower gains, Page views, and post impressions/clicks/engagement, for the Page total or per post; this is the free organic read, NOT linkedin_ads_report). LINKEDIN ADS (full three-tier management): list_linkedin_ads_campaigns (ad accounts, then a chosen account’s campaign groups, campaigns and — with campaignId — the CREATIVES under them) · linkedin_ads_report (impressions, clicks, cost, conversions, leads) · search_linkedin_ads_targeting (resolve locations / titles / industries / seniorities / company sizes to the URNs LinkedIn demands — never invent one) · linkedin_audience_count (HOW MANY members that targeting actually reaches, before a budget is committed — and a returned 0 means fewer than 300 people, LinkedIn’s privacy floor and also its campaign minimum, never an empty audience) · BOOST AN EXISTING LINKEDIN POST (a personal post, or a Company Page post with organization; an opt-in on the LinkedIn connection whose refusal carries the enable link): linkedin_boost_status (can it be boosted / is it already) · boost_linkedin_post (free preview without confirm; confirm:true creates it and LAUNCHES spend, there is no paused state) · end_linkedin_boost · linkedin_boost_analytics · linkedin_bid_pricing (LinkedIn’s own suggested bid and daily-budget range for that audience — quote it instead of guessing what LinkedIn costs) · create_linkedin_ads_campaign_group → create_linkedin_ads_campaign → create_linkedin_ads_creative (the tree, every tier born DRAFT) · set_linkedin_ads_budget / set_linkedin_ads_status / delete_linkedin_ads_object (budgets, activate/pause at any tier, delete — every spend change confirm-gated). LINKEDIN LEAD SYNC: list_linkedin_lead_forms · list_linkedin_leads / get_linkedin_lead (the LEADS its forms collected, answers named by field — PERSONAL DATA: show, never republish) · subscribe_linkedin_leads / list_linkedin_lead_events / list_linkedin_lead_subscriptions / delete_linkedin_lead_subscription (real-time push to Hermoso, optional forwardTo relay to a CRM; LinkedIn validates ONLY Hermoso’s own webhook). LinkedIn is a THREE-tier platform and the third tier is the one people forget: a campaign with no creative shows nothing, and all three tiers must be ACTIVE before a single impression is served. REDDIT ADS: list_reddit_ads_campaigns / reddit_ads_report (read the account tree + performance) · list_reddit_ads_profiles + list_reddit_ads_posts / create_reddit_ads_post / update_reddit_ads_post (the CREATIVE — a Reddit ad promotes a post) · create_reddit_ads_campaign / update_reddit_ads_campaign · create_reddit_ads_ad_group / update_reddit_ads_ad_group · create_reddit_ads_ad / update_reddit_ads_ad · create_reddit_ads_max_campaign / get_reddit_ads_max_template / update_reddit_ads_max_template (a Reddit MAX campaign: automated campaign, ad group and a template ad Reddit generates ads from, built from creative-library assets, all PAUSED) · set_reddit_ads_status (the ONLY switch that arms real spend, confirm-gated) · delete_reddit_ads_object (remove a campaign, ad group or ad — Reddit has no delete verb, removal is a status, and it refuses to delete anything touched in the last 3 hours) · delete_reddit_ads_saved_audience · search_reddit_ads_targeting / reddit_ads_forecast / reddit_ads_bid_suggestion (free planning) · list_reddit_ads_pixels + send_reddit_ads_conversions (conversion tracking — Reddit now requires a pixel on every ad group) · list_reddit_ads_audiences / create_reddit_ads_audience / update_reddit_ads_audience_users / delete_reddit_ads_audience (retargeting lists) · list_reddit_ads_saved_audiences / create_reddit_ads_saved_audience / update_reddit_ads_saved_audience · list_reddit_ads_lead_forms (read only: Reddit retired creating lead forms) · reddit_ads_history (who changed what, when). TELEGRAM: post_to_telegram (publish to a channel, group or chat as the brand’s own bot — text up to 4096 characters, but only 1024 once any photo or video is attached; one image, one video, or an album of 2–10 in which photos and videos may be mixed. chatId IS ALWAYS REQUIRED and is never guessed: the Bot API publishes NO method that lists the chats a bot belongs to, so pass the public channel’s @username or the numeric id) · list_telegram_chats (chats that MESSAGED the bot in the last 24 hours — a shortcut for finding an id, NOT a roster, and a chat missing from it can still be posted to) · list_telegram_dms (what those chats actually SAID, newest per chat — free, and a rolling 24-hour window rather than an inbox: the Bot API has no history endpoint at all) · delete_telegram_message (confirm-gated; Telegram refuses once a message is more than 48 hours old). WORDPRESS: post_to_wordpress (a blog post on the brand’s own self-hosted site — publish, draft, pending review, private, or scheduled natively with a future date; the first image is featured, the rest inline, a video embedded; a title is required) · update_wordpress_post · delete_wordpress_post (trash, or permanent; confirm-gated) · list_wordpress_posts · get_wordpress_post · list_wordpress_terms / create_wordpress_term. WordPress has no per-post views. DISCORD + SLACK: post_to_discord / post_to_slack (an ANNOUNCEMENT through the brand’s announcement webhook, never its alert one — Discord 2000 characters with files up to 20 MB attached, larger as a link, mentions off unless asked; Slack 40,000 characters with images inline and a video as a link) WHOP: post_to_whop (a FORUM post — title, Markdown, images and video, pin, members-only or public, paywall, poll — or a CHAT message, into the brand’s whop as the Hermoso app it installed) · list_whop_destinations · list_whop_products · edit_whop_post · get_whop_post · delete_whop_post (chat messages; Whop’s API cannot delete a forum post). A whop is connected from Hermoso (Connectors ▸ Whop) or by pressing Open Hermoso in the whop’s dashboard; nothing is billed through Whop. · edit_discord_message / delete_discord_message / get_discord_message (its reactions). A Slack webhook post cannot be edited or deleted. BLUESKY: post_to_bluesky (publish as the connected account — text up to 300 characters AND, separately, 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; either up to 10 images OR one MP4 video, never both, because a Bluesky post record carries exactly one embed; links are made clickable automatically) · delete_bluesky_post (PERMANENTLY remove one of the account’s own posts — no trash and no undelete. Call it WITHOUT confirm first: it deletes nothing and reports the post’s real text and live like/repost/reply/quote counts, and once the post has any engagement it also wants confirmText echoing its text. Takes the AT-URI or just the record key from the bsky.app link) · list_bluesky_convos / read_bluesky_dm / send_bluesky_dm / mark_bluesky_convo_read (the account’s DIRECT MESSAGES — free, 1000 characters each, text only, and they need a PRIVILEGED app password: an ordinary one posts fine and cannot chat). Replies, mentions AND direct messages all arrive in list_inbox and are answered with reply_to_inbox_item. X / TWITTER: post_to_x (publish a post — text, an image or a video render WITH alt text, a POLL, a reply, or a whole thread, and optionally restrict who may reply; X is the ONE channel that bills per API request, a post carrying a LINK costs roughly 13× one without, and each profile has a rolling 24-hour ceiling on X spend that refuses a request whole rather than publishing half of it) · delete_x_post (remove one) · x_post_metrics (the PUBLIC counts — impressions, likes, reposts, replies, quotes, bookmarks) · x_post_insights (the ADVERTISER numbers for your own posts — link clicks, profile visits, video views and completion quartiles, up to 25 posts at once; this is what says whether a creative worked, and x_post_metrics cannot tell you, but it only sees the LAST 28 HOURS) · x_post_insights_historical (the same advertiser numbers over ANY date range — the one to use for anything older than yesterday) · x_mentions (who is talking to the brand, in their own words — the read half of the reply loop, and a source of real customer language for ad copy) · list_x_dms (the brand’s X DIRECT MESSAGES, grouped into conversations, saying which are waiting on a reply — billed per message returned, and X keeps only 30 days) · send_x_dm (reply privately to one named person; never a broadcast) · x_chat_settings / update_x_chat_settings (the X account’s DM privacy: who may message the brand, the request filter, read receipts and calls). X IS THE ONE CONNECTOR THAT COSTS CREDITS PER CALL — X charges us per API request, so posting, deleting, reading metrics, reading insights and pulling mentions each bill the user, a post CONTAINING A LINK costs 13× one without, and insights and mentions are billed PER POST RETURNED. Say so before posting a thread or pulling a big page of mentions, and prefer one post over five when the content allows. X ADS (the PAID half — a SEPARATE connection from the organic tools above: its own product on its own host with OAuth 1.0a signing, and X grants API access PER AD ACCOUNT rather than per app, so the customer adds Hermoso’s X user at business.x.com → Account access before anything here resolves): list_x_ads_accounts (the ad accounts this profile can act on, WITH the permission level held on each — read it before attempting a write) · list_x_ads_funding_instruments (a campaign cannot be created without one) · list_x_ads_campaigns / list_x_ads_line_items / list_x_ads_promoted_tweets / list_x_ads_targeting (the whole tree as it stands) · x_ads_report (impressions, clicks, spend and engagements at any level) · x_ads_geo_search / x_ads_targeting_search (resolve places and targeting values to the ids X demands — never invent one) · create_x_ads_campaign → create_x_ads_line_item → create_x_ads_promoted_tweet (the tree, every tier born PAUSED with no override; A CAMPAIGN ALONE CANNOT SERVE ON X — it needs a line item and a promoted post underneath it, and the read-back says so rather than letting you call it a finished ad) · add_x_ads_targeting · update_x_ads_campaign / update_x_ads_line_item (throttle or raise spend on a running campaign without rebuilding it) · set_x_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_x_ads_object. PINTEREST — POSTING AND ADS ARE TWO SEPARATE CONNECTIONS on the same Pinterest login (Pinterest keeps ads access behind different permissions), so a brand can hold either without the other and connecting one does not connect the other; if an ads call says Pinterest Ads is not connected, that is the card to send them to, NOT the Pinterest posting one. ADS: pinterest_ads_async_report (the DEEP paid report — 914 days back where the quick one stops at 90, and three times the metric columns; generated asynchronously, so pass the returned token back rather than re-submitting) · pinterest_targeting_analytics (WHICH audience segment delivered — by keyword, interest, age, gender, location, placement) · pinterest_audience_insights (WHO the audience is: interest affinities plus demographics, the input to a creative brief rather than a performance report) · pinterest_analytics (ORGANIC performance — impressions, saves, Pin clicks, outbound clicks, for the account, the TOP PINS, the top video Pins, or one Pin; Pinterest keeps 90 days and publishes no board-level analytics at all) · create_pinterest_board (make a board — a NEW Pinterest account has none and a Pin needs one) · list_pinterest_boards (the user must pick a board — never choose one for them) · post_to_pinterest (create an image or video Pin on a chosen board, with a title, description and destination link) · list_pinterest_pins (the Pins on a board with their ids — where the pinId every Pin tool needs comes from, and it flags any Pin an ad is promoting) · update_pinterest_pin (retitle, re-describe, fix a dead link, move it — Pinterest keeps this endpoint in a limited BETA, so it may be refused outright and save_pinterest_pin is the generally-available way onto another board; a Pin’s picture can never be swapped by anyone) · save_pinterest_pin (copy a Pin onto another board) · delete_pinterest_pin (confirm-gated, and it says whether an ad is promoting the Pin first) · update_pinterest_board (rename, re-describe, or hide it — SECRET hides every Pin on the board, reversibly) · delete_pinterest_board (the heaviest one here: the board AND every Pin on it, confirm-gated with the Pin count echoed back — offer hiding it instead). GOOGLE ADS (full management): list_google_ads_campaigns (list accounts, then a customer’s campaigns + spend/CTR/CPC/conversions) · google_ads_report (any GAQL breakdown — ad groups, keywords, search terms, geo) · create_google_ads_campaign (paused) · set_google_ads_budget / set_google_ads_status (change budget, enable/pause — every spend change confirm-gated) · delete_google_ads_object (remove a campaign, ad group, ad, KEYWORD, asset LINK or conversion action — Google has no delete verb, `remove` is the terminal state and it cannot be undone; call it unconfirmed first to see the spend and the tree that go with it) · upload_google_ads_asset (add an image render or a YouTube video to the ad account’s asset library) · create_google_ads_performance_max_campaign (Google’s cross-surface campaign type, RETAIL INCLUDED — pass merchantCenterId to make it a Shopping-feed Performance Max advertising the WHOLE Merchant Center feed under one root listing group, and feedLabel to narrow it to a single feed; only PARTITIONING that feed by brand/category/custom label is refused by name) · add_google_ads_assets (sitelinks, callouts, structured snippets, call, promotion, price, lead-form, business name and logo assets, CREATED AND ATTACHED — an asset that is not attached shows nothing) · manage_google_ads_assets (list, edit, pause or detach them) · set_google_ads_tracking (UTMs / tracking template on a campaign, ad group or ad; new campaigns get standard UTMs by default) · manage_google_ads_audience + set_google_ads_audiences (remarketing lists, custom segments, affinity / in-market interests, demographics, Performance Max signals and search themes) · upload_google_ads_video (a render straight into Google Ads, no YouTube channel needed) · create_google_ads_demand_gen_campaign (YouTube Shorts / in-feed / in-stream video ads, born PAUSED) · list_google_ads_conversion_actions + create_google_ads_conversion_action (what Google counts as a result — MAXIMIZE_CONVERSIONS, TARGET_CPA, TARGET_ROAS and every Performance Max campaign are undeliverable without one, and Hermoso refuses to build them on an account that has none) · google_ads_keyword_ideas (Keyword Planner — real monthly search volume, competition and top-of-page bids; use it before choosing keywords) · google_ads_recommendations + manage_google_ads_recommendations (Google’s own advice and optimization score; apply / dismiss / auto-apply, spend-type ones only after the user’s yes) · preview_google_ads_ad (a shareable no-login preview link for a Performance Max asset group or YouTube video ad, for client approval) · google_ads_policy_issues (WHY Google disapproved or limited an ad, asset or keyword: the policy topic and the evidence) · google_ads_change_history (WHAT CHANGED ON THE ACCOUNT AND WHEN — the answer to “performance fell off a cliff on Tuesday, what happened?”. Its default source is field-level and reaches 30 days; the other source reaches 90 and is the ONLY one that sees Google Ads Editor and criterion edits, so check both before telling anyone nothing changed). GOOGLE MERCHANT CENTER (the product feed behind every Shopping ad and every free listing, on the SAME connection as Google Ads): register_merchant_developer (the ONE-TIME link between Hermoso’s Google Cloud project and the merchant’s account. Google refuses every other Merchant call until it is done, so run this first when calls are being refused) · list_merchant_accounts (which Merchant Centers this login can reach, and where the merchantCenterId every other tool needs comes from) · list_merchant_products (the feed itself, with each product’s disapprovals) · list_merchant_issues (account-level problems, the answer to "why is nothing showing at all") · merchant_issue_help + trigger_merchant_issue_action (Google’s OWN remediation steps for a problem, and the button that fires one. Several of those actions are one-shot in Google’s own words, so firing one is confirm-gated) · list_merchant_data_sources + create_merchant_data_source + delete_merchant_data_source (feeds. A product write only lands in an API-input feed, and most accounts have none until one is made, so check before writing) · upsert_merchant_product + update_merchant_product + delete_merchant_product (write the feed) · list_merchant_inventory + set_merchant_inventory (the per-STORE and per-REGION price, stock level and availability override on one product, which is what stops a Shopping ad advertising something the nearest store has sold out of. The write MERGES, because Google’s insert replaces the whole entry, and Google takes up to 30 minutes to reflect it on the product) · list_merchant_promotions + create_merchant_promotion (sale and discount badges on a listing. Google validates them asynchronously, so created is never the same as approved) · manage_merchant_notifications (Google POSTs to a URL THE MERCHANT RUNS the moment a product is disapproved, instead of someone having to poll) · merchant_account_status (WHY THE ACCOUNT IS OR IS NOT SERVING — the first thing to run when Shopping ads or free listings show nothing, and the one read that does not believe the program state: an account can report both programs ENABLED and serve in ZERO countries, because a region counts as active only where every requirement is met. It names Google’s own unmet requirements, then the settings that explain them: homepage claimed or not, business address, phone and support contact, active shipping services, return policies, terms accepted) · manage_merchant_settings (FIXES what merchant_account_status finds: shipping services merged by name so unmentioned ones are kept, return policies, business address and support contact, homepage claim, programs on or off; overwrites and removals ask first) · manage_merchant_conversion_source (WHERE MERCHANT CENTER GETS ITS CONVERSION DATA FROM, which is what free-listing and Shopping performance reporting is built on — a merchant with no conversion source sees clicks and no outcomes. Either a Google tag destination, whose MC-… id comes back only on the create and is the id the Google tag has to send conversions to, or a link to a GA4 property, which is IMMUTABLE and needs the connected Google account to be an admin there. A delete is an ARCHIVE and undelete restores it until the expiry Google reports) · merchant_quota (whether the account is simply out of daily API quota or out of product slots, which looks identical to a broken integration and is not. Google resets it at MIDDAY UTC) · merchant_report (the reports Google computes for free, including competitive visibility, best sellers and price competitiveness). MICROSOFT MERCHANT CENTER (the same job on Microsoft’s side, on the Microsoft Advertising connection): list_microsoft_merchant_stores · list_microsoft_merchant_products · upsert_microsoft_merchant_product · delete_microsoft_merchant_product · list_microsoft_merchant_issues · list_microsoft_merchant_catalogs + manage_microsoft_merchant_catalog. GOOGLE ANALYTICS (GA4 — the brand’s OWN site data, and a SEPARATE connection from Google Ads: a brand that spends on Ads every day may have no Analytics access at all, so never read one as the other): list_analytics_properties (call this FIRST — every other Analytics tool needs a NUMERIC property id, and what users actually know is the “G-XXXXXXX” Measurement ID from their tracking snippet, which no endpoint accepts; resolve it from this list rather than sending them hunting. It lists the properties SHARED WITH THIS PROFILE, not everything the Google account can see — Analytics access is handed out freely and one login often has Viewer on many clients’ properties, so the user ticks which belong to this profile and any other one is refused by name; an empty list means nothing is ticked yet, which set_connector_accounts or Connectors ▸ Google Analytics ▸ Manage accounts fixes) · analytics_report (what happened — sessions, users, revenue, conversions and engagement broken down by channel, source/medium, campaign, landing page, country, device or date, i.e. the read that says whether the traffic an ad bought actually did anything) · analytics_realtime (who is on the site right now, ~30 minutes — a DIFFERENT metric set that rejects `sessions` outright, never a shortcut for analytics_report) · list_analytics_definitions (what the property already measures: its key events and its own custom dimensions, and the check to run before creating either) · create_analytics_key_event (mark an event GA4 already collects as a KEY EVENT — the 2024 rename of a conversion, and what makes it importable into Google Ads; marking an event the site never fires creates one that can never fire) · create_analytics_custom_dimension (register an event parameter the site already sends so reports can break down by it — say out loud first that a GA4 custom dimension CANNOT be deleted, only archived, and a property is capped at 50 event-scoped ones, so a typo permanently burns a slot) · list_analytics_data_streams (the streams on a property and the measurement ID (G-...) each one carries, which is what a gtag or GTM install needs and what nobody can find in the GA4 UI when asked) · get_analytics_stream_setup (the finished gtag <script> block to paste into the site — the last mile list_analytics_data_streams stops short of — plus whether enhanced measurement is really collecting scrolls, outbound clicks, site search, video, downloads and form interactions, and whether redaction is stripping campaign parameters out of recorded URLs. Web streams only. Read the master switch before believing a toggle: with enhanced measurement off for the stream, every toggle is inert whatever it says) · list_analytics_metadata (every dimension and metric this property can be asked for, including its own custom ones, which is what stops analytics_report guessing a field name) · check_analytics_compatibility (whether a dimension and metric can appear in the same report before spending a call finding out they cannot) · create_analytics_custom_metric + archive_analytics_custom_metric · archive_analytics_custom_dimension · delete_analytics_key_event (all one-way in the same sense as their create twins: archiving is not deleting and there is no un-archive) · list_analytics_google_ads_links + link_google_ads_to_analytics + unlink_google_ads_from_analytics (the join that makes a GA4 audience usable in Google Ads and a GA4 key event importable as a conversion — without it a perfectly good audience simply never appears in the ads account, with no error anywhere) · list_analytics_audiences + create_analytics_audience + archive_analytics_audience (GA4 remarketing audiences, the input to Google Ads remarketing. Archiving is one-way) · manage_analytics_measurement_protocol_secret (mint the API secret that lets the customer’s OWN SERVER send events straight into GA4, the Google twin of the conversions APIs already here for Reddit, Snapchat and OpenAI Ads. Say out loud that there is NO rotation anywhere in the API, so replacing a secret means create the new one, move every sender across, then delete the old one) · manage_analytics_channel_group (HOW GA4 BUCKETS TRAFFIC — the answer to “why is my campaign showing as Unassigned”, and the one number an ad studio is judged on. Read the Default channel group’s rules before diagnosing anything, then author your own group whose channels catch the campaigns Hermoso publishes. The rule fields are the eachScope… names, NOT the sessionSource / medium dimensions reports use, and GA4 stops at the first rule that matches so order decides everything) · manage_analytics_calculated_metric (the derived number a marketer actually reports — cost per purchase, revenue per session — built from metrics GA4 already collects and then available to analytics_report under its own permanent API name. The id is permanent, and a formula naming a metric the property does not collect is created happily and flagged invalid, so read that flag back) · manage_analytics_annotations (mark a launch or a post on the customer’s own GA4 charts, so the traffic it moved shows where they look) · analytics_data_retention (how long GA4 keeps event data — the 2-month default caps every exploration; lowering it deletes data, so it is confirm-gated). MICROSOFT ADVERTISING / BING ADS (full management, mirroring Google): list_microsoft_ads_campaigns (list the shared ad accounts, then a chosen account’s campaigns + budgets) · microsoft_ads_geo_search (resolve country / region / city names to the Microsoft location ids a campaign needs — call it when an ask is ambiguous and let the USER pick) · microsoft_ads_report (impressions, clicks, CTR, average CPC, spend, conversions — generated asynchronously, so it may come back pending and must be called again) · create_microsoft_ads_campaign (campaign → ad group → responsive search ad → keywords, always Paused; with no locations[] it is created serving WORLDWIDE, Microsoft’s own default, and the read-back warns loudly — relay that before anyone activates it) · create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (fill in an existing account) · set_microsoft_ads_budget / set_microsoft_ads_status (change budget, activate/pause — every spend change confirm-gated; Microsoft statuses are Active/Paused, never Deleted) · search_microsoft_ads_profiles / list_microsoft_ads_profile_targeting / set_microsoft_ads_profile_targeting (LinkedIn profile targeting: company, industry, job function, seniority and job title bid adjustments on a Search, Shopping or DSA campaign; confirm-gated on an ACTIVE campaign) · delete_microsoft_ads_object (a REAL delete — campaign, ad group, ad or keyword — permanent, with no undelete; call it unconfirmed first to see what goes with it) · set_microsoft_ads_bidding (change a campaign’s BID STRATEGY — EnhancedCpc, MaxClicks, MaxConversions with an optional target CPA, MaxConversionValue with an optional target ROAS, TargetImpressionShare — read back) · list_microsoft_ads_negative_keywords + add_microsoft_ads_negative_keywords + remove_microsoft_ads_negative_keywords + manage_microsoft_ads_negative_keyword_list (NEGATIVE keywords: the searches a campaign or ad group must NOT pay for, Exact or Phrase only, and SHARED negative lists reused across campaigns; removing one on a LIVE campaign lets those searches trigger paid ads again, so it is confirm-gated) · list_microsoft_ads_uet_tags + create_microsoft_ads_uet_tag (the UET site tag and its install snippet — conversion goals, remarketing and conversion bidding all need one) · create_microsoft_ads_conversion_goal (Url / Event / OfflineConversion / Duration / PagesViewedPerVisit goals, read back) · list_microsoft_ads_audience_targeting + set_microsoft_ads_audience_targeting (put an audience — a customer list, remarketing, in-market — to work on a campaign or ad group, or EXCLUDE it, with a bid adjustment) · create_microsoft_ads_image_ad (turn a render into an IMAGE ad on the Microsoft Audience Network or a multimedia ad on Search; the picture is cropped to the 1.91:1 and 1:1 sizes Microsoft requires) · upload_microsoft_ads_video (put a render on the account as a video asset, or check one still processing; Microsoft refuses clips under 6 seconds) · microsoft_ads_keyword_ideas (Microsoft’s Keyword Planner — real search volume, competition and suggested bids, with NO planning-tier gate, unlike Google’s) · microsoft_ads_traffic_estimates (what those keywords would deliver at a named bid — a range, never one number) · microsoft_ads_budget_opportunities (where Microsoft says a budget is capping delivery, and what raising it is forecast to buy) · microsoft_ads_auction_insights (who ELSE is bidding on the same auctions — rival domains with their impression share, overlap and outranking share; shares of YOUR auctions, never a measure of a competitor’s whole account) · microsoft_ads_bulk_download (export the account as ONE bulk file — the only way to read ~185 Microsoft record types Hermoso cannot otherwise touch: sitelinks, callouts, structured snippets, labels, shared negative keyword lists, bid strategies, audiences, experiments, seasonality adjustments, conversion goals, asset groups, feeds) · microsoft_ads_bulk_upload (apply an edited bulk file — hundreds of objects in one request. IT IS GATED HARDER THAN ANYTHING ELSE ON THIS CONNECTOR, because a bulk file carries a Status column and can turn campaigns ON without ever touching set_microsoft_ads_status: confirm:true alone is refused, and you must first call it unconfirmed to get the row-by-row list of what it would ACTIVATE and DELETE, show that to the user, then echo both counts back as confirmActivations/confirmDeletions — or pass pauseInstead:true to land the file with every activation written as Paused) · list_microsoft_ads_conversion_goals (what the account counts as a conversion, and which goals are OFFLINE ones) · send_microsoft_ads_offline_conversions (close the loop: phone sales, in-store purchases and late-closing leads fed back so smart bidding stops optimising against website conversions alone — pass PLAIN emails and E.164 phones, hashing happens server-side to Microsoft’s own published spec) · microsoft_ads_editorial_reasons (WHY an ad or keyword was disapproved: the code, the word Microsoft objected to, where, and the appeal status) · list_microsoft_ads_audiences (the account’s Customer Match lists with their current sizes; a fresh list reads 0 for up to 48 hours and Microsoft will not use one under 300 people, so never call that a failed upload) · create_microsoft_ads_customer_list then apply_microsoft_ads_customer_list (build a Customer Match audience from PLAIN email addresses, normalized and SHA-256 hashed server-side to Microsoft’s own published spec so no plaintext ever leaves us; the user must be shown Microsoft’s Customer Match terms and agree first) · microsoft_ads_recommendations (what Microsoft ITSELF suggests changing, each one priced by Microsoft: budget raises carrying the current and recommended daily amount, new and broadened keywords, negative keywords it wants removed, and ads it has written. Every one INCREASES what the account buys, which is what they are for, so none is a free win and an empty list means Microsoft has no advice rather than that the account is optimal) · apply_microsoft_ads_recommendations (act on them, gated exactly like the bulk upload: confirm:true alone is REFUSED, so call it unconfirmed first to get every recommendation named with what it changes and Microsoft’s own cost estimate, show that to the user, then echo confirmCount and confirmCostIncrease back. Both are recomputed from a fresh read, and there is no undo) · dismiss_microsoft_ads_recommendations (take advice off the list. It cannot spend, so it needs no confirmation at all, and it is the right answer to “make it stop suggesting that” rather than applying something to clear it) · microsoft_ads_auto_apply (THE READ THAT ANSWERS “is Microsoft changing this account while nobody is looking?”, per type. An inherited account can already be opted in with nobody at the brand having done it) · set_microsoft_ads_auto_apply (turn that standing permission on or off. Switching any type ON is the strongest consent anywhere in Hermoso: Microsoft then writes and publishes its own ads under the brand’s name, deletes negative keywords so the account buys more searches, and changes conversion goals, unattended and indefinitely, with NOTHING to preview beforehand. So confirm:true is not enough and every type must be named in confirmTypes. Switching it OFF is never gated) · get_microsoft_ads_account_properties / set_microsoft_ads_account_properties (account-level settings: tracking template, final URL suffix, MSCLKID auto-tagging, view-through conversions, business attributes, AI asset enhancement opt-out and the AI-detected content watermark policy, read back after every change). GOOGLE BUSINESS PROFILE (the local-SEO channel — the listing panel on Google Search and Maps, which for a local business is where the demand actually is, and there is no delete): list_business_locations (the listings the connected Google account manages — call this first and let the USER pick when there is more than one; a Post on the wrong storefront is a public mistake) · post_to_google_business (publish a Post to the listing — text, ONE PHOTO and a call-to-action button; Google’s Posts API takes no video, so pass a still. EVENT and OFFER posts both require a title and a start date, and on an OFFER Google ignores the button link) · list_google_business_posts (what is showing right now, with each Post’s state) · delete_google_business_post (take one down — immediate and public, so confirm first) · list_google_business_reviews (the reviews on the listing, and which ones have NO reply yet — for a local business the highest-leverage surface there is) · reply_to_google_business_review (answer one publicly as the business; it is an UPSERT, so it replaces any existing reply) · list_google_business_questions + answer_google_business_question (the public Q&A on the listing) · google_business_search_keywords (the actual search terms people typed to find the listing — free local keyword data; low-volume terms are SUPPRESSED and come back as "fewer than N", never as zero) · google_business_insights (Search + Maps impressions, calls, website clicks, direction requests, messages, bookings — listing-level; Google discontinued per-Post insights in 2023 with no replacement, so never promise per-Post numbers) · get_business_location (everything the listing actually says — name, address, phone, website, categories, description, hours, service area — as the merchant set it; the answer to “what does our Google listing say?”) · update_business_location (change any of that — hours, phone, website, description, categories, even the name or address. It edits the live panel on Search and Maps with no draft and no undo, so call it WITHOUT confirm first: nothing is written, Google validates the payload, and you get the current value of every field you are about to change to show the user) · google_business_account (whose Business Profile account the listing is on, and whether the connected Google account’s role can edit it at all). Google gates this API behind a per-project access request and the default quota is zero, so the connection can be live and calls still refused — the error says so. CHATGPT ADS (ads under ChatGPT answers, via OpenAI’s Advertiser API — full management): list_openai_ads_campaigns (the ad account, then its campaigns, ad groups and ads with each ad’s review state) · openai_ads_report (impressions, clicks, spend, CTR, CPC, CPM at account / campaign / ad group / ad scope — run this first, it validates the key with zero spend risk) · openai_ads_geo_search (location ids) · list_openai_ads_audiences + create_openai_ads_audience (custom audiences — geo and these are the only list-based targeting this platform has; target them with customAudienceIds / excludedCustomAudienceIds on a campaign) · create_openai_ads_campaign (campaign → ad group → ad in one call, always PAUSED) · create_openai_ads_ad_group / create_openai_ads_ad (fill in an existing campaign) · update_openai_ads_object (rename, re-budget, rewrite context hints or the ad copy) · set_openai_ads_budget / set_openai_ads_status (change budget, activate, pause) · delete_openai_ads_object (ARCHIVE — this API has no delete and OpenAI say archiving is not reversible, so offer pausing first). TWO RULES THIS CHANNEL DOES NOT SHARE WITH THE OTHERS: it is connected by PASTING an Advertiser API key (no OAuth, no manager account, one key = one ad account), and it has exactly ONE creative format — a text plus image card, title 50 characters, body 100. There is NO VIDEO on ChatGPT Ads, so never offer a video ad here. GOOGLE DRIVE — ONE connection covering Drive, Sheets and Docs (full CRUD over the files Hermoso created there, plus any file the user hands over with the Google file picker in the app): save_to_drive · list_drive_files / get_drive_file · update_drive_file (rename/move/trash) · delete_drive_file · create_drive_folder. GOOGLE SHEETS (part of the Google Drive connection — export data to a spreadsheet the app creates, or read one the user picked; drive.file, no verification): create_sheet · append_to_sheet · read_sheet. GOOGLE DOCS (part of the Google Drive connection — export copy/brief/report as a doc, or read one the user picked; drive.file, no verification): create_doc · append_to_doc. GOOGLE SLIDES (part of the Google Drive connection — turn a swipefile collection into a real presentation, one slide per saved ad with the creative, brand, copy, run dates and platform; drive.file, no verification, no new scope): export_swipefile_deck — it CREATES a deck each time and cannot append to one the user already has, and a creative whose ad-library link has expired is reported rather than silently dropped. ONEDRIVE (full CRUD over the user’s Microsoft OneDrive): convert_onedrive_file (Microsoft converts a file server-side to PDF or JPG — ~130 formats including PowerPoint and Word decks, PSD, Illustrator, Sketch, 3D, video, iPhone HEIC and raw camera files; JPG needs both width and height) · save_to_onedrive · list_onedrive_files / get_onedrive_file · update_onedrive_file (rename/move) · delete_onedrive_file · create_onedrive_folder · share_onedrive_file (a view/edit link for a client) · onedrive_file_permissions (who can open it; revoke). INTERCOM (the brand’s own support inbox, nothing billed): list_intercom_conversations (filter by state, assignee, team, tag, channel, customer, dates, priority, unread, or app = the product the customer wrote from) · read_intercom_conversation · reply_to_intercom_conversation / add_intercom_note / manage_intercom_conversation (each a preview until confirm:true) · tag_intercom_conversation · list_intercom_tags · search_intercom_contacts · list_intercom_teammates · intercom_customer_language (customers’ own words for ad copy). MAILCHIMP (same shape): create_mailchimp_template · create_mailchimp_campaign · send_mailchimp_campaign (confirm:true + the count) · mailchimp_report and more. KLAVIYO (the brand’s own email, nothing billed): create_klaviyo_template (an ad + on-brand copy → an email) · create_klaviyo_campaign (a draft) · send_klaviyo_campaign (needs confirm:true + the recipient count) · klaviyo_report and more. VERCEL (the brand’s own sites, nothing billed): list_vercel_projects · list_vercel_deployments · list_vercel_domains · vercel_web_analytics (page views, visitors, top pages, referrers, countries) · vercel_event_analytics (custom-event conversions by campaign) · deploy_vercel_page (a static landing page, confirm-gated; preview by default) · delete_vercel_deployment. Use these standalone — Hermoso is a full posting/ads/file-storage control surface, not only an ad generator.',
|
|
302
303
|
'F) YOUR TOOL LIST IS A STARTING POINT, NOT THE PRODUCT \u2014 and the whole catalogue is two calls away. The listed roster is deliberately small (the core tools, plus whatever this connection asked for or you have switched on); every other tool in this map is held out of the LIST on SIZE alone and is fully built, fully live and fully callable. THE ROUTE THAT WORKS ON EVERY HOST, including the ones that cannot reload their tool list at all (claude.ai, ChatGPT): find_tools({query}) searches every tool by task or name and returns its parameters, its credit cost and its recent health in one line, then call_tool({name, args}) RUNS it \u2014 same account, same permissions, same result as if it had been listed. A direct tools/call to a name you already know works too. enable_tools({groups:[\u2026]}) additionally LISTS a whole group for hosts that re-list: `ads` (paid-campaign management on Meta, Google Ads, LinkedIn, Reddit, Microsoft, Pinterest, X, TikTok, Snapchat, ChatGPT Ads, Apple Search Ads \u2014 by far the heaviest group), `analytics` (GA4, Search Console), `channel_admin` (reading a channel back: insights, comments, DMs, catalogs, templates, webhooks, editing or deleting a published post), plus research, create, channels, files, workspace, or \'all\'. NEVER tell a user Hermoso cannot build a campaign, read a comment, answer a DM or pull a channel\u2019s numbers because you cannot see the tool \u2014 look it up and call it.',
|
|
303
304
|
].join('\n');
|
|
304
305
|
|
|
@@ -334,7 +335,7 @@ export const MCP_INSTRUCTIONS = [
|
|
|
334
335
|
'• RESEARCH winning ads: find_competitors, competitor_teardown, pull_competitor_ads, research_ads, search_meta_ads, search_google_ads, search_linkedin_ads, search_tiktok, search_instagram, search_youtube, search_reddit, search_threads, mine_angles, analyze_video, check_ad_policy.',
|
|
335
336
|
'• CREATE on-brand ads: render_ad, generate_image, generate_video, generate_avatar, make_template_ad, make_motion_film, make_thumbnail, make_explainer, plan_ad, plan_variations; get_brand / draft_brand / update_brand; list_creators / save_creator; edit_video, dub_video, clip_video, reframe_video, upscale_video, stitch_video.',
|
|
336
337
|
'• RAW MODELS, prompt only: generate_image / generate_video with useBrand:false, generate_voice, generate_text, upload_file (any file becomes a URL every tool accepts).',
|
|
337
|
-
'• PUBLISH & SCHEDULE to the user\'s OWN accounts: post_to_meta (+Threads), post_to_x, post_to_linkedin, post_to_tiktok, post_to_youtube, post_to_pinterest, post_to_bluesky, post_to_telegram, post_to_google_business, post_to_wordpress, post_to_discord, post_to_slack; schedule_post (+ list/reschedule/cancel); list_connectors. Not connected: say to connect it in
|
|
338
|
+
'• PUBLISH & SCHEDULE to the user\'s OWN accounts: post_to_meta (+Threads), post_to_x, post_to_linkedin, post_to_tiktok, post_to_youtube, post_to_pinterest, post_to_bluesky, post_to_telegram, post_to_google_business, post_to_wordpress, post_to_discord, post_to_slack; schedule_post (+ list/reschedule/cancel); list_connectors. Not connected: say to connect it in Connectors.',
|
|
338
339
|
// INSTAGRAM_PATHS_NOTE — an inline copy of lib/instagram-paths.mjs (the twins ship without lib/); tools/instagram-paths-check.mjs asserts they are byte-equal.
|
|
339
340
|
// THE LAST LINE OF THE HEAD, AND THE ONE THAT MAKES THE SHORT ROSTER SAFE (2026-09-17). Your tool LIST is now
|
|
340
341
|
// core-first; every name in the bullets above is held out of it on SIZE and answers a call anyway. Say that
|
|
@@ -354,7 +355,7 @@ export const MCP_INSTRUCTIONS = [
|
|
|
354
355
|
// POSITION: immediately AFTER the ADS bullet, because some hosts truncate this string to ~2KB and all five areas
|
|
355
356
|
// plus "ADS on eleven platforms" must stay inside that window — put it above and it evicts the ads bullet, which
|
|
356
357
|
// causes the exact failure it exists to stop. LENGTH: §2d of agent-surface-guidance-check caps the whole string,
|
|
357
|
-
// so it says only what is NEW and points at nothing already named below — `list_connectors`, `
|
|
358
|
+
// so it says only what is NEW and points at nothing already named below — `list_connectors`, `
|
|
358
359
|
// Connectors`, `?connect=<provider>` and the CLI escape hatch are all already in here, further down.
|
|
359
360
|
'A tool NOT in your roster is filtered out — that account is not connected here. Never say Hermoso lacks a platform.',
|
|
360
361
|
// SECOND LINE, before the capability map: those four jobs read as four STAGES, and by the time an agent has
|
|
@@ -380,7 +381,7 @@ export const MCP_INSTRUCTIONS = [
|
|
|
380
381
|
'• AD SPY / RESEARCH: find_competitors, competitor_teardown, pull_competitor_ads, research_ads; ad libraries search_meta_ads / search_google_ads / search_linkedin_ads; organic search_tiktok / search_instagram / search_youtube / search_reddit / search_threads; fetch_social_data; mine_angles; analyze_video; check_ad_policy; list_skills / get_skill.',
|
|
381
382
|
'• CREATE (on-brand ads): render_ad (Studio quality pipeline) or generate_image / generate_video / generate_avatar render on their own; plan_ad authors a board first when the ad wants one and render_ad takes it; get_brand (what we already know) / draft_brand (onboard one) / update_brand (patch a field) manage the saved brand; list_creators / save_creator / delete_creator (the reusable saved CAST — re-cast the same face; render_ad’s `creator` stars one of them in the ad); make_template_ad (native HTML formats); make_thumbnail (YouTube / Shorts / Instagram video thumbnails + covers — use it for any thumbnail or video-cover ask, never generate_image); clone_static / recast_motion / reframe_video / insert_scene / upscale_video / dub_video / change_voice / finish_video / fix_beat / hook_variants / stitch_video; plan_variations + score_ad.',
|
|
382
383
|
'• ACCOUNT & WORKSPACES: hermoso_credits, billing_status, buy_credits (one-click top-up / first-purchase link), upgrade_plan / set_auto_reload (admin), list_jobs / get_job; list_brands / create_brand / use_brand / delete_brand (one account holds MANY profiles: brands, clients or creators, so an agency runs every client through here, each with its own brand details, memory, Library and connectors; create_brand → draft_brand onboards a new one, delete_brand is confirm-gated); get_settings / update_settings (the LANGUAGE every ad, script and plan is written in (chat replies follow the language the user writes in) — set it once and every render obeys it — plus app appearance and the weekly competitor-watch email); list_alert_settings / set_alert_events / send_test_alert (where alerts go: Slack, Discord or a signed webhook); list_team / invite_member / remove_member / set_role.',
|
|
383
|
-
'• PUBLISH & MANAGE YOUR CHANNELS (the user’s connected accounts, over this MCP): Meta — post_to_meta (FB/IG/Threads), upload_file (post ANY external/local file), list_meta_ads + meta_insights (read campaigns/ad sets/ads + performance, broken down by age/gender/placement/country), preview_meta_ad (see the real ad per placement, 24h links), estimate_meta_reach (audience size before you spend), list_meta_audiences / create_meta_audience (retargeting + lookalikes), create_meta_campaign / create_meta_ad / upload_meta_asset (build), update_meta_object / delete_meta_object / set_meta_campaign_status (edit/delete/activate — spend + deletes confirm-gated), manage_meta_post (edit/delete a post); DM automations (a keyword comment, DM, story reply or ig.me link gets an automatic private reply) — save_dm_automation / test_dm_automation / list_dm_automations / delete_dm_automation on Instagram, Facebook (Page comments, ads, Messenger) and X (X billed in credits per event and send); X — x_chat_settings / update_x_chat_settings (who may DM the brand on X); Microsoft Advertising (Bing Ads) — list_microsoft_ads_campaigns, microsoft_ads_report, microsoft_ads_geo_search, create_microsoft_ads_campaign / create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (all created Paused), set_microsoft_ads_budget / set_microsoft_ads_status (spend confirm-gated); ChatGPT Ads (OpenAI Advertiser API) — list_openai_ads_campaigns, openai_ads_report, openai_ads_geo_search, create_openai_ads_campaign / create_openai_ads_ad_group / create_openai_ads_ad (all created PAUSED), update_openai_ads_object, set_openai_ads_budget / set_openai_ads_status (spend + archive confirm-gated). Connected by API key; ONE creative format (a text + image card, no video); Pinterest — list_pinterest_boards then post_to_pinterest (the user picks the board); Google Business Profile — list_business_locations, post_to_google_business, list_google_business_posts, delete_google_business_post, google_business_insights, get_business_location / update_business_location (read and CHANGE hours, phone, website, description, categories, name, address; live on Search and Maps, so an unconfirmed call writes nothing and previews the diff), google_business_account (whose account, and whether that role can edit); Google Drive (ONE connection covering Drive, Sheets and Docs) — save_to_drive, list_drive_files, get_drive_file, update_drive_file, delete_drive_file, create_drive_folder, plus create_sheet / append_to_sheet / read_sheet and create_doc / append_to_doc / read_doc (Hermoso-created files, plus any the user picks in the app); Microsoft OneDrive — save_to_onedrive, list_onedrive_files, get_onedrive_file, update_onedrive_file, delete_onedrive_file, create_onedrive_folder, share_onedrive_file, onedrive_file_permissions; MANAGING THE CONNECTIONS — list_connectors, list_connector_accounts + set_connector_accounts (which Pages / ad accounts / company Pages this profile may post to and spend from — fails closed, an empty choice shares nothing), leave_connector (remove just YOUR OWN account from a connector several teammates have each joined — theirs keep working) · disconnect_connector (confirm-gated). Google Merchant Center: manage_merchant_settings fixes the shipping, return, business-info and program settings that stop an account serving. LINKING a NEW account is the one step that is not headless (an OAuth consent screen) — send the user to
|
|
384
|
+
'• PUBLISH & MANAGE YOUR CHANNELS (the user’s connected accounts, over this MCP): Meta — post_to_meta (FB/IG/Threads), upload_file (post ANY external/local file), list_meta_ads + meta_insights (read campaigns/ad sets/ads + performance, broken down by age/gender/placement/country), preview_meta_ad (see the real ad per placement, 24h links), estimate_meta_reach (audience size before you spend), list_meta_audiences / create_meta_audience (retargeting + lookalikes), create_meta_campaign / create_meta_ad / upload_meta_asset (build), update_meta_object / delete_meta_object / set_meta_campaign_status (edit/delete/activate — spend + deletes confirm-gated), manage_meta_post (edit/delete a post); DM automations (a keyword comment, DM, story reply or ig.me link gets an automatic private reply) — save_dm_automation / test_dm_automation / list_dm_automations / delete_dm_automation on Instagram, Facebook (Page comments, ads, Messenger) and X (X billed in credits per event and send); X — x_chat_settings / update_x_chat_settings (who may DM the brand on X); Microsoft Advertising (Bing Ads) — list_microsoft_ads_campaigns, microsoft_ads_report, microsoft_ads_geo_search, create_microsoft_ads_campaign / create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (all created Paused), set_microsoft_ads_budget / set_microsoft_ads_status (spend confirm-gated); ChatGPT Ads (OpenAI Advertiser API) — list_openai_ads_campaigns, openai_ads_report, openai_ads_geo_search, create_openai_ads_campaign / create_openai_ads_ad_group / create_openai_ads_ad (all created PAUSED), update_openai_ads_object, set_openai_ads_budget / set_openai_ads_status (spend + archive confirm-gated). Connected by API key; ONE creative format (a text + image card, no video); Pinterest — list_pinterest_boards then post_to_pinterest (the user picks the board); Google Business Profile — list_business_locations, post_to_google_business, list_google_business_posts, delete_google_business_post, google_business_insights, get_business_location / update_business_location (read and CHANGE hours, phone, website, description, categories, name, address; live on Search and Maps, so an unconfirmed call writes nothing and previews the diff), google_business_account (whose account, and whether that role can edit); Google Drive (ONE connection covering Drive, Sheets and Docs) — save_to_drive, list_drive_files, get_drive_file, update_drive_file, delete_drive_file, create_drive_folder, plus create_sheet / append_to_sheet / read_sheet and create_doc / append_to_doc / read_doc (Hermoso-created files, plus any the user picks in the app); Microsoft OneDrive — save_to_onedrive, list_onedrive_files, get_onedrive_file, update_onedrive_file, delete_onedrive_file, create_onedrive_folder, share_onedrive_file, onedrive_file_permissions; MANAGING THE CONNECTIONS — list_connectors, list_connector_accounts + set_connector_accounts (which Pages / ad accounts / company Pages this profile may post to and spend from — fails closed, an empty choice shares nothing), leave_connector (remove just YOUR OWN account from a connector several teammates have each joined — theirs keep working) · disconnect_connector (confirm-gated). Google Merchant Center: manage_merchant_settings fixes the shipping, return, business-info and program settings that stop an account serving. LINKING a NEW account is the one step that is not headless (an OAuth consent screen) — send the user to Connectors in the app.',
|
|
384
385
|
'YOUR ROSTER IS CORE-FIRST, AND NOTHING IS MISSING OR UNFINISHED. What you are LISTED is the core tools plus whatever this connection asked for or you have switched on; the rest of the product \u2014 hundreds of tools across research, creation, publishing, paid campaigns, analytics and channel administration \u2014 is held out of the LIST on SIZE ALONE. IT IS ALL CALLABLE RIGHT NOW. find_tools({query}) searches EVERY tool, listed or not, and each row carries its parameters, its credit cost and its recent health; call_tool({name, args}) then runs it through the same handler, the same account and the same permissions; a direct tools/call to a name you already know works too. This works on every host, including claude.ai and ChatGPT, which fix their tool list at connect. enable_tools({groups:[\u2026]}) additionally LISTS a group for hosts that re-list \u2014 free and instant: the MOMENT the user asks to build, budget, target, report on or change a campaign, enable_tools({groups:[\'ads\']}); for sessions, conversions, revenue by channel or how their site ranks, enable_tools({groups:[\'analytics\']}); to read, moderate, reply to, measure or delete something ALREADY on a channel, enable_tools({groups:[\'channel_admin\']}). IF THE TOOLS DO NOT APPEAR after that call, do NOT tell the user the capability does not exist or keep retrying: use call_tool, which does not depend on the roster changing. `?tools=all` lists everything at connect. NEVER tell a user Hermoso cannot manage campaigns, read analytics or comments, answer a DM or pull channel numbers because you cannot see the tool \u2014 look it up with find_tools and run it with call_tool. The full set of group names is: core, research, create, channels, channel_admin, analytics, files, workspace, ads \u2014 or \'all\'.',
|
|
385
386
|
// HINTS (2026-09-17). The advice was always in the prose; this says the keyed copy exists so a client that
|
|
386
387
|
// prefers structure does not have to parse a sentence to find it.
|
|
@@ -405,7 +406,7 @@ export const MCP_INSTRUCTIONS_DIRECTORY = [
|
|
|
405
406
|
'• ANALYTICS: meta_insights, google_ads_report and the other per-platform reports, analytics_report, search_console_performance.',
|
|
406
407
|
'• PROFILE: get_brand, draft_brand, update_brand, list_connectors, list_brands, use_brand, hermoso_credits.',
|
|
407
408
|
'Not every tool is in your starting list: find_tools({query}) finds one and call_tool({name,args}) runs it.',
|
|
408
|
-
'Confirm with the user before anything is published, deleted or set to spend. A tool for a platform that is not connected answers with how to connect it (
|
|
409
|
+
'Confirm with the user before anything is published, deleted or set to spend. A tool for a platform that is not connected answers with how to connect it (Connectors); never say Hermoso lacks the platform.',
|
|
409
410
|
].join('\n');
|
|
410
411
|
// ── CHATGPT GETS ITS OWN INSTRUCTIONS (2026-09-30) ──────────────────────────────────────────────────────────────
|
|
411
412
|
// OpenAI's automated plugin scan marked MCP_INSTRUCTIONS "need further review", and read against the plugin
|
|
@@ -419,19 +420,31 @@ export const MCP_INSTRUCTIONS_DIRECTORY = [
|
|
|
419
420
|
// • no comparison, no "prefer", no price, plan, top-up, checkout or upgrade;
|
|
420
421
|
// • under ~2 KB, because ChatGPT's connector truncates server instructions near there.
|
|
421
422
|
// Every other host keeps MCP_INSTRUCTIONS unchanged.
|
|
423
|
+
// THE FAMILIES THE 1,000-TOOL CAP LEAVES OFF CHATGPT (2026-11-13). ChatGPT lists no generic executor, so a family in
|
|
424
|
+
// CHATGPT_ROSTER_LEFT_OUT is simply absent there, and the model would otherwise tell the user Hermoso has no X Ads. One
|
|
425
|
+
// line says they exist and where they work. The examples are the first left-out families in rank order (the ones most
|
|
426
|
+
// likely asked for), read from the GENERATED roster so a re-rank moves them too; a name the commerce rule below forbids
|
|
427
|
+
// on this host (a payment product) is skipped.
|
|
428
|
+
const LEFT_OUT_LABEL = Object.freeze({ x_ads: 'X Ads', bing_webmaster: 'Bing Webmaster Tools', openai_ads: 'ChatGPT Ads', tiktok_ads: 'TikTok Ads', tiktok: 'TikTok', youtube: 'YouTube', linkedin: 'LinkedIn', hubspot: 'HubSpot', posthog: 'PostHog', wordpress: 'WordPress', applovin_ads: 'AppLovin Ads', microsoft_onedrive: 'OneDrive', google_business: 'Google Business Profile', x: 'X' });
|
|
429
|
+
const leftOutLabel = (f) => LEFT_OUT_LABEL[f] || String(f).split('_').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
430
|
+
export const chatgptLeftOutLine = (left = CHATGPT_ROSTER_LEFT_OUT, n = 2) => {
|
|
431
|
+
const names = left.filter((f) => !/stripe|pay/i.test(f)).slice(0, n).map(leftOutLabel);
|
|
432
|
+
return names.length ? `Not listed here (ChatGPT's tool limit): ${names.join(', ')} and more; they work in the Hermoso app and other AI apps.` : '';
|
|
433
|
+
};
|
|
422
434
|
export const MCP_INSTRUCTIONS_WIDGET = [
|
|
423
|
-
"Hermoso runs a brand's marketing: research competitors' ads, make image and video ads, post
|
|
424
|
-
'
|
|
435
|
+
"Hermoso runs a brand's marketing: research competitors' ads, make image and video ads, post to their accounts, run paid campaigns, read results. Each area works on its own.",
|
|
436
|
+
'Asked to make something, make it; render tools pick a sound default model (hermoso_capabilities lists models and costs).',
|
|
425
437
|
'• RESEARCH: find_competitors, pull_competitor_ads, research_ads, competitor_teardown, mine_angles.',
|
|
426
|
-
'• CREATE: get_brand / draft_brand, plan_ad, render_ad, generate_image, generate_video, make_template_ad, clone_static, remake_video_for_brand; variants: edit_image, headline_variants, hook_variants, multiply_ad. Renders run in
|
|
427
|
-
"• FILES & SCHEDULING: upload_file turns a file into a URL; schedule_post queues a post; list_scheduled shows the queue; list_connectors shows what is connected
|
|
438
|
+
'• CREATE: get_brand / draft_brand, plan_ad, render_ad, generate_image, generate_video, make_template_ad, clone_static, remake_video_for_brand; variants: edit_image, headline_variants, hook_variants, multiply_ad. Renders run in background; poll get_job.',
|
|
439
|
+
"• FILES & SCHEDULING: upload_file turns a file into a URL; schedule_post queues a post; list_scheduled shows the queue; list_connectors shows what is connected; list_library lists past creations.",
|
|
428
440
|
'• PUBLISH: post_to_* (post_to_meta, post_to_tiktok...) post now; list_inbox and reply_to_inbox_item answer comments and messages.',
|
|
429
441
|
'• PAID ADS (Meta, Google, LinkedIn, TikTok, Reddit, Snapchat, ChatGPT): create_* builds campaigns paused; set_*_status and set_*_budget control spend.',
|
|
430
442
|
'• RESULTS: post_performance, analyze_campaigns. WORKSPACE: list_brands, use_brand, create_brand, hermoso_credits, report_bug, request_feature.',
|
|
431
|
-
"CREDITS: AI and research tools use the user's
|
|
432
|
-
'CONFIRM with the user before posting or scheduling, messaging customers, turning on a campaign
|
|
433
|
-
'SHOWING RESULTS: the widget already shows each finished image and video. Never repeat one as a markdown image or image link
|
|
434
|
-
|
|
443
|
+
"CREDITS: AI and research tools use the user's credits; the reply says how many. If too low, say so (account: hermoso.ai). Never start a purchase.",
|
|
444
|
+
'CONFIRM with the user before posting or scheduling, messaging customers, turning on a campaign, changing a budget, deleting anything, or replacing a saved profile (draft_brand with save:true).',
|
|
445
|
+
'SHOWING RESULTS: the widget already shows each finished image and video. Never repeat one as a markdown image or image link; describe it, link only if asked.',
|
|
446
|
+
chatgptLeftOutLine(), // the whole string, with the longest first-run line on top, must fit 2,048 bytes (mcp-first-run-guidance-check)
|
|
447
|
+
].filter(Boolean).join('\n');
|
|
435
448
|
// ── EACH FOCUSED APP GETS ITS OWN INSTRUCTIONS (2026-11-06) ─────────────────────────────────────────────────────────
|
|
436
449
|
// Same rules as MCP_INSTRUCTIONS_WIDGET: name only tools the app lists (tools/app-scope-check.mjs runs the real
|
|
437
450
|
// registration per app and fails on any other name), no price, plan or upgrade, no "prefer", under ~2 KB (ChatGPT
|
|
@@ -440,7 +453,7 @@ export const MCP_INSTRUCTIONS_WIDGET = [
|
|
|
440
453
|
// beside it, named by Keyword Planner volume (the owner, 2026-11-06; evidence in memory mcp-directory-listings.md).
|
|
441
454
|
export const APP_TITLES = Object.freeze({ create: 'Hermoso: AI Ad Generator', ads: 'Hermoso: Ads Manager', social: 'Hermoso: Social Media Manager' });
|
|
442
455
|
const APP_CONFIRM = 'CONFIRM with the user before posting or scheduling, messaging customers, turning on a campaign or changing a budget, deleting anything, or replacing a saved profile.';
|
|
443
|
-
const APP_WORKSPACE = 'WORKSPACE: get_brand, list_brands, use_brand, create_brand, list_connectors (accounts are connected in the Hermoso app,
|
|
456
|
+
const APP_WORKSPACE = 'WORKSPACE: get_brand, list_brands, use_brand, create_brand, list_connectors (accounts are connected in the Hermoso app, Connectors), hermoso_credits, report_bug, request_feature.';
|
|
444
457
|
const APP_ADS_LINES = [
|
|
445
458
|
"Hermoso Ads Manager runs and reports on the user's own paid campaigns on Meta, Google, Microsoft, TikTok, LinkedIn, Pinterest, Reddit, Snapchat, X, Apple and ChatGPT Ads, and reads website and business analytics: Google Analytics, Search Console, Bing Webmaster, Tag Manager, PostHog, Mixpanel, Amplitude, Merchant Center, Stripe and HubSpot.",
|
|
446
459
|
'• CAMPAIGNS: list_meta_ads, meta_insights, list_google_ads_campaigns, google_ads_report, tiktok_ads_report, linkedin_ads_report and the other list_*_campaigns / *_report tools read campaigns and results; analyze_campaigns compares them. create_* tools build campaigns, ad sets and ads PAUSED and read them back; set_*_status and set_*_budget control spend.',
|
|
@@ -510,10 +523,22 @@ async function imageBlock(url) {
|
|
|
510
523
|
const ct = (r.headers.get('content-type') || 'image/jpeg').split(';')[0];
|
|
511
524
|
if (!/^image\//.test(ct)) return null;
|
|
512
525
|
const buf = Buffer.from(await r.arrayBuffer());
|
|
513
|
-
|
|
526
|
+
// A FINISHED AD IS OFTEN A 1.5 MB PNG (2026-11-13, claude.ai showed only "view the ad"): a raster over IMAGE_BLOCK_MAX is
|
|
527
|
+
// shown as the server's display-sized JPEG of the same picture (/api/img/preview, our own files only); the link the
|
|
528
|
+
// caller gets is still the full-size original. A preview that cannot be read keeps the old answer: no block, the link.
|
|
529
|
+
if (buf.length > IMAGE_BLOCK_MAX && /^image\/(png|jpe?g|webp)$/i.test(ct) && typeof url === 'string' && /^https?:\/\//i.test(url)) {
|
|
530
|
+
try {
|
|
531
|
+
const p = await fetch(`${API_BASE}/api/img/preview?url=${encodeURIComponent(url)}`);
|
|
532
|
+
const pb = p.ok && /^image\/jpeg/i.test(p.headers.get('content-type') || '') ? Buffer.from(await p.arrayBuffer()) : null;
|
|
533
|
+
if (pb && pb.length && pb.length <= IMAGE_BLOCK_HARD_MAX) return { type: 'image', data: pb.toString('base64'), mimeType: 'image/jpeg' };
|
|
534
|
+
} catch {}
|
|
535
|
+
}
|
|
536
|
+
if (buf.length > IMAGE_BLOCK_HARD_MAX) return null;
|
|
514
537
|
return { type: 'image', data: buf.toString('base64'), mimeType: ct };
|
|
515
538
|
} catch { return null; }
|
|
516
539
|
}
|
|
540
|
+
// Above IMAGE_BLOCK_MAX a raster is sent as its display-sized preview; nothing above IMAGE_BLOCK_HARD_MAX is ever inlined.
|
|
541
|
+
const IMAGE_BLOCK_MAX = 700_000, IMAGE_BLOCK_HARD_MAX = 1_400_000;
|
|
517
542
|
// THE MCP CAPTURE SEAM (2026-08-05). Two halves, and the split is what stops the ledger double-counting:
|
|
518
543
|
// • `toolCtx.run({tool})` stamps `x-hermoso-tool` on every /api call this tool makes, so the SERVER's route()
|
|
519
544
|
// records the failure under the TOOL NAME. A path cannot do that — plan_ad, render_ad and make_template_ad all
|
|
@@ -526,9 +551,9 @@ async function imageBlock(url) {
|
|
|
526
551
|
// This gate used to DETECT the condition with a prose regex over the error message
|
|
527
552
|
// (/isn.?t connected|connect your .* (account|channel)|add it under .*connectors/i). Prose is not a classifier, and
|
|
528
553
|
// it over-matched in exactly the way metaErr's permission regex did three times ([[meta-error-class-check]]):
|
|
529
|
-
// • A SHARING refusal — "… isn’t shared with this brand. Add it under
|
|
554
|
+
// • A SHARING refusal — "… isn’t shared with this brand. Add it under Connectors ▸ … ▸ Manage
|
|
530
555
|
// accounts, then retry." (server.js, 400/403, connector ALREADY LINKED) — hit `add it under .*connectors` and
|
|
531
|
-
// got "Ask your human to connect it at app.hermoso.ai (
|
|
556
|
+
// got "Ask your human to connect it at app.hermoso.ai (Connectors)" bolted on. That instruction is
|
|
532
557
|
// false (it IS connected), it cannot fix anything (re-doing OAuth does not tick a Page/property), and it
|
|
533
558
|
// directly contradicts the correct Manage-accounts guidance sitting in the same message. Observed live
|
|
534
559
|
// 2026-08-10 on a Google Analytics property-sharing 403.
|
|
@@ -581,7 +606,7 @@ const CONNECT_LEGACY_PROVIDERS = [
|
|
|
581
606
|
// is a well-formed app deep link; only a server that predates it gets the bare link rebuilt here.
|
|
582
607
|
const CONNECT_LINK_HINT = (provider, url) => `\nHand your human this one-click connect link: ${/^https?:\/\/[^\s?#]+\/\?connect=[a-z_]{1,40}(?:&brand=[A-Za-z0-9_-]{1,64})?$/.test(String(url || '')) ? url : `https://app.hermoso.ai/?connect=${provider}`} — it opens Hermoso, signs them in if needed, and starts the connection. Then retry.`;
|
|
583
608
|
const KEY_CONNECT_HINT = (provider, msg) => (Object.prototype.hasOwnProperty.call(KEY_CONNECTORS, provider) && !/connect_connector/.test(String(msg || ''))) ? `\nOr, if the user would rather not open a browser, connect it right here with connect_connector (provider "${provider}"); a key pasted into this chat stays in its history, which the app's own paste box avoids.` : '';
|
|
584
|
-
const CONNECT_ASK_HINT = `\nAsk your human to connect it at https://app.hermoso.ai (
|
|
609
|
+
const CONNECT_ASK_HINT = `\nAsk your human to connect it at https://app.hermoso.ai (Connectors), then retry.`;
|
|
585
610
|
// PURE, and module-scope on purpose: tools/mcp-not-connected-gate-check.mjs lifts and RUNS this exact function over
|
|
586
611
|
// a table of real error shapes, so the classification is tested rather than read. Returns the sentence to APPEND, or
|
|
587
612
|
// '' when the failure is not a not-connected one (which must leave the caller's own message alone).
|
|
@@ -590,7 +615,7 @@ const CONNECT_ASK_HINT = `\nAsk your human to connect it at https://app.hermoso.
|
|
|
590
615
|
// lib/instagram-paths.mjs (the twins ship without lib/; the Meta help-center URLs it rests on are cited there), asserted
|
|
591
616
|
// byte-equal by tools/instagram-paths-check.mjs. Said when a Meta ADS tool answers "Meta is not connected" — the moment an
|
|
592
617
|
// Instagram-only user has asked for ads. Pure, so the check runs it.
|
|
593
|
-
export const INSTAGRAM_ADS_PATH_NOTE = 'INSTAGRAM BUT NO META AD ACCOUNT: Hermoso builds and reads ads only through the Meta connector, and Meta says a Meta ad account needs a personal Facebook profile, so say that plainly and walk them through the cheapest path: (1) log in to Facebook with a personal profile (or create one); every profile already has an ad account in Ads Manager (adsmanager.facebook.com); (2) create a Facebook Page for the business (Facebook > Pages > Create Page; it never has to post); (3) in the Instagram app, Edit profile > Page > Connect or create, and pick that Page; (4) add a payment method in Ads Manager on a computer or browser; (5) connect Meta in Hermoso (
|
|
618
|
+
export const INSTAGRAM_ADS_PATH_NOTE = 'INSTAGRAM BUT NO META AD ACCOUNT: Hermoso builds and reads ads only through the Meta connector, and Meta says a Meta ad account needs a personal Facebook profile, so say that plainly and walk them through the cheapest path: (1) log in to Facebook with a personal profile (or create one); every profile already has an ad account in Ads Manager (adsmanager.facebook.com); (2) create a Facebook Page for the business (Facebook > Pages > Create Page; it never has to post); (3) in the Instagram app, Edit profile > Page > Connect or create, and pick that Page; (4) add a payment method in Ads Manager on a computer or browser; (5) connect Meta in Hermoso (Connectors > Meta) and tick the Page, the Instagram account and the ad account. Only if they will not use Facebook at all, the fallback is a boost from instagram.com in a browser, never the iPhone or iPad Instagram app: Meta says boosts paid in the iOS app carry Apple\u2019s service fee and boosting on the web avoids it. A boost made that way stays in Instagram, and Hermoso cannot build or read it.';
|
|
594
619
|
const META_ADS_TOOL_RE = /^(?:(?:create|list|update|delete|set|preview|estimate|upload|find)_meta_(?:campaigns?|adsets?|ads?|audiences?|asset|reach|object|campaign_status)|meta_insights)$/;
|
|
595
620
|
export function metaAdsSetupNote(tool, e) {
|
|
596
621
|
if (Number(e?.status) !== 401 || String(e?.connector || '') !== 'meta' || !META_ADS_TOOL_RE.test(String(tool || ''))) return null;
|
|
@@ -834,7 +859,7 @@ const wrap = (fn) => {
|
|
|
834
859
|
try { recordToolOutcome(_tool, { ok: !r?.isError, ms: Date.now() - _t0 }); } catch {}
|
|
835
860
|
// the charge, once, on every paid reply that did not already say it (chargeNote above)
|
|
836
861
|
try {
|
|
837
|
-
const _c =
|
|
862
|
+
const _c = chargeTenth((_st.apiCharged || 0) + (_st.jobCharged || 0));
|
|
838
863
|
const _txt = Array.isArray(r?.content) ? r.content.find((b) => b && b.type === 'text' && typeof b.text === 'string') : null;
|
|
839
864
|
if (_c > 0 && !_st.chargeSaid && _txt && !CHARGED_RE.test(_txt.text)) _txt.text += r?.structuredContent?.stillRendering ? `\nCharged ${_c} credit${_c === 1 ? '' : 's'} so far; the render's own charge is reported by get_job when it finishes.` : chargeSentence(_c);
|
|
840
865
|
} catch {}
|
|
@@ -888,6 +913,9 @@ const wrap = (fn) => {
|
|
|
888
913
|
// FREE CREDITS WAITING (2026-10-25): the server's sentence already names the one fix (open the app once, signed in) and
|
|
889
914
|
// drops its top-up advice, so it passes through as written on every host, and neither buy_credits nor the pricing page is named.
|
|
890
915
|
else if (e?.welcomeClaim && e.welcomeClaim.url) _hints.push(welcomeClaimHint(e.welcomeClaim));
|
|
916
|
+
// EARNED FREE CREDITS WERE JUST PAID (2026-11-13, server settleEarnedRewards): the sentence says so and asks for the same request
|
|
917
|
+
// again; it is not a purchase moment, on any host.
|
|
918
|
+
else if (e?.rewardsSettled && e.rewardsSettled.credits > 0) _hints.push({ do: `call ${_tool} again with the same arguments`, why: `${e.rewardsSettled.credits} free credits this account had earned were just added, so the same request can now run` });
|
|
891
919
|
else if (_widget && /not enough credits|out of credits|insufficient credits|needs (a paid plan|the Pro plan)/i.test(msg)) { msg = neutralCreditsText(msg, e?.buy && typeof e.buy === 'object' ? { need: e.buy.need, balance: e.buy.balance } : {}); _hints.push(...creditsLowHints()); }
|
|
892
920
|
else if (_buy) { _hints.push(...buyDoorsHints(_buy, _tool)); msg += `\n${buyDoorsText(_buy, _tool)}`; }
|
|
893
921
|
else if (/not enough credits|out of credits|needs (a paid plan|the Pro plan)/i.test(msg)) _hints.push({ do: 'buy_credits({})', why: 'this account cannot cover the call; buy_credits quotes on a saved card or returns a checkout link, and billing_status shows the balance and the billing role' }), msg += `\nRun buy_credits to top up (credit packs): with a saved card it quotes (quoteToken included) then one-click charges on confirm:true + quote_token; with no card yet it returns a checkout link your human pays once (the card saves for one-click after). billing_status shows your balance, plan + billing role; if you're an admin, upgrade_plan moves to a bigger monthly plan (a person pays on Stripe). hermoso_credits shows the balance; hermoso_capabilities lists per-model credit costs.${/no markup on models/i.test(msg) ? ' Starter, Pro and Studio make credits go twice as far: upgrade_plan moves the user to one of them, with no markup on models (a person pays on Stripe).' : ''}`;
|
|
@@ -902,9 +930,12 @@ const wrap = (fn) => {
|
|
|
902
930
|
// so an agent relays it instead of retrying or telling the user to reconnect. Read from the STRUCTURED field.
|
|
903
931
|
if (e?.metaAuthHold === true) _hints.push({ do: 'stop retrying; ask the user to clear Meta\u2019s security check: as the Facebook profile that connected Hermoso, on a computer (not the Ads Manager app), create a NEW draft ad in Ads Manager, click Start authentication (or Fix errors) on the error it shows and complete it, or follow a red banner if one is shown; then run the same call again', why: 'Meta refuses new or edited ads from that profile until it re-authenticates; the connection and permissions are fine and reconnecting with the same profile does not clear it', ...(e?.remedy?.login ? { who: e.remedy.who } : {}) });
|
|
904
932
|
// A RATE LIMIT SAYS HOW LONG, IN A NUMBER AND A SENTENCE (2026-11-02: an agent called set_youtube_thumbnail 73 times on "wait a few minutes"). Read from the structured field the client carried.
|
|
905
|
-
|
|
933
|
+
// THE PER-ACCOUNT YOUTUBE LIMIT LIFTS AT MIDNIGHT PACIFIC, NOT IN A MINUTE (2026-11-13): the server's sentence already says when, so the
|
|
934
|
+
// generic "wait N seconds" below would contradict it. Read from the structured code, never the prose.
|
|
935
|
+
if (e?.apiCode === 'youtube_account_limit') _hints.push({ do: 'stop calling YouTube tools that upload or change anything for this account until midnight Pacific, and tell the user in the words of this message; reading videos and comments still works', why: 'this account has used its share of the shared YouTube allowance for today, so a retry before the reset is refused the same way and nothing is lost by waiting' });
|
|
936
|
+
else if (Number(e?.status) === 429 && e?.retryAfter > 0) { const _w = Number(e.retryAfter), _ww = _w < 90 ? `${Math.round(_w)} seconds` : `${Math.round(_w / 60)} minutes`; msg = `${msg} Do not call ${_tool} again yet: wait ${_ww} first. Calling it sooner is refused the same way and can make the limit last longer.`; _hints.push({ do: `wait ${_ww}, then call ${_tool} once`, why: 'the platform is rate-limiting this call, so an immediate retry cannot succeed' }); }
|
|
906
937
|
{ const _ig = metaAdsSetupNote(_tool, e); if (_ig) { msg += `\n${_ig.text}`; _hints.push(_ig.hint); } }
|
|
907
|
-
if (Number(e?.status) === 401 && e?.connector) _hints.push({ do: `have the user connect "${e.connector}" (
|
|
938
|
+
if (Number(e?.status) === 401 && e?.connector) _hints.push({ do: `have the user connect "${e.connector}" (Connectors, or the one-click link in this message)`, why: `${e.connector} is not connected in this workspace, so this tool can only answer 401 until it is` });
|
|
908
939
|
// THE FREE PLAN'S POSTING ALLOWANCE / AUTOPILOT ON A PAID PLAN (2026-11-02): read from the structured remedy, never the
|
|
909
940
|
// prose. The fix is a plan, not credits, so the agent is pointed at upgrade_plan (or, on a widget host, at the app).
|
|
910
941
|
if (e?.remedy?.code === 'posting_limit' || e?.remedy?.code === 'autopilot_paid_only' || e?.remedy?.code === 'model_paid_only') {
|
|
@@ -1092,10 +1123,14 @@ const resumeLine = (j) => {
|
|
|
1092
1123
|
// creditsUsed (client.mjs unwrap) and every job it waited for (awaitRenderJob). chargeNote states the job's charge plus
|
|
1093
1124
|
// the call's other paid requests (a planning call before a render) and marks it said; wrap() states the tally on any
|
|
1094
1125
|
// reply that has not, so a tool needs no line of its own and cannot forget one.
|
|
1126
|
+
// TO A TENTH, NEVER TO A WHOLE CREDIT (2026-11-13). A direct image render is charged to a tenth of a credit (server
|
|
1127
|
+
// ceilTenth, 2026-11-08), and Math.round printed a Seedream 5.5 4:5 render the balance had moved by exactly 2.5 as
|
|
1128
|
+
// "Charged 3 credits (the actual cost of this run)", and would have printed nothing at all for a 0.4 charge.
|
|
1129
|
+
const chargeTenth = (v) => Math.round((Number(v) || 0) * 10) / 10;
|
|
1095
1130
|
const chargeSentence = (c) => `\nCharged ${c} credit${c === 1 ? '' : 's'} (the actual cost of this run).`;
|
|
1096
|
-
const CHARGED_RE = /\nCharged \d[\d,]* credits? \(/;
|
|
1097
|
-
const chargedOf = (r) => { const v = r?.chargedCredits ?? r?.creditsUsed ?? renderPayload(r)?.creditsUsed ?? r?.result?.creditsUsed; return Number.isFinite(+v) && +v > 0 ?
|
|
1098
|
-
const chargeNote = (r) => { const st = toolCtx.getStore(); const tally =
|
|
1131
|
+
const CHARGED_RE = /\nCharged \d[\d,]*(?:\.\d+)? credits? \(/;
|
|
1132
|
+
const chargedOf = (r) => { const v = r?.chargedCredits ?? r?.creditsUsed ?? renderPayload(r)?.creditsUsed ?? r?.result?.creditsUsed; return Number.isFinite(+v) && +v > 0 ? chargeTenth(+v) : null; };
|
|
1133
|
+
const chargeNote = (r) => { const st = toolCtx.getStore(); const tally = chargeTenth((st?.jobCharged || 0) + (st?.apiCharged || 0)); const c = tally > 0 ? tally : (chargedOf(r) || 0); if (!(c > 0)) return ''; if (st) st.chargeSaid = true; return chargeSentence(c); }; // the tally when this call has one (it already holds the job's charge and any paid request beside it), else the record handed in (get_job reads a job it did not wait for)
|
|
1099
1134
|
const switchNote = (r) => { const n = renderPayload(r)?.modelNote || r?.modelNote; return (n ? `\n⚠ ${n}` : '') + chargeNote(r); };
|
|
1100
1135
|
// THE VISION-QA READ-BACK (2026-08-03). The server WATCHES every generatively-rendered clip before it reports it
|
|
1101
1136
|
// done — free ffmpeg cadence/freeze probes plus ONE vision call over frames sampled at the hook, either side of
|
|
@@ -3004,6 +3039,8 @@ export const WIDGET_WITHHELD_WHY = Object.freeze({
|
|
|
3004
3039
|
set_openai_ads_negative_keywords: 'OpenAI no longer serves account-level negative keywords, so there is nothing it can set',
|
|
3005
3040
|
save_location: 'a place is saved from the Hermoso app (the location picker) or the CLI; here list_elements reads the saved places and any @name in a prompt uses one',
|
|
3006
3041
|
delete_location: 'a place is removed in the Hermoso app (the location picker) or the CLI; here list_elements reads the saved places',
|
|
3042
|
+
layerize_image: 'the published ChatGPT app has a reviewed tool list at its cap, and a new tool would push a whole ranked family off it (2026-11-12); a picture is split into layers in the Hermoso app or on any other MCP host',
|
|
3043
|
+
product_move: 'the published ChatGPT app has a reviewed tool list at its cap, and a new tool would push a whole ranked family off it (2026-11-12); generate_video makes a product clip from a photo on this host, and every other MCP host runs this tool',
|
|
3007
3044
|
get_instructions: 'every tool description on this host is already the full one (SLIM_TOOL_DESCRIPTIONS is never applied here), so there is nothing to fetch',
|
|
3008
3045
|
});
|
|
3009
3046
|
export const WITHHELD_FROM_WIDGET_HOSTS = new Set(['buy_credits', 'upgrade_plan', 'set_auto_reload', 'billing_status', 'connect_connector', 'x_post_insights', 'x_post_insights_historical', 'fetch_social_data', 'remix_static', ...WIDGET_MONEY_MOVEMENT_TOOLS, ...Object.keys(WIDGET_WITHHELD_WHY)]);
|
|
@@ -3288,7 +3325,7 @@ export const widgetHostHides = (name, widgetHost, app = null) => (widgetHost
|
|
|
3288
3325
|
// self-describing schema, and the default roster sits at its token ceiling. find_tools finds these, call_tool or a
|
|
3289
3326
|
// direct tools/call runs them, and enable_tools({groups:['create']}) (or 'all', or ?tools=all) lists them. post_edit's description names
|
|
3290
3327
|
// edit_timeline, and edit_timeline's names video_frames, so a caller that starts from post_edit reaches both.
|
|
3291
|
-
export const ON_DEMAND_TOOLS = new Set(['list_story_cards', 'get_story_card', 'start_story_film', 'advance_story_film', 'submit_to_gallery', 'list_gallery_submissions', 'withdraw_from_gallery', 'save_location', 'delete_location', 'edit_timeline', 'video_frames', 'recast_hook', 'face_check', 'list_templates', 'finish_draft', 'make_static_ads', 'make_listing_set', 'reangle_video', 'repurpose_transcript', 'tiktok_ideas', 'generate_sfx', 'repurpose_image', 'add_presenter', 'list_products', 'add_product', 'refresh_products', 'update_product', 'remove_product', 'make_short', 'finish_short', 'rewrite_ad_line', 'save_video_preset', 'list_video_presets', 'update_video_preset', 'delete_video_preset', 'make_from_preset', 'list_winning_ads']); // rewrite_ad_line (2026-11-08): multiply_ad's description and its change note name it // list_winning_ads (2026-11-08): multiply_ad's description names it // the five video-preset tools (2026-11-08): clone_video's description names save_video_preset, whose reply names make_from_preset; list_video_presets names the rest // make_short / finish_short (2026-11-08): clone_video's description names make_short, and make_short's description and reply name finish_short // make_static_ads (2026-10-06): plan_variations' description names it, so a caller planning variants reaches it; // recast_hook and face_check (2026-09-24): hook_variants names recast_hook, and recast_hook's reply names face_check // finish_draft (2026-10-03): every 480p Seedance 2.5 video reply names it (draftLine) // generate_sfx (2026-11-03): find_sound's and generate_voice's descriptions name it
|
|
3328
|
+
export const ON_DEMAND_TOOLS = new Set(['layerize_image', 'product_move', 'list_story_cards', 'get_story_card', 'start_story_film', 'advance_story_film', 'submit_to_gallery', 'list_gallery_submissions', 'withdraw_from_gallery', 'save_location', 'delete_location', 'edit_timeline', 'video_frames', 'recast_hook', 'face_check', 'list_templates', 'finish_draft', 'make_static_ads', 'make_listing_set', 'reangle_video', 'repurpose_transcript', 'tiktok_ideas', 'generate_sfx', 'repurpose_image', 'add_presenter', 'list_products', 'add_product', 'refresh_products', 'update_product', 'remove_product', 'make_short', 'finish_short', 'rewrite_ad_line', 'save_video_preset', 'list_video_presets', 'update_video_preset', 'delete_video_preset', 'make_from_preset', 'list_winning_ads']); // rewrite_ad_line (2026-11-08): multiply_ad's description and its change note name it // list_winning_ads (2026-11-08): multiply_ad's description names it // the five video-preset tools (2026-11-08): clone_video's description names save_video_preset, whose reply names make_from_preset; list_video_presets names the rest // make_short / finish_short (2026-11-08): clone_video's description names make_short, and make_short's description and reply name finish_short // make_static_ads (2026-10-06): plan_variations' description names it, so a caller planning variants reaches it; // recast_hook and face_check (2026-09-24): hook_variants names recast_hook, and recast_hook's reply names face_check // finish_draft (2026-10-03): every 480p Seedance 2.5 video reply names it (draftLine) // generate_sfx (2026-11-03): find_sound's and generate_voice's descriptions name it
|
|
3292
3329
|
|
|
3293
3330
|
// ── A TOOL NAME A HOST STILL HOLDS MUST KEEP ANSWERING (2026-09-14) ─────────────────────────────────────────────
|
|
3294
3331
|
// ChatGPT users get the tool roster OpenAI SNAPSHOTTED at review time, never a live tools/list (memory:
|
|
@@ -3444,7 +3481,7 @@ export async function legacyToolAnswer(name, request, extra, ctx) {
|
|
|
3444
3481
|
reportDeadEnd('stale_roster', name, `${name} no longer exists — the host's tool list is an older published snapshot`);
|
|
3445
3482
|
if (spec.gone) {
|
|
3446
3483
|
const text = spec.gone === 'reddit'
|
|
3447
|
-
? `${name} is no longer offered: Hermoso does not post to Reddit; Reddit Ads is supported (the reddit_ads tools), organic Reddit posting is not. There is nothing the user can connect, so do not point them at
|
|
3484
|
+
? `${name} is no longer offered: Hermoso does not post to Reddit; Reddit Ads is supported (the reddit_ads tools), organic Reddit posting is not. There is nothing the user can connect, so do not point them at Connectors.`
|
|
3448
3485
|
: spec.gone === 'reddit_lead_form'
|
|
3449
3486
|
? `${name} is no longer offered: Reddit retired onsite lead forms (the API sunset on 2026-09-21, the create call answers 410 Gone since 2026-09-30, and ads still using one were paused), so nothing can create one. Existing forms stay readable with list_reddit_ads_lead_forms. For new leads from Reddit, use a form on the advertiser's own landing page inside a LEAD_GENERATION campaign (create_reddit_ads_campaign), measured with list_reddit_ads_pixels and send_reddit_ads_conversions. Reddit Ads itself is fully supported.`
|
|
3450
3487
|
: `${name} is no longer offered by Hermoso.`;
|
|
@@ -3977,19 +4014,19 @@ export async function ingestFileParam(tool, args, upload = apiUploadUrl) {
|
|
|
3977
4014
|
export const SLIM_TOOL_DESCRIPTIONS = Object.freeze({
|
|
3978
4015
|
make_thumbnail: 'Render a click-driving YOUTUBE / Shorts / Instagram THUMBNAIL or video cover through the full production pipeline (concept, casting, scene, render, tweaks, text), not a bare image prompt. Use it for any "thumbnail", "video cover" or MrBeast-style packaging ask INSTEAD of generate_image. Default: 4 variants at 1K (YouTube shows 1280x720 at most), each billed; 2K or 4K only when the user asks. dryRun:true prices it free. The headline overlay is free. WHO IS IN FRAME — never assume or silently substitute a stranger. A framework with a person and no face photo is refused (nothing charged): ask the user once — themselves (a face photo, identity-locked), a generated person (`castGenericPerson:true`), or a people-free framework. TEXT — default is a CLEAN render with the headline TYPESET over it (free, legible, correctly spelled): pass `headline`. `bakeText:true` only on an explicit ask for words painted INTO the image. ALWAYS check the returned postRenderCheck against the image before presenting it.',
|
|
3979
4016
|
schedule_post: 'Use this to schedule a social media post for later, on one channel or several. Costs no credits (X alone bills per call: hermoso_credits). FREE PLAN: 5 posts a month on up to 3 channels (a post counts once; UTC month); paid plans are unlimited. Over it the post is refused and nothing is queued: relay the refusal as written, never retry or split the post. A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order, never just slide 1. Name the time in `at`, or pass `useQueue:true` for the brand’s next free POSTING SLOT (“just queue it”). Hermoso never guesses the account: Pinterest needs `boardId` (list_pinterest_boards), else refused; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages), else it goes to the person’s own profile. Several Facebook Pages need `pageId` (list_meta_pages), for Facebook and for an Instagram that posts through a Page: none named is refused at scheduling with the Pages listed, so ask the user which. Resolve these FIRST; let the user pick. Posts GO LIVE PUBLICLY by default.',
|
|
3980
|
-
post_to_x: 'Use this when the user wants to post to X (Twitter) now. Publish to the user’s connected X (Twitter) account — a single post, a post with an image or video render attached, a reply to an existing post, or a whole THREAD (pass `thread` as an array and each part is posted as a reply to the one before). This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Text is NEVER truncated. Write altText whenever you attach a render. COSTS CREDITS at X’s per-post price, every post in a thread billed: a post that contains a link (any web address, and every quote post) is X’s link rate, far higher than one without (hermoso_credits gives both figures). When the user asks what a post will cost, call this with dryRun:true: it posts NOTHING and returns that exact post’s costCredits. THIS TOOL POSTS ORGANICALLY — it does not create an ad campaign. Needs X connected (
|
|
4017
|
+
post_to_x: 'Use this when the user wants to post to X (Twitter) now. Publish to the user’s connected X (Twitter) account — a single post, a post with an image or video render attached, a reply to an existing post, or a whole THREAD (pass `thread` as an array and each part is posted as a reply to the one before). This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Text is NEVER truncated. Write altText whenever you attach a render. COSTS CREDITS at X’s per-post price, every post in a thread billed: a post that contains a link (any web address, and every quote post) is X’s link rate, far higher than one without (hermoso_credits gives both figures). When the user asks what a post will cost, call this with dryRun:true: it posts NOTHING and returns that exact post’s costCredits. THIS TOOL POSTS ORGANICALLY — it does not create an ad campaign. Needs X connected (Connectors > X).',
|
|
3981
4018
|
clip_video: 'Cut ONE long video into several RANKED, ready-to-post short clips (podcast, webinar, interview, talk, long ad cut -> Reels/Shorts/TikTok). Transcribes with timestamps, picks the strongest SELF-CONTAINED moments, then cuts + reframes each with ffmpeg — no video model renders anything, so it is fast and cheap. NOT supported: TikTok / Instagram / Facebook links, and anything age-restricted, private, members-only, geo-blocked or still LIVE — those fail fast with the real reason and are fully refunded, so ask for a direct file or an upload rather than retrying. SUBTITLES ARE BURNED IN BY DEFAULT (white sentence case timed by the spoken words, thin black outline, bottom safe band, no box) because short-form is watched on mute; captions:false for clean footage. TIMING IS APPROXIMATE (per-sentence, not word-level); never promise frame-accurate sync.',
|
|
3982
|
-
post_to_bluesky: 'Use this when the user wants to post to Bluesky. Publishing costs no credits. Publish a post to Bluesky as the connected account. Text up to 300 characters — Bluesky ALSO caps a post at 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; Hermoso checks both and says which limit. Bluesky requires a CONFIRMED EMAIL on the account before it will process any video — if it is unconfirmed you get a refusal saying so, and reconnecting will not help. Returns the post\'s public bsky.app URL. Connect at
|
|
4019
|
+
post_to_bluesky: 'Use this when the user wants to post to Bluesky. Publishing costs no credits. Publish a post to Bluesky as the connected account. Text up to 300 characters — Bluesky ALSO caps a post at 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; Hermoso checks both and says which limit. Bluesky requires a CONFIRMED EMAIL on the account before it will process any video — if it is unconfirmed you get a refusal saying so, and reconnecting will not help. Returns the post\'s public bsky.app URL. Connect at Connectors > Bluesky.',
|
|
3983
4020
|
make_explainer: 'AI HOST EPISODE: format \'host_episode\' = one presenter talking to camera, the camera changing every piece (frontal, three-quarter, close), the same host and set throughout, native voice, 16:9. It returns a 480p DRAFT; HD (720p) is a SEPARATE call with `fromDraft` (quote it with dryRun, run it only when the user asks). Otherwise: turn a TOPIC into a finished narrated explainer video, in one of TWO LANES (`lane`). BURNED ON-SCREEN TEXT IS OFF BY DEFAULT; the film ships clean unless the user asks; `captions:true` adds held key points and `subtitles:true` adds subtitles timed to the narration. Takes SEVERAL minutes — one image render per frame; NOT for a short product ad: use render_ad, generate_video or make_template_ad.',
|
|
3984
4021
|
multiply_ad: 'MULTIPLY a winning video ad into N variants: each gets a NEW character, outfit, location and/or objects while the cut, the camera motion, the pacing and the ORIGINAL AUDIO stay exactly as they were, and the source\'s own captions and on-screen text stay exactly as they are. ONE JOB PER VARIANT: get_job each until done; never describe a variant before its URL arrives. COST: quoteOnly:true returns the exact credits free: state that first. A paid planning read (planCredits) runs next, then each variant bills perVariantCredits. dryRun:true pays for that read; pass its `plan` back to render without paying again. No ad: list_winning_ads. New words: rewrite_ad_line.',
|
|
3985
4022
|
set_post_refill: 'Turn AUTOPILOT POSTING (autoposting, the posting refill) on, change it or pause it. Available on paid plans: on the Free plan switching it on is refused, so relay that and do not retry. PASS ONLY WHAT CHANGES. It always makes NEW posts (a fresh image, carousel or video for each) and never re-posts the Library. Ask the user for anything missing (goal, mode, channels) rather than guessing. `mode` picks what happens to each batch: "review" (the default) keeps the fresh posts as drafts until a human approves them (run_post_refill approve), "auto" schedules them straight away. `enabled:false` is the PAUSE — it removes the recurring job outright, and posts already queued are left alone (cancel those with cancel_scheduled).',
|
|
3986
4023
|
post_to_linkedin_page: 'Use this when the user wants to post to a LinkedIn company Page. Publishing costs no credits. This is a DIFFERENT thing from post_to_linkedin, which publishes to the person’s own profile: pick the one the user actually asked for and never substitute. organizationId comes from list_linkedin_pages; omit it only when the account administers exactly one Page. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. LinkedIn does NOT allow the image, video, captions or thumbnail of a published post to be swapped afterwards, so get all of that right first (the copy can still be edited with manage_linkedin_post).',
|
|
3987
|
-
post_to_telegram: 'Use this when the user wants to post to a Telegram channel or group. Publishing costs no credits. Publish to a Telegram channel, group or chat as the brand\'s own bot. WHICH CHAT IS ALWAYS REQUIRED AND IS NEVER GUESSED: pass chatId as the public channel\'s @username (e.g. @yourchannel) or its numeric id (a group is negative; a supergroup or channel starts with -100). THE BOT MUST BE IN THE CHAT — an administrator with Post Messages for a channel, an unrestricted member for a group; if it is not, Telegram refuses and the error says to add it rather than to reconnect. 0 credits. Connect at
|
|
4024
|
+
post_to_telegram: 'Use this when the user wants to post to a Telegram channel or group. Publishing costs no credits. Publish to a Telegram channel, group or chat as the brand\'s own bot. WHICH CHAT IS ALWAYS REQUIRED AND IS NEVER GUESSED: pass chatId as the public channel\'s @username (e.g. @yourchannel) or its numeric id (a group is negative; a supergroup or channel starts with -100). THE BOT MUST BE IN THE CHAT — an administrator with Post Messages for a channel, an unrestricted member for a group; if it is not, Telegram refuses and the error says to add it rather than to reconnect. 0 credits. Connect at Connectors > Telegram.',
|
|
3988
4025
|
generate_image: 'Use this when the user asks for an image ad, ad creative, product photo or social post graphic for their business or product, or an ANIMATED GIF (format gif) or SVG file (format svg). It renders a finished image on a real image model, on their saved brand (product photos, logo), and returns a URL ready to post. For a video, use render_ad. The saved brand’s product photo is used automatically (see get_brand / list_product_photos), so never ask the user to upload it again. MULTI-BRAND: hydration uses the SAVED brand; for a different brand (a fresh draft_brand) pass its own productImages/logo as refImages with useBrand:false, or the wrong product lands in the shot. model = a catalog id from hermoso_capabilities (omit for the default). Seconds. Spends credits.',
|
|
3989
4026
|
make_motion_film: 'MOTION GRAPHICS and ANIMATED VIDEO in one call: kinetic typography (animated text), a logo animation or intro, a product launch film, an animated social cut. Pick it for \'animated video\', \'motion graphics\', \'kinetic text\', \'text animation\', \'logo animation\', \'logo intro\', \'animated explainer\' asks. STEPS: without confirm it is a FREE quote that gives both prices (length, the music\'s tempo; the spec in structuredContent): say both; preview:true is a 1-credit contact sheet of the still version; confirm:true renders. Only the user\'s or the brand\'s words appear: never invent a claim, number or review.',
|
|
3990
|
-
post_to_youtube: 'Use this when the user wants to upload a video or Short to YouTube. Publishing costs no credits. Publish a finished video to the brand’s connected YouTube channel. Pass a Hermoso render URL (or an upload_file url for a local/external file). PUBLISHES PUBLICLY BY DEFAULT: a plain "post this to YouTube" puts it ON the channel (confirm the title with the user, as for any publish) and notifies subscribers as YouTube does. Pass the privacy the user states instead: "unlisted" (link-only) or "private" (eyes-only). A video meant to run as a YouTube/Google AD should go up "unlisted" — private videos CANNOT be used as ads. Never choose unlisted for music on your own. Needs a connected YouTube channel (
|
|
4027
|
+
post_to_youtube: 'Use this when the user wants to upload a video or Short to YouTube. Publishing costs no credits. Publish a finished video to the brand’s connected YouTube channel. Pass a Hermoso render URL (or an upload_file url for a local/external file). PUBLISHES PUBLICLY BY DEFAULT: a plain "post this to YouTube" puts it ON the channel (confirm the title with the user, as for any publish) and notifies subscribers as YouTube does. Pass the privacy the user states instead: "unlisted" (link-only) or "private" (eyes-only). A video meant to run as a YouTube/Google AD should go up "unlisted" — private videos CANNOT be used as ads. Never choose unlisted for music on your own. Needs a connected YouTube channel (Connectors > YouTube).',
|
|
3991
4028
|
hook_variants: 'HOOK MULTIPLIER: give ONE finished video ad N NEW OPENING HOOKS, N complete versions to A/B test. Pass the video\'s FILE URL (a render, job result, list_library or upload_file). 1-5 versions, default 3. COST: quoteOnly:true returns the exact credits free: state that first. A paid planning read (planCredits) runs next, then each planned version bills like fix_beat for the hook\'s seconds. dryRun:true pays for that read; pass its `plan` back to render exactly those. Returns ONE JOB PER VERSION; call get_job on each until done, never describe a version before its URL arrives.',
|
|
3992
|
-
post_x_article: 'Publish a long-form ARTICLE to the user’s connected X (Twitter) account — X’s own long-form format, which is a different thing from a long POST. THE HARD LIMIT TO PLAN AROUND: X allows only about 10 Article DRAFTS and 5 Article PUBLISHES per account per DAY, it publishes that cap nowhere, and it counts a FAILED attempt against them — so never iterate on an article by republishing it, and use `publish: false` to save a draft for the user to read in X’s own composer when they want to review before it goes out. This PUBLISHES immediately and PUBLICLY: show the user the full text and get an explicit yes first, because a published Article can NEVER be edited (X’s own rule — edit_x_post will refuse it) and the only remedy is to delete and republish, which costs another of the five. Costs credits (X bills per API request, and this is three of them). Needs X connected (
|
|
4029
|
+
post_x_article: 'Publish a long-form ARTICLE to the user’s connected X (Twitter) account — X’s own long-form format, which is a different thing from a long POST. THE HARD LIMIT TO PLAN AROUND: X allows only about 10 Article DRAFTS and 5 Article PUBLISHES per account per DAY, it publishes that cap nowhere, and it counts a FAILED attempt against them — so never iterate on an article by republishing it, and use `publish: false` to save a draft for the user to read in X’s own composer when they want to review before it goes out. This PUBLISHES immediately and PUBLICLY: show the user the full text and get an explicit yes first, because a published Article can NEVER be edited (X’s own rule — edit_x_post will refuse it) and the only remedy is to delete and republish, which costs another of the five. Costs credits (X bills per API request, and this is three of them). Needs X connected (Connectors > X).',
|
|
3993
4030
|
render_ad: 'Use this when the user asks for a video ad, Reel, TikTok ad, UGC video or commercial for their business or product It renders a finished ad on a real video model, on their brand (real product photos, brand voice), as an mp4 ready to post: timed shots, clean speech, text set in post, no music, captions or end card unless asked. Pass plan_ad’s full output as `creative` (plan_ad turns a one-line ask into the concept). Most models cap a clip at 15s and the longest at 30s: dryRun:true shows the act split free, before spending. The brand’s saved product photo (get_brand / list_product_photos) is the real product in the shots, with no upload needed. CAST A SAVED CREATOR with `creator` (list_creators) so the same person stars in every ad; otherwise each render casts a new face. The user’s own pictures (creative.inserts, or `inserts`) are laid in full screen at their seconds. An app shown on a device screen with no real screens to lay there returns needsAppScreens, nothing charged: ask, then pass screenChoice or screens. 1–3 min; poll get_job while it renders. Spends credits.',
|
|
3994
4031
|
});
|
|
3995
4032
|
export const slimPointer = (name) => ` Full rules: get_instructions({topic:'${name}'}).`;
|
|
@@ -4022,7 +4059,7 @@ export function defForHost(name, def, widgetHost) {
|
|
|
4022
4059
|
// by-reference property) and a def with nothing to rename comes back as itself.
|
|
4023
4060
|
const WIDGET_RENAME_RE = new RegExp(`\\b(${Object.keys(WIDGET_HOST_RENAMES).join('|')})\\b`, 'g');
|
|
4024
4061
|
const _widgetRenamed = new WeakMap();
|
|
4025
|
-
// …AND A "(
|
|
4062
|
+
// …AND A "(Connectors > X, or connect_connector)" CLAUSE LOSES ITS SECOND HALF THERE (2026-10-18). On ChatGPT a
|
|
4026
4063
|
// key is connected in the Hermoso app, never pasted into the conversation, and OpenAI's scan held tools whose text
|
|
4027
4064
|
// offered that (post_to_bluesky / post_to_telegram, 2026-10-16). The app half of the sentence stays.
|
|
4028
4065
|
const WIDGET_KEY_PASTE_RE = /,? or connect_connector\b[^.;)]*/g;
|
|
@@ -4157,7 +4194,7 @@ export const holdReasonFor = (name, ctx) => {
|
|
|
4157
4194
|
if (ctx.app && ctx.directory === 'scoped' && (WITHHELD_FROM_DIRECTORY.has(name) || (DIRECTORY_BANNED_GROUPS.includes(ctx.groupOf[name]) && !APP_DIRECTORY_CREATE_ALLOW.has(name)))) return 'directory'; // the Claude cage answers for generation
|
|
4158
4195
|
if (ctx.app && toolHeldBackByApp(name, ctx.groupOf[name], ctx) && !(ctx.widgetHost && appWidgetUnlisted(name))) return 'app_scope'; // another focused app's tool (a name renamed again on the app stays callable)
|
|
4159
4196
|
if (toolHeldBackByConnectors(name, ctx.conn)) {
|
|
4160
|
-
// NOT CONNECTED vs NOT OFFERED are different answers (2026-09-03). "Connect it under
|
|
4197
|
+
// NOT CONNECTED vs NOT OFFERED are different answers (2026-09-03). "Connect it under Connectors" is
|
|
4161
4198
|
// right for a provider with a tile and a dead end for one without (google_business: no tile until Google grants
|
|
4162
4199
|
// the API). `offered` is null on a server that predates the field.
|
|
4163
4200
|
const prov = toolProvider(name);
|
|
@@ -4171,8 +4208,8 @@ export const holdReasonFor = (name, ctx) => {
|
|
|
4171
4208
|
// the same advice keyed {do, why} so an agent can act on it without parsing the sentence. Derived from the SAME
|
|
4172
4209
|
// `why` the sentence is, so the two can never disagree about what the hold was.
|
|
4173
4210
|
export const holdHints = (name, why, ctx = null) => {
|
|
4174
|
-
if (why === 'not_connected') { const p = toolProvider(name) || 'that'; const _ig = metaAdsSetupNote(canonicalToolName(name), { status: 401, connector: p }); return [...(_ig ? [_ig.hint] : []), { do: `have the user connect "${p}" under
|
|
4175
|
-
if (why === 'not_offered') return [{ do: `do not offer this capability and do not send the user to
|
|
4211
|
+
if (why === 'not_connected') { const p = toolProvider(name) || 'that'; const _ig = metaAdsSetupNote(canonicalToolName(name), { status: 401, connector: p }); return [...(_ig ? [_ig.hint] : []), { do: `have the user connect "${p}" under Connectors${Object.prototype.hasOwnProperty.call(KEY_CONNECTORS, p) ? ', or connect_connector right here if it is a paste-a-key account' : ''}, then call ${name} again`, why: `${name} needs the "${p}" connection and this workspace has not made it` }]; }
|
|
4212
|
+
if (why === 'not_offered') return [{ do: `do not offer this capability and do not send the user to Connectors`, why: `Hermoso does not offer the "${toolProvider(name) || 'required'}" connection yet, so there is nothing the user can connect` }];
|
|
4176
4213
|
if (why === 'shopify_store') return [{ do: 'do not offer this here; if the user runs a Shopify store, tell them to install Hermoso from the Shopify App Store and open it from their Shopify admin', why: `${name} ${SHOPIFY_STORE_NOTE}` }];
|
|
4177
4214
|
if (why === 'unavailable') return [{ do: 'do not offer this capability; carry on with the tools that are listed', why: `${name} is not available in Hermoso yet` }];
|
|
4178
4215
|
if (why === 'host_policy' && executorLike(name)) return [{ do: 'call the Hermoso tools in your list directly', why: `on this host every Hermoso operation is its own listed tool, so ${name} is not offered` }];
|
|
@@ -4186,9 +4223,9 @@ export const holdReasonText = (name, why, ctx = null) => {
|
|
|
4186
4223
|
if (why === 'not_offered') {
|
|
4187
4224
|
const prov = toolProvider(name) || 'that';
|
|
4188
4225
|
const because = ctx?.conn?.gated?.[prov] ? ` (${ctx.conn.gated[prov]})` : '';
|
|
4189
|
-
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
|
|
4226
|
+
return `${name} is not available: Hermoso does not offer the "${prov}" connection yet${because}. There is nothing the user can connect, so do not point them at Connectors and do not offer this capability.`;
|
|
4190
4227
|
}
|
|
4191
|
-
if (why === 'shopify_store') return `${name} ${SHOPIFY_STORE_NOTE} Do not send the user to
|
|
4228
|
+
if (why === 'shopify_store') return `${name} ${SHOPIFY_STORE_NOTE} Do not send the user to Connectors for it; there is no Shopify tile to connect.`;
|
|
4192
4229
|
if (why === 'unavailable') return `${name} is not available in Hermoso yet. Do not offer it or point the user at a setting for it; nothing they can connect turns it on.`;
|
|
4193
4230
|
// ONE sentence, two reasons: OpenAI's generic-executor rule for EXECUTOR_TOOLS, its commerce policy for the rest.
|
|
4194
4231
|
if (why === 'host_policy' && WIDGET_WITHHELD_WHY[name]) return `${name} is not offered on this host: ${WIDGET_WITHHELD_WHY[name]}.`;
|
|
@@ -4204,7 +4241,7 @@ export const holdReasonText = (name, why, ctx = null) => {
|
|
|
4204
4241
|
const ttCreate = name === 'create_tiktok_ads_account';
|
|
4205
4242
|
const link = tpl.replace('{provider}', prov) + (ttCreate ? `${tpl.includes('?') ? '&' : '?'}next=tiktok-ad-account` : '');
|
|
4206
4243
|
const _ig = metaAdsSetupNote(canonicalToolName(name), { status: 401, connector: prov }); // Instagram but no Meta ad account: the steps, said where they ask (INSTAGRAM_ADS_PATH_NOTE)
|
|
4207
|
-
return `${name} needs the "${prov}" connection and this workspace has not made it.${connectionBrandNote(ctx?.conn?.brand)}${metaAlternativeNote(name)}${_ig ? ` ${_ig.text}` : ''} Connect it under
|
|
4244
|
+
return `${name} needs the "${prov}" connection and this workspace has not made it.${connectionBrandNote(ctx?.conn?.brand)}${metaAlternativeNote(name)}${_ig ? ` ${_ig.text}` : ''} Connect it under Connectors in the Hermoso app${isKey ? ', or right here with connect_connector if the user prefers' : `, or hand the user this one-click link: ${link} (it opens Hermoso on this profile and goes straight to the sign-in${ttCreate ? ', then to the form that creates the TikTok ad account' : ''})`}, then call again.`;
|
|
4208
4245
|
}
|
|
4209
4246
|
if (why === 'app_scope') return `${name} is not part of this Hermoso app; it is in ${APP_HOME_TEXT(name, ctx)}. Carry on with the tools this connection lists.`;
|
|
4210
4247
|
if (why === 'directory') return `${name} is not part of this connection, which carries research, publishing, inbox, ads, analytics and account tools.`;
|
|
@@ -4405,7 +4442,7 @@ const makeEnableToolsHandler = (ctx) => async ({ groups }) => {
|
|
|
4405
4442
|
// an account that has not connected the platform yet, because they could only answer 401. Say that, and say where
|
|
4406
4443
|
// the one-click fix is — the failure this sentence prevents is an agent telling a user we cannot run their ads.
|
|
4407
4444
|
const held = heldBack
|
|
4408
|
-
? ` ${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
|
|
4445
|
+
? ` ${heldBack} more tool${heldBack === 1 ? ' is' : 's are'} built and ready but not listed because their account is not connected in this workspace yet — Hermoso supports them all; connect the account under Connectors (https://app.hermoso.ai/?connect=<provider>, connect_connector for a paste-a-key account, or list_connectors to see what is linked) and they appear.`
|
|
4409
4446
|
: '';
|
|
4410
4447
|
const route = 'call find_tools to locate the tool and call_tool to run it by name — that needs no reload and works on every host; or reconnect'
|
|
4411
4448
|
+ ' with `?tools=all` on the server URL, or run the `hermoso` CLI, which reaches every tool with no roster at all.';
|
|
@@ -5387,6 +5424,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5387
5424
|
// the whole meaning — `reddit` — was never consulted. The tell is that a deliberately bogus name
|
|
5388
5425
|
// (`list_reddit_postz`) produced the IDENTICAL six: a suggester that answers the same thing for a real name and
|
|
5389
5426
|
// a typo of it is not ranking anything. Overlap on ALL the tokens, longest name last, is the cheap honest fix.
|
|
5427
|
+
const DESTRUCTIVE_VERB = /^(delete|remove|disconnect|cancel|archive|leave|forget|withdraw|wipe|revoke)$/;
|
|
5390
5428
|
const nearestToolNames = (n, names, max = 6) => {
|
|
5391
5429
|
const want = String(n || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
5392
5430
|
if (!want.length) return [];
|
|
@@ -5399,6 +5437,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5399
5437
|
else if (w.length >= 4 && k.includes(w)) score += 2; // the word inside a token
|
|
5400
5438
|
else if (toks.some((t) => withinOneEdit(w, t))) score += 1; // a typo of a token
|
|
5401
5439
|
}
|
|
5440
|
+
// A READ IS NEVER POINTED AT A DELETE FIRST (chat review 2026-11-13): `get_youtube_video` tied with
|
|
5441
|
+
// delete_youtube_video and update_youtube_video, and the alphabet put the delete at the head of "Did you mean".
|
|
5442
|
+
// A destructive tool ranks under the others unless the caller's own verb was destructive.
|
|
5443
|
+
if (DESTRUCTIVE_VERB.test(toks[0]) && !DESTRUCTIVE_VERB.test(want[0])) score -= 2;
|
|
5402
5444
|
return { k, score };
|
|
5403
5445
|
})
|
|
5404
5446
|
.filter((r) => r.score > 0)
|
|
@@ -5531,7 +5573,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5531
5573
|
server.group('channels');
|
|
5532
5574
|
server.registerTool('post_to_bluesky', {
|
|
5533
5575
|
title: 'Post to Bluesky',
|
|
5534
|
-
description: "Use this when the user wants to post to Bluesky. Publishing costs no credits. Publish a post to Bluesky as the connected account. Text up to 300 characters — Bluesky ALSO caps a post at 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; Hermoso checks both and says which limit. @MENTIONS written as the full handle (@alice.bsky.social) are looked up and tagged as Bluesky mentions; one that does not resolve stays plain text and the reply names it. MEDIA: either up to 10 images (imageUrls + altText; 5–10 post as a Bluesky gallery, more than 10 is refused, never cut; a picture over Bluesky's 2 MB limit is re-encoded to fit and the reply says so) OR one MP4 video (videoUrl + videoAlt), never both. Video is MP4 only, up to 300MB at Bluesky's end (Hermoso can fetch up to 150MB from a URL), with optional WebVTT caption tracks; the aspect ratio is measured from the file. Bluesky requires a CONFIRMED EMAIL on the account before it will process any video — if it is unconfirmed you get a refusal saying so, and reconnecting will not help. Links in the text are made clickable automatically. LINK CARDS: Bluesky does NOT scrape links, so a URL posted bare renders as plain blue text — the client composing the post has to build the card. Hermoso builds one AUTOMATICALLY when the post has a URL and NO media: it fetches the page, uses its title/description and uploads its image as the card thumbnail. Pass linkCard:false to suppress it, or linkCard:{uri,title,description,thumbUrl} to control it (give both title and description and the page is not fetched at all). A POST CARRIES ONE EMBED, so a card and images/video cannot both ride: if you pass linkCard explicitly ALONGSIDE media the call is REFUSED by name rather than silently dropping one, and if the URL was merely in the text the MEDIA WINS and the reply says the card was skipped (the link stays clickable either way). Returns the post's public bsky.app URL. Connect at
|
|
5576
|
+
description: "Use this when the user wants to post to Bluesky. Publishing costs no credits. Publish a post to Bluesky as the connected account. Text up to 300 characters — Bluesky ALSO caps a post at 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; Hermoso checks both and says which limit. @MENTIONS written as the full handle (@alice.bsky.social) are looked up and tagged as Bluesky mentions; one that does not resolve stays plain text and the reply names it. MEDIA: either up to 10 images (imageUrls + altText; 5–10 post as a Bluesky gallery, more than 10 is refused, never cut; a picture over Bluesky's 2 MB limit is re-encoded to fit and the reply says so) OR one MP4 video (videoUrl + videoAlt), never both. Video is MP4 only, up to 300MB at Bluesky's end (Hermoso can fetch up to 150MB from a URL), with optional WebVTT caption tracks; the aspect ratio is measured from the file. Bluesky requires a CONFIRMED EMAIL on the account before it will process any video — if it is unconfirmed you get a refusal saying so, and reconnecting will not help. Links in the text are made clickable automatically. LINK CARDS: Bluesky does NOT scrape links, so a URL posted bare renders as plain blue text — the client composing the post has to build the card. Hermoso builds one AUTOMATICALLY when the post has a URL and NO media: it fetches the page, uses its title/description and uploads its image as the card thumbnail. Pass linkCard:false to suppress it, or linkCard:{uri,title,description,thumbUrl} to control it (give both title and description and the page is not fetched at all). A POST CARRIES ONE EMBED, so a card and images/video cannot both ride: if you pass linkCard explicitly ALONGSIDE media the call is REFUSED by name rather than silently dropping one, and if the URL was merely in the text the MEDIA WINS and the reply says the card was skipped (the link stays clickable either way). Returns the post's public bsky.app URL. Connect at Connectors > Bluesky.",
|
|
5535
5577
|
inputSchema: {
|
|
5536
5578
|
account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the profile has more than one bluesky account connected (several and none named is refused by name, never guessed); omit when there is one."),
|
|
5537
5579
|
brand: HOOK_ATTR.brand,
|
|
@@ -5561,7 +5603,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5561
5603
|
server.group('channel_admin');
|
|
5562
5604
|
server.registerTool('delete_bluesky_post', {
|
|
5563
5605
|
title: 'Delete a post from the connected Bluesky account',
|
|
5564
|
-
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 (
|
|
5606
|
+
description: "PERMANENTLY delete one of the connected Bluesky account's OWN posts. IRREVERSIBLE — the AT Protocol removes the record from the account's repo, there is no trash and no undelete, and the post's likes, reposts, replies and quotes go with it. Call it WITHOUT confirm first: nothing is deleted, and it reports the post's REAL text and its live like / repost / reply / quote counts read back from Bluesky. Show the user that, get an unambiguous yes, then call again with confirm:true — plus, once the post has ANY engagement, confirmText echoing the post's own text (the first 40 characters is enough; any longer leading run works too). confirmText exists because confirming that you meant to delete SOMETHING does not prove you aimed at the right post, and a wrong id must not be confirmable blind. A brand-new post with nothing on it stays a ONE-call delete. Identify the post by its AT-URI or by just its RECORD KEY — the short id at the end of its bsky.app link, e.g. 3mtc4n3fibn2x. Deleting only ever works on the connected account's own posts; another account's URI is refused. 0 credits. Needs Bluesky connected (Connectors > Bluesky, or connect_connector).",
|
|
5565
5607
|
inputSchema: {
|
|
5566
5608
|
...MANAGE_BRAND,
|
|
5567
5609
|
uri: z.string().describe("the post's AT-URI (at://did:plc:…/app.bsky.feed.post/…) as post_to_bluesky returned it, the handle form of the same URI for the CONNECTED account only (at://<its handle>/app.bsky.feed.post/…), or just its record key (3mtc4n3fibn2x)"),
|
|
@@ -5593,7 +5635,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5593
5635
|
server.group('channels');
|
|
5594
5636
|
server.registerTool('post_to_telegram', {
|
|
5595
5637
|
title: 'Post to Telegram',
|
|
5596
|
-
description: "Use this when the user wants to post to a Telegram channel or group. Publishing costs no credits. Publish to a Telegram channel, group or chat as the brand's own bot. WHICH CHAT IS ALWAYS REQUIRED AND IS NEVER GUESSED: pass chatId as the public channel's @username (e.g. @yourchannel) or its numeric id (a group is negative; a supergroup or channel starts with -100). There is no default and there cannot be one — the Telegram Bot API publishes no method that lists the chats a bot belongs to, so Hermoso genuinely cannot know them. list_telegram_chats reports the chats that have MESSAGED the bot in the last 24 hours, which is a shortcut for finding an id and is NOT a roster: a chat missing from it can still be posted to. TEXT: up to 4096 characters on a text-only message, but only 1024 the moment ANY photo or video is attached — a caption is not a message, and Hermoso refuses the over-long one before spending the round trip and says which budget applied. MEDIA: one image (imageUrl), one video (videoUrl), or an ALBUM of 2–10 (imageUrls, in order) in which photos and videos may be MIXED — pass a videoUrl alongside imageUrls and it joins the album as one more item. Hermoso uploads the bytes rather than handing Telegram a link, which is what buys the larger ceilings: 10MB per photo and 50MB per video, where a link would be 5MB and 20MB. THE BOT MUST BE IN THE CHAT — an administrator with Post Messages for a channel, an unrestricted member for a group; if it is not, Telegram refuses and the error says to add it rather than to reconnect. Returns the message id and, for a PUBLIC chat, its t.me link — a private group has no public web link, so `url` comes back null rather than as a link that would 404 for whoever you hand it to. 0 credits. Connect at
|
|
5638
|
+
description: "Use this when the user wants to post to a Telegram channel or group. Publishing costs no credits. Publish to a Telegram channel, group or chat as the brand's own bot. WHICH CHAT IS ALWAYS REQUIRED AND IS NEVER GUESSED: pass chatId as the public channel's @username (e.g. @yourchannel) or its numeric id (a group is negative; a supergroup or channel starts with -100). There is no default and there cannot be one — the Telegram Bot API publishes no method that lists the chats a bot belongs to, so Hermoso genuinely cannot know them. list_telegram_chats reports the chats that have MESSAGED the bot in the last 24 hours, which is a shortcut for finding an id and is NOT a roster: a chat missing from it can still be posted to. TEXT: up to 4096 characters on a text-only message, but only 1024 the moment ANY photo or video is attached — a caption is not a message, and Hermoso refuses the over-long one before spending the round trip and says which budget applied. MEDIA: one image (imageUrl), one video (videoUrl), or an ALBUM of 2–10 (imageUrls, in order) in which photos and videos may be MIXED — pass a videoUrl alongside imageUrls and it joins the album as one more item. Hermoso uploads the bytes rather than handing Telegram a link, which is what buys the larger ceilings: 10MB per photo and 50MB per video, where a link would be 5MB and 20MB. THE BOT MUST BE IN THE CHAT — an administrator with Post Messages for a channel, an unrestricted member for a group; if it is not, Telegram refuses and the error says to add it rather than to reconnect. Returns the message id and, for a PUBLIC chat, its t.me link — a private group has no public web link, so `url` comes back null rather than as a link that would 404 for whoever you hand it to. 0 credits. Connect at Connectors > Telegram.",
|
|
5597
5639
|
inputSchema: {
|
|
5598
5640
|
account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the profile has more than one telegram account connected (several and none named is refused by name, never guessed); omit when there is one."),
|
|
5599
5641
|
brand: HOOK_ATTR.brand,
|
|
@@ -5644,7 +5686,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5644
5686
|
description: "Read what people have SENT to the connected Telegram bot — the newest message from each chat, newest chat first, so you can see who is waiting on a reply. Reply with post_to_telegram using the chatId shown. "
|
|
5645
5687
|
+ "Warning: THIS IS A ROLLING 24-HOUR WINDOW, NOT AN INBOX. Telegram keeps undelivered updates for 24 hours and publishes NO history endpoint at all, so anything older is unrecoverable — never report an empty result as 'you have no messages', report it as 'nothing in the last 24 hours'. "
|
|
5646
5688
|
+ "Two more Telegram rules worth stating before someone concludes the feature is broken: a bot that has an outgoing WEBHOOK configured gets nothing from this at all (Telegram's own rule, and the reply says so), and a bot can never message someone first — they have to write to it. "
|
|
5647
|
-
+ "Free — no vendor charge and no credits. Needs Telegram connected (
|
|
5689
|
+
+ "Free — no vendor charge and no credits. Needs Telegram connected (Connectors > Telegram, or connect_connector).",
|
|
5648
5690
|
inputSchema: { limit: z.number().optional().describe('how many raw updates to scan, 1-100 (default 100). Messages are grouped per chat, so this is not the number of rows you get back.') },
|
|
5649
5691
|
outputSchema: { messages: z.array(z.any()).optional(), count: z.number().optional(), webhookSet: z.boolean().optional(), windowHours: z.number().optional(), note: z.string().optional() },
|
|
5650
5692
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
@@ -5664,7 +5706,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5664
5706
|
// and nothing to echo back. A confirmText gate would be asking the caller to echo something we invented.
|
|
5665
5707
|
server.registerTool('delete_telegram_message', {
|
|
5666
5708
|
title: 'Delete a Telegram message',
|
|
5667
|
-
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 (
|
|
5709
|
+
description: "PERMANENTLY delete one message the bot posted to a Telegram chat. Call it WITHOUT confirm first: nothing is deleted and you get a sentence to show the user. There is deliberately NO preview of the message — the Bot API has no method that reads one message back, so anything shown would be invented, and for the same reason the result after deleting is Telegram’s own success answer rather than a verified read-back. TWO VENDOR LIMITS, both Telegram’s and neither ours: \"A message can only be deleted if it was sent less than 48 hours ago\", and in a CHANNEL the bot needs the Post Messages right to remove even its own posts. Takes the same chatId as post_to_telegram plus the messageId post_to_telegram returned. 0 credits. Needs Telegram connected (Connectors > Telegram, or connect_connector).",
|
|
5668
5710
|
inputSchema: {
|
|
5669
5711
|
...MANAGE_BRAND,
|
|
5670
5712
|
chatId: z.string().describe("the chat the message is in — the same @username or numeric id it was posted with"),
|
|
@@ -5683,7 +5725,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5683
5725
|
// Studio agent and both MCP twins can never disagree about what a WordPress post is.
|
|
5684
5726
|
server.registerTool('post_to_wordpress', {
|
|
5685
5727
|
title: 'Publish a WordPress blog post',
|
|
5686
|
-
description: "Use this when the user wants to publish, draft or schedule a BLOG POST on their WordPress site. Publishing costs no credits. A title is REQUIRED and the text becomes the body: plain text or light markdown (## headings, - lists, **bold**, [links](url)) is turned into WordPress blocks, and HTML is sent as written. status: publish (default), draft, pending (submit for review), private (logged-in editors only) or future with a date — a future `date` makes WordPress schedule it natively. The FIRST image becomes the FEATURED image, every other image is uploaded and placed inline, and a video is uploaded and embedded (if the site's host refuses the file as too large, the post plays it from its own link and the reply says so). categories and tags take names or ids; a name the site lacks is CREATED and the reply says which. An over-long title is refused, never cut. WordPress has NO per-post views in its API: never quote a view count for a WordPress post. Self-hosted WordPress only — a WordPress.com site needs its own OAuth sign-in and cannot be connected. Not connected: the user connects it under
|
|
5728
|
+
description: "Use this when the user wants to publish, draft or schedule a BLOG POST on their WordPress site. Publishing costs no credits. A title is REQUIRED and the text becomes the body: plain text or light markdown (## headings, - lists, **bold**, [links](url)) is turned into WordPress blocks, and HTML is sent as written. status: publish (default), draft, pending (submit for review), private (logged-in editors only) or future with a date — a future `date` makes WordPress schedule it natively. The FIRST image becomes the FEATURED image, every other image is uploaded and placed inline, and a video is uploaded and embedded (if the site's host refuses the file as too large, the post plays it from its own link and the reply says so). categories and tags take names or ids; a name the site lacks is CREATED and the reply says which. An over-long title is refused, never cut. WordPress has NO per-post views in its API: never quote a view count for a WordPress post. Self-hosted WordPress only — a WordPress.com site needs its own OAuth sign-in and cannot be connected. Not connected: the user connects it under Connectors > WordPress, or you call connect_connector with provider wordpress (siteUrl, username, appPassword).",
|
|
5687
5729
|
inputSchema: {
|
|
5688
5730
|
brand: HOOK_ATTR.brand,
|
|
5689
5731
|
...INTENT_SHORT,
|
|
@@ -6256,7 +6298,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6256
6298
|
const c = await apiGet('/api/connectors');
|
|
6257
6299
|
const on = (c.connectors || []).filter(x => x.connected).map(x => x.provider);
|
|
6258
6300
|
const offered = c.providers || [];
|
|
6259
|
-
connLine = `\nConnected channels (usable NOW): ${on.join(', ') || 'none — the user links these in
|
|
6301
|
+
connLine = `\nConnected channels (usable NOW): ${on.join(', ') || 'none — the user links these in Connectors'}`
|
|
6260
6302
|
+ `\nOffered but not linked: ${offered.filter(p => !on.includes(p)).join(', ') || 'none'}`
|
|
6261
6303
|
+ `\nAny channel absent from BOTH lists is not available on this build — do not tell the user to connect it.`
|
|
6262
6304
|
+ `\nOnly the accounts a user TICKED under a connector's "Manage accounts" are usable; list_connector_accounts shows them and set_connector_accounts changes them.`;
|
|
@@ -6720,7 +6762,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6720
6762
|
const d = await apiDelete(`/api/brands/${encodeURIComponent(hit.id)}?${q}`);
|
|
6721
6763
|
const dis = d.connectorsDisconnected || [];
|
|
6722
6764
|
const nq = +d.scheduledPostsCancelled || 0; // its queued posts go with it (2026-10-01)
|
|
6723
|
-
return ok(`Deleted “${hit.name}” and everything in it.${dis.length ? ` Disconnected: ${dis.join(', ')}. Those connections can be restored for ${d.connectorsRecoverableForDays || 30} days from
|
|
6765
|
+
return ok(`Deleted “${hit.name}” and everything in it.${dis.length ? ` Disconnected: ${dis.join(', ')}. Those connections can be restored for ${d.connectorsRecoverableForDays || 30} days from Connectors; after that they are gone.` : ''}${nq ? ` Cancelled ${nq} scheduled post${nq === 1 ? '' : 's'} that had not published yet.` : ''}${d.brandsLeft === 0 ? ' That was the last profile on this account: create_brand makes a new one.' : ''}`, d);
|
|
6724
6766
|
}));
|
|
6725
6767
|
|
|
6726
6768
|
|
|
@@ -7298,17 +7340,17 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7298
7340
|
return ok(`${d.count} result(s) for "${d.q}":\n${lines.join('\n') || '(none)'}`, d);
|
|
7299
7341
|
}));
|
|
7300
7342
|
|
|
7301
|
-
// ---------- META publishing + ads management (needs a connected Meta account:
|
|
7343
|
+
// ---------- META publishing + ads management (needs a connected Meta account: Connectors ▸ Meta) ----------
|
|
7302
7344
|
server.group('channels');
|
|
7303
7345
|
server.registerTool('list_meta_pages', {
|
|
7304
7346
|
title: 'List Meta pages & ad accounts',
|
|
7305
|
-
description: 'List the Facebook Pages (with any linked Instagram business account) and ad accounts on the connected Meta account — use before post_to_meta / create_meta_campaign to pick the target. Requires the user to have connected Meta (
|
|
7347
|
+
description: 'List the Facebook Pages (with any linked Instagram business account) and ad accounts on the connected Meta account — use before post_to_meta / create_meta_campaign to pick the target. Requires the user to have connected Meta (Connectors > Meta); returns a connect hint if not.',
|
|
7306
7348
|
inputSchema: {},
|
|
7307
7349
|
outputSchema: { pages: z.array(z.any()).optional(), adAccounts: z.array(z.any()).optional() },
|
|
7308
7350
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
7309
7351
|
}, wrap(async () => {
|
|
7310
7352
|
const [pg, aa] = await Promise.all([apiGet('/api/meta/pages').catch((e) => ({ __err: e.message })), apiGet('/api/meta/adaccounts').catch((e) => ({ __err: e.message }))]);
|
|
7311
|
-
if (pg.__err && /connect/i.test(pg.__err)) return { content: [{ type: 'text', text: 'No Meta account connected yet — connect it in
|
|
7353
|
+
if (pg.__err && /connect/i.test(pg.__err)) return { content: [{ type: 'text', text: 'No Meta account connected yet — connect it in Connectors ▸ Meta, then try again.' }], isError: true };
|
|
7312
7354
|
// “none” IS NOT AN ANSWER HERE (2026-08-04). Meta is the connector that answers an unreachable workspace with
|
|
7313
7355
|
// 200 + a flag instead of a 401 — `/api/meta/pages` returns `{pages: [], needsSelection: true}` both when Meta
|
|
7314
7356
|
// was never connected AND when it is connected with nothing ticked. Only the ERROR shape was handled, so every
|
|
@@ -7316,7 +7358,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7316
7358
|
// their Page list is empty, and leaving the agent to conclude the brand has no Facebook presence. It is the
|
|
7317
7359
|
// same trap `list_connector_accounts` already special-cases for this provider; the two branches are not worth
|
|
7318
7360
|
// a second round trip to separate, so name both and give the one action that fixes either.
|
|
7319
|
-
if (pg.needsSelection && !(pg.pages || []).length) return { content: [{ type: 'text', text: 'No Facebook Page is shared with this profile, so there is nothing to post to or advertise from. Either Meta is not connected to this workspace yet, or it is connected and no Pages have been ticked for this profile — both are fixed in the same place:
|
|
7361
|
+
if (pg.needsSelection && !(pg.pages || []).length) return { content: [{ type: 'text', text: 'No Facebook Page is shared with this profile, so there is nothing to post to or advertise from. Either Meta is not connected to this workspace yet, or it is connected and no Pages have been ticked for this profile — both are fixed in the same place: Connectors ▸ Meta ▸ Manage accounts (or list_connector_accounts / set_connector_accounts). This is NOT a report that the account has no Pages. If list_connector_accounts shows NO Page to tick, Meta did not list one for this connection: do not send the user to tick it; tell them to reconnect Meta and, in Meta\'s dialog, choose the Page and the business that owns it, leaving business_management switched on.' }], isError: true };
|
|
7320
7362
|
const pages = pg.pages || [], adAccounts = aa.adAccounts || [];
|
|
7321
7363
|
return ok(`Pages: ${pages.map(p => p.name + (p.instagram ? ` (IG @${p.instagram.username})` : '')).join(', ') || 'none'}\nAd accounts: ${adAccounts.map(a => `${a.name} (act_${a.accountId}, ${a.currency}${a.accountStatus && a.accountStatus !== 'ACTIVE' ? `, Meta status ${a.accountStatus}${a.canDeliver ? ' (still delivering)' : ' (not delivering)'}` : a.active === false ? ', not active' : ''})`).join(', ') || 'none'}`, { pages, adAccounts });
|
|
7322
7364
|
}));
|
|
@@ -7495,7 +7537,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7495
7537
|
server.group('channels');
|
|
7496
7538
|
server.registerTool('list_whatsapp_accounts', {
|
|
7497
7539
|
title: 'WhatsApp Business accounts and numbers',
|
|
7498
|
-
description: 'The WhatsApp Business Accounts SHARED WITH THIS PROFILE and the phone numbers registered on each — the ids every other WhatsApp tool needs, plus each number’s QUALITY RATING, which is what decides how many messages Meta will let it send. Start here. A WABA with no number cannot send anything and the reply says so: Hermoso does not register or verify numbers, that is WhatsApp Manager. A business portfolio that could not be read is REPORTED rather than dropped — an account missing from this list would read as "the brand has no WhatsApp", which is a claim about their business and not about our read. TWO ACCOUNTS CAN CARRY THE SAME DISPLAY NAME — always quote the `display` field or the id when naming one to a user, never the bare name. ONLY THE ACCOUNTS SHARED WITH THIS PROFILE ARE REACHABLE. One Meta login often administers WhatsApp for several businesses, so a human ticks which belong to this profile under
|
|
7540
|
+
description: 'The WhatsApp Business Accounts SHARED WITH THIS PROFILE and the phone numbers registered on each — the ids every other WhatsApp tool needs, plus each number’s QUALITY RATING, which is what decides how many messages Meta will let it send. Start here. A WABA with no number cannot send anything and the reply says so: Hermoso does not register or verify numbers, that is WhatsApp Manager. A business portfolio that could not be read is REPORTED rather than dropped — an account missing from this list would read as "the brand has no WhatsApp", which is a claim about their business and not about our read. TWO ACCOUNTS CAN CARRY THE SAME DISPLAY NAME — always quote the `display` field or the id when naming one to a user, never the bare name. ONLY THE ACCOUNTS SHARED WITH THIS PROFILE ARE REACHABLE. One Meta login often administers WhatsApp for several businesses, so a human ticks which belong to this profile under Connectors > Meta > Manage accounts (or set_connector_accounts(provider:"meta")); nothing else here can see or touch the rest. With none ticked every WhatsApp tool refuses and names that as the way out. With exactly ONE ticked, wabaId is optional — pass it only to disambiguate. Read-only, 0 credits (WhatsApp conversations are billed by META to the business directly, never in Hermoso credits). Needs Meta connected.',
|
|
7499
7541
|
inputSchema: {},
|
|
7500
7542
|
outputSchema: { count: z.number().optional(), accounts: z.array(z.any()).optional(), unreadable: z.array(z.any()).optional(), note: z.string().optional(), optIn: z.string().optional(), pricing: z.string().optional() },
|
|
7501
7543
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -7651,7 +7693,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7651
7693
|
server.group('channels');
|
|
7652
7694
|
server.registerTool('post_to_meta', {
|
|
7653
7695
|
title: 'Post to Facebook, Instagram or Threads',
|
|
7654
|
-
description: 'Use this when the user wants to post now to Facebook, Instagram or Threads. Publish to a connected Facebook Page, its linked Instagram, OR the brand’s Threads account — text/link/image/VIDEO/CAROUSEL. A MULTI-SLIDE creative is a CAROUSEL, not several posts: pass the slides in order as imageUrls[] and they publish as ONE swipeable post (Instagram album, Threads carousel, Facebook multi-photo post). Never publish slide 1 of a deck on its own — the creative tells the viewer to swipe. target:"facebook" (default) posts to the Page; target:"instagram" publishes a photo or Reel to the linked IG business account (needs an image or video); target:"threads" posts to the connected Threads account (text, image, or video). Works with any media, an ad made here or the user’s own file: imageUrl/videoUrl accept a public https URL, a data: URI, or a Hermoso /generated path; for a LOCAL file (e.g. on the user’s desktop) call upload_file first and pass the url it returns. INSTAGRAM COLLAB: pass `collaborators` (up to 3 usernames) to invite other accounts to CO-AUTHOR the post — it then shows on their profile too once they accept. This PUBLISHES immediately — confirm the copy + media with the user first. Needs a connected Meta account (
|
|
7696
|
+
description: 'Use this when the user wants to post now to Facebook, Instagram or Threads. Publish to a connected Facebook Page, its linked Instagram, OR the brand’s Threads account — text/link/image/VIDEO/CAROUSEL. A MULTI-SLIDE creative is a CAROUSEL, not several posts: pass the slides in order as imageUrls[] and they publish as ONE swipeable post (Instagram album, Threads carousel, Facebook multi-photo post). Never publish slide 1 of a deck on its own — the creative tells the viewer to swipe. target:"facebook" (default) posts to the Page; target:"instagram" publishes a photo or Reel to the linked IG business account (needs an image or video); target:"threads" posts to the connected Threads account (text, image, or video). Works with any media, an ad made here or the user’s own file: imageUrl/videoUrl accept a public https URL, a data: URI, or a Hermoso /generated path; for a LOCAL file (e.g. on the user’s desktop) call upload_file first and pass the url it returns. INSTAGRAM COLLAB: pass `collaborators` (up to 3 usernames) to invite other accounts to CO-AUTHOR the post — it then shows on their profile too once they accept. This PUBLISHES immediately — confirm the copy + media with the user first. Needs a connected Meta account (Connectors > Meta) with posting permission; Threads needs its own connection.',
|
|
7655
7697
|
// LOOSE ON PURPOSE (2026-09-30): an argument this schema does not declare is FORWARDED, not stripped, so a caption
|
|
7656
7698
|
// sent as `caption` or a picture as `image_url` reaches the server and is refused BY NAME with the right field
|
|
7657
7699
|
// (metaPostAliasRefusal). Stripped, it vanished and the call read as empty: five identical refusals in 35 minutes.
|
|
@@ -8234,7 +8276,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8234
8276
|
d.targets?.summary ? `WHERE IT POSTS: ${d.targets.summary}` : '',
|
|
8235
8277
|
d.creditNote || '',
|
|
8236
8278
|
`Posting times: ${(d.postingTimes || []).join(', ') || 'none set (add some on the Schedule tab)'} ${d.postingSchedule?.timezone || 'UTC'}. It fills ${d.daysAhead} day(s) ahead; ${d.queuedPosts} post(s) queued now.`,
|
|
8237
|
-
(d.channelsView || []).length ? `Channels:\n${d.channelsView.map((c) => ` ${c.on ? '[on] ' : '[off]'} ${c.label} (${c.channel}): ${c.on ? `${c.postsPerDay} a day${c.times.length ? ` at ${c.times.join(', ')}` : ''}` : 'not posting'}${c.note ? ` · ${c.note}` : ''}${(c.accounts || []).length > 1 ? ` · accounts: ${c.accounts.map((a) => a.name).join(', ')}${c.pickedAccounts ? ` (picked: ${c.pickedAccounts === 'all' ? 'all' : c.accounts.filter((a) => c.pickedAccounts.includes(a.id)).map((a) => a.name).join(', ')})` : ''}` : ''}`).join('\n')}` : (d.connectedUnreadable ? 'Channels: could not be read just now.' : 'Channels: none connected — autopilot has nowhere to post. Connect one under
|
|
8279
|
+
(d.channelsView || []).length ? `Channels:\n${d.channelsView.map((c) => ` ${c.on ? '[on] ' : '[off]'} ${c.label} (${c.channel}): ${c.on ? `${c.postsPerDay} a day${c.times.length ? ` at ${c.times.join(', ')}` : ''}` : 'not posting'}${c.note ? ` · ${c.note}` : ''}${(c.accounts || []).length > 1 ? ` · accounts: ${c.accounts.map((a) => a.name).join(', ')}${c.pickedAccounts ? ` (picked: ${c.pickedAccounts === 'all' ? 'all' : c.accounts.filter((a) => c.pickedAccounts.includes(a.id)).map((a) => a.name).join(', ')})` : ''}` : ''}`).join('\n')}` : (d.connectedUnreadable ? 'Channels: could not be read just now.' : 'Channels: none connected — autopilot has nowhere to post. Connect one under Connectors.'),
|
|
8238
8280
|
`Makes ${d.postsPerDayMade || 0} new post(s) a day, every one a fresh render made for it${d.estCreditsPerDay != null && d.postsPerDayMade ? `, about ${d.estCreditsPerDay} credits a day at today's prices` : ''}.${d.zeroBudget ? ` ${d.budgetNote}` : ''} Used: ${d.spend?.today || 0} credits today, ${d.spend?.week || 0} this week.`,
|
|
8239
8281
|
`Brief: goal ${d.brief?.goal || 'not set (required to switch it on)'}${d.brief?.goalNote ? ` (${d.brief.goalNote})` : ''} · format ${d.brief?.formats || 'mix'}${(d.brief?.pillars || []).length ? ` · pillars: ${d.brief.pillars.join('; ')}` : (d.suggestedPillars || []).length ? ` · no pillars yet; suggested from the brand: ${d.suggestedPillars.join('; ')}` : ''}${(d.brief?.avoid || []).length ? ` · avoid: ${d.brief.avoid.join('; ')}` : ''}`,
|
|
8240
8282
|
(d.brief?.learnings || []).length || (d.brief?.editExamples || []).length ? `Learned from reviews (every batch follows these): ${(d.brief.learnings || []).map((x) => `"${x}"`).join('; ') || 'no notes'}${(d.brief.editExamples || []).length ? ` · imitates the user's last ${d.brief.editExamples.length} caption rewrite(s)` : ''}. Edit the list with set_post_refill learnings.` : '',
|
|
@@ -8341,7 +8383,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8341
8383
|
}));
|
|
8342
8384
|
server.registerTool('post_to_linkedin', {
|
|
8343
8385
|
title: 'Publish to LinkedIn',
|
|
8344
|
-
description: 'Use this when the user wants to post to their personal LinkedIn profile. Publishing costs no credits. Publish a post to the user’s connected LinkedIn profile — text, and optionally an image (imageUrl), a VIDEO (videoUrl, with captionsSrt and a videoThumbnailUrl cover), a 2–20 image MULTI-IMAGE post (imageUrls), a LINK post with a real preview card (linkUrl + linkTitle / linkDescription / linkThumbnailUrl — LinkedIn never scrapes the page), a DOCUMENT post (documentUrl: a PDF / PPTX readers swipe through), and @mentions of companies (mentions). The image does NOT have to be something Hermoso generated: LinkedIn is served the bytes from us, so the URL must be Hermoso-hosted, and upload_file turns ANY file the user already has into exactly that. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Needs a connected LinkedIn account (
|
|
8386
|
+
description: 'Use this when the user wants to post to their personal LinkedIn profile. Publishing costs no credits. Publish a post to the user’s connected LinkedIn profile — text, and optionally an image (imageUrl), a VIDEO (videoUrl, with captionsSrt and a videoThumbnailUrl cover), a 2–20 image MULTI-IMAGE post (imageUrls), a LINK post with a real preview card (linkUrl + linkTitle / linkDescription / linkThumbnailUrl — LinkedIn never scrapes the page), a DOCUMENT post (documentUrl: a PDF / PPTX readers swipe through), and @mentions of companies (mentions). The image does NOT have to be something Hermoso generated: LinkedIn is served the bytes from us, so the URL must be Hermoso-hosted, and upload_file turns ANY file the user already has into exactly that. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Needs a connected LinkedIn account (Connectors > LinkedIn).',
|
|
8345
8387
|
inputSchema: {
|
|
8346
8388
|
...HOOK_ATTR,
|
|
8347
8389
|
text: z.string().describe('the post text'),
|
|
@@ -8375,7 +8417,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8375
8417
|
// below say so explicitly, because an agent that fires ten posts to "see what sticks" is spending the user's money.
|
|
8376
8418
|
server.registerTool('post_to_x', {
|
|
8377
8419
|
title: 'Publish a post to X (Twitter)',
|
|
8378
|
-
description: 'Use this when the user wants to post to X (Twitter) now. Publish to the user’s connected X (Twitter) account — a single post, a post with an image or video render attached, a reply to an existing post, or a whole THREAD (pass `thread` as an array and each part is posted as a reply to the one before). This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Can also run a POLL (2-4 options) instead of media, share a CLIP of another post’s video (`clip`), and restrict who may reply. LENGTH IS THE POSTING ACCOUNT’S, NOT A FLAT 280: 280 characters on an ordinary account, but UP TO 25,000 if that account has X Premium. Hermoso reads the account’s own subscription from X and sends the post rather than refusing something it may be entitled to; if X declines it on length, the reply says so and names the subscription X reported. Over 25,000 is refused for free — that is X’s own ceiling on every account. Text is NEVER truncated. AND A LONG POST IS CHEAPER THAN A THREAD: it is ONE billed X call where the same words split across five posts are five, so prefer one long post over threading when the account has Premium. Write altText whenever you attach a render. COSTS CREDITS at X’s per-post price, every post in a thread billed: a post that contains a link (any web address, and every quote post) is X’s link rate, far higher than one without (hermoso_credits gives both figures). When the user asks what a post will cost, call this with dryRun:true: it posts NOTHING and returns that exact post’s costCredits. Each profile also has a rolling 24-hour ceiling on what it can spend at X, and a request that would cross it is refused WHOLE before anything publishes, so a batch of posts is bounded rather than open-ended. THIS TOOL POSTS ORGANICALLY — it does not create an ad campaign. X ads are built with the X Ads campaign tools (campaign, then line item, then promoted post), and a promoted post needs a post id, so publish here first and promote that post. Needs X connected (
|
|
8420
|
+
description: 'Use this when the user wants to post to X (Twitter) now. Publish to the user’s connected X (Twitter) account — a single post, a post with an image or video render attached, a reply to an existing post, or a whole THREAD (pass `thread` as an array and each part is posted as a reply to the one before). This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Can also run a POLL (2-4 options) instead of media, share a CLIP of another post’s video (`clip`), and restrict who may reply. LENGTH IS THE POSTING ACCOUNT’S, NOT A FLAT 280: 280 characters on an ordinary account, but UP TO 25,000 if that account has X Premium. Hermoso reads the account’s own subscription from X and sends the post rather than refusing something it may be entitled to; if X declines it on length, the reply says so and names the subscription X reported. Over 25,000 is refused for free — that is X’s own ceiling on every account. Text is NEVER truncated. AND A LONG POST IS CHEAPER THAN A THREAD: it is ONE billed X call where the same words split across five posts are five, so prefer one long post over threading when the account has Premium. Write altText whenever you attach a render. COSTS CREDITS at X’s per-post price, every post in a thread billed: a post that contains a link (any web address, and every quote post) is X’s link rate, far higher than one without (hermoso_credits gives both figures). When the user asks what a post will cost, call this with dryRun:true: it posts NOTHING and returns that exact post’s costCredits. Each profile also has a rolling 24-hour ceiling on what it can spend at X, and a request that would cross it is refused WHOLE before anything publishes, so a batch of posts is bounded rather than open-ended. THIS TOOL POSTS ORGANICALLY — it does not create an ad campaign. X ads are built with the X Ads campaign tools (campaign, then line item, then promoted post), and a promoted post needs a post id, so publish here first and promote that post. Needs X connected (Connectors > X).',
|
|
8379
8421
|
inputSchema: {
|
|
8380
8422
|
account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the profile has more than one x account connected (several and none named is refused by name, never guessed); omit when there is one."),
|
|
8381
8423
|
...HOOK_ATTR,
|
|
@@ -8422,7 +8464,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8422
8464
|
}));
|
|
8423
8465
|
server.registerTool('post_x_article', {
|
|
8424
8466
|
title: 'Publish a long-form Article to X',
|
|
8425
|
-
description: 'Publish a long-form ARTICLE to the user’s connected X (Twitter) account — X’s own long-form format, which is a different thing from a long POST. Give it a `title` and a `body` written in MARKDOWN (or plain prose) and Hermoso converts it into the DraftJS `content_state` structure X requires: headings, paragraphs, bulleted and numbered lists, blockquotes, bold / italic / strikethrough, links, horizontal rules, fenced code blocks and pipe tables all carry across. FORMATTING IS NEVER SILENTLY DROPPED — anything X Articles cannot represent (inline `code`, an inline image) REFUSES the article for free and names exactly what and why, and `allowLossy: true` is the explicit way to publish it as plain text anyway. THE HARD LIMIT TO PLAN AROUND: X allows only about 10 Article DRAFTS and 5 Article PUBLISHES per account per DAY, it publishes that cap nowhere, and it counts a FAILED attempt against them — so never iterate on an article by republishing it, and use `publish: false` to save a draft for the user to read in X’s own composer when they want to review before it goes out. This PUBLISHES immediately and PUBLICLY: show the user the full text and get an explicit yes first, because a published Article can NEVER be edited (X’s own rule — edit_x_post will refuse it) and the only remedy is to delete and republish, which costs another of the five. An Article appears in the timeline as a title card, not as body text; readers open it. Costs credits (X bills per API request, and this is three of them). Needs X connected (
|
|
8467
|
+
description: 'Publish a long-form ARTICLE to the user’s connected X (Twitter) account — X’s own long-form format, which is a different thing from a long POST. Give it a `title` and a `body` written in MARKDOWN (or plain prose) and Hermoso converts it into the DraftJS `content_state` structure X requires: headings, paragraphs, bulleted and numbered lists, blockquotes, bold / italic / strikethrough, links, horizontal rules, fenced code blocks and pipe tables all carry across. FORMATTING IS NEVER SILENTLY DROPPED — anything X Articles cannot represent (inline `code`, an inline image) REFUSES the article for free and names exactly what and why, and `allowLossy: true` is the explicit way to publish it as plain text anyway. THE HARD LIMIT TO PLAN AROUND: X allows only about 10 Article DRAFTS and 5 Article PUBLISHES per account per DAY, it publishes that cap nowhere, and it counts a FAILED attempt against them — so never iterate on an article by republishing it, and use `publish: false` to save a draft for the user to read in X’s own composer when they want to review before it goes out. This PUBLISHES immediately and PUBLICLY: show the user the full text and get an explicit yes first, because a published Article can NEVER be edited (X’s own rule — edit_x_post will refuse it) and the only remedy is to delete and republish, which costs another of the five. An Article appears in the timeline as a title card, not as body text; readers open it. Costs credits (X bills per API request, and this is three of them). Needs X connected (Connectors > X).',
|
|
8426
8468
|
inputSchema: {
|
|
8427
8469
|
...HOOK_ATTR,
|
|
8428
8470
|
title: z.string().describe('the Article title — X requires one and refuses a draft without it. This is what shows on the timeline card.'),
|
|
@@ -8571,7 +8613,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8571
8613
|
+ "COSTS CREDITS PER MESSAGE RETURNED plus one account lookup, so keep maxResults small (default 10) and tell the user the cost before pulling a big page. "
|
|
8572
8614
|
+ "X KEEPS 30 DAYS of direct-message history and offers no date filter, so a short list can mean an old conversation rather than a quiet inbox — say so rather than reporting silence. "
|
|
8573
8615
|
+ "There is no conversation-list endpoint at X: this builds the thread list by reading events and grouping them, so a conversation whose last message is older than the page you pulled will not appear. "
|
|
8574
|
-
+ "Needs X connected, AND an X account connected before direct messages shipped must be reconnected once (
|
|
8616
|
+
+ "Needs X connected, AND an X account connected before direct messages shipped must be reconnected once (Connectors > X) — X only grants permissions at authorize time.",
|
|
8575
8617
|
inputSchema: {
|
|
8576
8618
|
maxResults: z.number().optional().describe('how many messages to pull, 1-100 (default 10) — EVERY ONE IS BILLED, and omitting it would make X return 100'),
|
|
8577
8619
|
conversationId: z.string().optional().describe('read just this conversation (from a previous call). One-to-one ids look like "12-345"; group ids are one long number.'),
|
|
@@ -8600,7 +8642,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8600
8642
|
description: "Send ONE direct message from the connected X account, either into an existing conversation (conversationId) or to a named account (participantId — the NUMERIC id, not an @handle; sending to someone with no existing thread starts one). "
|
|
8601
8643
|
+ "THIS IS A PRIVATE MESSAGE TO A REAL PERSON AND IT CANNOT BE TAKEN BACK: show the user the exact wording and the exact recipient and get an explicit yes BEFORE calling. Never send the same message to several people — send one, to one named recipient. "
|
|
8602
8644
|
+ "X may refuse a message to someone whose settings do not accept DMs from accounts they do not follow; that refusal comes from X, not from Hermoso. Costs credits per send. "
|
|
8603
|
-
+ "Needs X connected with direct-message access — an X account connected before DMs shipped must be reconnected once (
|
|
8645
|
+
+ "Needs X connected with direct-message access — an X account connected before DMs shipped must be reconnected once (Connectors > X).",
|
|
8604
8646
|
inputSchema: {
|
|
8605
8647
|
text: z.string().describe('the message, exactly as it should be sent'),
|
|
8606
8648
|
conversationId: z.string().optional().describe('reply into an existing conversation (from list_x_dms)'),
|
|
@@ -8622,7 +8664,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8622
8664
|
server.group('channels');
|
|
8623
8665
|
server.registerTool('list_pinterest_boards', {
|
|
8624
8666
|
title: 'List Pinterest boards',
|
|
8625
|
-
description: 'List the boards on the user’s connected Pinterest account — id, name, privacy and pin count. ALWAYS call this before post_to_pinterest and let the USER pick: Pinterest requires a board and Hermoso never chooses one for them. Read-only, 0 credits. Needs Pinterest connected (
|
|
8667
|
+
description: 'List the boards on the user’s connected Pinterest account — id, name, privacy and pin count. ALWAYS call this before post_to_pinterest and let the USER pick: Pinterest requires a board and Hermoso never chooses one for them. Read-only, 0 credits. Needs Pinterest connected (Connectors > Pinterest).',
|
|
8626
8668
|
inputSchema: { privacy: z.enum(['ALL', 'PUBLIC', 'PROTECTED', 'SECRET']).optional().describe('filter by board privacy; default is everything the connection can see') },
|
|
8627
8669
|
outputSchema: { count: z.number().optional(), boards: z.array(z.object({ id: z.string().optional(), name: z.string().optional(), privacy: z.string().optional(), description: z.string().optional(), pins: z.number().nullable().optional(), followers: z.number().nullable().optional() })).optional() },
|
|
8628
8670
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -8770,7 +8812,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8770
8812
|
server.group('channels');
|
|
8771
8813
|
server.registerTool('post_to_pinterest', {
|
|
8772
8814
|
title: 'Create a Pin',
|
|
8773
|
-
description: 'Use this when the user wants to publish a Pin to Pinterest. Publishing costs no credits. Create a Pin on one of the user’s Pinterest boards from any finished visual they have — image, video, or a 2–5 slide CAROUSEL (pass the slides in order as imageUrls[] and Pinterest publishes one swipeable Pin). Title, description and destination link ride along. The link is what makes a Pin drive traffic, so ask for it rather than omitting it, and the description is the text Pinterest search actually reads. boardId is REQUIRED: call list_pinterest_boards first and let the user choose. This PUBLISHES to their public profile — confirm the board, title and link before calling. Video Pins take a minute or two while Pinterest ingests the file. Needs Pinterest connected (
|
|
8815
|
+
description: 'Use this when the user wants to publish a Pin to Pinterest. Publishing costs no credits. Create a Pin on one of the user’s Pinterest boards from any finished visual they have — image, video, or a 2–5 slide CAROUSEL (pass the slides in order as imageUrls[] and Pinterest publishes one swipeable Pin). Title, description and destination link ride along. The link is what makes a Pin drive traffic, so ask for it rather than omitting it, and the description is the text Pinterest search actually reads. boardId is REQUIRED: call list_pinterest_boards first and let the user choose. This PUBLISHES to their public profile — confirm the board, title and link before calling. Video Pins take a minute or two while Pinterest ingests the file. Needs Pinterest connected (Connectors > Pinterest).',
|
|
8774
8816
|
inputSchema: {
|
|
8775
8817
|
account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the profile has more than one pinterest account connected (several and none named is refused by name, never guessed); omit when there is one."),
|
|
8776
8818
|
...HOOK_ATTR,
|
|
@@ -8984,7 +9026,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8984
9026
|
server.group('channels');
|
|
8985
9027
|
server.registerTool('list_business_locations', {
|
|
8986
9028
|
title: 'List Google business listings',
|
|
8987
|
-
description: 'List the Google Business Profile listings SHARED WITH THIS PROFILE — id, title, address, website and Maps link. These are the only listings anything here can post to or read: one Google login often manages several businesses (an agency manages its clients’), and the user ticks which of them belong to this profile. Call this before posting whenever more than one is shared and let the USER pick: a Post on the wrong storefront is a public mistake Hermoso will not make for them. If nothing is shared, ask the user to choose — list_connector_accounts("google_business") then set_connector_accounts — and never name or guess a listing. Read-only, 0 credits. Needs Google Business Profile connected (
|
|
9029
|
+
description: 'List the Google Business Profile listings SHARED WITH THIS PROFILE — id, title, address, website and Maps link. These are the only listings anything here can post to or read: one Google login often manages several businesses (an agency manages its clients’), and the user ticks which of them belong to this profile. Call this before posting whenever more than one is shared and let the USER pick: a Post on the wrong storefront is a public mistake Hermoso will not make for them. If nothing is shared, ask the user to choose — list_connector_accounts("google_business") then set_connector_accounts — and never name or guess a listing. Read-only, 0 credits. Needs Google Business Profile connected (Connectors > Google Business Profile).',
|
|
8988
9030
|
inputSchema: {},
|
|
8989
9031
|
outputSchema: { count: z.number().optional(), shared: z.number().optional(), missing: z.array(z.string()).optional(), locations: z.array(z.object({ id: z.string().optional(), account: z.string().optional(), accountName: z.string().optional(), title: z.string().optional(), address: z.string().optional(), website: z.string().optional(), phone: z.string().optional(), mapsUrl: z.string().optional(), canPost: z.boolean().optional() })).optional() },
|
|
8990
9032
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -8992,13 +9034,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8992
9034
|
// THE SHARED SET, NEVER THE ROSTER. /api/google-business/locations walks every Business Profile account the
|
|
8993
9035
|
// Google login manages; it is the owner-only PICKER route, reached through list_connector_accounts alone.
|
|
8994
9036
|
const d = await apiGet('/api/google-business/shared-locations');
|
|
8995
|
-
if (!d.shared) return ok('No Google Business listing is shared with this profile yet, so there is nothing to post to. The user chooses which listings belong to this profile:
|
|
8996
|
-
if (!d.count) return ok(`The ${d.shared === 1 ? 'listing' : `${d.shared} listings`} shared with this profile ${d.shared === 1 ? 'is' : 'are'} no longer reachable on this Google connection — removed, or the connected account lost manager access. Ask the user to re-pick under
|
|
9037
|
+
if (!d.shared) return ok('No Google Business listing is shared with this profile yet, so there is nothing to post to. The user chooses which listings belong to this profile: Connectors ▸ Google Business Profile ▸ Manage accounts, or list_connector_accounts then set_connector_accounts. Do not name or guess one.', d);
|
|
9038
|
+
if (!d.count) return ok(`The ${d.shared === 1 ? 'listing' : `${d.shared} listings`} shared with this profile ${d.shared === 1 ? 'is' : 'are'} no longer reachable on this Google connection — removed, or the connected account lost manager access. Ask the user to re-pick under Connectors ▸ Google Business Profile ▸ Manage accounts (or call list_connector_accounts with provider google_business, then set_connector_accounts).`, d);
|
|
8997
9039
|
return ok(`${d.count} listing${d.count === 1 ? '' : 's'} shared with this profile: ${(d.locations || []).map(l => `${l.title || '(untitled)'} (${l.id})`).join(', ')}.${d.count > 1 ? ' Show these to the user and let them pick which one to post to.' : ''} These are the ONLY listings usable here; any other listing on this Google account is not shared and must never be named or offered.`, d);
|
|
8998
9040
|
}));
|
|
8999
9041
|
server.registerTool('post_to_google_business', {
|
|
9000
9042
|
title: 'Post to Google Business Profile',
|
|
9001
|
-
description: 'Publish a Post to the brand’s Google Business Profile — the panel that appears on Google Search and Maps for the business. Text, optionally ONE PHOTO, and a call-to-action button. Google’s Posts API accepts NO VIDEO, so pass a still image. This PUBLISHES immediately and publicly on the business listing — show the user the exact text, photo and button and get an explicit yes BEFORE calling. If the account manages several listings, call list_business_locations first and pass locationId. EVENT and OFFER posts both REQUIRE a title and a start date (Google’s rule). On an OFFER, Google IGNORES the button’s link — pass redeemOnlineUrl instead. A CALL button dials the number on the listing and takes no link. Needs Google Business Profile connected (
|
|
9043
|
+
description: 'Publish a Post to the brand’s Google Business Profile — the panel that appears on Google Search and Maps for the business. Text, optionally ONE PHOTO, and a call-to-action button. Google’s Posts API accepts NO VIDEO, so pass a still image. This PUBLISHES immediately and publicly on the business listing — show the user the exact text, photo and button and get an explicit yes BEFORE calling. If the account manages several listings, call list_business_locations first and pass locationId. EVENT and OFFER posts both REQUIRE a title and a start date (Google’s rule). On an OFFER, Google IGNORES the button’s link — pass redeemOnlineUrl instead. A CALL button dials the number on the listing and takes no link. Needs Google Business Profile connected (Connectors > Google Business Profile).',
|
|
9002
9044
|
inputSchema: {
|
|
9003
9045
|
...HOOK_ATTR,
|
|
9004
9046
|
summary: z.string().optional().describe('the body text of the Post'),
|
|
@@ -9141,7 +9183,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9141
9183
|
// gbpAccountInfo, so these stay thin and no surface can carry a weaker gate than another.
|
|
9142
9184
|
server.registerTool('get_business_location', {
|
|
9143
9185
|
title: 'Read a Google Business Profile listing',
|
|
9144
|
-
description: 'Read everything Google holds on one of the brand’s Google Business Profile listings — business name, address, phone numbers, website, categories, description, regular and special hours, service area, labels, store code, open state, and whether the listing can carry a Post at all. This is the listing AS THE MERCHANT LAST SET IT, which is exactly what update_business_location edits; it can differ from what Google Maps shows today, because Google and the public can suggest changes on top. Call it before offering to change anything, and to answer “what does our Google listing actually say?”. Read-only, 0 credits. Needs Google Business Profile connected (
|
|
9186
|
+
description: 'Read everything Google holds on one of the brand’s Google Business Profile listings — business name, address, phone numbers, website, categories, description, regular and special hours, service area, labels, store code, open state, and whether the listing can carry a Post at all. This is the listing AS THE MERCHANT LAST SET IT, which is exactly what update_business_location edits; it can differ from what Google Maps shows today, because Google and the public can suggest changes on top. Call it before offering to change anything, and to answer “what does our Google listing actually say?”. Read-only, 0 credits. Needs Google Business Profile connected (Connectors > Google Business Profile).',
|
|
9145
9187
|
inputSchema: { locationId: z.string().optional().describe('which listing, e.g. \'locations/123\' from list_business_locations — only needed when more than one is shared with this profile') },
|
|
9146
9188
|
outputSchema: { locationId: z.string().optional(), account: z.string().optional(), title: z.string().optional(), readMask: z.string().optional(), location: z.record(z.any()).optional() },
|
|
9147
9189
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -9203,7 +9245,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9203
9245
|
};
|
|
9204
9246
|
server.registerTool('post_to_youtube', {
|
|
9205
9247
|
title: 'Post a video to YouTube',
|
|
9206
|
-
description: 'Use this when the user wants to upload a video or Short to YouTube. Publishing costs no credits. Publish a finished video to the brand’s connected YouTube channel. Pass a Hermoso render URL (or an upload_file url for a local/external file). PUBLISHES PUBLICLY BY DEFAULT: a plain "post this to YouTube" puts it ON the channel (confirm the title with the user, as for any publish) and notifies subscribers as YouTube does. Pass the privacy the user states instead: "unlisted" (link-only) or "private" (eyes-only). A video meant to run as a YouTube/Google AD should go up "unlisted" — private videos CANNOT be used as ads. SCHEDULE it with publishAt, FILE it under the right categoryId (the default 22 "People & Blogs" is wrong for most ads), SUBSCRIBER NOTIFICATIONS FOLLOW PRIVACY — a public publish announces the video to the channel’s subscribers (YouTube’s own default), while unlisted/private uploads stay quiet; pass notifySubscribers explicitly to override either way. THUMBNAIL: pass thumbnailUrl, or a frame of the video is set for free; a thumbnail YouTube refuses never fails the upload, and thumbnailNote says why. YOUTUBE MUSIC: YouTube’s API takes no music track. Only when the user wants a track from YouTube’s own Audio Library: BEFORE uploading, tell them plainly it will go up UNLISTED (not on their channel, nobody sees it) so they can add the track in YouTube Studio on desktop (Content > the video > Editor > Audio), and that THEY must then switch it to Public there themselves; get their yes, then pass privacy:"unlisted". Never choose unlisted for music on your own. Needs a connected YouTube channel (
|
|
9248
|
+
description: 'Use this when the user wants to upload a video or Short to YouTube. Publishing costs no credits. Publish a finished video to the brand’s connected YouTube channel. Pass a Hermoso render URL (or an upload_file url for a local/external file). PUBLISHES PUBLICLY BY DEFAULT: a plain "post this to YouTube" puts it ON the channel (confirm the title with the user, as for any publish) and notifies subscribers as YouTube does. Pass the privacy the user states instead: "unlisted" (link-only) or "private" (eyes-only). A video meant to run as a YouTube/Google AD should go up "unlisted" — private videos CANNOT be used as ads. SCHEDULE it with publishAt, FILE it under the right categoryId (the default 22 "People & Blogs" is wrong for most ads), SUBSCRIBER NOTIFICATIONS FOLLOW PRIVACY — a public publish announces the video to the channel’s subscribers (YouTube’s own default), while unlisted/private uploads stay quiet; pass notifySubscribers explicitly to override either way. THUMBNAIL: pass thumbnailUrl, or a frame of the video is set for free; a thumbnail YouTube refuses never fails the upload, and thumbnailNote says why. YOUTUBE MUSIC: YouTube’s API takes no music track. Only when the user wants a track from YouTube’s own Audio Library: BEFORE uploading, tell them plainly it will go up UNLISTED (not on their channel, nobody sees it) so they can add the track in YouTube Studio on desktop (Content > the video > Editor > Audio), and that THEY must then switch it to Public there themselves; get their yes, then pass privacy:"unlisted". Never choose unlisted for music on your own. Needs a connected YouTube channel (Connectors > YouTube).',
|
|
9207
9249
|
inputSchema: {
|
|
9208
9250
|
account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the profile has more than one youtube account connected (several and none named is refused by name, never guessed); omit when there is one."),
|
|
9209
9251
|
...HOOK_ATTR,
|
|
@@ -9665,14 +9707,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9665
9707
|
}));
|
|
9666
9708
|
server.registerTool('hide_x_reply', {
|
|
9667
9709
|
title: 'Hide or unhide a reply on X',
|
|
9668
|
-
description: 'Hide a reply under the brand’s own X post (hidden:false to unhide) — spam and abuse moderation. A hidden reply is not deleted; it moves behind "View hidden replies". Only works on replies in a conversation the connected account started. Needs the tweet.moderate.write permission: an X connection made before 2026-10-27 must be reconnected once under
|
|
9710
|
+
description: 'Hide a reply under the brand’s own X post (hidden:false to unhide) — spam and abuse moderation. A hidden reply is not deleted; it moves behind "View hidden replies". Only works on replies in a conversation the connected account started. Needs the tweet.moderate.write permission: an X connection made before 2026-10-27 must be reconnected once under Connectors > X (the tool says so without calling X). Costs credits (X bills per API call).',
|
|
9669
9711
|
inputSchema: { replyId: z.string().describe('the REPLY’s id, or its x.com link'), hidden: z.boolean().optional().describe('default true; false unhides') },
|
|
9670
9712
|
outputSchema: { replyId: z.string().optional(), hidden: z.boolean().optional(), costCredits: z.number().optional(), note: z.string().optional() },
|
|
9671
9713
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
9672
9714
|
}, wrap(async (a = {}) => { const d = await apiPost('/api/x/hide-reply', a); return ok(d.note, d); }));
|
|
9673
9715
|
server.registerTool('list_x_blocks', {
|
|
9674
9716
|
title: 'Who the connected X account has blocked',
|
|
9675
|
-
description: 'The accounts the connected X account has BLOCKED, with handle, name, bio and follower count. READ-ONLY: X refuses the block and unblock WRITE on the access tier Hermoso\'s app runs on (measured 2026-09-15: X\'s own "attached to a Project" refusal with the scope granted), so blocking itself is done on x.com and there is no block tool here. Needs the block.read permission: an X connection made before 2026-09-15 must be reconnected once under
|
|
9717
|
+
description: 'The accounts the connected X account has BLOCKED, with handle, name, bio and follower count. READ-ONLY: X refuses the block and unblock WRITE on the access tier Hermoso\'s app runs on (measured 2026-09-15: X\'s own "attached to a Project" refusal with the scope granted), so blocking itself is done on x.com and there is no block tool here. Needs the block.read permission: an X connection made before 2026-09-15 must be reconnected once under Connectors > X. Costs credits (X bills per API call).',
|
|
9676
9718
|
inputSchema: { maxResults: z.number().optional().describe('1–1000, default 100'), paginationToken: z.string().optional().describe('next page token from the previous reply') },
|
|
9677
9719
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
9678
9720
|
}, wrap(async (a = {}) => {
|
|
@@ -9682,7 +9724,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9682
9724
|
}));
|
|
9683
9725
|
// X LIVE BROADCASTS (2026-09-21) — 16 operations in X's OpenAPI 2.168 as five tools; gates and payloads live in
|
|
9684
9726
|
// lib/x-broadcast.mjs on the server, so every refusal below is the server's own sentence and costs nothing.
|
|
9685
|
-
const XB_SCOPE_NOTE = ' Needs X connected with the broadcast permissions added 2026-09-21: an X connection made before then must be reconnected once under
|
|
9727
|
+
const XB_SCOPE_NOTE = ' Needs X connected with the broadcast permissions added 2026-09-21: an X connection made before then must be reconnected once under Connectors > X (the tool says so without calling X). Costs credits (X bills per call).';
|
|
9686
9728
|
server.registerTool('list_x_broadcasts', {
|
|
9687
9729
|
title: 'List X live broadcasts',
|
|
9688
9730
|
description: "The connected X account's LIVE VIDEO broadcasts: past and current ones (title, state, share URL, live and total viewers, start/end) and SCHEDULED ones (start, end, whether it goes live automatically or waits for go_live). kind 'all' (default) reads both, 'live' or 'scheduled' one; pass id for a single broadcast. Read-only." + XB_SCOPE_NOTE,
|
|
@@ -9791,7 +9833,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9791
9833
|
// X CHAT (2026-10-02) — the /2/chat/* endpoints X's OpenAPI 2.169 added, as four tools; gates and payloads live in
|
|
9792
9834
|
// lib/x-chat.mjs on the server, so every refusal below is the server's own sentence and costs nothing. X Chat is
|
|
9793
9835
|
// END-TO-END ENCRYPTED: Hermoso holds no conversation key, so nothing here reads or writes message text or group names.
|
|
9794
|
-
const XC_SCOPE_NOTE = ' Needs X connected with direct-message access: an X connection made before 2026-08-25 must be reconnected once under
|
|
9836
|
+
const XC_SCOPE_NOTE = ' Needs X connected with direct-message access: an X connection made before 2026-08-25 must be reconnected once under Connectors > X (the tool says so without calling X). Costs credits (X bills per call).';
|
|
9795
9837
|
const xcSettingsLine = (s = {}) => `who can message: ${s.allowDmsFrom ?? '?'}${s.alwaysAllowSubscribers ? ' (subscribers always)' : ''}; quality filter ${s.qualityFilter == null ? '?' : s.qualityFilter ? 'on' : 'off'}; read receipts ${s.readReceipts == null ? '?' : s.readReceipts ? 'on' : 'off'}; calls ${s.callsEnabled == null ? '?' : s.callsEnabled ? 'on' : 'off'}`;
|
|
9796
9838
|
server.registerTool('x_chat_settings', {
|
|
9797
9839
|
title: 'Read the X Chat privacy settings',
|
|
@@ -9887,7 +9929,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9887
9929
|
}));
|
|
9888
9930
|
server.registerTool('x_account', {
|
|
9889
9931
|
title: 'Read the connected X account',
|
|
9890
|
-
description: 'ACCOUNT-LEVEL numbers for the connected X account \u2014 FOLLOWERS, following, total posts, listed count, media count \u2014 plus display name, bio, location, verified status and when the account was created. This is the tool for \u201chow many followers do we have on X\u201d, \u201cis our X growing\u201d, or to confirm whose account is linked before posting; for a single post use x_post_metrics or x_post_insights instead. A counter X does not return comes back under `absent` with the reason and is NEVER reported as zero. Read-only. Rides the account lookup Hermoso already makes when it posts \u2014 X bills per request and not per field \u2014 so it costs at most one User: Read and is free on a cached lookup. Needs X connected (
|
|
9932
|
+
description: 'ACCOUNT-LEVEL numbers for the connected X account \u2014 FOLLOWERS, following, total posts, listed count, media count \u2014 plus display name, bio, location, verified status and when the account was created. This is the tool for \u201chow many followers do we have on X\u201d, \u201cis our X growing\u201d, or to confirm whose account is linked before posting; for a single post use x_post_metrics or x_post_insights instead. A counter X does not return comes back under `absent` with the reason and is NEVER reported as zero. Read-only. Rides the account lookup Hermoso already makes when it posts \u2014 X bills per request and not per field \u2014 so it costs at most one User: Read and is free on a cached lookup. Needs X connected (Connectors > X).',
|
|
9891
9933
|
inputSchema: {},
|
|
9892
9934
|
outputSchema: { id: z.string().optional(), username: z.string().optional(), name: z.string().nullable().optional(), verified: z.boolean().optional(), verifiedType: z.string().nullable().optional(), protected: z.boolean().optional(), bio: z.string().nullable().optional(), location: z.string().nullable().optional(), avatar: z.string().nullable().optional(), createdAt: z.string().nullable().optional(), subscriptionType: z.string().nullable().optional(), metrics: z.record(z.number()).optional(), absent: z.record(z.string()).optional(), costCredits: z.number().optional() },
|
|
9893
9935
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -10089,7 +10131,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10089
10131
|
}));
|
|
10090
10132
|
server.registerTool('bluesky_account', {
|
|
10091
10133
|
title: 'Read a Bluesky account',
|
|
10092
|
-
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 (
|
|
10134
|
+
description: 'ACCOUNT-LEVEL numbers for a Bluesky account \u2014 FOLLOWERS, following and total posts \u2014 plus display name, bio, avatar and when it was created. With no argument it reads the CONNECTED account, which is the answer to \u201chow many followers do we have on Bluesky\u201d; pass `actor` (a handle or DID) to read any other account, which is free and is how you size a competitor there. The reply says which one it read: `self` is true only for the connected account, compared on the DID because a Bluesky handle can change. A counter Bluesky does not return comes back under `absent` with the reason and is NEVER reported as zero \u2014 all three are optional in the lexicon. Read-only, 0 credits, no extra permission. Needs Bluesky connected (Connectors > Bluesky, or connect_connector).',
|
|
10093
10135
|
inputSchema: { actor: z.string().optional().describe('a Bluesky handle (hermoso.ai or @hermoso.ai) or a did:plc:\u2026 \u2014 omit it to read the connected account') },
|
|
10094
10136
|
outputSchema: { did: z.string().nullable().optional(), handle: z.string().nullable().optional(), displayName: z.string().nullable().optional(), bio: z.string().nullable().optional(), avatar: z.string().nullable().optional(), banner: z.string().nullable().optional(), createdAt: z.string().nullable().optional(), indexedAt: z.string().nullable().optional(), self: z.boolean().optional(), metrics: z.record(z.number()).optional(), absent: z.record(z.string()).optional(), costCredits: z.number().optional() },
|
|
10095
10137
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -10211,7 +10253,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10211
10253
|
}));
|
|
10212
10254
|
server.registerTool('tiktok_creator_info', {
|
|
10213
10255
|
title: 'Read the connected TikTok creator’s posting options',
|
|
10214
|
-
description: 'Read the connected TikTok creator’s REAL posting options BEFORE posting: which privacy levels THEY are allowed to use, whether comments / duet / stitch are available on their account, their maximum video length, and their nickname. TikTok REQUIRES that the user is shown these actual options and picks a privacy level — never assume or default one. Call this first, show the options, get the user’s pick, then call post_to_tiktok with destination:"post". The SAME privacy options govern PHOTO posts (slideshows), not just video — TikTok takes the same four levels on both. Needs TikTok connected (
|
|
10256
|
+
description: 'Read the connected TikTok creator’s REAL posting options BEFORE posting: which privacy levels THEY are allowed to use, whether comments / duet / stitch are available on their account, their maximum video length, and their nickname. TikTok REQUIRES that the user is shown these actual options and picks a privacy level — never assume or default one. Call this first, show the options, get the user’s pick, then call post_to_tiktok with destination:"post". The SAME privacy options govern PHOTO posts (slideshows), not just video — TikTok takes the same four levels on both. Needs TikTok connected (Connectors > TikTok).',
|
|
10215
10257
|
inputSchema: {},
|
|
10216
10258
|
outputSchema: { nickname: z.string().nullable().optional(), username: z.string().nullable().optional(), avatar: z.string().nullable().optional(), privacyOptions: z.array(z.string()).optional(), commentDisabled: z.boolean().optional(), duetDisabled: z.boolean().optional(), stitchDisabled: z.boolean().optional(), maxDurationSeconds: z.number().nullable().optional() },
|
|
10217
10259
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -10222,7 +10264,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10222
10264
|
server.group('channels');
|
|
10223
10265
|
server.registerTool('post_to_tiktok', {
|
|
10224
10266
|
title: 'Post a video or photo post to TikTok',
|
|
10225
|
-
description: 'Use this when the user wants to post a video or photo post to TikTok now. Publishing costs no credits. Publish to the user’s connected TikTok account — a finished VIDEO, or a PHOTO POST (TikTok’s photo/slideshow format). A photo post carries 1 to 35 images and ONE image is simply a one-slide photo post, so there is nothing special to do for a single picture: pass imageUrls, in the order the slides should appear, and optionally coverIndex. Pass videoUrl for a video. Never pass both — TikTok has no mixed post. TWO destinations. destination:"post" (THE DEFAULT) publishes it LIVE on their profile: TikTok requires the user to CHOOSE the privacy themselves (no default is allowed), so call tiktok_creator_info, show them their real privacy options, and get their choice and an explicit yes before calling. destination:"draft" is ONLY for when the user asks for a draft, or wants to add a TikTok sound or trending audio (TikTok’s API takes no sound for a VIDEO): BEFORE sending, tell them plainly it lands in their TikTok inbox as a DRAFT, that they add the sound in TikTok’s editor, and that THEY must publish it from the TikTok app — nothing goes live until they do. Never pick draft on your own. A photo post published with destination:"post" gets a TikTok-recommended track automatically (autoAddMusic, default on) that they can change in the app. Pass Hermoso render URLs (or upload_file urls for local/external files). Needs TikTok connected (
|
|
10267
|
+
description: 'Use this when the user wants to post a video or photo post to TikTok now. Publishing costs no credits. Publish to the user’s connected TikTok account — a finished VIDEO, or a PHOTO POST (TikTok’s photo/slideshow format). A photo post carries 1 to 35 images and ONE image is simply a one-slide photo post, so there is nothing special to do for a single picture: pass imageUrls, in the order the slides should appear, and optionally coverIndex. Pass videoUrl for a video. Never pass both — TikTok has no mixed post. TWO destinations. destination:"post" (THE DEFAULT) publishes it LIVE on their profile: TikTok requires the user to CHOOSE the privacy themselves (no default is allowed), so call tiktok_creator_info, show them their real privacy options, and get their choice and an explicit yes before calling. destination:"draft" is ONLY for when the user asks for a draft, or wants to add a TikTok sound or trending audio (TikTok’s API takes no sound for a VIDEO): BEFORE sending, tell them plainly it lands in their TikTok inbox as a DRAFT, that they add the sound in TikTok’s editor, and that THEY must publish it from the TikTok app — nothing goes live until they do. Never pick draft on your own. A photo post published with destination:"post" gets a TikTok-recommended track automatically (autoAddMusic, default on) that they can change in the app. Pass Hermoso render URLs (or upload_file urls for local/external files). Needs TikTok connected (Connectors > TikTok).',
|
|
10226
10268
|
inputSchema: {
|
|
10227
10269
|
account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the profile has more than one tiktok account connected (several and none named is refused by name, never guessed); omit when there is one."),
|
|
10228
10270
|
...HOOK_ATTR,
|
|
@@ -10256,7 +10298,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10256
10298
|
// `tiktok_post_status` on 2026-10-01 and dead-ended. Same read as the web app's /api/tiktok/publish-status.
|
|
10257
10299
|
server.registerTool('tiktok_post_status', {
|
|
10258
10300
|
title: 'Check whether a TikTok post finished publishing',
|
|
10259
|
-
description: 'Follow a TikTok post that post_to_tiktok sent but TikTok was still processing (it came back pending:true). Pass the publishId it returned. Answers one of three states: done (PUBLISH_COMPLETE, with the public `url` once TikTok\u2019s moderation has named it; `urlStatus` is ready, pending, private or draft), failed (with TikTok\u2019s reason), or still processing, which is normal for a few minutes and is NOT a failure. Never post the video again while it is processing. Free, read-only. Needs TikTok connected (
|
|
10301
|
+
description: 'Follow a TikTok post that post_to_tiktok sent but TikTok was still processing (it came back pending:true). Pass the publishId it returned. Answers one of three states: done (PUBLISH_COMPLETE, with the public `url` once TikTok\u2019s moderation has named it; `urlStatus` is ready, pending, private or draft), failed (with TikTok\u2019s reason), or still processing, which is normal for a few minutes and is NOT a failure. Never post the video again while it is processing. Free, read-only. Needs TikTok connected (Connectors > TikTok).',
|
|
10260
10302
|
inputSchema: {
|
|
10261
10303
|
publishId: z.string().describe('the publishId post_to_tiktok returned'),
|
|
10262
10304
|
account: z.string().optional().describe('which connected TikTok account the post went out on (@handle or id from list_connector_accounts) when the profile has more than one'),
|
|
@@ -10273,7 +10315,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10273
10315
|
server.group('channel_admin');
|
|
10274
10316
|
server.registerTool('tiktok_account', {
|
|
10275
10317
|
title: 'Read the connected TikTok account',
|
|
10276
|
-
description: 'Read the connected TikTok account: display name, username, bio, verified status, and their follower / following / total-likes / video counts. Use it for “how many followers do we have on TikTok”, “how is our TikTok doing”, or to confirm whose account is linked before posting. Read-only. Needs TikTok connected (
|
|
10318
|
+
description: 'Read the connected TikTok account: display name, username, bio, verified status, and their follower / following / total-likes / video counts. Use it for “how many followers do we have on TikTok”, “how is our TikTok doing”, or to confirm whose account is linked before posting. Read-only. Needs TikTok connected (Connectors > TikTok).',
|
|
10277
10319
|
inputSchema: {},
|
|
10278
10320
|
outputSchema: { openId: z.string().nullable().optional(), displayName: z.string().nullable().optional(), username: z.string().nullable().optional(), avatar: z.string().nullable().optional(), bio: z.string().nullable().optional(), profileLink: z.string().nullable().optional(), verified: z.boolean().optional(), followers: z.number().nullable().optional(), following: z.number().nullable().optional(), likes: z.number().nullable().optional(), videos: z.number().nullable().optional(), partial: z.boolean().optional() },
|
|
10279
10321
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -11244,7 +11286,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11244
11286
|
}));
|
|
11245
11287
|
server.registerTool('list_meta_lead_forms', {
|
|
11246
11288
|
title: 'List Meta instant (lead) forms',
|
|
11247
|
-
description: 'List the INSTANT LEAD FORMS on a connected Facebook Page — id, name, status, how many leads each has collected and what each one asks. This is where the formId every other lead tool needs comes from, and calling it before create_meta_lead_form is how you avoid building a duplicate. Read-only, free. NEEDS the pages_manage_ads permission, which Hermoso’s Meta consent screen asks for. If Meta answers "Requires pages_manage_ads", the user must RECONNECT Meta under
|
|
11289
|
+
description: 'List the INSTANT LEAD FORMS on a connected Facebook Page — id, name, status, how many leads each has collected and what each one asks. This is where the formId every other lead tool needs comes from, and calling it before create_meta_lead_form is how you avoid building a duplicate. Read-only, free. NEEDS the pages_manage_ads permission, which Hermoso’s Meta consent screen asks for. If Meta answers "Requires pages_manage_ads", the user must RECONNECT Meta under Connectors > Meta — a connection made before that permission was added cannot gain it by retrying. Call the tool rather than pre-refusing: any refusal comes from Meta and names the one thing that fixes it.',
|
|
11248
11290
|
inputSchema: {
|
|
11249
11291
|
pageId: z.string().optional().describe('which connected Page (from list_meta_pages) — required only if the profile has more than one'),
|
|
11250
11292
|
limit: z.number().optional().describe('max rows (1–100, default 25)'),
|
|
@@ -11346,7 +11388,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11346
11388
|
server.group('channel_admin'); // Messenger marketing — the marker at the end of this block re-opens 'ads'
|
|
11347
11389
|
server.registerTool('list_messenger_subscribers', {
|
|
11348
11390
|
title: 'List Messenger marketing-message subscribers',
|
|
11349
|
-
description: 'The people who OPTED IN to marketing messages from a Facebook Page — their subscription tokens, the only address a paid Messenger marketing message can go to (there is no "message everyone"). Also reports whether Messenger marketing is turned on for this Meta connection. It is an OPTIONAL add-on, not part of the default Meta connect: when it is off, the Messenger marketing tools answer with the link that turns it on (
|
|
11391
|
+
description: 'The people who OPTED IN to marketing messages from a Facebook Page — their subscription tokens, the only address a paid Messenger marketing message can go to (there is no "message everyone"). Also reports whether Messenger marketing is turned on for this Meta connection. It is an OPTIONAL add-on, not part of the default Meta connect: when it is off, the Messenger marketing tools answer with the link that turns it on (Connectors > Meta > Enable Messenger marketing), never with \'reconnect Meta\'; Meta then asks the user to confirm the Pages and pick an ad account, and lists only ones their own Facebook profile fully controls. Free.',
|
|
11350
11392
|
inputSchema: {
|
|
11351
11393
|
pageId: z.string().optional().describe('Facebook Page id (from list_meta_pages); defaults to the one shared Page'),
|
|
11352
11394
|
limit: z.number().optional().describe('1–1000, default 100'),
|
|
@@ -11966,7 +12008,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11966
12008
|
}));
|
|
11967
12009
|
server.registerTool('list_google_ads_campaigns', {
|
|
11968
12010
|
title: 'List Google Ads accounts / campaigns',
|
|
11969
|
-
description: 'Read the connected Google Ads account(s). Call with NO customerId to list the accessible accounts (customerId + name + currency) — do this first to pick a target. Call WITH customerId to list that account’s campaigns (id, name, status, daily budget, channel) plus performance metrics (impressions, clicks, CTR, avg CPC, cost, conversions). Date window: datePreset (LAST_7_DAYS | LAST_30_DAYS | TODAY | THIS_MONTH | LAST_90_DAYS …) or since+until (YYYY-MM-DD). Read-only, free. Needs Google Ads connected (
|
|
12011
|
+
description: 'Read the connected Google Ads account(s). Call with NO customerId to list the accessible accounts (customerId + name + currency) — do this first to pick a target. Call WITH customerId to list that account’s campaigns (id, name, status, daily budget, channel) plus performance metrics (impressions, clicks, CTR, avg CPC, cost, conversions). Date window: datePreset (LAST_7_DAYS | LAST_30_DAYS | TODAY | THIS_MONTH | LAST_90_DAYS …) or since+until (YYYY-MM-DD). Read-only, free. Needs Google Ads connected (Connectors > Google Ads).',
|
|
11970
12012
|
inputSchema: {
|
|
11971
12013
|
customerId: z.string().optional().describe('10-digit account id (dashes ok) — omit to list accessible accounts'),
|
|
11972
12014
|
status: z.enum(['ENABLED', 'PAUSED', 'REMOVED']).optional().describe('filter campaigns by status'),
|
|
@@ -11989,7 +12031,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11989
12031
|
const lines = (d.accounts || []).map(c => `• ${c.name} (${c.customerId})`);
|
|
11990
12032
|
return ok((d.accounts || []).length
|
|
11991
12033
|
? `${d.accounts.length} Google Ads account(s) shared with this profile:\n${lines.join('\n')}\nPass a customerId to see its campaigns.`
|
|
11992
|
-
: 'No Google Ads account is shared with this profile yet — choose which ones it may use in
|
|
12034
|
+
: 'No Google Ads account is shared with this profile yet — choose which ones it may use in Connectors ▸ Google Ads ▸ Manage accounts (or with set_connector_accounts).', d);
|
|
11993
12035
|
}
|
|
11994
12036
|
const d = await apiGet('/api/google-ads/campaigns', { customerId: a.customerId, status: a.status, datePreset: a.datePreset, since: a.since, until: a.until, metrics: a.metrics === false ? 'false' : undefined, loginCustomerId: a.loginCustomerId, limit: a.limit });
|
|
11995
12037
|
const lines = (d.campaigns || []).map(c => `• ${c.name} (${c.id}) — ${c.status}${c.dailyBudgetUsd != null ? `, ${moneyText(c.dailyBudget ?? c.dailyBudgetUsd, c.currency || d.currency)}/day` : ''}${c.metrics ? `, ${c.metrics.impressions} impr · ${c.metrics.clicks} clk · ${moneyText(c.metrics.cost ?? c.metrics.costUsd, c.currency || d.currency)} spend · ${c.metrics.conversions} conv` : ''}`);
|
|
@@ -12816,7 +12858,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12816
12858
|
// of these says so, and list_analytics_properties exists to RESOLVE it rather than making the user go find it.
|
|
12817
12859
|
server.registerTool('list_analytics_properties', {
|
|
12818
12860
|
title: 'List the GA4 properties shared with this profile',
|
|
12819
|
-
description: 'The GA4 properties SHARED WITH THIS PROFILE, with their numeric property ids, display names and the Analytics account each sits under. CALL THIS FIRST — every other Analytics tool needs a PROPERTY ID, which is NUMERIC (e.g. 123456789) and is NOT the "G-XXXXXXX" Measurement ID people usually know from their tracking snippet; the API accepts the numeric id and nothing else. Resolve the property yourself from this list instead of asking the user to go and find one, and only ask when two names are genuinely ambiguous. THIS IS NOT EVERY PROPERTY THE GOOGLE ACCOUNT CAN SEE, deliberately: Analytics access is handed out freely, so one login often has Viewer on many different clients\' properties, and the user ticks which ones belong to THIS profile. Only ticked properties can be reported on, and any other one is refused by name. An empty list means nothing is ticked yet — say so and point the user at
|
|
12861
|
+
description: 'The GA4 properties SHARED WITH THIS PROFILE, with their numeric property ids, display names and the Analytics account each sits under. CALL THIS FIRST — every other Analytics tool needs a PROPERTY ID, which is NUMERIC (e.g. 123456789) and is NOT the "G-XXXXXXX" Measurement ID people usually know from their tracking snippet; the API accepts the numeric id and nothing else. Resolve the property yourself from this list instead of asking the user to go and find one, and only ask when two names are genuinely ambiguous. THIS IS NOT EVERY PROPERTY THE GOOGLE ACCOUNT CAN SEE, deliberately: Analytics access is handed out freely, so one login often has Viewer on many different clients\' properties, and the user ticks which ones belong to THIS profile. Only ticked properties can be reported on, and any other one is refused by name. An empty list means nothing is ticked yet — say so and point the user at Connectors > Google Analytics > Manage accounts (or call list_connector_accounts / set_connector_accounts with provider "google_analytics"); never name or guess a property. Read-only, 0 credits. Needs Google Analytics connected (Connectors > Google Analytics).',
|
|
12820
12862
|
inputSchema: {},
|
|
12821
12863
|
outputSchema: { properties: z.array(z.object({ property: z.string().optional(), displayName: z.string().optional(), account: z.string().optional(), propertyType: z.string().optional() })).optional(), count: z.number().optional(), shared: z.number().optional(), missing: z.array(z.string()).optional() },
|
|
12822
12864
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -12827,8 +12869,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12827
12869
|
// "nothing ticked" and "the ticked ones vanished" are different problems with different fixes — a single
|
|
12828
12870
|
// sentence covering both sends half of the users to the wrong place.
|
|
12829
12871
|
return ok(d.shared
|
|
12830
|
-
? `The ${d.shared} propert${d.shared === 1 ? 'y' : 'ies'} shared with this profile ${d.shared === 1 ? 'is' : 'are'} no longer visible to the connected Google account (access removed in GA4 ▸ Admin ▸ Property access management, or deleted). Ask the user to re-pick under
|
|
12831
|
-
: 'No GA4 property is shared with this profile yet, so there is nothing to read. Ask the user to pick which properties belong to this profile under
|
|
12872
|
+
? `The ${d.shared} propert${d.shared === 1 ? 'y' : 'ies'} shared with this profile ${d.shared === 1 ? 'is' : 'are'} no longer visible to the connected Google account (access removed in GA4 ▸ Admin ▸ Property access management, or deleted). Ask the user to re-pick under Connectors ▸ Google Analytics ▸ Manage accounts (or call list_connector_accounts with provider google_analytics, then set_connector_accounts).`
|
|
12873
|
+
: 'No GA4 property is shared with this profile yet, so there is nothing to read. Ask the user to pick which properties belong to this profile under Connectors ▸ Google Analytics ▸ Manage accounts (or call set_connector_accounts with provider "google_analytics"). Do not name or guess one.', d);
|
|
12832
12874
|
}));
|
|
12833
12875
|
server.registerTool('analytics_report', {
|
|
12834
12876
|
title: 'Run a GA4 report',
|
|
@@ -13286,7 +13328,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13286
13328
|
// and list_tag_manager_tags answers the second one explicitly.
|
|
13287
13329
|
server.registerTool('list_tag_manager_containers', {
|
|
13288
13330
|
title: 'List the Tag Manager containers shared with this profile',
|
|
13289
|
-
description: "The Google Tag Manager containers SHARED WITH THIS PROFILE, with the exact containerPath every other Tag Manager tool needs. CALL THIS FIRST. A container is addressed by PATH, \"accounts/<id>/containers/<id>\", never by the GTM-XXXXXX public id a human reads off the snippet: no Tag Manager method accepts the public id, so passing one is refused. Each row carries the public id beside the path so you can match what the user says to what the API wants. THIS IS NOT EVERY CONTAINER THE GOOGLE ACCOUNT CAN SEE: one agency login routinely administers every client's container, and a container is not data about a client, it is control of what runs on their website, so the user ticks which containers belong to THIS profile and only those are reachable. An empty list means nothing is ticked yet: say so and point the user at
|
|
13331
|
+
description: "The Google Tag Manager containers SHARED WITH THIS PROFILE, with the exact containerPath every other Tag Manager tool needs. CALL THIS FIRST. A container is addressed by PATH, \"accounts/<id>/containers/<id>\", never by the GTM-XXXXXX public id a human reads off the snippet: no Tag Manager method accepts the public id, so passing one is refused. Each row carries the public id beside the path so you can match what the user says to what the API wants. THIS IS NOT EVERY CONTAINER THE GOOGLE ACCOUNT CAN SEE: one agency login routinely administers every client's container, and a container is not data about a client, it is control of what runs on their website, so the user ticks which containers belong to THIS profile and only those are reachable. An empty list means nothing is ticked yet: say so and point the user at Connectors, Google Tag Manager, Manage accounts (or call list_connector_accounts with provider google_tag_manager, then set_connector_accounts). Never name or guess a container. Read-only, free. Read-only, 0 credits. Needs Google Tag Manager connected.",
|
|
13290
13332
|
inputSchema: {},
|
|
13291
13333
|
outputSchema: { containers: z.array(z.object({ containerPath: z.string().optional(), publicId: z.string().optional(), name: z.string().optional(), accountName: z.string().optional() })).optional(), count: z.number().optional(), shared: z.number().optional(), missing: z.array(z.string()).optional(), note: z.string().optional() },
|
|
13292
13334
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -13296,8 +13338,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13296
13338
|
if (rows.length) return ok(`${rows.length} Tag Manager container(s) shared with this profile:\n${rows.join('\n')}`, d);
|
|
13297
13339
|
// "nothing ticked" and "the ticked ones vanished" are different problems with different fixes.
|
|
13298
13340
|
return ok(d.shared
|
|
13299
|
-
? `The ${d.shared} container${d.shared === 1 ? '' : 's'} shared with this profile ${d.shared === 1 ? 'is' : 'are'} no longer visible to the connected Google account (its permission was removed in Tag Manager ▸ Admin ▸ User Management, or the container was deleted). Ask the user to re-pick under
|
|
13300
|
-
: 'No Tag Manager container is shared with this profile yet, so there is nothing to read. Ask the user to pick which containers belong to this profile under
|
|
13341
|
+
? `The ${d.shared} container${d.shared === 1 ? '' : 's'} shared with this profile ${d.shared === 1 ? 'is' : 'are'} no longer visible to the connected Google account (its permission was removed in Tag Manager ▸ Admin ▸ User Management, or the container was deleted). Ask the user to re-pick under Connectors ▸ Google Tag Manager ▸ Manage accounts (or call list_connector_accounts with provider google_tag_manager, then set_connector_accounts).`
|
|
13342
|
+
: 'No Tag Manager container is shared with this profile yet, so there is nothing to read. Ask the user to pick which containers belong to this profile under Connectors ▸ Google Tag Manager ▸ Manage accounts (or call set_connector_accounts with provider "google_tag_manager"). Do not name or guess one.', d);
|
|
13301
13343
|
}));
|
|
13302
13344
|
server.registerTool('list_tag_manager_tags', {
|
|
13303
13345
|
title: 'What is actually firing on the site',
|
|
@@ -13454,7 +13496,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13454
13496
|
|
|
13455
13497
|
server.registerTool('list_search_console_sites', {
|
|
13456
13498
|
title: 'List the Search Console properties shared with this profile',
|
|
13457
|
-
description: 'The Google Search Console properties SHARED WITH THIS PROFILE, each with the exact property string every other Search Console tool takes and the connected account\'s permission level on it. CALL THIS FIRST. A property is EITHER "sc-domain:example.com" (a Domain property, covering every scheme and subdomain) OR the full URL-prefix form "https://example.com/" including scheme and trailing slash — Google treats those as different properties and one of them will 403, so resolve it here rather than guessing, and never pass a bare domain. THIS IS NOT EVERY PROPERTY THE GOOGLE ACCOUNT CAN SEE, deliberately: one login commonly holds a dozen clients\' properties, so the user ticks which belong to THIS profile and any other one is refused by name. An empty list means nothing is ticked yet — say so and point the user at
|
|
13499
|
+
description: 'The Google Search Console properties SHARED WITH THIS PROFILE, each with the exact property string every other Search Console tool takes and the connected account\'s permission level on it. CALL THIS FIRST. A property is EITHER "sc-domain:example.com" (a Domain property, covering every scheme and subdomain) OR the full URL-prefix form "https://example.com/" including scheme and trailing slash — Google treats those as different properties and one of them will 403, so resolve it here rather than guessing, and never pass a bare domain. THIS IS NOT EVERY PROPERTY THE GOOGLE ACCOUNT CAN SEE, deliberately: one login commonly holds a dozen clients\' properties, so the user ticks which belong to THIS profile and any other one is refused by name. An empty list means nothing is ticked yet — say so and point the user at Connectors > Google Search Console > Manage accounts (or call list_connector_accounts / set_connector_accounts with provider "google_search_console"); never name or guess a property. A row whose permissionLevel is siteUnverifiedUser will refuse every later call. Read-only, 0 credits. Needs Google Search Console connected.',
|
|
13458
13500
|
inputSchema: {},
|
|
13459
13501
|
outputSchema: { sites: z.array(z.object({ siteUrl: z.string().optional(), permissionLevel: z.string().optional() })).optional(), count: z.number().optional(), shared: z.number().optional(), missing: z.array(z.string()).optional() },
|
|
13460
13502
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -13464,8 +13506,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13464
13506
|
if (rows.length) return ok(`${rows.length} Search Console propert(ies) shared with this profile:\n${rows.join('\n')}`, d);
|
|
13465
13507
|
// "nothing ticked" and "the ticked ones vanished" are different problems with different fixes.
|
|
13466
13508
|
return ok(d.shared
|
|
13467
|
-
? `The ${d.shared} propert${d.shared === 1 ? 'y' : 'ies'} shared with this profile ${d.shared === 1 ? 'is' : 'are'} no longer visible to the connected Google account (verification removed under Settings ▸ Users and permissions, or the property deleted). Ask the user to re-pick under
|
|
13468
|
-
: 'No Search Console property is shared with this profile yet, so there is nothing to read. Ask the user to pick which properties belong to this profile under
|
|
13509
|
+
? `The ${d.shared} propert${d.shared === 1 ? 'y' : 'ies'} shared with this profile ${d.shared === 1 ? 'is' : 'are'} no longer visible to the connected Google account (verification removed under Settings ▸ Users and permissions, or the property deleted). Ask the user to re-pick under Connectors ▸ Google Search Console ▸ Manage accounts (or call list_connector_accounts with provider google_search_console, then set_connector_accounts).`
|
|
13510
|
+
: 'No Search Console property is shared with this profile yet, so there is nothing to read. Ask the user to pick which properties belong to this profile under Connectors ▸ Google Search Console ▸ Manage accounts (or call set_connector_accounts with provider "google_search_console"). Do not name or guess one.', d);
|
|
13469
13511
|
}));
|
|
13470
13512
|
server.registerTool('search_console_performance', {
|
|
13471
13513
|
title: 'What people searched for on Google, and what they clicked',
|
|
@@ -13560,7 +13602,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13560
13602
|
}));
|
|
13561
13603
|
server.registerTool('add_search_console_site', {
|
|
13562
13604
|
title: 'Add a property to Search Console',
|
|
13563
|
-
description: 'Add a property to the connected Google account. TWO THINGS THAT MUST REACH THE USER, and the answer states both: (1) ADDING IS NOT VERIFYING — the property arrives with the account as an unverified user and EVERY read on it is refused until ownership is proven with a DNS record, an HTML file or a tag, which no API can do and which the user completes in Search Console itself; (2) the new property is NOT yet shared with this profile, so someone has to tick it under
|
|
13605
|
+
description: 'Add a property to the connected Google account. TWO THINGS THAT MUST REACH THE USER, and the answer states both: (1) ADDING IS NOT VERIFYING — the property arrives with the account as an unverified user and EVERY read on it is refused until ownership is proven with a DNS record, an HTML file or a tag, which no API can do and which the user completes in Search Console itself; (2) the new property is NOT yet shared with this profile, so someone has to tick it under Connectors > Google Search Console > Manage accounts (or call list_connector_accounts with provider google_search_console, then set_connector_accounts) before any tool here can use it. The permission level is READ BACK from Google, so the answer says which of those two states it is actually in. 0 credits.',
|
|
13564
13606
|
inputSchema: {
|
|
13565
13607
|
siteUrl: z.string().describe('"sc-domain:example.com" for a Domain property (covers every scheme and subdomain), or the full URL-prefix form "https://example.com/". These are different properties — pick deliberately.'),
|
|
13566
13608
|
},
|
|
@@ -13595,7 +13637,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13595
13637
|
// which is the one thing here that has no free Google equivalent.
|
|
13596
13638
|
server.registerTool('list_bing_webmaster_sites', {
|
|
13597
13639
|
title: 'List the Bing Webmaster sites shared with this profile',
|
|
13598
|
-
description: 'The Bing Webmaster Tools sites SHARED WITH THIS PROFILE, each exactly as Bing holds it (e.g. "https://example.com"). CALL THIS FIRST — every other Bing tool takes that exact string. THIS IS NOT EVERY SITE THE CONNECTION CAN REACH: Microsoft issues one key per USER covering every site that Bing account verified, so the user ticks which belong to THIS profile and any other one is refused by name. An empty list means nothing is ticked yet — point the user at
|
|
13640
|
+
description: 'The Bing Webmaster Tools sites SHARED WITH THIS PROFILE, each exactly as Bing holds it (e.g. "https://example.com"). CALL THIS FIRST — every other Bing tool takes that exact string. THIS IS NOT EVERY SITE THE CONNECTION CAN REACH: Microsoft issues one key per USER covering every site that Bing account verified, so the user ticks which belong to THIS profile and any other one is refused by name. An empty list means nothing is ticked yet — point the user at Connectors > Bing Webmaster Tools > Manage accounts (or call set_connector_accounts with provider "bing_webmaster") and never name or guess a site. A site flagged not verified will refuse every later call. Read-only, 0 credits.',
|
|
13599
13641
|
inputSchema: {},
|
|
13600
13642
|
outputSchema: { sites: z.array(z.object({ siteUrl: z.string().optional(), isVerified: z.boolean().optional() })).optional(), count: z.number().optional(), shared: z.number().optional(), missing: z.array(z.string()).optional() },
|
|
13601
13643
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -13604,8 +13646,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13604
13646
|
const rows = (d.sites || []).map(s => `• ${s.siteUrl}${s.isVerified ? '' : ' (NOT VERIFIED in Bing — every call on it will be refused)'}`);
|
|
13605
13647
|
if (rows.length) return ok(`${rows.length} Bing Webmaster site(s) shared with this profile:\n${rows.join('\n')}`, d);
|
|
13606
13648
|
return ok(d.shared
|
|
13607
|
-
? `The ${d.shared} site(s) shared with this profile are no longer visible to the connected Bing account. Ask the user to re-pick under
|
|
13608
|
-
: 'No Bing Webmaster site is shared with this profile yet. Ask the user to pick which of their verified sites belong to this profile under
|
|
13649
|
+
? `The ${d.shared} site(s) shared with this profile are no longer visible to the connected Bing account. Ask the user to re-pick under Connectors ▸ Bing Webmaster Tools ▸ Manage accounts (or call list_connector_accounts with provider bing_webmaster, then set_connector_accounts).`
|
|
13650
|
+
: 'No Bing Webmaster site is shared with this profile yet. Ask the user to pick which of their verified sites belong to this profile under Connectors ▸ Bing Webmaster Tools ▸ Manage accounts (or call list_connector_accounts with provider bing_webmaster, then set_connector_accounts). Do not name or guess one.', d);
|
|
13609
13651
|
}));
|
|
13610
13652
|
server.registerTool('bing_webmaster_traffic', {
|
|
13611
13653
|
title: 'Bing clicks and impressions',
|
|
@@ -13802,7 +13844,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13802
13844
|
}));
|
|
13803
13845
|
server.registerTool('add_bing_webmaster_site', {
|
|
13804
13846
|
title: 'Add a site to the Bing Webmaster account',
|
|
13805
|
-
description: 'Add a site to the connected Bing Webmaster account. Important: ADDING IS NOT VERIFYING, and saying so is most of this tool\'s value: the site arrives UNVERIFIED and every read on it is refused until an ownership proof is placed on the site itself — an XML file at the root, a meta tag in the home page <head>, or a CNAME DNS record — which no API can do. Place one, then call verify_bing_webmaster_site. Microsoft documents that adding a site which is already there does NOT error, so a success here is not even evidence anything changed, which is why the answer is read back from Bing\'s site list. It is also NOT shared with this profile until the user ticks it under
|
|
13847
|
+
description: 'Add a site to the connected Bing Webmaster account. Important: ADDING IS NOT VERIFYING, and saying so is most of this tool\'s value: the site arrives UNVERIFIED and every read on it is refused until an ownership proof is placed on the site itself — an XML file at the root, a meta tag in the home page <head>, or a CNAME DNS record — which no API can do. Place one, then call verify_bing_webmaster_site. Microsoft documents that adding a site which is already there does NOT error, so a success here is not even evidence anything changed, which is why the answer is read back from Bing\'s site list. It is also NOT shared with this profile until the user ticks it under Connectors > Bing Webmaster Tools > Manage accounts (or call list_connector_accounts with provider bing_webmaster, then set_connector_accounts). 0 credits.',
|
|
13806
13848
|
inputSchema: { siteUrl: z.string().describe('the site with its scheme, e.g. "https://example.com"') },
|
|
13807
13849
|
outputSchema: { siteUrl: z.string().optional(), added: z.boolean().optional(), confirmed: z.boolean().nullable().optional(), isVerified: z.boolean().nullable().optional(), shared: z.boolean().optional(), note: z.string().optional() },
|
|
13808
13850
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
@@ -13859,7 +13901,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13859
13901
|
// questions about traffic; these answer WHICH HUMAN DID WHAT — per-user funnels, retention cohorts and
|
|
13860
13902
|
// session replay, which GA4 structurally has no equivalent for. All three are PASTE-AN-API-KEY, so
|
|
13861
13903
|
// there is no OAuth consent screen and no sensitive scope; the user creates a key in the vendor's own
|
|
13862
|
-
// settings and pastes it under
|
|
13904
|
+
// settings and pastes it under Connectors.
|
|
13863
13905
|
//
|
|
13864
13906
|
// THE ONE RULE THAT OUTRANKS THE REST, and it must survive into every description below: PostHog's own
|
|
13865
13907
|
// policy is that "Third-party connectors must use batch exports, not /query. Connectors built on
|
|
@@ -13868,7 +13910,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13868
13910
|
// tool copy SAYS SO — an agent that has not been told the rule will cheerfully write the loop.
|
|
13869
13911
|
server.registerTool('list_posthog_projects', {
|
|
13870
13912
|
title: 'List the PostHog projects this key can see',
|
|
13871
|
-
description: 'The PostHog projects the connected personal API key can see, and which ONE this profile is pointed at. Only the ACTIVE project is readable. That is deliberate: PostHog\'s API otherwise falls back to "the last project you visited in the UI", which would make every answer depend on the user\'s browsing history, so Hermoso pins a project at connect time instead of relying on their implicit default. To move this profile to a different project, the user reconnects PostHog under
|
|
13913
|
+
description: 'The PostHog projects the connected personal API key can see, and which ONE this profile is pointed at. Only the ACTIVE project is readable. That is deliberate: PostHog\'s API otherwise falls back to "the last project you visited in the UI", which would make every answer depend on the user\'s browsing history, so Hermoso pins a project at connect time instead of relying on their implicit default. To move this profile to a different project, the user reconnects PostHog under Connectors > PostHog, or with connect_connector, with that project id. Read-only, 0 credits.',
|
|
13872
13914
|
inputSchema: {},
|
|
13873
13915
|
outputSchema: { projects: z.array(z.any()).optional(), count: z.number().optional(), active: z.string().optional(), note: z.string().optional() },
|
|
13874
13916
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -14316,7 +14358,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14316
14358
|
return ok([head, ...rows, d.note || '', d.pageNote || ''].filter(Boolean).join('\n'), d);
|
|
14317
14359
|
}));
|
|
14318
14360
|
// ---------- Vercel (2026-10-16): the brand's OWN websites — projects, deployments, domains, Web Analytics, and a landing page deployed from files ----------
|
|
14319
|
-
const VC_NEEDS = ' Needs Vercel connected (
|
|
14361
|
+
const VC_NEEDS = ' Needs Vercel connected (Connectors > Vercel, or connect_connector with an access token).';
|
|
14320
14362
|
// One renderer for page views and custom events; `by` may be one dimension or two (2026-10-27).
|
|
14321
14363
|
const vcText = (d, events) => {
|
|
14322
14364
|
const t = d.totals || {}, by = [].concat(d.by || []).join(' x ');
|
|
@@ -14430,7 +14472,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14430
14472
|
return ok(d.note, d);
|
|
14431
14473
|
}));
|
|
14432
14474
|
// ---------- Mailchimp (2026-11-03): the brand's OWN email marketing — audiences, segments, campaigns, templates, File Manager, reports ----------
|
|
14433
|
-
const MC_NEEDS = ' Needs Mailchimp connected (
|
|
14475
|
+
const MC_NEEDS = ' Needs Mailchimp connected (Connectors > Mailchimp, or connect_connector with an API key).';
|
|
14434
14476
|
const mcPct = (x) => (x == null ? '?' : `${(x * 100).toFixed(1)}%`);
|
|
14435
14477
|
const mcPage = { limit: z.number().optional(), offset: z.number().optional() };
|
|
14436
14478
|
server.registerTool('list_mailchimp_audiences', {
|
|
@@ -14577,7 +14619,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14577
14619
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
14578
14620
|
}, wrap(async (a) => { const d = await apiPost('/api/mailchimp/sync-leads', a); return ok(d.note, d); }));
|
|
14579
14621
|
// ---------- Klaviyo (2026-11-03): the brand's OWN email marketing — lists, segments, campaigns, flows, templates, images, performance ----------
|
|
14580
|
-
const KL_NEEDS = ' Needs Klaviyo connected (
|
|
14622
|
+
const KL_NEEDS = ' Needs Klaviyo connected (Connectors > Klaviyo, or connect_connector with a private API key).';
|
|
14581
14623
|
const klPage = { limit: z.number().optional(), cursor: z.string().optional().describe('nextCursor from a previous call') };
|
|
14582
14624
|
const klPct = (x) => (x == null ? '?' : `${(x * 100).toFixed(1)}%`);
|
|
14583
14625
|
const klGroups = (seg) => async (a = {}) => {
|
|
@@ -14757,7 +14799,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14757
14799
|
// ---------- Stripe (2026-09-09): the brand's OWN revenue, read-only via a restricted key ----------
|
|
14758
14800
|
server.registerTool('stripe_report', {
|
|
14759
14801
|
title: 'Stripe revenue report',
|
|
14760
|
-
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 (
|
|
14802
|
+
description: "Revenue from the brand's OWN Stripe account: gross, refunds, net and succeeded-charge count per day/week/month, new customers in the window, and active subscriptions + MRR where the key can read them. This is the money side of the loop — read an ad, a hook or a launch against real revenue instead of clicks. Default window is the last 30 days; a window with no charges answers a real zero, not a failed read. Read-only, never moves money, free. Needs Stripe connected (Connectors > Stripe, or connect_connector: paste a restricted key from Developers > Restricted keys).",
|
|
14761
14803
|
inputSchema: {
|
|
14762
14804
|
since: z.string().optional().describe('YYYY-MM-DD (default 30 days ago)'),
|
|
14763
14805
|
until: z.string().optional().describe('YYYY-MM-DD (default today)'),
|
|
@@ -14793,104 +14835,104 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14793
14835
|
// ---------- Stripe, the full surface (2026-09-09): reads, and writes that confirm on a live key ----------
|
|
14794
14836
|
server.registerTool('list_stripe_subscriptions', {
|
|
14795
14837
|
title: 'list stripe subscriptions',
|
|
14796
|
-
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 (
|
|
14838
|
+
description: "Subscriptions on the brand's OWN Stripe account with each item's price in MAJOR units (19.00 means $19.00), interval and quantity, and the MRR each contributes. With NO status it returns everything that counts toward MRR — active + trialing + past_due — and says so; pass status all, canceled, unpaid, incomplete, incomplete_expired or paused for the rest, or filter by customer or price. Read-only, free. Needs Stripe connected (Connectors > Stripe, or connect_connector).",
|
|
14797
14839
|
inputSchema: { status: z.string().optional().describe('filter by status (all, active, trialing, past_due, canceled and others); omit for MRR statuses'), customerId: z.string().optional().describe('Stripe customer id, cus_...'), priceId: z.string().optional().describe('Stripe price id, price_...; only subscriptions on this price'), limit: z.number().optional().describe('rows to return, 1 to 100; default 25'), after: z.string().optional().describe('pagination cursor: the last subscription id from the previous page') },
|
|
14798
14840
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
14799
14841
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
14800
14842
|
}, wrap(async (a) => { const d = await apiGet('/api/stripe/subscriptions', a); return ok(d.count ? `${d.note}\n${d.subscriptions.map(x => `• ${x.id} ${x.status} — ${x.customerEmail || x.customer}: ${x.items.map(i => `${i.quantity}×${i.amount == null ? '?' : i.amount.toFixed(2)} ${i.currency}/${i.interval || 'once'}${i.nickname ? ` (${i.nickname})` : ''}`).join(', ')} · MRR ${x.mrr}${x.cancelAtPeriodEnd ? ' · cancels at period end' : ''}`).join('\n')}` : d.note, d); }));
|
|
14801
14843
|
server.registerTool('list_stripe_invoices', {
|
|
14802
14844
|
title: 'list stripe invoices',
|
|
14803
|
-
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 (
|
|
14845
|
+
description: "Invoices on the brand's Stripe account, newest first: number, status (draft, open, paid, uncollectible, void), amount due and paid, customer email, dates and the hosted invoice link; filter by status or customer. Read-only, free. Needs Stripe connected (Connectors > Stripe, or connect_connector).",
|
|
14804
14846
|
inputSchema: { status: z.string().optional().describe('filter to draft, open, paid, uncollectible or void'), customerId: z.string().optional().describe('Stripe customer id, cus_...'), limit: z.number().optional().describe('rows to return, 1 to 100; default 25'), after: z.string().optional().describe('pagination cursor: the last invoice id from the previous page') },
|
|
14805
14847
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
14806
14848
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
14807
14849
|
}, wrap(async (a) => { const d = await apiGet('/api/stripe/invoices', a); return ok(d.count ? `${d.count} invoice(s):\n${d.invoices.map(i => `• ${i.number || i.id} ${i.status} — ${i.amountPaid}/${i.amountDue} ${i.currency} — ${i.customerEmail || i.customer} — ${i.created.slice(0, 10)}${i.hostedInvoiceUrl ? ` ${i.hostedInvoiceUrl}` : ''}`).join('\n')}${d.hasMore ? '\n(more: pass after=' + d.invoices[d.invoices.length - 1].id + ')' : ''}` : d.note, d); }));
|
|
14808
14850
|
server.registerTool('list_stripe_products', {
|
|
14809
14851
|
title: 'list stripe products',
|
|
14810
|
-
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 (
|
|
14852
|
+
description: "Products on the brand's Stripe account with their active prices (amount, currency, one-time or recurring interval), which is what create_stripe_payment_link and create_stripe_subscription take. active:false lists archived products too. Read-only, free. Needs Stripe connected (Connectors > Stripe, or connect_connector).",
|
|
14811
14853
|
inputSchema: { active: z.boolean().optional().describe('false also lists archived products; omit or true for active products only'), limit: z.number().optional().describe('rows to return, 1 to 100; default 25'), after: z.string().optional().describe('pagination cursor: the last product id from the previous page') },
|
|
14812
14854
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
14813
14855
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
14814
14856
|
}, wrap(async (a) => { const d = await apiGet('/api/stripe/products', a); return ok(d.count ? `${d.count} product(s):\n${d.products.map(p => `• ${p.name} (${p.id})${p.active ? '' : ' · archived'}: ${p.prices.map(x => `${x.id} ${x.amount == null ? '?' : x.amount.toFixed(2)} ${x.currency}${x.interval ? '/' + x.interval : ''}${x.nickname ? ` (${x.nickname})` : ''}`).join(', ') || 'no active price'}`).join('\n')}${d.hasMore ? '\n(more: pass after=' + d.products[d.products.length - 1].id + ')' : ''}` : d.note, d); }));
|
|
14815
14857
|
server.registerTool('stripe_balance', {
|
|
14816
14858
|
title: 'stripe balance',
|
|
14817
|
-
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 (
|
|
14859
|
+
description: "The brand's Stripe balance (available and pending, per currency) and the most recent payouts with status and arrival date. Read-only, free. Needs Stripe connected (Connectors > Stripe, or connect_connector).",
|
|
14818
14860
|
inputSchema: { limit: z.number().optional().describe('number of recent payouts to show, 1 to 100; default 25') },
|
|
14819
14861
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
14820
14862
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
14821
14863
|
}, wrap(async (a) => { const d = await apiGet('/api/stripe/balance', a); return ok(`${d.note}\n${(d.payouts || []).map(p => `• ${p.arrivalDate} ${p.amount} ${p.currency} ${p.status} (${p.id})`).join('\n')}`, d); }));
|
|
14822
14864
|
server.registerTool('list_stripe_refunds', {
|
|
14823
14865
|
title: 'list stripe refunds',
|
|
14824
|
-
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 (
|
|
14866
|
+
description: "Refunds on the brand's Stripe account, newest first: amount, status, reason and the charge refunded; filter by charge. Read-only, free. Needs Stripe connected (Connectors > Stripe, or connect_connector).",
|
|
14825
14867
|
inputSchema: { chargeId: z.string().optional().describe('only refunds of this charge, ch_...'), limit: z.number().optional().describe('rows to return, 1 to 100; default 25'), after: z.string().optional().describe('pagination cursor: the last refund id from the previous page') },
|
|
14826
14868
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
14827
14869
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
14828
14870
|
}, wrap(async (a) => { const d = await apiGet('/api/stripe/refunds', a); return ok(d.count ? `${d.count} refund(s):\n${d.refunds.map(r => `• ${r.created.slice(0, 10)} ${r.amount} ${r.currency} ${r.status}${r.reason ? ` (${r.reason})` : ''} on ${r.charge} (${r.id})`).join('\n')}` : d.note, d); }));
|
|
14829
14871
|
server.registerTool('list_stripe_coupons', {
|
|
14830
14872
|
title: 'list stripe coupons',
|
|
14831
|
-
description: "Coupons on the brand's Stripe account: percent or amount off, duration, validity and redemptions. Read-only, free. Needs Stripe connected (
|
|
14873
|
+
description: "Coupons on the brand's Stripe account: percent or amount off, duration, validity and redemptions. Read-only, free. Needs Stripe connected (Connectors > Stripe, or connect_connector).",
|
|
14832
14874
|
inputSchema: { limit: z.number().optional().describe('rows to return, 1 to 100; default 25'), after: z.string().optional().describe('pagination cursor: the last coupon id from the previous page') },
|
|
14833
14875
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
14834
14876
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
14835
14877
|
}, wrap(async (a) => { const d = await apiGet('/api/stripe/coupons', a); return ok(d.count ? `${d.count} coupon(s):\n${d.coupons.map(c => `• ${c.id}${c.name ? ` ${c.name}` : ''}: ${c.percentOff != null ? c.percentOff + '% off' : c.amountOff + ' ' + c.currency + ' off'}, ${c.duration}${c.durationInMonths ? ' ' + c.durationInMonths + ' months' : ''}${c.valid ? '' : ' · no longer valid'} · redeemed ${c.timesRedeemed}${c.maxRedemptions ? '/' + c.maxRedemptions : ''}`).join('\n')}` : d.note, d); }));
|
|
14836
14878
|
server.registerTool('create_stripe_product', {
|
|
14837
14879
|
title: 'create stripe product',
|
|
14838
|
-
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 (
|
|
14880
|
+
description: "Create a product on the brand's Stripe account together with its default price: name, optional description, `amount` in MAJOR units — 19 or 19.00 both mean $19.00, and a zero-decimal currency like JPY is whole — a 3-letter currency, and an optional recurring interval (day, week, month, year) with intervalCount. Returns both ids, read back. On a LIVE key it shows what it is about to create and needs confirm:true; on a test key it just does it. Idempotent on its arguments. Needs Stripe connected (Connectors > Stripe, or connect_connector).",
|
|
14839
14881
|
inputSchema: { name: z.string().describe('product name'), description: z.string().optional().describe('product description shown to customers in Stripe'), amount: z.number().describe('MAJOR units — 19 or 19.00 is $19.00, never 1900'), currency: z.string().describe('3-letter ISO currency code, e.g. usd'), interval: z.string().optional().describe('recurring interval: day, week, month or year; omit for a one-time price'), intervalCount: z.number().optional().describe('intervals between charges, e.g. 3 with month for quarterly'), nickname: z.string().optional().describe('internal label for the default price; not shown to customers'), metadata: z.record(z.any()).optional().describe('key/value metadata stored on the product'), confirm: z.boolean().optional().describe('true on a live key, after the user approves the preview') },
|
|
14840
14882
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
14841
14883
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
14842
14884
|
}, wrap(async (a) => { const d = await apiPost('/api/stripe/product', a); return ok(d.note, d); }));
|
|
14843
14885
|
server.registerTool('create_stripe_price', {
|
|
14844
14886
|
title: 'create stripe price',
|
|
14845
|
-
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 (
|
|
14887
|
+
description: "Add a price to an existing Stripe product: `amount` in MAJOR units (19 or 19.00 is $19.00, never 1900), currency, optional recurring interval and intervalCount, optional nickname. Read back. LIVE key: confirm:true after showing the preview. Idempotent on its arguments. Needs Stripe connected (Connectors > Stripe, or connect_connector).",
|
|
14846
14888
|
inputSchema: { productId: z.string().describe('product id, prod_..., from create_stripe_product or list_stripe_products'), amount: z.number().describe('MAJOR units — 19 or 19.00 is $19.00, never 1900'), currency: z.string().describe('3-letter ISO currency code, e.g. usd'), interval: z.string().optional().describe('recurring interval: day, week, month or year; omit for a one-time price'), intervalCount: z.number().optional().describe('intervals between charges, e.g. 3 with month for quarterly'), nickname: z.string().optional().describe('internal label for the price; not shown to customers'), confirm: z.boolean().optional().describe('true on a live key, after the user approves the preview') },
|
|
14847
14889
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
14848
14890
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
14849
14891
|
}, wrap(async (a) => { const d = await apiPost('/api/stripe/price', a); return ok(d.note, d); }));
|
|
14850
14892
|
server.registerTool('create_stripe_payment_link', {
|
|
14851
14893
|
title: 'create stripe payment link',
|
|
14852
|
-
description: "Create a shareable Stripe Payment Link: for ONE price pass priceId (price_…) with an optional quantity; for several pass lineItems: [{priceId, quantity}]. Optionally redirect to afterCompletionUrl when paid. allowPromotionCodes:true shows the promotion-code box at checkout — needed for any 'use code X' ad. referenceId and utm return a taggedUrl: put THAT URL in the ad, and get_stripe_payment_link_revenue (groupBy reference) then counts that ad's purchases. Returns the URL. LIVE key: confirm:true after showing the preview. Idempotent on its arguments. Needs Stripe connected (
|
|
14894
|
+
description: "Create a shareable Stripe Payment Link: for ONE price pass priceId (price_…) with an optional quantity; for several pass lineItems: [{priceId, quantity}]. Optionally redirect to afterCompletionUrl when paid. allowPromotionCodes:true shows the promotion-code box at checkout — needed for any 'use code X' ad. referenceId and utm return a taggedUrl: put THAT URL in the ad, and get_stripe_payment_link_revenue (groupBy reference) then counts that ad's purchases. Returns the URL. LIVE key: confirm:true after showing the preview. Idempotent on its arguments. Needs Stripe connected (Connectors > Stripe, or connect_connector).",
|
|
14853
14895
|
inputSchema: { priceId: z.string().optional().describe('one price — the shorthand for a single line item'), quantity: z.number().optional().describe('quantity of priceId, e.g. 2'), lineItems: z.array(z.object({ priceId: z.string().describe('price id, price_...'), quantity: z.number().optional().describe('quantity of this price in the link') })).optional().describe('several prices at once; use instead of priceId'), afterCompletionUrl: z.string().optional().describe('URL the buyer is sent to after paying'), allowPromotionCodes: z.boolean().optional().describe('show the promotion-code box at checkout'), referenceId: z.string().optional().describe('letters, digits, dashes, underscores (up to 200) — tags the returned taggedUrl for one ad; Stripe stores it on each purchase'), utm: z.object({ source: z.string().optional().describe('UTM source on the tagged URL; letters, digits, dashes and underscores, up to 150'), medium: z.string().optional().describe('UTM medium on the tagged URL; letters, digits, dashes and underscores, up to 150'), campaign: z.string().optional().describe('UTM campaign on the tagged URL; letters, digits, dashes and underscores, up to 150'), content: z.string().optional().describe('UTM content on the tagged URL; letters, digits, dashes and underscores, up to 150'), term: z.string().optional().describe('UTM term on the tagged URL; letters, digits, dashes and underscores, up to 150') }).optional().describe('UTM values on the taggedUrl (letters, digits, dashes, underscores, up to 150 each); Stripe passes them to the after-payment redirect only'), metadata: z.record(z.any()).optional().describe('key/value metadata stored on the payment link'), confirm: z.boolean().optional().describe('true on a live key, after the user approves the preview') },
|
|
14854
14896
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
14855
14897
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
14856
14898
|
}, wrap(async (a) => { const d = await apiPost('/api/stripe/payment-link', a); return ok(d.note, d); }));
|
|
14857
14899
|
server.registerTool('create_stripe_coupon', {
|
|
14858
14900
|
title: 'create stripe coupon',
|
|
14859
|
-
description: "Create a Stripe coupon: exactly one of percentOff (1–100) or amountOff (major units) + currency; duration once (default), repeating (with durationInMonths) or forever; optional name, id and maxRedemptions. A coupon alone has NO code a customer can type: pass `code` (letters, digits, dashes) to also mint the customer-typable promotion code in the same call, with optional codeMaxRedemptions, expiresAt, firstTimeOnly and minimumAmount. Read back. LIVE key: confirm:true after showing the preview. Needs Stripe connected (
|
|
14901
|
+
description: "Create a Stripe coupon: exactly one of percentOff (1–100) or amountOff (major units) + currency; duration once (default), repeating (with durationInMonths) or forever; optional name, id and maxRedemptions. A coupon alone has NO code a customer can type: pass `code` (letters, digits, dashes) to also mint the customer-typable promotion code in the same call, with optional codeMaxRedemptions, expiresAt, firstTimeOnly and minimumAmount. Read back. LIVE key: confirm:true after showing the preview. Needs Stripe connected (Connectors > Stripe, or connect_connector).",
|
|
14860
14902
|
inputSchema: { name: z.string().optional().describe('display name for the coupon, for your records'), id: z.string().optional().describe('custom coupon id; Stripe generates one if omitted'), percentOff: z.number().optional().describe('percent discount, 1 to 100; use this or amountOff'), amountOff: z.number().optional().describe('fixed discount in major units, e.g. 5 = 5.00; needs currency'), currency: z.string().optional().describe('3-letter currency code; required with amountOff'), duration: z.string().optional().describe('once (default), repeating or forever'), durationInMonths: z.number().optional().describe('months the discount applies, with duration repeating'), maxRedemptions: z.number().optional().describe('uses of the COUPON across every code'), code: z.string().optional().describe('the customer-typable promotion code, e.g. SUMMER20 — letters, digits and dashes only; refused (never altered) otherwise'), codeMaxRedemptions: z.number().optional().describe('uses of this CODE'), expiresAt: z.string().optional().describe('YYYY-MM-DD the code stops working'), firstTimeOnly: z.boolean().optional().describe("the code works only on a customer's first purchase"), minimumAmount: z.number().optional().describe('minimum order in MAJOR units for the code'), minimumAmountCurrency: z.string().optional().describe("currency for the code's minimumAmount, e.g. usd"), confirm: z.boolean().optional().describe('true on a live key, after the user approves the preview') },
|
|
14861
14903
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
14862
14904
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
14863
14905
|
}, wrap(async (a) => { const d = await apiPost('/api/stripe/coupon', a); return ok(d.note, d); }));
|
|
14864
14906
|
server.registerTool('create_stripe_customer', {
|
|
14865
14907
|
title: 'create stripe customer',
|
|
14866
|
-
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 (
|
|
14908
|
+
description: "Create a Stripe customer by email (optional name, phone, description, metadata). If a customer with that email already exists it is returned instead and nothing is created — pass allowDuplicate:true to create a second one on purpose. LIVE key: confirm:true after showing the preview. Needs Stripe connected (Connectors > Stripe, or connect_connector).",
|
|
14867
14909
|
inputSchema: { email: z.string().describe('customer email; the key Stripe matches duplicates on'), name: z.string().optional().describe('customer name'), phone: z.string().optional().describe('customer phone number'), description: z.string().optional().describe('internal note on the customer record'), metadata: z.record(z.any()).optional().describe('key/value metadata stored on the customer'), allowDuplicate: z.boolean().optional().describe('true = create a second customer even if this email already has one'), confirm: z.boolean().optional().describe('true on a live key, after the user approves the preview') },
|
|
14868
14910
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
14869
14911
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
14870
14912
|
}, wrap(async (a) => { const d = await apiPost('/api/stripe/customer', a); return ok(d.note, d); }));
|
|
14871
14913
|
server.registerTool('create_stripe_subscription', {
|
|
14872
14914
|
title: 'create stripe subscription',
|
|
14873
|
-
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 (
|
|
14915
|
+
description: "Subscribe a Stripe customer (cus_…) to a price (price_…), optional quantity and trialDays. THIS CHARGES THE CUSTOMER'S SAVED PAYMENT METHOD when there is no trial, so it ALWAYS needs confirm:true (test keys included) after you show the user exactly what will be created. Read back; an INCOMPLETE status means no chargeable card is on file. Needs Stripe connected (Connectors > Stripe, or connect_connector).",
|
|
14874
14916
|
inputSchema: { customerId: z.string().describe('customer id, cus_...'), priceId: z.string().describe('recurring price id, price_...'), quantity: z.number().optional().describe('quantity of the price, e.g. number of seats'), trialDays: z.number().optional().describe('free trial length in days before the first charge'), metadata: z.record(z.any()).optional().describe('key/value metadata stored on the subscription'), confirm: z.boolean().optional().describe('true to charge the saved card; always required, test keys included') },
|
|
14875
14917
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
14876
14918
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
14877
14919
|
}, wrap(async (a) => { const d = await apiPost('/api/stripe/subscription', a); return ok(d.note, d); }));
|
|
14878
14920
|
server.registerTool('cancel_stripe_subscription', {
|
|
14879
14921
|
title: 'cancel stripe subscription',
|
|
14880
|
-
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 (
|
|
14922
|
+
description: "Cancel a Stripe subscription: at the end of the current period by default (the customer keeps access until then), or immediately:true to end it now. ALWAYS needs confirm:true, test keys included. The read-back distinguishes the two — a period-end cancel reports the date it will end and the status it keeps until then, an immediate one reports status canceled. Needs Stripe connected (Connectors > Stripe, or connect_connector).",
|
|
14881
14923
|
inputSchema: { subscriptionId: z.string().describe('subscription id, sub_...'), immediately: z.boolean().optional().describe('true = end now; omitted or false = end at the period end'), confirm: z.boolean().optional().describe('true after the user approves this cancellation') },
|
|
14882
14924
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
14883
14925
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
14884
14926
|
}, wrap(async (a) => { const d = await apiPost('/api/stripe/subscription/cancel', a); return ok(d.note, d); }));
|
|
14885
14927
|
server.registerTool('refund_stripe_charge', {
|
|
14886
14928
|
title: 'refund stripe charge',
|
|
14887
|
-
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 (
|
|
14929
|
+
description: "Refund a Stripe charge (ch_… or a pi_… payment intent): the full refundable amount, or a partial `amount` in major units; optional reason duplicate / fraudulent / requested_by_customer. ALWAYS needs confirm:true, test keys included. Read back. Needs Stripe connected (Connectors > Stripe, or connect_connector).",
|
|
14888
14930
|
inputSchema: { chargeId: z.string().describe('charge id, ch_..., or payment intent id, pi_...'), amount: z.number().optional().describe('partial refund in major units; omit to refund the full refundable amount'), reason: z.string().optional().describe('duplicate, fraudulent or requested_by_customer'), confirm: z.boolean().optional().describe('true after the user approves this refund') },
|
|
14889
14931
|
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
14890
14932
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
14891
14933
|
}, wrap(async (a) => { const d = await apiPost('/api/stripe/refund', a); return ok(d.note, d); }));
|
|
14892
14934
|
// ---------- Stripe promotion codes, payment-link edits and revenue per link (2026-10-27) ----------
|
|
14893
|
-
const stripeConnected = 'Needs Stripe connected (
|
|
14935
|
+
const stripeConnected = 'Needs Stripe connected (Connectors > Stripe, or connect_connector).';
|
|
14894
14936
|
server.registerTool('list_stripe_promotion_codes', {
|
|
14895
14937
|
title: 'list stripe promotion codes',
|
|
14896
14938
|
description: `Promotion codes on the brand's Stripe account — the codes customers TYPE at checkout — with the coupon each applies, active or not, times redeemed / max, expiry, first-purchase-only and minimum-order rules. Filter by active, code or couponId. Read-only, free. ${stripeConnected}`,
|
|
@@ -14936,7 +14978,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14936
14978
|
// ---------- HubSpot (2026-09-09): the brand's OWN CRM — contacts, an upsert by email, lead forms → contacts, deals ----------
|
|
14937
14979
|
server.registerTool('list_hubspot_contacts', {
|
|
14938
14980
|
title: 'List or search HubSpot contacts',
|
|
14939
|
-
description: "Contacts from the brand's OWN HubSpot CRM, newest first — email, name, lifecycle stage, lead status, created date, original source — or a search by email / first name / last name (whole-token match, wildcards allowed). Read-only, free. Needs HubSpot connected (
|
|
14981
|
+
description: "Contacts from the brand's OWN HubSpot CRM, newest first — email, name, lifecycle stage, lead status, created date, original source — or a search by email / first name / last name (whole-token match, wildcards allowed). Read-only, free. Needs HubSpot connected (Connectors > HubSpot).",
|
|
14940
14982
|
inputSchema: { search: z.string().optional().describe('email or name to search for'), limit: z.number().optional().describe('1–100, default 25'), after: z.string().optional().describe('pagination cursor from the previous page') },
|
|
14941
14983
|
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), contacts: z.array(z.any()).optional(), after: z.string().nullable().optional(), note: z.string().optional() },
|
|
14942
14984
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -15019,7 +15061,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15019
15061
|
? ` [${m.event || 'event'}${m.author?.name ? ` by ${m.author.name}` : ''}${m.createdAt ? ` ${icStamp(m.createdAt)}` : ''}]${m.text ? ` ${m.text}` : ''}`
|
|
15020
15062
|
: ` ${m.kind === 'customer' ? 'CUSTOMER' : m.kind === 'note' ? 'NOTE (internal)' : m.kind === 'bot' ? 'BOT' : 'TEAMMATE'}${m.author?.name ? ` ${m.author.name}` : ''}${m.createdAt ? ` | ${icStamp(m.createdAt)}` : ''}: ${m.text || '(empty)'}`);
|
|
15021
15063
|
const icWrite = (d) => (d.dryRun ? `${d.note || ''}\n${d.preview || ''}` : `${d.note || ''}${d.posted ? `\nAs Intercom shows it: ${d.posted}` : ''}`);
|
|
15022
|
-
const icNeeds = 'Needs Intercom connected (
|
|
15064
|
+
const icNeeds = 'Needs Intercom connected (Connectors > Intercom, or connect_connector).';
|
|
15023
15065
|
server.registerTool('list_intercom_conversations', {
|
|
15024
15066
|
title: 'List Intercom conversations',
|
|
15025
15067
|
description: "Support conversations from the brand's OWN Intercom inbox, newest activity first: subject, state, whether the customer is waiting, assignee, customer email, channel, tags and the first message. One Intercom workspace can serve several products, so every filter below combines with the others. "
|
|
@@ -15171,7 +15213,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15171
15213
|
}));
|
|
15172
15214
|
server.registerTool('list_microsoft_ads_campaigns', {
|
|
15173
15215
|
title: 'List Microsoft Advertising accounts / campaigns',
|
|
15174
|
-
description: 'Read the brand’s connected Microsoft Advertising (Bing Ads) account(s). Call with NO adAccountId to list the accounts shared with this profile — do this first to pick a target. Call WITH adAccountId to list that account’s campaigns (id, name, status, daily budget, campaign type, and whether the budget is SHARED). Microsoft statuses are Active / Paused — never Google’s ENABLED — and Microsoft also sets BudgetPaused, BudgetAndManualPaused and Suspended on its own, so report the status you read rather than assuming a paused campaign was paused by a person. Read-only, free. Needs Microsoft Advertising connected (
|
|
15216
|
+
description: 'Read the brand’s connected Microsoft Advertising (Bing Ads) account(s). Call with NO adAccountId to list the accounts shared with this profile — do this first to pick a target. Call WITH adAccountId to list that account’s campaigns (id, name, status, daily budget, campaign type, and whether the budget is SHARED). Microsoft statuses are Active / Paused — never Google’s ENABLED — and Microsoft also sets BudgetPaused, BudgetAndManualPaused and Suspended on its own, so report the status you read rather than assuming a paused campaign was paused by a person. Read-only, free. Needs Microsoft Advertising connected (Connectors > Microsoft Advertising).',
|
|
15175
15217
|
inputSchema: {
|
|
15176
15218
|
...msAcct,
|
|
15177
15219
|
},
|
|
@@ -15180,9 +15222,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15180
15222
|
}, wrap(async (a) => {
|
|
15181
15223
|
if (!(a.adAccountId || a.accountId)) {
|
|
15182
15224
|
const d = await apiGet('/api/microsoft-ads/shared-accounts', {});
|
|
15183
|
-
if (!(d.accounts || []).length) return ok('No Microsoft Advertising account is shared with this profile yet. Ask the user to tick the ones it may use in
|
|
15225
|
+
if (!(d.accounts || []).length) return ok('No Microsoft Advertising account is shared with this profile yet. Ask the user to tick the ones it may use in Connectors ▸ Microsoft Ads ▸ Manage accounts (or call set_connector_accounts), then retry.', d);
|
|
15184
15226
|
const lines = (d.accounts || []).map(c => `• ${c.name} (${c.accountId})${c.number ? ` — ${c.number}` : ''}${c.selected ? ' [shared with this profile]' : ''}${c.status ? ` · ${c.status}` : ''}`);
|
|
15185
|
-
return ok(`${(d.accounts || []).length} Microsoft Advertising account(s) shared with this profile${d.sandbox ? ' (SANDBOX environment)' : ''}:\n${lines.join('\n') || '(none)'}\nPass an adAccountId to see its campaigns. Only accounts ticked in
|
|
15227
|
+
return ok(`${(d.accounts || []).length} Microsoft Advertising account(s) shared with this profile${d.sandbox ? ' (SANDBOX environment)' : ''}:\n${lines.join('\n') || '(none)'}\nPass an adAccountId to see its campaigns. Only accounts ticked in Connectors can be managed.`, d);
|
|
15186
15228
|
}
|
|
15187
15229
|
const d = await apiGet('/api/microsoft-ads/campaigns', { adAccountId: (a.adAccountId || a.accountId) });
|
|
15188
15230
|
const lines = (d.campaigns || []).map(c => `• ${c.name} (${c.id}) — ${c.status}${c.dailyBudget != null ? `, ${c.dailyBudget}/day${c.sharedBudget ? ' (SHARED budget — read-only here)' : ''}` : ''}${c.type ? `, ${c.type}` : ''}`);
|
|
@@ -16012,7 +16054,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16012
16054
|
}).describe('which products of a product-feed campaign this ad group advertises; omit for every product in the feed');
|
|
16013
16055
|
server.registerTool('list_openai_ads_campaigns', {
|
|
16014
16056
|
title: 'List ChatGPT Ads account / campaigns / ad groups / ads',
|
|
16015
|
-
description: 'Read the brand’s connected ChatGPT Ads account — the ads that appear below ChatGPT answers. Every campaign, ad group and ad row carries servingIssues, OpenAI’s own list of what is blocking delivery (payment method, brand review, budget spent, ad in review, landing page not crawlable, country policy…) with a plain meaning each: null means OpenAI was not asked, [] means it reports no blocker, which is still not a promise of impressions. Call with NO ids to get the ad account itself (its three names: brandName shown in ads, accountName internal to Ads Manager, legalName; currency, status, brand review and business review state) plus its campaigns; with campaignId to list that campaign’s ad groups; with adGroupId to list that ad group’s ads, including each ad’s REVIEW status, which is what decides whether it can ever show. Statuses here are active / paused / archived. Read-only, free, zero spend risk. Needs ChatGPT Ads connected under
|
|
16057
|
+
description: 'Read the brand’s connected ChatGPT Ads account — the ads that appear below ChatGPT answers. Every campaign, ad group and ad row carries servingIssues, OpenAI’s own list of what is blocking delivery (payment method, brand review, budget spent, ad in review, landing page not crawlable, country policy…) with a plain meaning each: null means OpenAI was not asked, [] means it reports no blocker, which is still not a promise of impressions. Call with NO ids to get the ad account itself (its three names: brandName shown in ads, accountName internal to Ads Manager, legalName; currency, status, brand review and business review state) plus its campaigns; with campaignId to list that campaign’s ad groups; with adGroupId to list that ad group’s ads, including each ad’s REVIEW status, which is what decides whether it can ever show. Statuses here are active / paused / archived. Read-only, free, zero spend risk. Needs ChatGPT Ads connected under Connectors > ChatGPT Ads in the Hermoso app; one connection covers one ad account.',
|
|
16016
16058
|
inputSchema: {
|
|
16017
16059
|
campaignId: z.string().optional().describe('list this campaign’s ad groups'),
|
|
16018
16060
|
adGroupId: z.string().optional().describe('list this ad group’s ads'),
|
|
@@ -16333,7 +16375,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16333
16375
|
// sentences move with them.
|
|
16334
16376
|
server.registerTool('list_apple_ads_campaigns', {
|
|
16335
16377
|
title: 'List Apple Ads campaigns',
|
|
16336
|
-
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 (
|
|
16378
|
+
description: 'Read the brand’s connected Apple Ads (Apple Search Ads) campaigns — App Store search ads, on Apple’s Platform API. Each row carries status, the system-computed displayStatus, the daily budget, the bid strategy, the countries and placements it runs in and, when a campaign cannot run, systemStatusReasons stating exactly why. Read-only, free, zero spend risk. Needs Apple Ads connected (Connectors > Apple Ads, or connect_connector): there is no OAuth — Hermoso generates an EC signing key, the user pastes the public half into Apple Ads > Account Settings > API and pastes back clientId / teamId / keyId. To BUILD on this account, use create_apple_ads_campaign / create_apple_ads_ad_group / add_apple_ads_keywords: everything is created PAUSED and only set_apple_ads_status(confirm:true) can arm spend.',
|
|
16337
16379
|
inputSchema: {
|
|
16338
16380
|
limit: z.number().optional().describe('page size, default 100, Apple’s max is 1000'),
|
|
16339
16381
|
offset: z.number().optional().describe('offset pagination'),
|
|
@@ -16388,7 +16430,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16388
16430
|
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/report', a); return ok(`${d.note}\n${JSON.stringify((d.rows || []).slice(0, 40))}`, d); }));
|
|
16389
16431
|
server.registerTool('list_apple_ads_orgs', {
|
|
16390
16432
|
title: 'List Apple Ads organizations',
|
|
16391
|
-
description: 'The Apple Ads ad accounts these credentials can act as, with the roles held on each, plus the organization’s currency, time zone and payment model. NAMING: Apple’s older API called these “organizations” (campaign groups) and its Platform API calls them AD ACCOUNTS — the id is the same number, and `orgId` and `adAccountId` on each row are equal. One login can cover several: an agency managing multiple clients has one per client. Reading this does NOT switch account: Apple Ads is pinned to the ONE chosen when the connection was made, so an agent can never act as another client’s. To use a different one, pick it in
|
|
16433
|
+
description: 'The Apple Ads ad accounts these credentials can act as, with the roles held on each, plus the organization’s currency, time zone and payment model. NAMING: Apple’s older API called these “organizations” (campaign groups) and its Platform API calls them AD ACCOUNTS — the id is the same number, and `orgId` and `adAccountId` on each row are equal. One login can cover several: an agency managing multiple clients has one per client. Reading this does NOT switch account: Apple Ads is pinned to the ONE chosen when the connection was made, so an agent can never act as another client’s. To use a different one, pick it in Connectors > Apple Ads > Manage accounts, with set_connector_accounts, or by reconnecting. A payment model of null is worth reporting: Apple states that without one, campaigns cannot run. Read-only, free.',
|
|
16392
16434
|
inputSchema: {},
|
|
16393
16435
|
outputSchema: { ok: z.boolean().optional(), active: z.string().nullable().optional(), count: z.number().optional(), orgs: z.array(z.any()).optional(), org: z.any().optional(), note: z.string().optional() },
|
|
16394
16436
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -17933,7 +17975,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17933
17975
|
|
|
17934
17976
|
server.registerTool('list_pinterest_ads_campaigns', {
|
|
17935
17977
|
title: 'List Pinterest ad accounts / campaigns',
|
|
17936
|
-
description: 'Read the brand’s connected Pinterest AD account(s). Call with NO adAccountId to list the ad accounts shared with this profile — do this first to pick a target. Call WITH adAccountId to list that account’s campaigns (id, name, status, objective, budget, and Pinterest’s own summary status). Add campaignId to get that ONE campaign’s whole tree — its AD GROUPS and ADS with their ids, statuses and review status. That is the only way to enumerate them, and it matters: Pinterest has no delete, so an ad group you cannot see is one you cannot even archive. All money is in the AD ACCOUNT’S currency, which is not necessarily dollars. Pinterest’s own list default hides DRAFT and ARCHIVED objects; this asks for all four statuses so nothing is silently missing. Read-only, free. Needs Pinterest connected (
|
|
17978
|
+
description: 'Read the brand’s connected Pinterest AD account(s). Call with NO adAccountId to list the ad accounts shared with this profile — do this first to pick a target. Call WITH adAccountId to list that account’s campaigns (id, name, status, objective, budget, and Pinterest’s own summary status). Add campaignId to get that ONE campaign’s whole tree — its AD GROUPS and ADS with their ids, statuses and review status. That is the only way to enumerate them, and it matters: Pinterest has no delete, so an ad group you cannot see is one you cannot even archive. All money is in the AD ACCOUNT’S currency, which is not necessarily dollars. Pinterest’s own list default hides DRAFT and ARCHIVED objects; this asks for all four statuses so nothing is silently missing. Read-only, free. Needs Pinterest connected (Connectors > Pinterest) and the ad account ticked under Manage accounts (or call list_connector_accounts with provider pinterest_ads, then set_connector_accounts).',
|
|
17937
17979
|
inputSchema: {
|
|
17938
17980
|
adAccountId: z.string().optional().describe('Pinterest ad account id — omit to list the ad accounts shared with this profile'),
|
|
17939
17981
|
campaignId: z.string().optional().describe('one campaign -> its ad groups and ads too (the only way to enumerate them)'),
|
|
@@ -17944,9 +17986,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17944
17986
|
}, wrap(async (a) => {
|
|
17945
17987
|
if (!a.adAccountId) {
|
|
17946
17988
|
const d = await apiGet('/api/pinterest/shared-ad-accounts', {});
|
|
17947
|
-
if (!(d.accounts || []).length) return ok('No Pinterest ad account is shared with this profile yet. Ask the user to tick the ones it may use in
|
|
17989
|
+
if (!(d.accounts || []).length) return ok('No Pinterest ad account is shared with this profile yet. Ask the user to tick the ones it may use in Connectors ▸ Pinterest ▸ Manage accounts (or call set_connector_accounts), then retry.', d);
|
|
17948
17990
|
const lines = (d.accounts || []).map(c => `• ${c.name} (${c.adAccountId})${c.currency ? ` — ${c.currency}` : ''}${c.selected ? ' [shared with this profile]' : ''}`);
|
|
17949
|
-
return ok(`${(d.accounts || []).length} Pinterest ad account(s) shared with this profile:\n${lines.join('\n') || '(none)'}\nPass an adAccountId to see its campaigns. Only accounts ticked in
|
|
17991
|
+
return ok(`${(d.accounts || []).length} Pinterest ad account(s) shared with this profile:\n${lines.join('\n') || '(none)'}\nPass an adAccountId to see its campaigns. Only accounts ticked in Connectors can be managed.`, d);
|
|
17950
17992
|
}
|
|
17951
17993
|
const d = await apiGet('/api/pinterest/ads-campaigns', { adAccountId: a.adAccountId, ...(a.campaignId ? { campaignId: a.campaignId } : {}), ...(a.statuses ? { statuses: a.statuses } : {}) });
|
|
17952
17994
|
const lines = (d.campaigns || []).map(c => `• ${c.name} (${c.id}) — ${c.status}${c.objective ? `, ${c.objective}` : ''}${c.dailyBudget != null ? `, ${c.dailyBudget}/day` : c.lifetimeBudget != null ? `, ${c.lifetimeBudget} lifetime` : ''}${c.summaryStatus ? ` · ${c.summaryStatus}` : ''}`);
|
|
@@ -18363,14 +18405,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18363
18405
|
// account the brand was not given, and everything is created PAUSED with no override.
|
|
18364
18406
|
server.registerTool('list_reddit_ads_campaigns', {
|
|
18365
18407
|
title: 'List Reddit ad accounts / campaigns',
|
|
18366
|
-
description: 'Read the brand’s connected Reddit AD account(s). Call with NO adAccountId to list the ad accounts shared with this profile — do this first to pick a target. Call WITH adAccountId to read that account’s whole tree at once: campaigns, ad groups and ads, each with its configured status and Reddit’s own effective status (the effective one is what says whether it could actually serve — PENDING_APPROVAL, CAMPAIGN_PAUSED, REJECTED and so on). Read-only, free. Needs Reddit Ads connected (
|
|
18408
|
+
description: 'Read the brand’s connected Reddit AD account(s). Call with NO adAccountId to list the ad accounts shared with this profile — do this first to pick a target. Call WITH adAccountId to read that account’s whole tree at once: campaigns, ad groups and ads, each with its configured status and Reddit’s own effective status (the effective one is what says whether it could actually serve — PENDING_APPROVAL, CAMPAIGN_PAUSED, REJECTED and so on). Read-only, free. Needs Reddit Ads connected (Connectors > Reddit Ads) and the ad account ticked under Manage accounts (or call list_connector_accounts with provider reddit_ads, then set_connector_accounts).',
|
|
18367
18409
|
inputSchema: { adAccountId: z.string().optional().describe('Reddit ad account id (a2_…) — omit to list the ad accounts shared with this profile') },
|
|
18368
18410
|
outputSchema: { accounts: z.array(z.any()).optional(), adAccountId: z.string().optional(), name: z.string().optional(), campaigns: z.array(z.any()).optional(), adGroups: z.array(z.any()).optional(), ads: z.array(z.any()).optional(), costCap: z.any().optional() },
|
|
18369
18411
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
18370
18412
|
}, wrap(async (a) => {
|
|
18371
18413
|
if (!a.adAccountId) {
|
|
18372
18414
|
const d = await apiGet('/api/reddit-ads/shared-accounts', {});
|
|
18373
|
-
if (!(d.accounts || []).length) return ok('No Reddit ad account is shared with this profile yet. Ask the user to tick the ones it may use in
|
|
18415
|
+
if (!(d.accounts || []).length) return ok('No Reddit ad account is shared with this profile yet. Ask the user to tick the ones it may use in Connectors ▸ Reddit Ads ▸ Manage accounts (or call set_connector_accounts), then retry.', d);
|
|
18374
18416
|
const lines = (d.accounts || []).map(c => `• ${c.name} (${c.id})${c.currency ? ` — ${c.currency}` : ''}`);
|
|
18375
18417
|
return ok(`${(d.accounts || []).length} Reddit ad account(s) shared with this profile:\n${lines.join('\n')}\nPass an adAccountId to read its campaigns, ad groups and ads.`, d);
|
|
18376
18418
|
}
|
|
@@ -19390,7 +19432,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
19390
19432
|
const TIKTOK_WEB_OPT_EVENTS = ['ON_WEB_DETAIL', 'ON_WEB_SEARCH', 'ON_WEB_ADD_TO_WISHLIST', 'ON_WEB_CART', 'INITIATE_ORDER', 'ADD_BILLING', 'SHOPPING', 'CONSULT', 'DOWNLOAD_START', 'FORM', 'ON_WEB_REGISTER', 'ON_WEB_SUBSCRIBE'];
|
|
19391
19433
|
server.registerTool('list_tiktok_ads_accounts', {
|
|
19392
19434
|
title: 'List TikTok advertiser accounts',
|
|
19393
|
-
description: 'List the TikTok ADVERTISER accounts this profile can act on — id, name, currency, timezone and status. Every other TikTok Ads tool needs an advertiserId, and this is where it comes from: call this first and let the USER pick when there is more than one. Read-only, free, spends nothing. Needs TikTok ADS connected (
|
|
19435
|
+
description: 'List the TikTok ADVERTISER accounts this profile can act on — id, name, currency, timezone and status. Every other TikTok Ads tool needs an advertiserId, and this is where it comes from: call this first and let the USER pick when there is more than one. Read-only, free, spends nothing. Needs TikTok ADS connected (Connectors > TikTok Ads) — that is a DIFFERENT connection from the TikTok posting connector behind post_to_tiktok, so a brand that publishes to TikTok every day may still have nothing here. When nothing is shared and the user\'s TikTok login has no ad account at all, the reply says so: offer to create one with create_tiktok_ads_account.',
|
|
19394
19436
|
inputSchema: {},
|
|
19395
19437
|
outputSchema: { advertisers: z.array(z.any()).optional(), count: z.number().optional(), hasOwnAdAccounts: z.boolean().nullable().optional(), note: z.string().optional() },
|
|
19396
19438
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -21344,7 +21386,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21344
21386
|
// raise spend takes confirm:true plus the scope token read off the stored rule, and per-account caps bound every raise.
|
|
21345
21387
|
server.registerTool('create_meta_ad_rule', {
|
|
21346
21388
|
title: "Create a Meta automated rule (created switched OFF)",
|
|
21347
|
-
description: "AUTOMATED RULES ON META — a standing instruction META runs on the user's ad account on its own schedule, with nobody watching: \"pause any ad set whose cost per result is over $X today\", \"raise the budget 20% when ROAS is above 3, at most once a day, never above $200\". Meta runs these rules itself (Ads Manager
|
|
21389
|
+
description: "AUTOMATED RULES ON META — a standing instruction META runs on the user's ad account on its own schedule, with nobody watching: \"pause any ad set whose cost per result is over $X today\", \"raise the budget 20% when ROAS is above 3, at most once a day, never above $200\". Meta runs these rules itself (Ads Manager > Automated rules shows them too). EVERY RULE IS CREATED SWITCHED OFF and read back from Meta to prove it; switching it on is a separate act (set_meta_ad_rule_status). Actions: pause, enable, notify, or change a budget/bid by a percent or amount. A rule that RAISES needs a limit (the ceiling), and the reply says plainly what it will be able to do unattended. Money values are in the account currency's BASE UNIT (for USD 1000 = $10.00), as Meta defines them. Conditions take any Meta rule filter field; for anything the plain fields cannot say, pass Meta's raw evaluationSpec / executionSpec / scheduleSpec. Free.",
|
|
21348
21390
|
inputSchema: {
|
|
21349
21391
|
adAccountId: z.string().describe("Meta ad account id (act_… or the bare number) from list_meta_ads; required — every rule lives on one ad account"),
|
|
21350
21392
|
name: z.string().describe("the rule's name in Ads Manager"),
|
|
@@ -21760,7 +21802,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21760
21802
|
//
|
|
21761
21803
|
// ⚠ A GRANT IS NOT A TOKEN. TikTok binds permissions at AUTHORIZE time and never retroactively, so every
|
|
21762
21804
|
// tiktok_ads connection minted before the approval — which is all of them — answers 40001 on these routes until
|
|
21763
|
-
// its owner RECONNECTS in
|
|
21805
|
+
// its owner RECONNECTS in Connectors. Measured 2026-08-20: on our own production connection
|
|
21764
21806
|
// /advertiser/info/ answered code 0 in the same second that /store/list/, /account/verification/status/,
|
|
21765
21807
|
// /payment_portfolio/get/ and /tto/oauth2/info/ each answered "advertiser does not grant you …". That is
|
|
21766
21808
|
// TikTok's binding rule working, not a defect.
|
|
@@ -22222,7 +22264,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22222
22264
|
// account the login can reach, including other clients' — so no agent surface may read it.
|
|
22223
22265
|
const d = await apiGet('/api/snapchat-ads/shared-accounts', {});
|
|
22224
22266
|
const list = d.adAccounts || [];
|
|
22225
|
-
if (!list.length) return ok('Snapchat Ads is connected but NO ad account is SHARED with this profile yet, so nothing can be built or read. A connection is not a licence to spend from every account on it — one Snapchat login often administers several clients. Ask the user to tick the ones this profile may use in
|
|
22267
|
+
if (!list.length) return ok('Snapchat Ads is connected but NO ad account is SHARED with this profile yet, so nothing can be built or read. A connection is not a licence to spend from every account on it — one Snapchat login often administers several clients. Ask the user to tick the ones this profile may use in Connectors ▸ Snapchat Ads ▸ Manage accounts (or use set_connector_accounts).', d);
|
|
22226
22268
|
return ok(`${list.length} Snapchat ad account(s):\n${list.map(a => `· ${a.name || '(unnamed)'} — ${a.adAccountId}${a.currency ? `, ${a.currency}` : ''}${a.test ? ' ⚠ TEST account (cannot serve real ads)' : ''} [org ${a.organization || a.organizationId}]`).join('\n')}`, d);
|
|
22227
22269
|
}));
|
|
22228
22270
|
server.registerTool('list_snapchat_ads_campaigns', {
|
|
@@ -22672,7 +22714,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22672
22714
|
const held = Number(d.held || 0);
|
|
22673
22715
|
if (!list.length) {
|
|
22674
22716
|
return ok(held
|
|
22675
|
-
? `NONE of the ${held} LinkedIn Page(s) this account administers is shared with this profile, so nothing can be posted as a Page. Tell the user to tick the Page(s) that belong to this profile under
|
|
22717
|
+
? `NONE of the ${held} LinkedIn Page(s) this account administers is shared with this profile, so nothing can be posted as a Page. Tell the user to tick the Page(s) that belong to this profile under Connectors ▸ LinkedIn ▸ Manage accounts (or call list_connector_accounts with provider linkedin, then set_connector_accounts). Do not guess a Page id.`
|
|
22676
22718
|
: 'This LinkedIn connection administers NO company Page. The account needs an admin role on the Page, and Hermoso’s LinkedIn app needs LinkedIn’s organization scopes granted. Do not guess a Page id.', d);
|
|
22677
22719
|
}
|
|
22678
22720
|
return ok(`${list.length} LinkedIn Page(s) shared with this profile. Let the USER pick:\n${list.map(o => `• ${o.name || '(unnamed)'} (id ${o.id}) — ${(o.roles || []).join(', ')}`).join('\n')}${held ? `\n(${held} further Page(s) this account administers are NOT shared with this profile and cannot be posted to.)` : ''}`, d);
|
|
@@ -22721,7 +22763,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22721
22763
|
server.group('channel_admin');
|
|
22722
22764
|
server.registerTool('list_linkedin_comments', {
|
|
22723
22765
|
title: 'Read the comments on a LinkedIn Page post',
|
|
22724
|
-
description: "Read the comments people have left on one of your LinkedIn company Page's posts. Community Management surface, and it needs its OWN connection: LinkedIn grants the comment scopes on a different app from the one that posts, so connecting LinkedIn does NOT connect this — the card is “LinkedIn Community Management” in
|
|
22766
|
+
description: "Read the comments people have left on one of your LinkedIn company Page's posts. Community Management surface, and it needs its OWN connection: LinkedIn grants the comment scopes on a different app from the one that posts, so connecting LinkedIn does NOT connect this — the card is “LinkedIn Community Management” in Connectors. Pass the post's urn, which is what post_to_linkedin_page returns. A post nobody has commented on comes back as an EMPTY LIST with a note, never an error: LinkedIn answers 404 for a post with no social activity, and that means “no comments yet”, not “no such post”. Each comment carries its text, author, time, like and reply counts, and `isOurs` so your Page's own replies are distinguishable from other people's comments; `fromOthers` counts the ones actually awaiting an answer. The connected member must hold an ADMINISTRATOR role on the Page.",
|
|
22725
22767
|
inputSchema: {
|
|
22726
22768
|
postUrn: z.string().describe('the post to read — urn:li:share:…, urn:li:ugcPost:… or urn:li:activity:…'),
|
|
22727
22769
|
limit: z.number().optional().describe('how many to fetch (default 50; clamped below LinkedIn’s own 200 timeout bound rather than refused)'),
|
|
@@ -22827,7 +22869,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22827
22869
|
}, wrap(async (a) => {
|
|
22828
22870
|
if (!a.adAccountId) {
|
|
22829
22871
|
const d = await apiGet('/api/linkedin/ads-shared', {});
|
|
22830
|
-
if (!(d.accounts || []).length) return ok('No LinkedIn ad account is shared with this profile yet. Ask the user to tick the ones it may use in
|
|
22872
|
+
if (!(d.accounts || []).length) return ok('No LinkedIn ad account is shared with this profile yet. Ask the user to tick the ones it may use in Connectors ▸ LinkedIn ▸ Manage accounts (or call set_connector_accounts), then retry.', d);
|
|
22831
22873
|
const lines = (d.accounts || []).map(c => `• ${c.name || c.id} (${c.id})${c.currency ? ` — ${c.currency}` : ''}${c.test ? ' [TEST account — never serves, no analytics]' : ''}${c.role ? ` · ${c.role}` : ''}`);
|
|
22832
22874
|
return ok(`${(d.accounts || []).length} LinkedIn ad account(s) shared with this profile:\n${lines.join('\n') || '(none — LinkedIn’s Advertising API product may not be granted on this app, or the member holds no ad-account role)'}\nPass an adAccountId to see its campaigns.`, d);
|
|
22833
22875
|
}
|
|
@@ -23377,7 +23419,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23377
23419
|
server.group('channel_admin');
|
|
23378
23420
|
server.registerTool('manage_meta_post', {
|
|
23379
23421
|
title: 'Edit or delete a published post',
|
|
23380
|
-
description: 'Edit the text of, or delete, a published post. target:"facebook" -> edit the message and/or Meta\u2019s AI-generated label (action:"edit", message, aiGenerated) OR delete (action:"delete"), and on a Facebook Reel list or invite collaborator Pages (action:"collaborators"); target:"threads" -> delete only (Threads has no edit API); target:"instagram" -> DELETE ONLY — Meta lets you change nothing on a published Instagram post except whether comments are enabled, so a caption cannot be fixed; deleting covers ordinary posts, Stories, Reels and ENTIRE carousel albums (Instagram cannot remove one card out of an album — pass the album’s own media id, from list_instagram_media). Deleting is permanent. FOR INSTAGRAM, CALL IT WITHOUT confirm FIRST: nothing is deleted and you get back the post’s real caption, its likes and comments and how many carousel cards go with it — show the user exactly that, then call again with confirm:true plus confirmName (and confirmChildren for an album) if the refusal asks for them. A post nobody has liked or commented on yet stays a one-call delete. INSTAGRAM DELETE NEEDS A RECONNECT ON AN OLD CONNECTION: the `instagram_manage_contents` permission joined Hermoso’s Meta grant on 2026-08-05, so any Meta connection made before then must be reconnected (
|
|
23422
|
+
description: 'Edit the text of, or delete, a published post. target:"facebook" -> edit the message and/or Meta\u2019s AI-generated label (action:"edit", message, aiGenerated) OR delete (action:"delete"), and on a Facebook Reel list or invite collaborator Pages (action:"collaborators"); target:"threads" -> delete only (Threads has no edit API); target:"instagram" -> DELETE ONLY — Meta lets you change nothing on a published Instagram post except whether comments are enabled, so a caption cannot be fixed; deleting covers ordinary posts, Stories, Reels and ENTIRE carousel albums (Instagram cannot remove one card out of an album — pass the album’s own media id, from list_instagram_media). Deleting is permanent. FOR INSTAGRAM, CALL IT WITHOUT confirm FIRST: nothing is deleted and you get back the post’s real caption, its likes and comments and how many carousel cards go with it — show the user exactly that, then call again with confirm:true plus confirmName (and confirmChildren for an album) if the refusal asks for them. A post nobody has liked or commented on yet stays a one-call delete. INSTAGRAM DELETE NEEDS A RECONNECT ON AN OLD CONNECTION: the `instagram_manage_contents` permission joined Hermoso’s Meta grant on 2026-08-05, so any Meta connection made before then must be reconnected (Connectors > Meta) before Instagram will accept a delete. Call the tool rather than pre-refusing — every refusal it can raise names the one thing that fixes it.',
|
|
23381
23423
|
inputSchema: {
|
|
23382
23424
|
...MANAGE_BRAND,
|
|
23383
23425
|
postId: z.string().describe('the post id returned by post_to_meta — for Instagram, the media id from list_instagram_media'),
|
|
@@ -23408,7 +23450,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23408
23450
|
server.group('files');
|
|
23409
23451
|
server.registerTool('save_to_drive', {
|
|
23410
23452
|
title: 'Save file(s) to Google Drive',
|
|
23411
|
-
description: 'Save a Hermoso render — or ANY file — into the user’s connected Google Drive. Pass a Hermoso render URL as url (or urls[] for several); for a local/external file, call upload_file first and pass the url it returns. Optional folder (created if new) + name. Returns the Drive file(s) with a webViewLink. Needs Google Drive connected (
|
|
23453
|
+
description: 'Save a Hermoso render — or ANY file — into the user’s connected Google Drive. Pass a Hermoso render URL as url (or urls[] for several); for a local/external file, call upload_file first and pass the url it returns. Optional folder (created if new) + name. Returns the Drive file(s) with a webViewLink. Needs Google Drive connected (Connectors > Google Drive — one connection covers Drive, Sheets and Docs). NOTE: Hermoso uses the drive.file scope, so it reaches ONLY the files it created plus any the user explicitly handed over with the Google file picker in the app — never their whole Drive.',
|
|
23412
23454
|
inputSchema: {
|
|
23413
23455
|
url: z.string().optional().describe('a single Hermoso render URL to save'),
|
|
23414
23456
|
urls: z.array(z.string()).optional().describe('several render URLs (up to 20) to save in one call'),
|
|
@@ -23547,7 +23589,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23547
23589
|
server.group('files');
|
|
23548
23590
|
server.registerTool('create_sheet', {
|
|
23549
23591
|
title: 'Create a Google Sheet',
|
|
23550
|
-
description: 'Create a new Google Spreadsheet in the user’s Drive and optionally fill it with rows — e.g. export a swipefile, ad list, or performance report. Pass rows as an array of row arrays (first row = headers). Returns the spreadsheet id + URL. Needs Google Drive connected (
|
|
23592
|
+
description: 'Create a new Google Spreadsheet in the user’s Drive and optionally fill it with rows — e.g. export a swipefile, ad list, or performance report. Pass rows as an array of row arrays (first row = headers). Returns the spreadsheet id + URL. Needs Google Drive connected (Connectors > Google Drive — one connection covers Drive, Sheets and Docs).',
|
|
23551
23593
|
inputSchema: {
|
|
23552
23594
|
title: z.string().optional().describe('spreadsheet title'),
|
|
23553
23595
|
rows: z.array(z.array(z.union([z.string(), z.number(), z.boolean()]))).optional().describe('rows to write — array of row arrays; first row = headers'),
|
|
@@ -23593,7 +23635,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23593
23635
|
server.group('files');
|
|
23594
23636
|
server.registerTool('export_swipefile_deck', {
|
|
23595
23637
|
title: 'Swipefile to Google Slides',
|
|
23596
|
-
description: 'Turn a SWIPEFILE COLLECTION into a real Google Slides deck — one slide per saved ad, carrying the creative, the brand, the ad copy, the run dates with the run length, and the platform. This is the thing a marketer actually presents to a client or a team. Returns the presentation id + URL. Creates a NEW deck every time: under the drive.file scope Hermoso can only touch files it created, so it cannot add slides to a deck the user already has. A creative whose ad-library link has expired cannot be embedded — Meta signs those URLs with a short expiry — so that slide says so in words and keeps its copy and run dates, and the reply reports how many. Needs Google Drive connected (
|
|
23638
|
+
description: 'Turn a SWIPEFILE COLLECTION into a real Google Slides deck — one slide per saved ad, carrying the creative, the brand, the ad copy, the run dates with the run length, and the platform. This is the thing a marketer actually presents to a client or a team. Returns the presentation id + URL. Creates a NEW deck every time: under the drive.file scope Hermoso can only touch files it created, so it cannot add slides to a deck the user already has. A creative whose ad-library link has expired cannot be embedded — Meta signs those URLs with a short expiry — so that slide says so in words and keeps its copy and run dates, and the reply reports how many. Needs Google Drive connected (Connectors > Google Drive — one connection covers Drive, Sheets, Docs and Slides).',
|
|
23597
23639
|
inputSchema: {
|
|
23598
23640
|
collection: z.string().optional().describe('the swipefile collection to export, by name or id (default: the first collection)'),
|
|
23599
23641
|
title: z.string().optional().describe('deck title (default: the collection name)'),
|
|
@@ -23612,7 +23654,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23612
23654
|
server.group('files');
|
|
23613
23655
|
server.registerTool('list_notion_pages', {
|
|
23614
23656
|
title: 'List Notion pages',
|
|
23615
|
-
description: "Find the Notion pages and databases the user shared with Hermoso, by title, newest edited first. Gives the page id that save_to_notion, append_to_notion_page and read_notion_page take, or the database id to save rows into. Read-only, free. Needs Notion connected (
|
|
23657
|
+
description: "Find the Notion pages and databases the user shared with Hermoso, by title, newest edited first. Gives the page id that save_to_notion, append_to_notion_page and read_notion_page take, or the database id to save rows into. Read-only, free. Needs Notion connected (Connectors > Notion).",
|
|
23616
23658
|
inputSchema: { query: z.string().optional().describe('words in the title'), type: z.enum(['page', 'database']).optional().describe('limit results to pages or to databases; omit for both'), limit: z.number().optional().describe('1-100, default 25'), cursor: z.string().optional().describe('from the previous page') },
|
|
23617
23659
|
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), results: z.array(z.any()).optional(), nextCursor: z.string().nullable().optional(), note: z.string().optional() },
|
|
23618
23660
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -23654,7 +23696,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23654
23696
|
server.group('files');
|
|
23655
23697
|
server.registerTool('query_notion_database', {
|
|
23656
23698
|
title: 'Read a Notion database',
|
|
23657
|
-
description: "Read the ROWS of a Notion database the user shared with Hermoso — e.g. the team's content calendar: each row's title, id, link and every property as a plain value (status, date, channels, caption, links), plus the database's columns with their types and options. Filter with match {\"Status\": \"Scheduled\"} (typed from the database's own columns) or a raw Notion filter object; sort with sortBy (a column, or created_time / last_edited_time) and direction. Up to 100 rows per call, cursor for more; the answer says when Notion stopped at its 10,000-result limit. Use it to import a calendar and schedule its posts, or to find the row update_notion_row changes. Read-only, free. Needs Notion connected (
|
|
23699
|
+
description: "Read the ROWS of a Notion database the user shared with Hermoso — e.g. the team's content calendar: each row's title, id, link and every property as a plain value (status, date, channels, caption, links), plus the database's columns with their types and options. Filter with match {\"Status\": \"Scheduled\"} (typed from the database's own columns) or a raw Notion filter object; sort with sortBy (a column, or created_time / last_edited_time) and direction. Up to 100 rows per call, cursor for more; the answer says when Notion stopped at its 10,000-result limit. Use it to import a calendar and schedule its posts, or to find the row update_notion_row changes. Read-only, free. Needs Notion connected (Connectors > Notion).",
|
|
23658
23700
|
inputSchema: { databaseId: z.string().optional().describe('database id or link (list_notion_pages type:"database")'), dataSourceId: z.string().optional().describe('data source id from an earlier read, as an alternative to databaseId'), match: z.record(z.any()).optional().describe('{"Column": value} — equality for status/select/checkbox/number/date, contains for text/multi-select'), filter: z.record(z.any()).optional().describe('a raw Notion filter object'), sortBy: z.string().optional().describe('column name to sort by, or created_time or last_edited_time'), direction: z.enum(['ascending', 'descending']).optional().describe('ascending (default) or descending'), limit: z.number().optional().describe('1-100, default 50'), cursor: z.string().optional().describe('nextCursor from the previous page of rows'), archived: z.boolean().optional().describe('read archived rows instead') },
|
|
23659
23701
|
outputSchema: { ok: z.boolean().optional(), database: z.string().optional(), dataSourceId: z.string().optional(), schema: z.any().optional(), count: z.number().optional(), rows: z.array(z.any()).optional(), nextCursor: z.string().nullable().optional(), incomplete: z.boolean().optional(), note: z.string().optional() },
|
|
23660
23702
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -23679,7 +23721,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23679
23721
|
server.group('files');
|
|
23680
23722
|
server.registerTool('save_to_canva', {
|
|
23681
23723
|
title: 'Save to Canva',
|
|
23682
|
-
description: "Send Hermoso renders to the user's Canva: each is uploaded to their Canva uploads, ready to drag into any design. asDesign:true also asks Canva AI to rebuild a PNG, JPEG or WEBP image as a new design whose text, objects and background are separate editable layers: it uses the account's Canva credits and can redraw details, so only when the user asks for an editable design. Pass Library ids (L0… from list_library), Hermoso render links, or any public https image/video link (upload_file turns a local file into one); optional title, folderId (list_canva_folder / create_canva_folder), asDesign:false for the upload only. Answers with each upload, and each design's edit link. Free on Hermoso. Needs Canva connected (
|
|
23724
|
+
description: "Send Hermoso renders to the user's Canva: each is uploaded to their Canva uploads, ready to drag into any design. asDesign:true also asks Canva AI to rebuild a PNG, JPEG or WEBP image as a new design whose text, objects and background are separate editable layers: it uses the account's Canva credits and can redraw details, so only when the user asks for an editable design. Pass Library ids (L0… from list_library), Hermoso render links, or any public https image/video link (upload_file turns a local file into one); optional title, folderId (list_canva_folder / create_canva_folder), asDesign:false for the upload only. Answers with each upload, and each design's edit link. Free on Hermoso. Needs Canva connected (Connectors > Canva).",
|
|
23683
23725
|
inputSchema: { ids: z.array(z.string()).optional().describe('Library ids, e.g. L0'), urls: z.array(z.string()).optional().describe('render links or any public https image/video link'), title: z.string().optional().describe('title for the new Canva design or upload'), folderId: z.string().optional().describe('Canva folder id from list_canva_folder or create_canva_folder; omit for the default folder'), asDesign: z.boolean().optional().describe('true asks Canva AI to rebuild an image as an editable layered design (uses Canva credits); default false: the upload only') },
|
|
23684
23726
|
outputSchema: { ok: z.boolean().optional(), sent: z.number().optional(), failed: z.number().optional(), results: z.array(z.any()).optional(), note: z.string().optional() },
|
|
23685
23727
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
@@ -23740,7 +23782,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23740
23782
|
server.group('files');
|
|
23741
23783
|
server.registerTool('create_doc', {
|
|
23742
23784
|
title: 'Create a Google Doc',
|
|
23743
|
-
description: 'Create a new Google Doc in the user’s Drive with a title + optional body text — e.g. export ad copy, a creative brief, or a report. Returns the document id + URL. Needs Google Drive connected (
|
|
23785
|
+
description: 'Create a new Google Doc in the user’s Drive with a title + optional body text — e.g. export ad copy, a creative brief, or a report. Returns the document id + URL. Needs Google Drive connected (Connectors > Google Drive — one connection covers Drive, Sheets and Docs).',
|
|
23744
23786
|
inputSchema: {
|
|
23745
23787
|
title: z.string().optional().describe('document title'),
|
|
23746
23788
|
text: z.string().optional().describe('body text to insert'),
|
|
@@ -23904,7 +23946,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23904
23946
|
server.group('files');
|
|
23905
23947
|
server.registerTool('save_to_onedrive', {
|
|
23906
23948
|
title: 'Save file(s) to OneDrive',
|
|
23907
|
-
description: 'Save a Hermoso render — or ANY file — into the user’s connected Microsoft OneDrive. Pass a Hermoso render URL as url (or urls[] for several); for a local/external file, call upload_file first and pass the url it returns. Optional folder (created if new) + name. Returns the OneDrive file(s) with a webViewLink. Needs OneDrive connected (
|
|
23949
|
+
description: 'Save a Hermoso render — or ANY file — into the user’s connected Microsoft OneDrive. Pass a Hermoso render URL as url (or urls[] for several); for a local/external file, call upload_file first and pass the url it returns. Optional folder (created if new) + name. Returns the OneDrive file(s) with a webViewLink. Needs OneDrive connected (Connectors > OneDrive).',
|
|
23908
23950
|
inputSchema: {
|
|
23909
23951
|
url: z.string().optional().describe('a single Hermoso render URL to save'),
|
|
23910
23952
|
urls: z.array(z.string()).optional().describe('several render URLs (up to 20) to save in one call'),
|
|
@@ -24080,7 +24122,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
24080
24122
|
};
|
|
24081
24123
|
server.registerTool('plan_ad', {
|
|
24082
24124
|
title: 'Plan an ad concept',
|
|
24083
|
-
description: 'Plan an ad: turns a brand and a product or brief into an ad concept, with copy variants (headline, primary text, call to action) and either an image prompt or a timed video storyboard, plus the recipe and the model ids to render with. It renders nothing; pass its output to render_ad, generate_image or generate_video. A named length ("a 30 second ad") goes in durationSeconds and the storyboard is written to it; a video with none is not planned: a free question comes back to ask the user (15s is one clip, or 30s, or any 4-180). The user\u2019s own screenshots or pictures to show inside the video go in `inserts`. The brand\u2019s SAVED product photo (get_brand / list_product_photos) is used automatically; never ask the user to upload it again. Uses credits for the planning step.',
|
|
24125
|
+
description: 'Plan an ad: turns a brand and a product or brief into an ad concept, with copy variants (headline, primary text, call to action) and either an image prompt or a timed video storyboard, plus the recipe and the model ids to render with. It renders nothing; pass its output to render_ad, generate_image or generate_video. For a single image ad it is optional: generate_image alone makes one from the saved brand, so skip this step unless the user wants concepts or copy options first. A named length ("a 30 second ad") goes in durationSeconds and the storyboard is written to it; a video with none is not planned: a free question comes back to ask the user (15s is one clip, or 30s, or any 4-180). The user\u2019s own screenshots or pictures to show inside the video go in `inserts`. The brand\u2019s SAVED product photo (get_brand / list_product_photos) is used automatically; never ask the user to upload it again. Uses credits for the planning step.',
|
|
24084
24126
|
inputSchema: {
|
|
24085
24127
|
brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name, or a brand profile object {name,domain,category,palette,products,…}. OMIT to use the workspace’s SAVED brand + memory automatically (see get_brand); use draft_brand to onboard a new one'),
|
|
24086
24128
|
product: z.string().describe('what to advertise + any angle/offer the user specified'),
|
|
@@ -24927,6 +24969,20 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
24927
24969
|
const pal = (Array.isArray(b.palette) ? b.palette : []).filter((c) => /^#[0-9a-f]{6}$/i.test(String(c || '')));
|
|
24928
24970
|
_cfg = { ..._cfg, endCard: { ..._cfg.endCard, ...(!_cfg.endCard.logo && b.logo ? { logo: b.logo } : {}), ...(!_cfg.endCard.domain && b.domain ? { domain: b.domain } : {}) }, ...(!Array.isArray(_cfg.palette) && pal.length ? { palette: pal } : {}) };
|
|
24929
24971
|
}
|
|
24972
|
+
// THE BRAND'S FONTS AND COLOURS REACH A PRESET TOO (2026-11-13). The web composer sends the saved fonts and palette with
|
|
24973
|
+
// every template; over MCP a preset got neither unless the caller wrote them into config, so a customer who set his
|
|
24974
|
+
// fonts saw them on the web and never on an agent's render. The server sets the designed presets in the brand fonts
|
|
24975
|
+
// (and leaves the chat / Notes look-alikes in their system type); the palette fills only when the caller gave none.
|
|
24976
|
+
// A profile set to "just exploring" is never applied.
|
|
24977
|
+
if (_cfg && typeof _cfg === 'object' && String(_cfg.template || '') !== 'custom') {
|
|
24978
|
+
let b = await readStore('heist.brand.v1').catch(() => null); if (!b || typeof b !== 'object' || Array.isArray(b)) b = {};
|
|
24979
|
+
if (!b.setAside) {
|
|
24980
|
+
const fonts = (Array.isArray(b.fonts) ? b.fonts : []).filter((f) => typeof f === 'string' && f.trim());
|
|
24981
|
+
if (fonts.length) _brand = { ...(_brand || {}), fonts };
|
|
24982
|
+
const pal = (Array.isArray(b.palette) ? b.palette : []).filter((c) => /^#[0-9a-f]{6}$/i.test(String(c || '')));
|
|
24983
|
+
if (!Array.isArray(_cfg.palette) && pal.length) _cfg = { ..._cfg, palette: pal };
|
|
24984
|
+
}
|
|
24985
|
+
}
|
|
24930
24986
|
const r = await renderJob('templatead', { config: _cfg, ...(_brand ? { brand: _brand } : {}) }, 'MCP template ad');
|
|
24931
24987
|
if (Array.isArray(r?.raw?.images) && r.raw.images.length) { // carousel: one PNG per slide → list every URL + inline the first slide
|
|
24932
24988
|
const urls = r.raw.images.map((u) => abs(u));
|
|
@@ -25622,7 +25678,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
25622
25678
|
if (st && st.status === 'running') return ok(`${said.join('\n')}${said.length ? '\n' : ''}The ${st.label.toLowerCase()} stage is still rendering [job ${st.jobId}]. Call advance_story_film({runId:"${run.id}"}) again in a minute or two to pick it up; nothing more is charged by asking.\n${storyStageLine(run)}`, { run, stillRendering: true, jobId: st.jobId });
|
|
25623
25679
|
const next = run.next || {};
|
|
25624
25680
|
if (next.kind === 'create') {
|
|
25625
|
-
if (!approved) return ok(`${said.join('\n')}${said.length ? '\n' : ''}Next: the screenplay for "${run.title}" (${run.lengthSeconds}s). ${next.billing}
|
|
25681
|
+
if (!approved) return ok(`${said.join('\n')}${said.length ? '\n' : ''}Next: the screenplay for "${run.title}" (${run.lengthSeconds}s). ${next.billing}. Call again with approve:true to write it.\n${storyStageLine(run)}`, { run });
|
|
25626
25682
|
const c = await apiPost('/api/create', d.screenplayBody || {});
|
|
25627
25683
|
const creative = c?.creative || c;
|
|
25628
25684
|
if (!creative?.video_storyboard) return ok(`The planner did not return a screenplay: ${c?.message || c?.error || 'no storyboard'}. Nothing was recorded; call again to retry.`, { run, planner: c });
|
|
@@ -26617,24 +26673,30 @@ function memoryNoteVerdict(text) {
|
|
|
26617
26673
|
})));
|
|
26618
26674
|
server.registerTool('update_brand', {
|
|
26619
26675
|
title: 'Update profile fields',
|
|
26620
|
-
description: 'Patch SPECIFIC fields of the active profile (name, domain, sells, summary, category, audience, positioning, voice, style, goal, logos, brand/product name pronunciations, profileType: explore = kept but unused) WITHOUT overwriting the rest — a read-modify-write on the saved brand. Use for “change our voice to playful”, “we sell to dentists now”. To onboard a brand from scratch, use draft_brand. If no brand is saved yet and you only INFERRED one from what the user is making, ask them to confirm it is their brand before saving it (they may be working for a client or just trying things). Only pass the fields you’re changing.',
|
|
26676
|
+
description: 'Patch SPECIFIC fields of the active profile (name, domain, sells, summary, category, audience, positioning, voice, style, goal, fonts, palette, logos, brand/product name pronunciations, profileType: explore = kept but unused) WITHOUT overwriting the rest — a read-modify-write on the saved brand. Use for “change our voice to playful”, “we sell to dentists now”, “our fonts are Benne and Josefin Sans”, “our colours are #3D4C41 and #EEE4DA”. The fonts and palette are what template ads are set in and what every planned ad is told to use, so set them here rather than describing them in style. To onboard a brand from scratch, use draft_brand. If no brand is saved yet and you only INFERRED one from what the user is making, ask them to confirm it is their brand before saving it (they may be working for a client or just trying things). Only pass the fields you’re changing.',
|
|
26621
26677
|
inputSchema: { ...STORE_BRAND,
|
|
26622
26678
|
name: z.string().optional().describe('brand name'), domain: z.string().optional().describe('website domain'), sells: z.string().optional().describe('what the brand sells'),
|
|
26623
26679
|
summary: z.string().optional().describe('one-line description'), category: z.string().optional().describe('product or business category, one line'), audience: z.string().optional().describe('who the brand sells to, one line'),
|
|
26624
26680
|
positioning: z.string().optional().describe('how the brand is positioned against alternatives, one line'), voice: z.string().optional().describe('brand voice/tone'), style: z.string().optional().describe('visual style — palette, typography, aesthetic'), goal: z.string().optional().describe('current marketing goal'),
|
|
26625
26681
|
pronounce: z.string().optional().describe('how the brand NAME is said aloud, as a simple respelling with the stressed syllable in capitals (e.g. "KOH-dee-ak"). Videos use it as a delivery note beside the spoken line; set it when a render mispronounced the name.'),
|
|
26626
26682
|
pronunciations: z.record(z.string()).optional().describe('how PRODUCT names are said aloud, e.g. {"Power Cakes": "POW-er cakes"}. Merged into the saved ones; an empty string removes one.'),
|
|
26683
|
+
fonts: z.union([z.array(z.string()), z.object({ headline: z.string().optional(), body: z.string().optional() })]).optional().describe('the brand fonts, any family name: a list with the HEADLINE font first and the BODY font second (it replaces the saved list), or {headline, body} to change one and keep the other ("" removes one). Template ads load any Google Fonts family; the reply says what each will be set in.'),
|
|
26684
|
+
palette: z.array(z.string()).optional().describe(`the brand colours as six-digit hex codes like "#3D4C41", in order, up to ${PALETTE_MAX}. Replaces the saved palette; [] clears it. A short code (#abc) or one without # is refused, never rewritten.`),
|
|
26627
26685
|
logo: z.string().optional().describe('logo image: URL or local file path to a PNG, JPEG or WebP (no SVG)'),
|
|
26628
26686
|
logoLight: z.string().optional().describe('for dark pictures; "none" removes'),
|
|
26629
26687
|
logoDark: z.string().optional().describe('for light pictures'),
|
|
26630
26688
|
profileType: z.enum(['website', 'social', 'describe', 'explore']).optional().describe('website, social, describe or explore; explore keeps the profile but stops using it'),
|
|
26631
26689
|
},
|
|
26632
|
-
outputSchema: { ok: z.boolean().optional(), updated: z.array(z.string()).optional(), brand: z.any().optional(), profileType: z.string().optional() },
|
|
26690
|
+
outputSchema: { ok: z.boolean().optional(), updated: z.array(z.string()).optional(), brand: z.any().optional(), profileType: z.string().optional(), fontCheck: z.any().optional().describe('what template ads will set each font in (exact, a similar stand-in, or none)') },
|
|
26633
26691
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
26634
26692
|
}, wrap(inBrand(async (a) => {
|
|
26635
26693
|
const allow = ['name', 'domain', 'sells', 'summary', 'category', 'audience', 'positioning', 'voice', 'style', 'goal', 'pronounce'];
|
|
26636
26694
|
const patch = {}; for (const k of allow) if (a[k] != null && String(a[k]).trim()) patch[k] = String(a[k]).slice(0, 400);
|
|
26637
26695
|
let brand = await readStore('heist.brand.v1'); if (!brand || typeof brand !== 'object' || Array.isArray(brand)) brand = {};
|
|
26696
|
+
// THE FONTS AND THE PALETTE (2026-11-13): a customer could not change either on any surface. One parser for every door
|
|
26697
|
+
// (./brand-look.mjs): trimmed, otherwise kept exactly as written, and refused by name when unusable — nothing is saved then.
|
|
26698
|
+
if (a.fonts != null) { const p = parseBrandFonts(a.fonts, brand.fonts); if (p.error) return { content: [{ type: 'text', text: `${p.error} Nothing was changed.` }], isError: true }; patch.fonts = p.value; }
|
|
26699
|
+
if (a.palette != null) { const p = parseBrandPalette(a.palette); if (p.error) return { content: [{ type: 'text', text: `${p.error} Nothing was changed.` }], isError: true }; patch.palette = p.value; }
|
|
26638
26700
|
// PRODUCT-NAME PRONUNCIATIONS merge into the saved map ('' removes one) — the user's correction of a derived hint.
|
|
26639
26701
|
if (a.pronunciations && typeof a.pronunciations === 'object' && !Array.isArray(a.pronunciations)) {
|
|
26640
26702
|
const cur = { ...((brand.pronunciations && typeof brand.pronunciations === 'object' && !Array.isArray(brand.pronunciations)) ? brand.pronunciations : {}) };
|
|
@@ -26661,7 +26723,17 @@ function memoryNoteVerdict(text) {
|
|
|
26661
26723
|
const merged = { ...brand, ...patch };
|
|
26662
26724
|
await writeStore('heist.brand.v1', merged); // the store PUT preserves server-side brand enrichments (playbook/pronounce)
|
|
26663
26725
|
const _typeNote = patch.profileType === 'explore' ? ' The profile is now set to just exploring: it stays saved, and nothing you make uses it until profileType is switched back.' : patch.profileType ? ` It is now a ${patch.profileType} profile, used for what you make.` : '';
|
|
26664
|
-
|
|
26726
|
+
// WHAT A TEMPLATE WILL SET THE FONTS IN, said now rather than discovered on a render. A failed check says nothing.
|
|
26727
|
+
let _fontNote = '', _fontCheck;
|
|
26728
|
+
if (patch.fonts) {
|
|
26729
|
+
_fontNote = patch.fonts.length ? ` Fonts: ${describeBrandFonts(patch.fonts)}.` : ' Fonts cleared.';
|
|
26730
|
+
if (patch.fonts.length) { try { _fontCheck = await apiGet('/api/brand/font-check?fonts=' + encodeURIComponent(JSON.stringify(patch.fonts))); } catch { _fontCheck = undefined; }
|
|
26731
|
+
const miss = (_fontCheck?.fonts || []).filter((f) => f.how !== 'exact');
|
|
26732
|
+
if (_fontCheck && miss.length) _fontNote += ' ' + miss.map((f) => f.how === 'similar' ? `Template ads set ${f.asked} in ${f.as}, the closest font they can load.` : `Template ads cannot load ${f.asked}; they use ${_fontCheck.headline?.used || 'a clean system sans'} instead.`).join(' ');
|
|
26733
|
+
else if (_fontCheck) _fontNote += ' Template ads load both.'; }
|
|
26734
|
+
}
|
|
26735
|
+
const _palNote = patch.palette ? (patch.palette.length ? ` Palette: ${patch.palette.join(', ')}.` : ' Palette cleared.') : '';
|
|
26736
|
+
return ok(`Updated ${merged.name ? `“${merged.name}”` : 'the brand'}${namedBrand(a) ? ` (the brand you named, ${namedBrand(a)})` : ''} — ${Object.keys(patch).filter((k) => k !== 'setAside').join(', ')}.${_typeNote}${_fontNote}${_palNote}`, { ok: true, updated: Object.keys(patch).filter((k) => k !== 'setAside'), brand: merged, ...(patch.profileType ? { profileType: patch.profileType } : {}), ...(_fontCheck ? { fontCheck: _fontCheck } : {}) });
|
|
26665
26737
|
})));
|
|
26666
26738
|
server.registerTool('store_get', {
|
|
26667
26739
|
title: 'Read a workspace store',
|
|
@@ -26685,21 +26757,32 @@ function memoryNoteVerdict(text) {
|
|
|
26685
26757
|
// so setting it once changes what every subsequent ad, plan and answer is written in.
|
|
26686
26758
|
server.registerTool('get_settings', {
|
|
26687
26759
|
title: 'Read app settings',
|
|
26688
|
-
description: 'Read this account\'s app settings — the LANGUAGE Hermoso writes ads and copy in (chat replies follow the language the user writes in), the app appearance (theme), and
|
|
26760
|
+
description: 'Read this account\'s app settings — the LANGUAGE Hermoso writes ads and copy in (chat replies follow the language the user writes in), the app appearance (theme), and which Hermoso emails are on (tips, product news, credit and plan notices, competitor watch, autopilot reviews, weekly posts, and the all-emails switch). Same settings as the web app\'s Settings pane. Read-only, free.',
|
|
26689
26761
|
inputSchema: {},
|
|
26690
26762
|
outputSchema: { language: z.string().optional(), theme: z.string().optional(), notifications: z.any().optional(), privacy: z.any().optional() },
|
|
26691
26763
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
26692
26764
|
}, wrap(async () => {
|
|
26693
26765
|
const d = await apiGet('/api/settings');
|
|
26694
|
-
|
|
26766
|
+
const em = d.notifications?.emails || {};
|
|
26767
|
+
const emLine = Object.keys(em).length ? `\nEmails: ${Object.entries(em).map(([k, v]) => `${k} ${v === false ? 'off' : 'on'}`).join(', ')}` : '';
|
|
26768
|
+
return ok(`Language: ${d.language}\nAppearance: ${d.theme}\nWeekly competitor-watch email: ${d.notifications?.watchEmail === false ? 'off' : 'on'}${emLine}\n${d.privacy?.note || ''}`, d);
|
|
26695
26769
|
}));
|
|
26696
26770
|
server.registerTool('update_settings', {
|
|
26697
26771
|
title: 'Change app settings',
|
|
26698
|
-
description: 'Change this account\'s app settings. language = the language EVERY ad, script and plan is written in from now on (chat replies follow the language the user writes in) (say the language in plain English, e.g. "German", "Japanese", "Brazilian Portuguese") — it applies to renders made over MCP as well as in the app. theme = the app\'s appearance, "dark" or "light". watchEmail = the weekly competitor-watch email on/off. Only pass what you are changing. Account-wide (every profile), and it takes effect on the next call.',
|
|
26772
|
+
description: 'Change this account\'s app settings. language = the language EVERY ad, script and plan is written in from now on (chat replies follow the language the user writes in) (say the language in plain English, e.g. "German", "Japanese", "Brazilian Portuguese") — it applies to renders made over MCP as well as in the app. theme = the app\'s appearance, "dark" or "light". watchEmail = the weekly competitor-watch email on/off. emails = turn Hermoso emails on or off by group, including turning back on what an unsubscribe link turned off. Only pass what you are changing. Account-wide (every profile), and it takes effect on the next call.',
|
|
26699
26773
|
inputSchema: {
|
|
26700
26774
|
language: z.string().optional().describe('language for generated ads and copy (chat replies follow the language the user writes in) — e.g. "English", "German", "Japanese"'),
|
|
26701
26775
|
theme: z.enum(['dark', 'light']).optional().describe('app appearance'),
|
|
26702
26776
|
watchEmail: z.boolean().optional().describe('weekly competitor-watch email on/off'),
|
|
26777
|
+
emails: z.object({
|
|
26778
|
+
tips: z.boolean().optional().describe('tips and how-tos after signup'),
|
|
26779
|
+
productNews: z.boolean().optional().describe('product news and launches'),
|
|
26780
|
+
notices: z.boolean().optional().describe('low-credit and plan notices'),
|
|
26781
|
+
watch: z.boolean().optional().describe('weekly competitor-watch email'),
|
|
26782
|
+
autopilot: z.boolean().optional().describe('posts ready for review'),
|
|
26783
|
+
weeklyPosts: z.boolean().optional().describe('weekly email about your posts'),
|
|
26784
|
+
all: z.boolean().optional().describe('master switch; off = only sign-in and billing emails arrive'),
|
|
26785
|
+
}).optional().describe('email switches by group; pass only the ones you are changing'),
|
|
26703
26786
|
},
|
|
26704
26787
|
outputSchema: { ok: z.boolean().optional(), changed: z.array(z.string()).optional(), language: z.string().optional(), theme: z.string().optional(), notifications: z.any().optional() },
|
|
26705
26788
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
@@ -26708,13 +26791,16 @@ function memoryNoteVerdict(text) {
|
|
|
26708
26791
|
if (a.language != null) body.language = a.language;
|
|
26709
26792
|
if (a.theme != null) body.theme = a.theme;
|
|
26710
26793
|
if (a.watchEmail != null) body.watchEmail = a.watchEmail;
|
|
26711
|
-
if (
|
|
26794
|
+
if (a.emails && Object.keys(a.emails).length) body.emails = a.emails;
|
|
26795
|
+
if (!Object.keys(body).length) return { content: [{ type: 'text', text: 'Pass at least one of: language, theme, watchEmail, emails.' }], isError: true };
|
|
26712
26796
|
const d = await apiPost('/api/settings', body);
|
|
26713
|
-
|
|
26797
|
+
const em = d.notifications?.emails || {};
|
|
26798
|
+
const emLine = body.emails && Object.keys(em).length ? ` · Emails: ${Object.entries(em).map(([k, v]) => `${k} ${v === false ? 'off' : 'on'}`).join(', ')}` : '';
|
|
26799
|
+
return ok(`Updated ${(d.changed || []).join(', ')}. Language: ${d.language} · Appearance: ${d.theme} · Weekly competitor-watch email: ${d.notifications?.watchEmail === false ? 'off' : 'on'}${emLine}.${body.language ? '\nEvery ad, script and answer from here on is written in ' + d.language + '.' : ''}`, d);
|
|
26714
26800
|
}));
|
|
26715
26801
|
server.registerTool('list_connectors', {
|
|
26716
26802
|
title: 'List connectors',
|
|
26717
|
-
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
|
|
26803
|
+
description: 'List the third-party accounts connected to this workspace (Meta, Google Ads, Google Drive/Sheets/Docs, YouTube, LinkedIn, OneDrive, Slack, …) — provider, status and the connected account label — PLUS which providers are available to connect. IT ALSO FLAGS A CONNECTION WHOSE PERMISSIONS ARE OUT OF DATE: a provider writes its granted permission set into the token at consent time, so a connection authorized before a permission was approved does not carry it and never will \u2014 those calls are refused by the provider and no retry or wait can change it. Check this FIRST when a connected provider starts refusing things. The fix is a browser: the user reconnects under Connectors. Read-only, free.',
|
|
26718
26804
|
inputSchema: {},
|
|
26719
26805
|
outputSchema: { connectors: z.array(z.any()).optional(), providers: z.array(z.string()).optional() },
|
|
26720
26806
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
@@ -26735,8 +26821,8 @@ function memoryNoteVerdict(text) {
|
|
|
26735
26821
|
// (2026-10-07, OpenAI plugin guidelines: restricted data includes "Access credentials and authentication secrets (such
|
|
26736
26822
|
// as API keys …)"). So there the paste-a-key half points at the app instead of at a tool the host does not list.
|
|
26737
26823
|
const widget = hostRendersWidgets();
|
|
26738
|
-
const emptyWidget = '\nIf this profile should have connected accounts, check WHICH workspace this connection is on: list_brands names every profile on this account plus every one shared with you, and use_brand switches. Otherwise the user connects an account in the Hermoso app under
|
|
26739
|
-
const empty = '\nIf this profile should have connected accounts, check WHICH workspace this connection is on: list_brands names every profile on this account plus every one shared with you, and use_brand switches. Otherwise connect one: an account that connects through a sign-in screen needs a browser (hand the user its connect link, below), and a paste-a-key account can be connected right here with connect_connector or in
|
|
26824
|
+
const emptyWidget = '\nIf this profile should have connected accounts, check WHICH workspace this connection is on: list_brands names every profile on this account plus every one shared with you, and use_brand switches. Otherwise the user connects an account in the Hermoso app under Connectors (hand them the connect link below for a sign-in account).';
|
|
26825
|
+
const empty = '\nIf this profile should have connected accounts, check WHICH workspace this connection is on: list_brands names every profile on this account plus every one shared with you, and use_brand switches. Otherwise connect one: an account that connects through a sign-in screen needs a browser (hand the user its connect link, below), and a paste-a-key account can be connected right here with connect_connector or in Connectors, whichever the user prefers.';
|
|
26740
26826
|
// AN AGENT NEVER SEES A PICKER PROVIDER'S RAW `accountLabel` (2026-08-04). It names whoever AUTHORIZED the
|
|
26741
26827
|
// connection, and on LinkedIn that is a tickable, postable identity the user has almost certainly NOT ticked
|
|
26742
26828
|
// (config.liMember is off by default) — printing it told Studio a personal profile was postable when it was not.
|
|
@@ -26770,12 +26856,12 @@ function memoryNoteVerdict(text) {
|
|
|
26770
26856
|
const lines = on.map(c => ` • ${c.provider}${c.agentLabel ? ` — ${c.agentLabel}` : ''} (${c.status || 'active'})${c.grantMode === 'posting' ? ' · POSTING ONLY' : ''}${c.scopeDrift?.status === 'missing' ? ` ⚠ missing ${c.scopeDrift.missing.length} permission(s) — needs a reconnect` : ''}${c.needsReconnect && c.revokedReason ? ` ⚠ RECONNECT NEEDED — ${c.revokedReason}` : ''}${c.deliveryWarning?.why ? ` ⚠ ${String(c.deliveryWarning.badge || 'not delivering').toUpperCase()} — ${c.deliveryWarning.why}` : ''}`);
|
|
26771
26857
|
const postingNote = posting.length
|
|
26772
26858
|
? '\n\n' + posting.map(c => `ℹ ${c.grantModeNote}`).join('\n')
|
|
26773
|
-
+ '\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:
|
|
26859
|
+
+ '\nAds, catalog, WhatsApp and Business-portfolio tools will refuse on this connection, and that is the user\'s deliberate choice rather than a fault — do not retry them. Widening it is an OAuth consent screen, so the user does it in a browser: Connectors ▸ Meta ▸ Reconnect with everything, or open ' + linkFor('meta') + '.'
|
|
26774
26860
|
: '';
|
|
26775
26861
|
const gapNote = gap.length
|
|
26776
26862
|
? `\n\n⚠ ${gap.length} connection(s) need their permissions updated:\n`
|
|
26777
26863
|
+ gap.map(c => ` • ${c.scopeDriftNote || `${c.provider}: missing ${(c.scopeDrift.missing || []).join(', ')}`}`).join('\n')
|
|
26778
|
-
+ '\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 —
|
|
26864
|
+
+ '\nThis is not something an agent can fix: re-authorizing is an OAuth consent screen, so the user has to do it in a browser — Connectors ▸ the connector ▸ Reconnect. Everything the connection already carries keeps working until then.' + gap.map(c => `\n Reconnect ${c.provider}: ${linkFor(c.provider)}`).join('')
|
|
26779
26865
|
: '';
|
|
26780
26866
|
// A DEAD CONNECTION IS LEFT OUT OF `on` (status revoked), so without this it read as "not connected" (2026-11-12).
|
|
26781
26867
|
const broken = await brokenConnectorNotes();
|
|
@@ -26783,7 +26869,7 @@ function memoryNoteVerdict(text) {
|
|
|
26783
26869
|
const safe = { ...d, connectors: (d.connectors || []).map(({ accountLabel, ...c }) => c) };
|
|
26784
26870
|
if (broken.length) safe.needsAttention = broken.map(({ provider, label, who, reason, steps, connectUrl, postsWaiting, nextPostAt, agentNote }) => ({ provider, label, who, reason, steps, connectUrl, postsWaiting, nextPostAt, note: agentNote }));
|
|
26785
26871
|
const onBrand = d.brand && typeof d.brand.profileId === 'string' ? `On ${d.brand.name ? `"${d.brand.name}" (${d.brand.profileId})` : `profile ${d.brand.profileId}`}${d.brand.shared ? ', shared with you' : ''}. ` : '';
|
|
26786
|
-
return ok(`${onBrand}${on.length} connected:\n${lines.join('\n') || ' (none)'}\nAvailable to connect through a sign-in screen (needs a browser; the link is ${linkFor('<provider>')}): ${(d.providers || []).join(', ') || '(none configured)'}.\nPaste-a-key, ${widget ? 'connected in the Hermoso app under
|
|
26872
|
+
return ok(`${onBrand}${on.length} connected:\n${lines.join('\n') || ' (none)'}\nAvailable to connect through a sign-in screen (needs a browser; the link is ${linkFor('<provider>')}): ${(d.providers || []).join(', ') || '(none configured)'}.\nPaste-a-key, ${widget ? 'connected in the Hermoso app under Connectors (keys are never pasted into this chat)' : 'connectable from here with connect_connector or in the app'}: ${Object.keys(KEY_CONNECTORS).join(', ')}.${on.length ? '' : (widget ? emptyWidget : empty)}${brokenNote}${gapNote}${waitingNote}${postingNote}`, safe);
|
|
26787
26873
|
}));
|
|
26788
26874
|
// ── ALERTS (2026-10-27): Slack / Discord / a signed generic webhook, for renders, scheduled posts, drafts waiting for
|
|
26789
26875
|
// approval, competitor-watch findings, new leads and low credits. Rules: lib/alert-sinks.mjs; routes /api/alerts*.
|
|
@@ -27028,7 +27114,7 @@ function memoryNoteVerdict(text) {
|
|
|
27028
27114
|
const p = CONN_ACCOUNT_PICKERS[a.provider];
|
|
27029
27115
|
if (!p) return { content: [{ type: 'text', text: `list_connector_accounts covers: ${CONN_PICKER_IDS.join(', ')}.` }], isError: true };
|
|
27030
27116
|
const d = await apiGet(p.read);
|
|
27031
|
-
if (p.needsConnect?.(d)) return { content: [{ type: 'text', text: `${p.label} isn’t connected to this workspace yet. Linking it is an OAuth consent screen, so the user has to do it in a browser:
|
|
27117
|
+
if (p.needsConnect?.(d)) return { content: [{ type: 'text', text: `${p.label} isn’t connected to this workspace yet. Linking it is an OAuth consent screen, so the user has to do it in a browser: Connectors ▸ ${p.label}. Then call this again.` }], isError: true };
|
|
27032
27118
|
const rows = p.identities(d);
|
|
27033
27119
|
const sel = [...new Set(rows.filter(r => r.selected).map(r => r.id))];
|
|
27034
27120
|
const _bad = p.unreadable?.(d) || [];
|
|
@@ -27049,7 +27135,7 @@ function memoryNoteVerdict(text) {
|
|
|
27049
27135
|
if (!p.write) return { content: [{ type: 'text', text: `${p.label} accounts are not shared by ticking — every account connected to this profile is usable as it is. Add another by connecting again in the app; remove one with disconnect_connector({provider:'${a.provider}', account:'@handle', confirm:true}).` }], isError: true };
|
|
27050
27136
|
const ids = (a.accountIds || []).map(x => String(x).trim()).filter(Boolean);
|
|
27051
27137
|
const before = await apiGet(p.read); // also the source LinkedIn needs to tell its personal profile from a Page
|
|
27052
|
-
if (p.needsConnect?.(before)) return { content: [{ type: 'text', text: `${p.label} isn’t connected to this workspace yet — there is nothing to scope. The user has to link it in a browser first:
|
|
27138
|
+
if (p.needsConnect?.(before)) return { content: [{ type: 'text', text: `${p.label} isn’t connected to this workspace yet — there is nothing to scope. The user has to link it in a browser first: Connectors ▸ ${p.label}.` }], isError: true };
|
|
27053
27139
|
const [path, body] = p.write(ids, before);
|
|
27054
27140
|
const _wr = (await apiPost(path, body)) || {};
|
|
27055
27141
|
// READ BACK. The server keeps only the ids the live connection can actually reach, so what was asked for and what
|
|
@@ -27092,7 +27178,7 @@ function memoryNoteVerdict(text) {
|
|
|
27092
27178
|
// is moot now that nothing revokes — the hazard is closed rather than guarded.
|
|
27093
27179
|
const _one = await apiPost(`/api/connectors/${encodeURIComponent(provider)}/disconnect`, a.account ? { account: String(a.account) } : {});
|
|
27094
27180
|
if (_one?.data?.removedAccount) return ok(`Removed ${provider} account ${_one.data.removedAccount} from this brand — ${_one.data.remaining} account(s) remain connected.`, { ok: true, provider, disconnected: false, removedAccount: _one.data.removedAccount, remaining: _one.data.remaining });
|
|
27095
|
-
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
|
|
27181
|
+
return ok(`Disconnected ${provider}${hit.agentLabel ? ` (${hit.agentLabel})` : ''} from this brand.${hit.removalNote ? ` ${hit.removalNote}` : ''} ${_isKey ? 'Reconnect it with connect_connector, or in the app under Connectors.' : `Reconnect from the app: Connectors ▸ ${provider}, or hand the user ${_link}.`}`, { ok: true, provider, disconnected: true, revokesAtProvider: hit.revokesAtProvider || null });
|
|
27096
27182
|
}));
|
|
27097
27183
|
// ── CONNECT A PASTE-A-KEY ACCOUNT FROM HERE (2026-09-12) ─────────────────────────────────────────────────────────────
|
|
27098
27184
|
// The why and the derived set are at KEY_CONNECTORS. The key goes to the SAME route the app's paste box uses, so the
|
|
@@ -27104,7 +27190,7 @@ function memoryNoteVerdict(text) {
|
|
|
27104
27190
|
const keyConnectRedact = (text, secrets) => { let t = String(text ?? ''); for (const s of secrets) if (s && s.length >= 4) t = t.split(s).join('[redacted]'); return t; };
|
|
27105
27191
|
server.registerTool('connect_connector', {
|
|
27106
27192
|
title: 'Connect a paste-a-key account',
|
|
27107
|
-
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 (
|
|
27193
|
+
description: `Connect an account that links with a KEY rather than a sign-in screen, from here, with no browser: ${Object.values(KEY_CONNECTORS).map(s => s.label).join(', ')}. Pass provider and fields in that provider's own field names (listed below, * = required). The key is checked live with the provider before anything is saved, exactly as the app's Connectors page checks it, and the reply is read back from the saved connection. OFFER BOTH WAYS AND LET THE USER CHOOSE: a key pasted into this chat stays in this conversation's history, while pasting it in the app (Connectors, or the one-click link https://app.hermoso.ai/?connect=<provider>) keeps it out of the chat. Hermoso never repeats a submitted key back. An account that connects through the provider's own sign-in screen (OAuth) cannot be connected here: this answers with the link to hand the user instead. Apple Ads with no key material first generates a signing key pair and returns the public key to register with Apple plus a setupToken to send back. Fields: ${Object.entries(KEY_CONNECTORS).map(([id, s]) => `${id} {${keyConnectFieldList(s)}}`).join(' · ')}.`,
|
|
27108
27194
|
inputSchema: {
|
|
27109
27195
|
provider: z.string().describe(`the connector id: ${Object.keys(KEY_CONNECTORS).join(', ')}`),
|
|
27110
27196
|
fields: z.record(z.string()).optional().describe('that provider\'s own field names and values, e.g. {"apiKey":"…"}; the names for each provider are in the description'),
|
|
@@ -27245,7 +27331,7 @@ function memoryNoteVerdict(text) {
|
|
|
27245
27331
|
catch (e) { if (e?.status === 404 || e?.status === 403) throw e; }
|
|
27246
27332
|
return ok(pv?.message
|
|
27247
27333
|
? `${pv.message} Confirm with the user, then call again with confirm:true.`
|
|
27248
|
-
: `This will remove ${a.email || '(no email)'} from this profile and disconnect any connections they made on it. I could not read which ones just now — check
|
|
27334
|
+
: `This will remove ${a.email || '(no email)'} from this profile and disconnect any connections they made on it. I could not read which ones just now — check Connectors before confirming. Confirm with the user, then call again with confirm:true.`,
|
|
27249
27335
|
{ ok: false, ...(pv ? { connectionsDisconnected: pv.connectionsDisconnected || [], scheduledPostsNotGoingOut: pv.scheduledPostsNotGoingOut || 0 } : {}) });
|
|
27250
27336
|
}
|
|
27251
27337
|
// The route 404s BY NAME when the address is not a member (a typo, a stale address, the wrong brand) — that error
|
|
@@ -28103,7 +28189,7 @@ function memoryNoteVerdict(text) {
|
|
|
28103
28189
|
server.group('workspace');
|
|
28104
28190
|
server.registerTool('get_brand', {
|
|
28105
28191
|
title: 'Get saved profile',
|
|
28106
|
-
description: 'What Hermoso ALREADY KNOWS for the active profile: the same saved profile (products, logos, palette, positioning) + learned memory the web Studio uses. Call it when you need to know whether a brand is on file: if hasBrand is true you can omit brand everywhere; if false, onboard with draft_brand. Not a required first step before a render: the create tools read the saved brand by themselves. 0 credits.',
|
|
28192
|
+
description: 'What Hermoso ALREADY KNOWS for the active profile: the same saved profile (products, logos, palette, fonts, positioning) + learned memory the web Studio uses. Call it when you need to know whether a brand is on file: if hasBrand is true you can omit brand everywhere; if false, onboard with draft_brand. Not a required first step before a render: the create tools read the saved brand by themselves. 0 credits.',
|
|
28107
28193
|
inputSchema: { ...STORE_BRAND },
|
|
28108
28194
|
outputSchema: {
|
|
28109
28195
|
hasBrand: z.boolean().optional().describe('whether brand details are saved for this profile'),
|
|
@@ -28137,6 +28223,7 @@ function memoryNoteVerdict(text) {
|
|
|
28137
28223
|
if (_s(b.logo)) lines.push(`Logo: ${abs(_s(b.logo))}`);
|
|
28138
28224
|
const pal = (Array.isArray(b.palette) ? b.palette : []).map((c) => _s(typeof c === 'string' ? c : c?.hex || c?.color)).filter(Boolean);
|
|
28139
28225
|
if (pal.length) lines.push(`Palette: ${pal.slice(0, 8).join(', ')}`);
|
|
28226
|
+
const _fonts = describeBrandFonts(b.fonts); if (_fonts) lines.push(`Fonts: ${_fonts}`);
|
|
28140
28227
|
const prods = (Array.isArray(b.products) ? b.products : []).map((x) => _s(typeof x === 'string' ? x : x?.title || x?.name)).filter(Boolean);
|
|
28141
28228
|
if (prods.length) lines.push(`Products: ${prods.slice(0, 12).join(', ')}`);
|
|
28142
28229
|
return lines.length ? '\n' + lines.join('\n') : '';
|
|
@@ -28153,7 +28240,7 @@ function memoryNoteVerdict(text) {
|
|
|
28153
28240
|
|
|
28154
28241
|
server.registerTool('draft_brand', {
|
|
28155
28242
|
title: 'Draft profile',
|
|
28156
|
-
description: 'Fill in a profile (a business brand or a creator) from a website domain, a free-text description, or a social handle: its name, products, logo and summary, returned as a {name, products, logo, …} object and saved as the brand profile when none is saved yet.
|
|
28243
|
+
description: 'Fill in a profile (a business brand or a creator) from a website domain, a free-text description, or a social handle: its name, products, logo and summary, returned as a {name, products, logo, …} object and saved as the brand profile when none is saved yet. On an account with NO saved brand one call does it all: it saves the brand and reads its product photos (no second call with confirmed:true). Check the business it returns against what you know about the user and go straight on to the ad unless you have a real reason to think it is a different business. Next, for an image ad call generate_image directly (plan_ad is for a video or for concepts to choose from). A domain can resolve to a DIFFERENT company than intended (e.g. bala.com is an engineering firm, not the Bala fitness brand at shopbala.com): if the returned `name` or `summary` is clearly not the business the user named, re-draft with the correct domain or a description.',
|
|
28157
28244
|
inputSchema: {
|
|
28158
28245
|
domain: z.string().optional().describe('a website to scrape, or a social link (profile, YouTube channel or one post) to draft from the account it names'),
|
|
28159
28246
|
description: z.string().optional().describe('a free-text brand description (no website)'),
|
|
@@ -28183,8 +28270,17 @@ function memoryNoteVerdict(text) {
|
|
|
28183
28270
|
// of a brand nobody wanted. This tool cannot ask, and it read the photos in the SAME call that returns the name the
|
|
28184
28271
|
// caller is told to verify: the same premature charge. So the site photo read runs only on confirmed:true. A confirmed
|
|
28185
28272
|
// call asks the draft to `resume`, so a second call within 30 minutes is answered from the first draft, not run again.
|
|
28186
|
-
|
|
28187
|
-
|
|
28273
|
+
// A FIRST BRAND NEEDS NO "IS THIS YOU?" TURN (2026-11-13, agent-first-run QA, owner): ChatGPT stopped to ask "Is Brightland your
|
|
28274
|
+
// correct brand?" before saving a brand that replaced nothing, and Claude made a second confirmed:true call that took two
|
|
28275
|
+
// minutes. The photo review that the yes guarded is free inside the first-brand onboarding window (/api/site/images
|
|
28276
|
+
// markOnbWindow), so on an account with NO saved brand the default save also reads the photos in this same call, and the
|
|
28277
|
+
// read starts BESIDE the draft, as the web onboarding does. Replacing a saved profile keeps every gate it had.
|
|
28278
|
+
let _cur0 = null;
|
|
28279
|
+
if (save !== false && save !== true && confirmed !== true) _cur0 = await apiGet('/api/brand/current').catch(() => null);
|
|
28280
|
+
const _firstBrand = !!(_cur0 && _cur0.hasBrand === false);
|
|
28281
|
+
const _photosConfirmed = confirmed === true || _firstBrand;
|
|
28282
|
+
const _earlyPhotos = _firstBrand && a.domain && !a.socialHandle ? apiGet('/api/site/images', { url: a.domain }).catch(() => null) : null; // read beside the draft; used only if the draft keeps it (a product brand, same site)
|
|
28283
|
+
const d = await apiPost('/api/brand/draft', confirmed === true && a.domain ? { ...a, resume: true } : a);
|
|
28188
28284
|
const p = d.profile || d;
|
|
28189
28285
|
// ALWAYS TRY THE WEBSITE (2026-07-28). /api/brand/draft returns the PROFILE only — it never fetched a single
|
|
28190
28286
|
// product photo, so an MCP-onboarded brand was structurally photo-less even with a perfectly good domain, and every
|
|
@@ -28205,7 +28301,8 @@ function memoryNoteVerdict(text) {
|
|
|
28205
28301
|
if (_photosConfirmed && p && p.domain && p.physical_product !== false && !_creatorNoProduct) {
|
|
28206
28302
|
_photosRead = true;
|
|
28207
28303
|
try {
|
|
28208
|
-
const
|
|
28304
|
+
const _sameSite = (x, y) => String(x || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/[/?#].*$/, '') === String(y || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/[/?#].*$/, '');
|
|
28305
|
+
const site = (_earlyPhotos && _sameSite(a.domain, p.domain) ? await _earlyPhotos : null) || await apiGet('/api/site/images', { url: p.domain });
|
|
28209
28306
|
const imgs = [...(site?.images || [])].filter(Boolean);
|
|
28210
28307
|
if (imgs.length) { p.productImages = imgs.slice(0, 12); if (!p.product) p.product = imgs[0]; }
|
|
28211
28308
|
// THE PRODUCTS' OWN NAMES beside their photos (2026-09-29): the shop's title, else the name the photo review gave
|
|
@@ -28224,7 +28321,7 @@ function memoryNoteVerdict(text) {
|
|
|
28224
28321
|
let saved = false, keptNewer = null;
|
|
28225
28322
|
if (save !== false) {
|
|
28226
28323
|
try {
|
|
28227
|
-
const cur = save === true ? null : await apiGet('/api/brand/current').catch(() => null);
|
|
28324
|
+
const cur = save === true ? null : (_cur0 || await apiGet('/api/brand/current').catch(() => null));
|
|
28228
28325
|
const _host = (x) => String(x || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/[/?#].*$/, '');
|
|
28229
28326
|
const _photoFields = ['productImages', 'product', 'productTitles', 'products', 'logo'].filter((f) => p[f] != null && !(Array.isArray(p[f]) && !p[f].length));
|
|
28230
28327
|
if (save !== true && _photosRead && cur?.hasBrand && cur.brand && _host(cur.brand.domain) && _host(cur.brand.domain) === _host(p.domain) && _photoFields.length) {
|
|
@@ -28260,7 +28357,17 @@ function memoryNoteVerdict(text) {
|
|
|
28260
28357
|
// repeat draft, a failure or a second agent never runs it again in a loop. Best-effort: never delays or fails the draft.
|
|
28261
28358
|
if (saved && p && p.name) { const _kind = a.socialHandle ? 'creator' : (p.physical_product === false ? 'service' : 'product'); void apiPost('/api/studio/recommend', { brand: p, kind: _kind, context: '' }).catch(() => {}); }
|
|
28262
28359
|
const _photoNote = _photosHeld ? ` Product photos NOT read yet: the website's photo review is paid, so it waits for the user's yes. Ask them whether ${p.name || 'this'} (${p.domain}) is their brand; if it is, call draft_brand again with domain "${p.domain}" and confirmed:true (the draft is reused, not billed again) to read and save the photos. If it is not, nothing was spent on photos.` : '';
|
|
28263
|
-
|
|
28360
|
+
// WHAT TO DO NEXT, IN AS FEW CALLS AS THE ASK NEEDS (2026-11-13): "set up my brand and make my first image ad" took three
|
|
28361
|
+
// drafts and a 6-credit plan_ad before the image. An image ad is ONE generate_image call on the saved brand; plan_ad is for a
|
|
28362
|
+
// video or when the user wants concepts to choose from. A first brand saved here was this account's only one, so a wrong
|
|
28363
|
+
// business is fixed by drafting the right one with save:true, with nothing of the user's to lose.
|
|
28364
|
+
const _nPhotos = Array.isArray(p.productImages) ? p.productImages.length : 0;
|
|
28365
|
+
// THE AGENT CONFIRMS THE BUSINESS ITSELF (owner, 2026-11-13: "Can't the agent confirm if its the right business, they already
|
|
28366
|
+
// likely know about their user."): the reply names what was found, and the agent checks it against what it knows.
|
|
28367
|
+
const _found = `${p.name || 'unnamed'}${p.domain ? ` (${p.domain})` : ''}${p.summary ? `: ${String(p.summary).replace(/\s+/g, ' ').trim().slice(0, 160)}` : ''}`;
|
|
28368
|
+
const _firstSaved = saved && _firstBrand ? ` Found ${_found}. Saved as this account's first brand${_nPhotos ? ` with ${_nPhotos} product photo${_nPhotos === 1 ? '' : 's'}` : ''}. Check it against what you already know about the user: if it fits, or nothing suggests otherwise, go straight on to the ad. Only if you have a real reason to think it is a different business, tell the user and draft the right one with save:true.` : '';
|
|
28369
|
+
const _next = saved ? ' Next: for an image ad call generate_image directly (the saved brand and its photos ride automatically; no plan_ad needed); for a video ad, plan_ad then render_ad.' : '';
|
|
28370
|
+
return ok(`Drafted brand: ${p.name || 'unnamed'}${p.category ? ' · ' + p.category : ''}.${saved ? (keptNewer ? ` Saved as the workspace brand, EXCEPT ${keptNewer.join(', ')}: a newer edit to those is already stored and was kept.` : ' Saved as the workspace brand: plan_ad/create now use it automatically.') : ' Pass this object to plan_ad.'}${_firstSaved}${_photoNote}${_next}`, { ...p, ...(_photosHeld ? { photosHeld: true } : {}), ...(saved && _firstBrand ? { firstBrand: true } : {}) });
|
|
28264
28371
|
}));
|
|
28265
28372
|
|
|
28266
28373
|
// ---------- assets ----------
|
|
@@ -28638,6 +28745,54 @@ function memoryNoteVerdict(text) {
|
|
|
28638
28745
|
const lost = (d.files || []).filter((f) => (f.lostLines || []).length).length;
|
|
28639
28746
|
return ok(`Made ${(d.files || []).length} file${(d.files || []).length === 1 ? '' : 's'} [job ${r.jobId}]${lost ? `; ${lost} came back with a line missing (named below; look before using them)` : '; every line checked'}:\n${filesText(d.files)}${(d.notes || []).length ? `\nNOTE: ${d.notes.join(' ')}` : ''}`, { ...r, files: (d.files || []).map((f) => ({ ...f, url: abs(f.url) })), manifest: d.manifest, ...(d.notes ? { notes: d.notes } : {}) });
|
|
28640
28747
|
}));
|
|
28748
|
+
// LAYERS (2026-11-12, server workLayerize + lib/seedream-layerize.mjs): one finished static ad → a base picture plus
|
|
28749
|
+
// separately editable transparent layers, each with its position, and the text of every text layer read out.
|
|
28750
|
+
server.registerTool('layerize_image', {
|
|
28751
|
+
title: 'Split an image into editable layers',
|
|
28752
|
+
description: "Split ONE finished static ad (or any picture) into a BASE image plus separately editable transparent PNG LAYERS: each text block, the product, the logo, a person, badges, buttons and shapes come out on their own layer at their original position (each layer's box in base-image pixels), and the base keeps the background with the gaps filled. The words of every ordinary text layer are READ OUT (the text, each line's box, an approximate font, colour, bold, italic), so a designer or an agent can change the copy, translate it or rebuild the ad in a design tool. `layers` = how many layers at most (default 6, up to 20; the base is extra and every picture returned is billed). `prompt` names what to lift (e.g. 'only the headline and the product'). dryRun:true = the price, nothing made. To change the ad itself rather than take it apart, use edit_image.",
|
|
28753
|
+
inputSchema: {
|
|
28754
|
+
image: z.string().describe('the picture: an https / upload_file URL, a Library id (L0…), or a local path on the CLI; 512x512 to 6000x6000 pixels'),
|
|
28755
|
+
layers: z.number().int().min(1).max(20).optional().describe('the most layers to return besides the base (default 6)'),
|
|
28756
|
+
prompt: z.string().optional().describe('optional: what to separate, in words'),
|
|
28757
|
+
size: z.enum(['1K', '1.5K', '2K']).optional().describe("output size tier (default '1.5K'; '2K' costs more per picture)"),
|
|
28758
|
+
text: z.boolean().optional().describe('false = skip reading the words out (default on)'),
|
|
28759
|
+
dryRun: z.boolean().optional().describe('price only: nothing made, nothing charged'),
|
|
28760
|
+
},
|
|
28761
|
+
outputSchema: { ...JOB_OUT, base: z.any().optional(), layers: z.array(z.any()).optional().describe('[{url, z, name, description, box:[l,t,r,b], text}]'), text: z.array(z.any()).optional().describe('the words read out of each text layer, top to bottom') },
|
|
28762
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
28763
|
+
}, wrap(async (a) => {
|
|
28764
|
+
const _in = { image: await mediaRef(a.image, 'image'), ...(a.layers != null ? { layers: a.layers } : {}), ...(a.prompt ? { prompt: a.prompt } : {}), ...(a.size ? { size: a.size } : {}), ...(a.text === false ? { text: false } : {}) };
|
|
28765
|
+
if (a.dryRun) return ok(quoteText(await quoteJob('layerize', _in), `splitting that picture into a base and up to ${a.layers || 6} layers`), {});
|
|
28766
|
+
const r = await renderJob('layerize', _in, 'MCP layers');
|
|
28767
|
+
if (r.stillRendering) return ok(stillMsg(r), r);
|
|
28768
|
+
const d = r?.raw || {};
|
|
28769
|
+
const layers = (d.layers || []).map((l) => ({ ...l, url: abs(l.url) }));
|
|
28770
|
+
const words = (d.text || []).map((t) => `layer ${t.layer}: "${String(t.text).replace(/\n/g, ' / ')}"${t.font ? ` (${t.font}${t.color ? `, ${t.color}` : ''})` : ''}`);
|
|
28771
|
+
return ok(`Split into a base and ${layers.length} layer${layers.length === 1 ? '' : 's'} [job ${r.jobId}]\nBase: ${abs(d.image || d.base?.url)}\n${layers.map((l) => `${l.z}. ${l.name || 'layer'}${l.box ? ` at [${l.box.join(', ')}]` : ''}: ${l.url}`).join('\n')}${words.length ? `\nText read out:\n${words.join('\n')}` : ''}${d.note ? `\nNOTE: ${d.note}` : ''}`, { ...r, base: d.base ? { ...d.base, url: abs(d.base.url) } : null, layers, text: d.text || [] });
|
|
28772
|
+
}));
|
|
28773
|
+
// SIX-SECOND PRODUCT MOVES (2026-11-12, server workProductMove + lib/product-moves.mjs): a product photo → a clean start
|
|
28774
|
+
// frame → one 6-second silent 1080p clip of a named move, or a move in the user's own words.
|
|
28775
|
+
server.registerTool('product_move', {
|
|
28776
|
+
title: 'Six-second product move',
|
|
28777
|
+
description: "A 6-SECOND SILENT 1080p PRODUCT CLIP from ONE product photo: the product is first set on a clean seamless background in a colour taken from the product itself (centred, about two thirds of the frame, one soft shadow), then one MOVE is filmed from that frame. Moves by name: label-trace, crane-reveal, floating-roll, detail-scan, half-turn, impact, light-sweep, liquid-wrap, macro-glide, paint-wave, product-spin, pull-back, push-in, shadow-motion, sunrise-pass, texture-track, topdown-dive, whip-pan. Or describe your own move in `describe` (with no `move`); with a named move, `describe` adds to it. Default 3:4; square 1:1, landscape 16:9, Reel/Story 9:16. No people, hands, words or music are added. Two paid steps (the start frame, then the clip); dryRun:true = the price of both, nothing made. For a longer cut-together product film use product_sizzle.",
|
|
28778
|
+
inputSchema: {
|
|
28779
|
+
product: z.string().describe('the product photo: an https / upload_file URL, a Library id (L0…), or a local path on the CLI'),
|
|
28780
|
+
move: z.string().optional().describe("one of the named moves (e.g. 'push-in', 'product-spin'), or omit and use describe"),
|
|
28781
|
+
describe: z.string().optional().describe('a move in your own words, or something to add to a named move'),
|
|
28782
|
+
aspectRatio: z.string().optional().describe("'3:4' (default), '1:1', '16:9' or '9:16' (or square / landscape / reel / story)"),
|
|
28783
|
+
dryRun: z.boolean().optional().describe('price only: nothing made, nothing charged'),
|
|
28784
|
+
},
|
|
28785
|
+
outputSchema: { ...JOB_OUT, startFrame: z.string().optional(), move: z.string().optional() },
|
|
28786
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
28787
|
+
}, wrap(async (a) => {
|
|
28788
|
+
if (!a.move && !a.describe) throw new Error('Which move? Name one (push-in, product-spin, light-sweep, label-trace … see the description) or describe your own. Nothing was charged.');
|
|
28789
|
+
const _in = { product: await mediaRef(a.product, 'image'), ...(a.move ? { move: a.move } : {}), ...(a.describe ? { describe: a.describe } : {}), ...(a.aspectRatio ? { aspectRatio: a.aspectRatio } : {}) };
|
|
28790
|
+
if (a.dryRun) return ok(quoteText(await quoteJob('productmove', _in), `that product move (the start frame and the 6-second 1080p clip)`), {});
|
|
28791
|
+
const r = await renderJob('productmove', _in, 'MCP product move');
|
|
28792
|
+
if (r.stillRendering) return okVideo('', r);
|
|
28793
|
+
const d = r?.raw || {};
|
|
28794
|
+
return okVideo(`Product move (${d.moveTitle || a.move || 'your move'}, ${d.aspectRatio || a.aspectRatio || '3:4'}, 6 s, silent): ${r.url} [job ${r.jobId}]\nStart frame: ${abs(d.startFrame) || '—'}`, { ...r, startFrame: abs(d.startFrame), move: d.move });
|
|
28795
|
+
}));
|
|
28641
28796
|
server.registerTool('add_presenter', {
|
|
28642
28797
|
title: 'Add a presenter to any video',
|
|
28643
28798
|
description: "Put a PRESENTER on ANY video, only when the user asks for one: their pick of person (`creator`: a saved or preset creator from list_creators; or `image`: a photo they gave; never invent one), filmed on a green screen saying the words, cut out and laid over the video in a bottom corner (default), a round badge, or split screen, lip-synced, block by block across the whole video (about 10 s and about 25 spoken words a block). The words are `script` (verbatim; refused free if it cannot fit) or, without one, written to fit from what the video says and shows (`brief` steers them). The video's own sound stays under the presenter unless baseAudio 'off'; the video without the presenter is kept. A face the video model refuses STOPS it with nothing charged: offer another creator, another photo, or the video as it is. dryRun:true = the exact price, which is what it charges. For a finished make_explainer film, make_explainer with fromFilm.",
|