hermoso 0.1.436 → 0.1.438
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/mcp/client.mjs +1 -1
- package/mcp/openai-extensions.mjs +1 -1
- package/mcp/roster-scope.mjs +2 -0
- package/mcp/tool-hints.mjs +8 -0
- package/mcp/tools.mjs +408 -137
- package/package.json +2 -2
package/mcp/tools.mjs
CHANGED
|
@@ -29,7 +29,7 @@ function metaAlternativeNote(name) { return metaAlternativeNoteOf(canonicalToolN
|
|
|
29
29
|
import { toolCostClass, costLabel, costKindOf, creditRangeFrom } from './tool-cost.mjs';
|
|
30
30
|
import { recordToolOutcome, toolHealth, healthLabel, healthPenalty } from './tool-health.mjs';
|
|
31
31
|
// THE NEXT STEP, NAMED. The prose we already write stays; this is the same advice as an addressable field.
|
|
32
|
-
import { withHints, videoChoiceText, videoChoiceHints, neutralCreditsText, creditsLowHints } from './tool-hints.mjs';
|
|
32
|
+
import { withHints, videoChoiceText, videoChoiceHints, neutralCreditsText, creditsLowHints, welcomeClaimHint } from './tool-hints.mjs';
|
|
33
33
|
// THE CHATGPT EXTENSIONS APP (home, Library panel, concept picker, brief, setup, settings, @-mentions), registered only
|
|
34
34
|
// behind HERMOSO_CHATGPT_EXT=1 + `?ext=1` on a widget host — see registerChatGPTExtensions() below and
|
|
35
35
|
// ./openai-extensions.mjs. Same twin-safe specifier rule.
|
|
@@ -255,7 +255,7 @@ export const CAPABILITY_MAP = [
|
|
|
255
255
|
'A) AD SPY / RESEARCH — spy on the ads already winning in any market, then mine them. find_competitors · competitor_teardown · pull_competitor_ads · research_ads (open brief) · ad libraries search_meta_ads / search_google_ads / search_linkedin_ads · organic social search_tiktok / search_instagram / search_youtube / search_reddit / search_threads · search_instagram_hashtag (LISTENING on the brand’s OWN Meta credentials rather than a scraper: the real public posts carrying a hashtag, with their captions — feed them into mine_angles or write the next post from the language you found. “recent” is the LAST 24 HOURS only, so a huge tag legitimately returns zero on a quiet day; ask again with edge “top” before saying anything about how busy it is) · instagram_profile (any Instagram @handle → the account’s NUMERIC Instagram id from Meta itself, plus its real name, bio, follower and post counts — Meta’s own numbers, not a scraper’s. It is also the ONLY way to get the id manage_meta_partnership_creator’s allowTagging list requires; professional accounts only) · find_instagram_marketplace_creators / instagram_marketplace_creator / list_instagram_marketplace_audiences (Instagram’s own creator marketplace searched as the brand, with Meta’s first-party creator insights; find_creators ranks by public posts and takes marketplace:true to add it) · fetch_social_data (any allowlisted endpoint) · mine_angles · analyze_video · check_ad_policy · list_skills / get_skill (teardowns + creative playbooks).',
|
|
256
256
|
'B) CREATE — finished, on-brand image & video ads (real product composited in, copy + CTA baked). draft_brand / get_brand / update_brand (patch single fields without re-onboarding) / use_brand · list_brands / create_brand / delete_brand (one account holds MANY 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) · clone_static / recast_motion / reframe_video / upscale_video / dub_video / change_voice / finish_video / fix_beat / hook_variants / stitch_video · plan_variations + score_ad (fan out + rank).',
|
|
257
257
|
'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.',
|
|
258
|
-
'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
|
|
258
|
+
'D) ACCOUNT — hermoso_credits (balance) · billing_status (plan + your billing role) · buy_credits (one-click top-up on the saved card, or a first-purchase checkout link) · upgrade_plan / set_auto_reload (admin) · list_jobs / get_job (track async renders) · get_settings / update_settings (the LANGUAGE every ad, script and plan is written in (chat replies follow the language the user writes in) — set it once and every render obeys it, over MCP as well as in the app — plus app appearance and the weekly competitor-watch email) · list_team / invite_member / remove_member / set_role (who else can work in this profile).',
|
|
259
259
|
'E) PUBLISH & MANAGE YOUR CHANNELS — post, run ads, and organize files on the user’s OWN connected accounts (Settings ▸ Connectors), all driven over this MCP. Bring ANY file in with upload_file (desktop/external media, not just Hermoso renders). MANAGING THE CONNECTIONS THEMSELVES: list_connectors (what is linked, and what could be) · list_connector_accounts then set_connector_accounts (WHICH Facebook Pages / Instagram / Meta ad accounts / Google Ads customers / LinkedIn company Pages and ad accounts / Pinterest ad accounts / Microsoft Advertising accounts / Reddit ad accounts / Google Business listings / Google Analytics properties this profile may post to, spend from and read — one person often administers or has access to several belonging to different clients, only the chosen ones are usable anywhere, and an empty choice shares nothing) · connect_connector (connect a PASTE-A-KEY account from here: ' + Object.values(KEY_CONNECTORS).map((s) => s.label).join(', ') + '; offer it beside the Connectors page in the app and let the user choose, because a key pasted into a chat stays in its history) · disconnect_connector (drop a connection from this brand, without revoking access at the platform; confirm-gated because reconnecting a sign-in account needs a browser) · leave_connector (on a connector several teammates can each contribute their OWN account to, remove just YOURS — teammates’ accounts keep working and nothing is revoked at the provider). LINKING an account that connects through a provider sign-in screen (OAuth) is the one step that is not headless: hand the user its connect link, https://app.hermoso.ai/?connect=<provider>, or send them to Workspace ▸ Connectors in the app. META: list_meta_pages · instagram_insights (ACCOUNT-level Instagram performance — views, reach, accounts engaged, interactions, saves, profile link taps — plus the audience DEMOGRAPHICS by age / city / country / gender) · list_instagram_media (the brand’s own recent Instagram posts, and where the media id every other Instagram tool needs comes from) · list_instagram_dm_automations / save_instagram_dm_automation / test_instagram_dm_automation / delete_instagram_dm_automation (INSTAGRAM DM AUTOMATIONS — a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply; test shows what a sample would send without sending) · list_dm_automations / save_dm_automation / test_dm_automation / delete_dm_automation (THE SAME ON EVERY CHANNEL — Instagram, Facebook Page comments and ads + Messenger, and X: a keyword mention or reply gets a public reply, a DM keyword a DM; X is billed in credits per delivered event and per send) · search_instagram_audio (licensed music and original sounds an Instagram Reel may use, by keyword or trending) · list_instagram_collab_invites then respond_instagram_collab_invite (collab-post invitations waiting on the account; accept or decline one, read back from Instagram) · list_instagram_collab_media (posts this account co-authors) · like_instagram (like a post or comment as the connected account) · post_to_meta (Facebook / Instagram / Threads) · list_meta_posts (the Page’s / Instagram account’s OWN existing posts with their ids — THIS is where the postId every other Meta read needs comes from; without it an agent that did not itself just publish has no way to name a post) · list_meta_ads + meta_insights (read existing campaigns/ad sets/ads + spend/CTR/CPC, with breakdowns by age / gender / placement / country) · preview_meta_ad (Meta renders the REAL ad per placement — a link the user can look at, valid 24h) · list_meta_pixels + create_meta_pixel (the pixel a conversion-optimised campaign REQUIRES — Meta will not let a build optimise for conversions without one, and until these existed a caller had no way to discover the id they had to pass) · estimate_meta_reach (how many people a targeting spec reaches, BEFORE a budget is committed) · list_meta_audiences / create_meta_audience (website-pixel retargeting, Page + Instagram engagement audiences, and lookalikes — creating one spends nothing) · list_meta_conversations / read_meta_conversation / reply_to_meta_message (MESSENGER AND INSTAGRAM DMs — the brand’s direct-message threads and a reply to someone who wrote first. Meta only permits a reply within 24 HOURS of the person acting, and read_meta_conversation says whether that window is open BEFORE anything is drafted; Hermoso sends replies only, never a proactive message or a message tag) · subscribe_meta_webhooks / meta_webhook_status / unsubscribe_meta_webhooks / list_meta_webhook_events (REAL-TIME EVENTS — have Meta PUSH new comments, mentions, lead-form submissions and inbound DMs to Hermoso instead of polling for them. Every other inbox read asks an edge “anything new?”; this is the only way to be TOLD, and it is how a lead arrives the moment it is submitted rather than when somebody thinks to look. An empty feed is ambiguous — check meta_webhook_status first, because an unsubscribed Page is silent and looks exactly like a quiet one) · instagram_collaborators (who ACCEPTED a Collab invite on an Instagram post — publishing only SENDS the invite, so this is the only way to know whether the post is actually live on the other account too) · list_instagram_shopping_catalogs / search_instagram_shopping_products / manage_instagram_product_tags (INSTAGRAM SHOPPING — make a post SHOPPABLE. Check eligibility and the account’s taggable catalogs, find the product ids, then pass productTags to post_to_meta so tapping the picture opens the product’s price sheet inside Instagram. Tagging needs an APPROVED Instagram Shop, so check FIRST — otherwise it fails after the media is already uploaded — and note that a tag whose product is not “approved” is stored and shown to nobody. Meta publishes no way to REMOVE a tag) · create_meta_catalog / update_meta_catalog / meta_catalog_blast_radius / delete_meta_catalog (BUILD AND RETIRE A CATALOG — create one on a named business portfolio, rename or re-point it, and, before ever proposing a delete, read meta_catalog_blast_radius: a catalog delete is PERMANENT with no archive and no undo, its product sets go with it, and any ad set still bound to one keeps spending with nothing to show) · list_meta_partnership_creators / manage_meta_partnership_creator (PARTNERSHIP ADS — the creators whose content this profile may run as an advert, and who may tag this brand as a paid partner. Two separate lists, neither implying the other, and neither defaults on; adding is a REQUEST the creator must accept, and an ad naming a creator who is only PENDING fails for a reason nothing in the error says) · list_meta_catalogs / list_meta_product_sets / list_meta_catalog_products (PRODUCT CATALOGS — the merchant’s own Meta catalogs, the product SETS inside each and the products themselves with Meta’s review status. A catalog is the input to Advantage+ catalog ads, the highest-performing ecommerce format on Meta: pass productCatalogId to create_meta_campaign and productSetId to create_meta_adset / create_meta_ad, and Meta builds every impression from the product’s own image, name and price — no render needed. An empty list is a fact about which business portfolio this login administers, NEVER about whether the merchant has a catalog) · create_meta_campaign / create_meta_ad / upload_meta_asset (build) · 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 + reply_to_youtube_comment (read viewer questions and objections in their own words, and answer as the channel) · moderate_youtube_comment (hide, reject, spam-report or delete an abusive comment — reject is reversible, delete is not) · list_youtube_playlists + manage_youtube_playlist + manage_youtube_playlist_items (organise the channel: create playlists, add/remove/re-order videos in them) · manage_youtube_playlist_image (a custom cover on a playlist — make_thumbnail renders the artwork, this is the call that puts it on. YouTube answers every failure here as an HTTP 500 whose real reason is buried inside it, and the tool unpacks that; if it comes back refused, check channel verification first) · manage_youtube_channel_section (the SHELVES ON THE CHANNEL HOMEPAGE — put a chosen playlist or a featured channel above YouTube’s own default layout, and re-order them. Every write is PUBLIC IMMEDIATELY, a delete has no undo, and YouTube’s own section list LAGS a write by a few seconds in both directions, so never treat a list taken straight afterwards as proof either way) · list_youtube_captions + manage_youtube_caption (real subtitle TRACKS — what YouTube indexes the video by and what a viewer toggles on, which is NOT the same as captions burned into the picture; downloading one is also the quickest way to get an existing video’s script back) · list_youtube_categories (which categoryId post_to_youtube will accept in a given country) · youtube_bulk_report (THE ONLY PLACE YOUTUBE PUBLISHES THUMBNAIL IMPRESSIONS AND THUMBNAIL CTR — a different, SCHEDULED API: the first call starts a job and returns nothing, then YouTube writes one file per day, the first within 48 hours, plus a 30-day backfill. It also carries per-card and per-end-screen metrics and an uncapped list of the search terms people arrived on) · list_youtube_report_jobs (whether that thumbnail history is already accumulating, and since when — check before promising a number) · delete_youtube_report_job (stop one; the job IS the history, so deleting it throws the accumulated files away) · youtube_channel (read title + subscriber/view/video counts for reporting). TIKTOK: post_to_tiktok (post a finished video — or a PHOTO POST, TikTok’s photo/slideshow format of 1 to 35 images where a single image is just a one-slide post —LIVE to the profile, or into TikTok drafts to review in the app) · tiktok_creator_info (the creator’s REAL privacy options — read them and let the user choose before any direct post) · tiktok_post_status (follow a post_to_tiktok that came back pending: done, failed or still processing) · tiktok_account (bio, verified status, follower/following/likes/video counts) · list_tiktok_videos (their own posts with views/likes/comments/shares — either the most recent, or specific videoIds read directly however old they are). ⚠️ TIKTOK HAS NO DELETE AND NO EDIT: its API publishes no way to remove a posted video or change its caption, privacy, cover or comment/duet/stitch settings — every one of those is fixed at publish time and there is no delete scope in TikTok’s scope catalogue at all. If the user wants a TikTok taken down or changed, say plainly that it has to be done in the TikTok app rather than hunting for a tool. TIKTOK ACCOUNT AUTHORIZATION (a SECOND, separate consent on the SAME TikTok app the TikTok Ads connection uses — holding one does NOT give you the other, so a brand fully connected for ads can still be unauthorized here, and that is a real third state rather than a broken session): tiktok_account_status (which state this profile is in, the TikTok business id, the scopes the grant carries and any MISSING from it — TikTok binds scopes at authorize time and never retroactively, so only a re-authorization picks up a new one — plus the exact URL to send the user to, because authorizing is the one step that needs a browser) · list_tiktok_comments + list_tiktok_comment_replies (the comments on the brand’s OWN posts, hidden ones included — TikTok’s answer to list_meta_comments and list_youtube_comments) · comment_on_tiktok_video · reply_to_tiktok_comment · moderate_tiktok_comment (LIKE / UNLIKE / HIDE / UNHIDE / DELETE — you can only DELETE a comment this account wrote, so HIDE is the tool for a stranger’s, and TikTok warns UNHIDE may not take effect when its own moderation is what hid it) · upload_tiktok_comment_image (a new comment will not take a raw image URL; a reply will) · set_tiktok_post_ad_authorization (THIS IS WHERE A SPARK ADS AUTHORIZATION CODE COMES FROM for the brand’s OWN post — previously a human had to copy one out of the TikTok app; hand the code to authorize_tiktok_ads_spark_post) · get_tiktok_post_ad_authorization · extend_tiktok_post_ad_authorization (the days are ADDED to what is left, not set as an absolute) · delete_tiktok_post_ad_authorization. BRAND MONITORING AND AUDIENCE, on that same account authorization (these need permissions added on 2026-08-20, so a brand that authorized before then holds a grant that predates them and has to authorize once more; tiktok_account_status names exactly which are missing, and the remedy is always to authorize the TikTok ACCOUNT again rather than to touch the advertiser connection, which is a separate grant and is unaffected): list_tiktok_mentions (public posts whose caption @-mentions the brand, TikTok’s answer to x_mentions and list_threads_mentions) · list_tiktok_mention_comments (comments whose text mentions it) · get_tiktok_mention (one mention in full, for the mentions webhook, and TikTok only keeps that data 48 hours) · tiktok_mention_top_terms (the top 20 keywords and top 20 hashtags inside those mentions) · list_tiktok_brand_hashtags + manage_tiktok_brand_hashtags + list_tiktok_brand_hashtag_posts (the hashtags TikTok counts as this brand’s, up to 50, and the posts carrying them; a new one is not counted for 24 hours and cannot be removed for 7 days) · tiktok_account_insights (follower demographics by age, gender, country and city plus the daily performance series, needing a BUSINESS account with 100+ followers, and capped at 60 days rather than the 90 the mention tools cover) · tiktok_category_benchmark (the same numbers averaged across an industry, so ‘are we ahead of our category’ is answerable). ALL OF THIS IS ORGANIC LISTENING ON THE BRAND’S OWN ACCOUNT, not ad research: for competitors’ ads use the ad-library research tools instead. TIKTOK ADS (a SEPARATE connection from the TikTok posting connector above — Settings ▸ Connectors ▸ TikTok Ads; a brand that posts to TikTok every day may still have no ad account here, so never read one as the other): list_tiktok_ads_accounts (the ADVERTISER accounts this profile can act on — every other TikTok Ads tool needs an advertiserId and this is where it comes from) · list_tiktok_ads_pixels + create_tiktok_ads_pixel + list_tiktok_ads_custom_conversions + tiktok_ads_pixel_stats (CONVERSION TRACKING — a conversion-optimised ad group dies at creation with "Please select a pixel" without one, so discover the pixel and its events BEFORE building the tree; note TikTok publishes no way to DELETE a pixel, so one you create is permanent) · list_tiktok_ads_campaigns (the whole tree — campaigns, ad groups and ads with their statuses) · tiktok_ads_report (impressions, clicks, spend, CTR, CPC, conversions and video views at any level) · list_tiktok_ads_identities (the TikTok accounts an ad may post AS — MANDATORY, with NO default: call it and let the USER pick, because the ad runs publicly under whichever account is named) · search_tiktok_ads_targeting (resolve location / interest / hashtag / language ids — an ad group cannot be created without location ids, and a guessed id targets the wrong people) · list_tiktok_ads_identity_posts (the ORGANIC posts an identity has already published — where a Spark Ad’s post id comes from) · list_tiktok_ads_spark_posts (the posts authorised for Spark Ads, i.e. promoting an organic post instead of uploading a new video) · authorize_tiktok_ads_spark_post + unbind_tiktok_ads_spark_post (add a creator’s post to that authorised set with the code they generated in the TikTok app, or release it again) · upload_tiktok_ads_creative (THE STEP THAT TURNS A RENDER INTO AN AD — put a finished Hermoso video on the ad account and it hands back the videoId AND the coverImageId create_tiktok_ads_ad needs; there is no other source for either) · create_tiktok_ads_campaign → create_tiktok_ads_ad_group → create_tiktok_ads_ad (the tree) · set_tiktok_ads_budget · set_tiktok_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_tiktok_ads_object (removal on TikTok is a STATUS, not a verb — the same route as set_tiktok_ads_status) · create_tiktok_smart_campaign → create_tiktok_smart_ad_group → create_tiktok_smart_ad (Smart+, TikTok’s Performance Max — born PAUSED) · list_tiktok_smart_campaigns · set_tiktok_smart_status (the Smart+ money switch, confirm-gated) · tiktok_bid_protection (the ad-credit compensation TikTok pays when a Smart+ object misses its bid) · list_tiktok_ads_lead_forms + list_tiktok_ads_lead_fields + download_tiktok_ads_leads + manage_tiktok_ads_test_lead (LEAD ADS — an Instant Form is built in TikTok Ads Manager and NO API creates one, so list them to find the id a LEAD_GENERATION ad group needs. The lead REGION is required with no default: it selects which of three separate lead stores you read, and leaving it out is a THIRD value rather than “all”, so an advertiser who omits it downloads an empty file and wrongly concludes there are no leads) · list_tiktok_ads_audiences + create_tiktok_ads_audience + create_tiktok_ads_lookalike_audience + apply_tiktok_ads_audience + update_tiktok_ads_audience + delete_tiktok_ads_audience + tiktok_ads_audience_overlap (CUSTOM AUDIENCES and lookalikes — TikTok targeting is otherwise interests-and-geo only. A freshly created audience reports itself invalid for up to 48 hours BY DESIGN, so that is not a failure to retry) · list_tiktok_ads_business_centers + list_tiktok_ads_catalogs + create_tiktok_ads_catalog + list_tiktok_ads_catalog_products + list_tiktok_ads_catalog_sets + manage_tiktok_ads_catalog_feed + tiktok_ads_catalog_diagnostics (DPA / PRODUCT CATALOGS, the Shopify lane — a catalog is keyed on a BUSINESS CENTER id, NOT an advertiser id, so list the Business Centers first or every call refuses) · list_tiktok_ads_apps + list_tiktok_ads_app_events (the registered apps an APP_INSTALL campaign needs — nothing else can produce an app id) · tiktok_ads_rf_inventory_estimate + create_tiktok_ads_rf_ad_group (REACH & FREQUENCY — a RESERVATION, so it is confirm-gated like a status change rather than born paused, and it needs a per-ad-account allowlist plus a signed branding contract that no endpoint reports. Always price it with the estimate first: TikTok silently books its own maximum rather than refusing an out-of-range value) · send_tiktok_ads_events (SERVER-SIDE conversion events — there is a vendor-sanctioned test code for exercising it without entering the advertiser’s real reporting), and its offline/crm sources take the event-set ids the two tools below mint) · list_tiktok_ads_offline_event_sets + manage_tiktok_ads_offline_event_set + send_tiktok_ads_offline_events (REAL-WORLD CONVERSIONS — an in-store purchase, a phone booking, a signed contract, reported so TikTok can attribute them to the ads that caused them. The timestamp is an ISO-8601 STRING here and a Unix NUMBER on send_tiktok_ads_events; a wrong-shaped one is accepted by TikTok and attributed to nothing. There is NO test code on this pair, so everything sent is a real permanent conversion — rehearse through send_tiktok_ads_events with eventSource “offline” and a testEventCode instead. Reporting also needs the connected user to be an ADMIN or OPERATOR of the advertiser, which managing the event SETS does not) · list_tiktok_ads_crm_event_sets + create_tiktok_ads_crm_event_set (LEAD-LIFECYCLE events — sending “this lead qualified / closed” back is what makes a LEAD_GENERATION campaign optimise toward leads that convert rather than form fills. TikTok publishes create and list and nothing else, so one of these is PERMANENT) · list_tiktok_tto_accounts + list_tiktok_creator_labels + discover_tiktok_creators + tiktok_creator_leaderboard + check_tiktok_creator_status + list_tiktok_tto_brand_profiles + create_tiktok_tto_brand_profile + list_tiktok_tto_campaigns + create_tiktok_tto_campaign + update_tiktok_tto_campaign + link_tiktok_tto_video + list_tiktok_tto_link_requests + tiktok_tto_campaign_report + request_tiktok_tto_spark_authorization + get_tiktok_tto_spark_authorization + manage_tiktok_tto_anchor (TIKTOK ONE / CREATOR MARKETPLACE: INFLUENCER MARKETING, and the only place in Hermoso that does it: find creators by audience size, engagement, price and who their followers actually are, check whether they have joined TikTok One, invite them to a campaign with an invite link, ask them to tag a video to it, and read every metric SPLIT ORGANIC VERSUS PAID. Its account id is a THIRD id space; not an advertiser id and not a Business Center id; so start at list_tiktok_tto_accounts. It rides this same connection with nothing extra to apply for. IT ALSO CLOSES THE SPARK ADS LOOP: request_tiktok_tto_spark_authorization asks a creator directly and get_tiktok_tto_spark_authorization returns the code authorize_tiktok_ads_spark_post takes, which is otherwise obtainable only by the creator pasting one out of the TikTok app. Two things put a notification in a real person’s inbox; a campaign invitation and a video-linking request; and a repeated linking request is a REMINDER that TikTok caps at two, so read list_tiktok_tto_link_requests before re-sending anything) · list_tiktok_ads_stores + list_tiktok_ads_store_products (TIKTOK SHOPS: what a Shopping Ads or GMV Max campaign sells from; the store list is keyed on an ad account and the product list on a BUSINESS CENTER, which each store row names) · tiktok_ads_verification_status + list_tiktok_ads_verification_documents + submit_tiktok_ads_verification (BUSINESS VERIFICATION: an unverified account hits limits that get diagnosed as something else, so it is worth reading during onboarding. Hermoso never handles a verification DOCUMENT: submitting sends account details plus the ids of images the user uploaded in TikTok Ads Manager, and the legal name and document number can never be changed afterwards, so it is confirm-gated) · list_tiktok_ads_payment_portfolios + list_tiktok_ads_payment_portfolio_links (HOW THE AD ACCOUNTS ARE FUNDED: read-only, because "why did delivery stop" is often a funding answer, and because deciding where a customer’s money sits is not ours to do) · create_tiktok_ads_rule + list_tiktok_ads_rules + update_tiktok_ads_rule + bind_tiktok_ads_rule + set_tiktok_ads_rule_status + tiktok_ads_rule_results (AUTOMATED RULES — standing instructions TikTok runs on the account unattended. THE SECOND SPEND SWITCH ON THIS PLATFORM and gated in TWO CLASSES: a rule that can only pause, decrease or email needs confirm:true, while one that can TURN_ON an object or RAISE a budget or bid needs confirm:true AND confirmScope echoing the token list_tiktok_ads_rules prints, computed from the rule as TikTok STORES it. Every rule is created TURNED OFF and read back to prove it, because TikTok publishes no way to create one in the off position. TikTok emails rule notifications to the DEVELOPER address on the app rather than to the advertiser, so tiktok_ads_rule_results is the only place a customer sees what a rule did — and TikTok itself says this endpoint is for direct advertisers and may refuse a platform-managed account entirely). · list_tiktok_ads_comments + tiktok_ads_comment_thread + moderate_tiktok_ads_comment + reply_to_tiktok_ads_comment + delete_tiktok_ads_comment (COMMENT MODERATION on your own TikTok ads — the platform where the comment section IS the ad. HIDE is the moderation verb and works on anyone’s comment and is reversible; DELETE only ever removes a comment your OWN identity posted, which TikTok reports per comment as canDelete. Comments are scoped to an AD GROUP and to nothing else, and the time window may span at most 30 DAYS, so an empty answer means “none in these 30 days” rather than “none ever”) · list_tiktok_ads_blocked_words + manage_tiktok_ads_blocked_words (a standing 500-word filter that auto-hides any comment containing one of these across EVERY ad on the account — nothing else in Hermoso does this, and removing a word republishes every comment it had hidden) · tiktok_ads_diagnosis (TikTok’s own issues-and-suggestions verdict on your ad groups — creative, bid/budget with its full estimated-delivery tables, and a pixel that has gone quiet. It covers ACTIVE ad groups only and omits any it has nothing to say about, so an empty answer is not a clean bill of health) · get_tiktok_ads_brand_safety + set_tiktok_ads_brand_safety (what content the ads may appear next to. Two things to say out loud: TikTok applies this to Smart+ campaigns and explicitly NOT to the regular campaigns create_tiktok_ads_campaign builds, and coverAllObjectives is a ONE-WAY DOOR TikTok cannot set back). TWO THINGS HERE ARE UNLIKE EVERY OTHER AD PLATFORM: TikTok creates objects ENABLED by default, so Hermoso forces every campaign, ad group and ad PAUSED with no override and nothing serves until set_tiktok_ads_status(confirm:true); and TikTok’s QPS is 1, so every call is serialized and a tree build or a bulk read is SLOW BY DESIGN — a throttle is not a broken connection. SNAPCHAT ADS (the tenth ad platform — Settings ▸ Connectors ▸ Snapchat Ads; a SEPARATE connection from Snapchat posting): list_snapchat_ads_accounts (the organizations and AD ACCOUNTS this profile can act on — every other Snapchat tool needs an adAccountId and this is where it comes from) · list_snapchat_ads_campaigns (the whole tree — campaigns, ad squads and ads) · snapchat_ads_report (impressions, spend, swipes and video quartiles at any level) · search_snapchat_ads_targeting (resolve country / region / interest / language ids — an ad squad cannot be created without at least one country) · upload_snapchat_ads_creative (put a finished render on the ad account as MEDIA and then as the CREATIVE an ad points at — Snapchat has no upload-from-URL, so Hermoso streams the bytes) · create_snapchat_ads_campaign → create_snapchat_ads_ad_squad → create_snapchat_ads_ad (the tree, every tier born PAUSED) · set_snapchat_ads_budget · set_snapchat_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_snapchat_ads_object (a REAL delete verb here, unlike TikTok — irreversible, so offer PAUSED first). THREE THINGS TO SAY OUT LOUD ON THIS PLATFORM: money is MICRO-CURRENCY (1,000,000 = one unit), so quote plain amounts and let Hermoso convert, and never pass both units — under-converting fails loudly while double-converting asks for a budget a million times too large; the creative HEADLINE is capped at 34 characters and brandName at 32, far shorter than Meta or Google, and over-long copy is refused rather than truncated; and a Snapchat ad points at a CREATIVE, never at a media id. SNAPCHAT POSTING (Stories / Spotlights on a Public Profile) IS BUILT BUT NOT YET REACHABLE — Snap’s Public Profile API is allowlist-only and Hermoso has not been allowlisted, so the connector is deliberately not offered; say that plainly rather than looking for a tool. LINKEDIN: post_to_linkedin (publish a finished post to the connected LinkedIn PROFILE) · list_linkedin_pages (the company Pages this connection administers — call this first and let the USER pick, never guess a Page) · post_to_linkedin_page (publish as a company PAGE rather than a person — this is the one most brands actually want) · manage_linkedin_post (edit the copy of a published post, or delete it) \u00b7 list_linkedin_comments / reply_to_linkedin_comment / delete_linkedin_comment (moderate the comments on your Page\u2019s posts \u2014 a SEPARATE LinkedIn authorization grants these, see Connectors) · linkedin_page_analytics (ORGANIC Page performance — followers, follower gains, Page views, and post impressions/clicks/engagement, for the Page total or per post; this is the free organic read, NOT linkedin_ads_report). LINKEDIN ADS (full three-tier management): list_linkedin_ads_campaigns (ad accounts, then a chosen account’s campaign groups, campaigns and — with campaignId — the CREATIVES under them) · linkedin_ads_report (impressions, clicks, cost, conversions, leads) · search_linkedin_ads_targeting (resolve locations / titles / industries / seniorities / company sizes to the URNs LinkedIn demands — never invent one) · linkedin_audience_count (HOW MANY members that targeting actually reaches, before a budget is committed — and a returned 0 means fewer than 300 people, LinkedIn’s privacy floor and also its campaign minimum, never an empty audience) · linkedin_bid_pricing (LinkedIn’s own suggested bid and daily-budget range for that audience — quote it instead of guessing what LinkedIn costs) · create_linkedin_ads_campaign_group → create_linkedin_ads_campaign → create_linkedin_ads_creative (the tree, every tier born DRAFT) · set_linkedin_ads_budget / set_linkedin_ads_status / delete_linkedin_ads_object (budgets, activate/pause at any tier, delete — every spend change confirm-gated). LINKEDIN LEAD SYNC: list_linkedin_lead_forms · list_linkedin_leads / get_linkedin_lead (the LEADS its forms collected, answers named by field — PERSONAL DATA: show, never republish) · subscribe_linkedin_leads / list_linkedin_lead_events / list_linkedin_lead_subscriptions / delete_linkedin_lead_subscription (real-time push to Hermoso, optional forwardTo relay to a CRM; LinkedIn validates ONLY Hermoso’s own webhook). LinkedIn is a THREE-tier platform and the third tier is the one people forget: a campaign with no creative shows nothing, and all three tiers must be ACTIVE before a single impression is served. REDDIT ADS: list_reddit_ads_campaigns / reddit_ads_report (read the account tree + performance) · list_reddit_ads_profiles + list_reddit_ads_posts / create_reddit_ads_post / update_reddit_ads_post (the CREATIVE — a Reddit ad promotes a post) · create_reddit_ads_campaign / update_reddit_ads_campaign · create_reddit_ads_ad_group / update_reddit_ads_ad_group · create_reddit_ads_ad / update_reddit_ads_ad · create_reddit_ads_max_campaign / get_reddit_ads_max_template / update_reddit_ads_max_template (a Reddit MAX campaign: automated campaign, ad group and a template ad Reddit generates ads from, built from creative-library assets, all PAUSED) · set_reddit_ads_status (the ONLY switch that arms real spend, confirm-gated) · delete_reddit_ads_object (remove a campaign, ad group or ad — Reddit has no delete verb, removal is a status, and it refuses to delete anything touched in the last 3 hours) · delete_reddit_ads_saved_audience · search_reddit_ads_targeting / reddit_ads_forecast / reddit_ads_bid_suggestion (free planning) · list_reddit_ads_pixels + send_reddit_ads_conversions (conversion tracking — Reddit now requires a pixel on every ad group) · list_reddit_ads_audiences / create_reddit_ads_audience / update_reddit_ads_audience_users / delete_reddit_ads_audience (retargeting lists) · list_reddit_ads_saved_audiences / create_reddit_ads_saved_audience / update_reddit_ads_saved_audience · list_reddit_ads_lead_forms / create_reddit_ads_lead_form · reddit_ads_history (who changed what, when). TELEGRAM: post_to_telegram (publish to a channel, group or chat as the brand’s own bot — text up to 4096 characters, but only 1024 once any photo or video is attached; one image, one video, or an album of 2–10 in which photos and videos may be mixed. chatId IS ALWAYS REQUIRED and is never guessed: the Bot API publishes NO method that lists the chats a bot belongs to, so pass the public channel’s @username or the numeric id) · list_telegram_chats (chats that MESSAGED the bot in the last 24 hours — a shortcut for finding an id, NOT a roster, and a chat missing from it can still be posted to) · list_telegram_dms (what those chats actually SAID, newest per chat — free, and a rolling 24-hour window rather than an inbox: the Bot API has no history endpoint at all) · delete_telegram_message (confirm-gated; Telegram refuses once a message is more than 48 hours old). BLUESKY: post_to_bluesky (publish as the connected account — text up to 300 characters AND, separately, 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; either up to 4 images OR one MP4 video, never both, because a Bluesky post record carries exactly one embed; links are made clickable automatically) · delete_bluesky_post (PERMANENTLY remove one of the account’s own posts — no trash and no undelete. Call it WITHOUT confirm first: it deletes nothing and reports the post’s real text and live like/repost/reply/quote counts, and once the post has any engagement it also wants confirmText echoing its text. Takes the AT-URI or just the record key from the bsky.app link) · list_bluesky_convos / read_bluesky_dm / send_bluesky_dm / mark_bluesky_convo_read (the account’s DIRECT MESSAGES — free, 1000 characters each, text only, and they need a PRIVILEGED app password: an ordinary one posts fine and cannot chat). Replies, mentions AND direct messages all arrive in list_inbox and are answered with reply_to_inbox_item. X / TWITTER: post_to_x (publish a post — text, an image or a video render WITH alt text, a POLL, a reply, or a whole thread, and optionally restrict who may reply; X is the ONE channel that bills per API request, a post carrying a LINK costs roughly 13× one without, and each profile has a rolling 24-hour ceiling on X spend that refuses a request whole rather than publishing half of it) · delete_x_post (remove one) · x_post_metrics (the PUBLIC counts — impressions, likes, reposts, replies, quotes, bookmarks) · x_post_insights (the ADVERTISER numbers for your own posts — link clicks, profile visits, video views and completion quartiles, up to 25 posts at once; this is what says whether a creative worked, and x_post_metrics cannot tell you, but it only sees the LAST 28 HOURS) · x_post_insights_historical (the same advertiser numbers over ANY date range — the one to use for anything older than yesterday) · x_mentions (who is talking to the brand, in their own words — the read half of the reply loop, and a source of real customer language for ad copy) · list_x_dms (the brand’s X DIRECT MESSAGES, grouped into conversations, saying which are waiting on a reply — billed per message returned, and X keeps only 30 days) · send_x_dm (reply privately to one named person; never a broadcast) · x_chat_settings / update_x_chat_settings (the X account’s DM privacy: who may message the brand, the request filter, read receipts and calls). X IS THE ONE CONNECTOR THAT COSTS CREDITS PER CALL — X charges us per API request, so posting, deleting, reading metrics, reading insights and pulling mentions each bill the user, a post CONTAINING A LINK costs 13× one without, and insights and mentions are billed PER POST RETURNED. Say so before posting a thread or pulling a big page of mentions, and prefer one post over five when the content allows. X ADS (the PAID half — a SEPARATE connection from the organic tools above: its own product on its own host with OAuth 1.0a signing, and X grants API access PER AD ACCOUNT rather than per app, so the customer adds Hermoso’s X user at business.x.com → Account access before anything here resolves): list_x_ads_accounts (the ad accounts this profile can act on, WITH the permission level held on each — read it before attempting a write) · list_x_ads_funding_instruments (a campaign cannot be created without one) · list_x_ads_campaigns / list_x_ads_line_items / list_x_ads_promoted_tweets / list_x_ads_targeting (the whole tree as it stands) · x_ads_report (impressions, clicks, spend and engagements at any level) · x_ads_geo_search / x_ads_targeting_search (resolve places and targeting values to the ids X demands — never invent one) · create_x_ads_campaign → create_x_ads_line_item → create_x_ads_promoted_tweet (the tree, every tier born PAUSED with no override; A CAMPAIGN ALONE CANNOT SERVE ON X — it needs a line item and a promoted post underneath it, and the read-back says so rather than letting you call it a finished ad) · add_x_ads_targeting · update_x_ads_campaign / update_x_ads_line_item (throttle or raise spend on a running campaign without rebuilding it) · set_x_ads_status (the ONLY switch that arms real money, confirm-gated) · delete_x_ads_object. PINTEREST — POSTING AND ADS ARE TWO SEPARATE CONNECTIONS on the same Pinterest login (Pinterest keeps ads access behind different permissions), so a brand can hold either without the other and connecting one does not connect the other; if an ads call says Pinterest Ads is not connected, that is the card to send them to, NOT the Pinterest posting one. ADS: pinterest_ads_async_report (the DEEP paid report — 914 days back where the quick one stops at 90, and three times the metric columns; generated asynchronously, so pass the returned token back rather than re-submitting) · pinterest_targeting_analytics (WHICH audience segment delivered — by keyword, interest, age, gender, location, placement) · pinterest_audience_insights (WHO the audience is: interest affinities plus demographics, the input to a creative brief rather than a performance report) · pinterest_analytics (ORGANIC performance — impressions, saves, Pin clicks, outbound clicks, for the account, the TOP PINS, the top video Pins, or one Pin; Pinterest keeps 90 days and publishes no board-level analytics at all) · create_pinterest_board (make a board — a NEW Pinterest account has none and a Pin needs one) · list_pinterest_boards (the user must pick a board — never choose one for them) · post_to_pinterest (create an image or video Pin on a chosen board, with a title, description and destination link) · list_pinterest_pins (the Pins on a board with their ids — where the pinId every Pin tool needs comes from, and it flags any Pin an ad is promoting) · update_pinterest_pin (retitle, re-describe, fix a dead link, move it — Pinterest keeps this endpoint in a limited BETA, so it may be refused outright and save_pinterest_pin is the generally-available way onto another board; a Pin’s picture can never be swapped by anyone) · save_pinterest_pin (copy a Pin onto another board) · delete_pinterest_pin (confirm-gated, and it says whether an ad is promoting the Pin first) · update_pinterest_board (rename, re-describe, or hide it — SECRET hides every Pin on the board, reversibly) · delete_pinterest_board (the heaviest one here: the board AND every Pin on it, confirm-gated with the Pin count echoed back — offer hiding it instead). GOOGLE ADS (full management): list_google_ads_campaigns (list accounts, then a customer’s campaigns + spend/CTR/CPC/conversions) · google_ads_report (any GAQL breakdown — ad groups, keywords, search terms, geo) · create_google_ads_campaign (paused) · set_google_ads_budget / set_google_ads_status (change budget, enable/pause — every spend change confirm-gated) · delete_google_ads_object (remove a campaign, ad group, ad, KEYWORD, asset LINK or conversion action — Google has no delete verb, `remove` is the terminal state and it cannot be undone; call it unconfirmed first to see the spend and the tree that go with it) · upload_google_ads_asset (add an image render or a YouTube video to the ad account’s asset library) · create_google_ads_performance_max_campaign (Google’s cross-surface campaign type, RETAIL INCLUDED — pass merchantCenterId to make it a Shopping-feed Performance Max advertising the WHOLE Merchant Center feed under one root listing group, and feedLabel to narrow it to a single feed; only PARTITIONING that feed by brand/category/custom label is refused by name) · add_google_ads_assets (sitelinks, callouts and structured snippets, CREATED AND ATTACHED — an asset that is not attached shows nothing) · list_google_ads_conversion_actions + create_google_ads_conversion_action (what Google counts as a result — MAXIMIZE_CONVERSIONS, TARGET_CPA, TARGET_ROAS and every Performance Max campaign are undeliverable without one, and Hermoso refuses to build them on an account that has none) · google_ads_keyword_ideas (Keyword Planner — real monthly search volume, competition and top-of-page bids; use it before choosing keywords) · google_ads_change_history (WHAT CHANGED ON THE ACCOUNT AND WHEN — the answer to “performance fell off a cliff on Tuesday, what happened?”. Its default source is field-level and reaches 30 days; the other source reaches 90 and is the ONLY one that sees Google Ads Editor and criterion edits, so check both before telling anyone nothing changed). GOOGLE MERCHANT CENTER (the product feed behind every Shopping ad and every free listing, on the SAME connection as Google Ads): register_merchant_developer (the ONE-TIME link between Hermoso’s Google Cloud project and the merchant’s account. Google refuses every other Merchant call until it is done, so run this first when calls are being refused) · list_merchant_accounts (which Merchant Centers this login can reach, and where the merchantCenterId every other tool needs comes from) · list_merchant_products (the feed itself, with each product’s disapprovals) · list_merchant_issues (account-level problems, the answer to "why is nothing showing at all") · merchant_issue_help + trigger_merchant_issue_action (Google’s OWN remediation steps for a problem, and the button that fires one. Several of those actions are one-shot in Google’s own words, so firing one is confirm-gated) · list_merchant_data_sources + create_merchant_data_source + delete_merchant_data_source (feeds. A product write only lands in an API-input feed, and most accounts have none until one is made, so check before writing) · upsert_merchant_product + update_merchant_product + delete_merchant_product (write the feed) · list_merchant_inventory + set_merchant_inventory (the per-STORE and per-REGION price, stock level and availability override on one product, which is what stops a Shopping ad advertising something the nearest store has sold out of. The write MERGES, because Google’s insert replaces the whole entry, and Google takes up to 30 minutes to reflect it on the product) · list_merchant_promotions + create_merchant_promotion (sale and discount badges on a listing. Google validates them asynchronously, so created is never the same as approved) · manage_merchant_notifications (Google POSTs to a URL THE MERCHANT RUNS the moment a product is disapproved, instead of someone having to poll) · merchant_account_status (WHY THE ACCOUNT IS OR IS NOT SERVING — the first thing to run when Shopping ads or free listings show nothing, and the one read that does not believe the program state: an account can report both programs ENABLED and serve in ZERO countries, because a region counts as active only where every requirement is met. It names Google’s own unmet requirements, then the settings that explain them: homepage claimed or not, business address, phone and support contact, active shipping services, return policies, terms accepted) · manage_merchant_conversion_source (WHERE MERCHANT CENTER GETS ITS CONVERSION DATA FROM, which is what free-listing and Shopping performance reporting is built on — a merchant with no conversion source sees clicks and no outcomes. Either a Google tag destination, whose MC-… id comes back only on the create and is the id the Google tag has to send conversions to, or a link to a GA4 property, which is IMMUTABLE and needs the connected Google account to be an admin there. A delete is an ARCHIVE and undelete restores it until the expiry Google reports) · merchant_quota (whether the account is simply out of daily API quota or out of product slots, which looks identical to a broken integration and is not. Google resets it at MIDDAY UTC) · merchant_report (the reports Google computes for free, including competitive visibility, best sellers and price competitiveness). MICROSOFT MERCHANT CENTER (the same job on Microsoft’s side, on the Microsoft Advertising connection): list_microsoft_merchant_stores · list_microsoft_merchant_products · upsert_microsoft_merchant_product · delete_microsoft_merchant_product · list_microsoft_merchant_issues · list_microsoft_merchant_catalogs + manage_microsoft_merchant_catalog. GOOGLE ANALYTICS (GA4 — the brand’s OWN site data, and a SEPARATE connection from Google Ads: a brand that spends on Ads every day may have no Analytics access at all, so never read one as the other): list_analytics_properties (call this FIRST — every other Analytics tool needs a NUMERIC property id, and what users actually know is the “G-XXXXXXX” Measurement ID from their tracking snippet, which no endpoint accepts; resolve it from this list rather than sending them hunting. It lists the properties SHARED WITH THIS PROFILE, not everything the Google account can see — Analytics access is handed out freely and one login often has Viewer on many clients’ properties, so the user ticks which belong to this profile and any other one is refused by name; an empty list means nothing is ticked yet, which set_connector_accounts or Settings ▸ Connectors ▸ Google Analytics ▸ Manage accounts fixes) · analytics_report (what happened — sessions, users, revenue, conversions and engagement broken down by channel, source/medium, campaign, landing page, country, device or date, i.e. the read that says whether the traffic an ad bought actually did anything) · analytics_realtime (who is on the site right now, ~30 minutes — a DIFFERENT metric set that rejects `sessions` outright, never a shortcut for analytics_report) · list_analytics_definitions (what the property already measures: its key events and its own custom dimensions, and the check to run before creating either) · create_analytics_key_event (mark an event GA4 already collects as a KEY EVENT — the 2024 rename of a conversion, and what makes it importable into Google Ads; marking an event the site never fires creates one that can never fire) · create_analytics_custom_dimension (register an event parameter the site already sends so reports can break down by it — say out loud first that a GA4 custom dimension CANNOT be deleted, only archived, and a property is capped at 50 event-scoped ones, so a typo permanently burns a slot) · list_analytics_data_streams (the streams on a property and the measurement ID (G-...) each one carries, which is what a gtag or GTM install needs and what nobody can find in the GA4 UI when asked) · get_analytics_stream_setup (the finished gtag <script> block to paste into the site — the last mile list_analytics_data_streams stops short of — plus whether enhanced measurement is really collecting scrolls, outbound clicks, site search, video, downloads and form interactions, and whether redaction is stripping campaign parameters out of recorded URLs. Web streams only. Read the master switch before believing a toggle: with enhanced measurement off for the stream, every toggle is inert whatever it says) · list_analytics_metadata (every dimension and metric this property can be asked for, including its own custom ones, which is what stops analytics_report guessing a field name) · check_analytics_compatibility (whether a dimension and metric can appear in the same report before spending a call finding out they cannot) · create_analytics_custom_metric + archive_analytics_custom_metric · archive_analytics_custom_dimension · delete_analytics_key_event (all one-way in the same sense as their create twins: archiving is not deleting and there is no un-archive) · list_analytics_google_ads_links + link_google_ads_to_analytics + unlink_google_ads_from_analytics (the join that makes a GA4 audience usable in Google Ads and a GA4 key event importable as a conversion — without it a perfectly good audience simply never appears in the ads account, with no error anywhere) · list_analytics_audiences + create_analytics_audience + archive_analytics_audience (GA4 remarketing audiences, the input to Google Ads remarketing. Archiving is one-way) · manage_analytics_measurement_protocol_secret (mint the API secret that lets the customer’s OWN SERVER send events straight into GA4, the Google twin of the conversions APIs already here for Reddit, Snapchat and OpenAI Ads. Say out loud that there is NO rotation anywhere in the API, so replacing a secret means create the new one, move every sender across, then delete the old one) · manage_analytics_channel_group (HOW GA4 BUCKETS TRAFFIC — the answer to “why is my campaign showing as Unassigned”, and the one number an ad studio is judged on. Read the Default channel group’s rules before diagnosing anything, then author your own group whose channels catch the campaigns Hermoso publishes. The rule fields are the eachScope… names, NOT the sessionSource / medium dimensions reports use, and GA4 stops at the first rule that matches so order decides everything) · manage_analytics_calculated_metric (the derived number a marketer actually reports — cost per purchase, revenue per session — built from metrics GA4 already collects and then available to analytics_report under its own permanent API name. The id is permanent, and a formula naming a metric the property does not collect is created happily and flagged invalid, so read that flag back). MICROSOFT ADVERTISING / BING ADS (full management, mirroring Google): list_microsoft_ads_campaigns (list the shared ad accounts, then a chosen account’s campaigns + budgets) · microsoft_ads_geo_search (resolve country / region / city names to the Microsoft location ids a campaign needs — call it when an ask is ambiguous and let the USER pick) · microsoft_ads_report (impressions, clicks, CTR, average CPC, spend, conversions — generated asynchronously, so it may come back pending and must be called again) · create_microsoft_ads_campaign (campaign → ad group → responsive search ad → keywords, always Paused; with no locations[] it is created serving WORLDWIDE, Microsoft’s own default, and the read-back warns loudly — relay that before anyone activates it) · create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (fill in an existing account) · set_microsoft_ads_budget / set_microsoft_ads_status (change budget, activate/pause — every spend change confirm-gated; Microsoft statuses are Active/Paused, never Deleted) · search_microsoft_ads_profiles / list_microsoft_ads_profile_targeting / set_microsoft_ads_profile_targeting (LinkedIn profile targeting: company, industry, job function, seniority and job title bid adjustments on a Search, Shopping or DSA campaign; confirm-gated on an ACTIVE campaign) · delete_microsoft_ads_object (a REAL delete — campaign, ad group, ad or keyword — permanent, with no undelete; call it unconfirmed first to see what goes with it) · microsoft_ads_keyword_ideas (Microsoft’s Keyword Planner — real search volume, competition and suggested bids, with NO planning-tier gate, unlike Google’s) · microsoft_ads_traffic_estimates (what those keywords would deliver at a named bid — a range, never one number) · microsoft_ads_budget_opportunities (where Microsoft says a budget is capping delivery, and what raising it is forecast to buy) · microsoft_ads_auction_insights (who ELSE is bidding on the same auctions — rival domains with their impression share, overlap and outranking share; shares of YOUR auctions, never a measure of a competitor’s whole account) · microsoft_ads_bulk_download (export the account as ONE bulk file — the only way to read ~185 Microsoft record types Hermoso cannot otherwise touch: sitelinks, callouts, structured snippets, labels, shared negative keyword lists, bid strategies, audiences, experiments, seasonality adjustments, conversion goals, asset groups, feeds) · microsoft_ads_bulk_upload (apply an edited bulk file — hundreds of objects in one request. IT IS GATED HARDER THAN ANYTHING ELSE ON THIS CONNECTOR, because a bulk file carries a Status column and can turn campaigns ON without ever touching set_microsoft_ads_status: confirm:true alone is refused, and you must first call it unconfirmed to get the row-by-row list of what it would ACTIVATE and DELETE, show that to the user, then echo both counts back as confirmActivations/confirmDeletions — or pass pauseInstead:true to land the file with every activation written as Paused) · list_microsoft_ads_conversion_goals (what the account counts as a conversion, and which goals are OFFLINE ones) · send_microsoft_ads_offline_conversions (close the loop: phone sales, in-store purchases and late-closing leads fed back so smart bidding stops optimising against website conversions alone — pass PLAIN emails and E.164 phones, hashing happens server-side to Microsoft’s own published spec) · list_microsoft_ads_audiences (the account’s Customer Match lists with their current sizes; a fresh list reads 0 for up to 48 hours and Microsoft will not use one under 300 people, so never call that a failed upload) · create_microsoft_ads_customer_list then apply_microsoft_ads_customer_list (build a Customer Match audience from PLAIN email addresses, normalized and SHA-256 hashed server-side to Microsoft’s own published spec so no plaintext ever leaves us; the user must be shown Microsoft’s Customer Match terms and agree first) · microsoft_ads_recommendations (what Microsoft ITSELF suggests changing, each one priced by Microsoft: budget raises carrying the current and recommended daily amount, new and broadened keywords, negative keywords it wants removed, and ads it has written. Every one INCREASES what the account buys, which is what they are for, so none is a free win and an empty list means Microsoft has no advice rather than that the account is optimal) · apply_microsoft_ads_recommendations (act on them, gated exactly like the bulk upload: confirm:true alone is REFUSED, so call it unconfirmed first to get every recommendation named with what it changes and Microsoft’s own cost estimate, show that to the user, then echo confirmCount and confirmCostIncrease back. Both are recomputed from a fresh read, and there is no undo) · dismiss_microsoft_ads_recommendations (take advice off the list. It cannot spend, so it needs no confirmation at all, and it is the right answer to “make it stop suggesting that” rather than applying something to clear it) · microsoft_ads_auto_apply (THE READ THAT ANSWERS “is Microsoft changing this account while nobody is looking?”, per type. An inherited account can already be opted in with nobody at the brand having done it) · set_microsoft_ads_auto_apply (turn that standing permission on or off. Switching any type ON is the strongest consent anywhere in Hermoso: Microsoft then writes and publishes its own ads under the brand’s name, deletes negative keywords so the account buys more searches, and changes conversion goals, unattended and indefinitely, with NOTHING to preview beforehand. So confirm:true is not enough and every type must be named in confirmTypes. Switching it OFF is never gated). GOOGLE BUSINESS PROFILE (the local-SEO channel — the listing panel on Google Search and Maps, which for a local business is where the demand actually is, and there is no delete): list_business_locations (the listings the connected Google account manages — call this first and let the USER pick when there is more than one; a Post on the wrong storefront is a public mistake) · post_to_google_business (publish a Post to the listing — text, ONE PHOTO and a call-to-action button; Google’s Posts API takes no video, so pass a still. EVENT and OFFER posts both require a title and a start date, and on an OFFER Google ignores the button link) · list_google_business_posts (what is showing right now, with each Post’s state) · delete_google_business_post (take one down — immediate and public, so confirm first) · list_google_business_reviews (the reviews on the listing, and which ones have NO reply yet — for a local business the highest-leverage surface there is) · reply_to_google_business_review (answer one publicly as the business; it is an UPSERT, so it replaces any existing reply) · list_google_business_questions + answer_google_business_question (the public Q&A on the listing) · google_business_search_keywords (the actual search terms people typed to find the listing — free local keyword data; low-volume terms are SUPPRESSED and come back as "fewer than N", never as zero) · google_business_insights (Search + Maps impressions, calls, website clicks, direction requests, messages, bookings — listing-level; Google discontinued per-Post insights in 2023 with no replacement, so never promise per-Post numbers) · get_business_location (everything the listing actually says — name, address, phone, website, categories, description, hours, service area — as the merchant set it; the answer to “what does our Google listing say?”) · update_business_location (change any of that — hours, phone, website, description, categories, even the name or address. It edits the live panel on Search and Maps with no draft and no undo, so call it WITHOUT confirm first: nothing is written, Google validates the payload, and you get the current value of every field you are about to change to show the user) · google_business_account (whose Business Profile account the listing is on, and whether the connected Google account’s role can edit it at all). Google gates this API behind a per-project access request and the default quota is zero, so the connection can be live and calls still refused — the error says so. CHATGPT ADS (ads under ChatGPT answers, via OpenAI’s Advertiser API — full management): list_openai_ads_campaigns (the ad account, then its campaigns, ad groups and ads with each ad’s review state) · openai_ads_report (impressions, clicks, spend, CTR, CPC, CPM at account / campaign / ad group / ad scope — run this first, it validates the key with zero spend risk) · openai_ads_geo_search (location ids) · list_openai_ads_audiences + create_openai_ads_audience (custom audiences — geo and these are the only list-based targeting this platform has; target them with customAudienceIds / excludedCustomAudienceIds on a campaign) · create_openai_ads_campaign (campaign → ad group → ad in one call, always PAUSED) · create_openai_ads_ad_group / create_openai_ads_ad (fill in an existing campaign) · update_openai_ads_object (rename, re-budget, rewrite context hints or the ad copy) · set_openai_ads_budget / set_openai_ads_status (change budget, activate, pause) · delete_openai_ads_object (ARCHIVE — this API has no delete and OpenAI say archiving is not reversible, so offer pausing first). TWO RULES THIS CHANNEL DOES NOT SHARE WITH THE OTHERS: it is connected by PASTING an Advertiser API key (no OAuth, no manager account, one key = one ad account), and it has exactly ONE creative format — a text plus image card, title 50 characters, body 100. There is NO VIDEO on ChatGPT Ads, so never offer a video ad here. GOOGLE DRIVE — ONE connection covering Drive, Sheets and Docs (full CRUD over the files Hermoso created there, plus any file the user hands over with the Google file picker in the app): save_to_drive · list_drive_files / get_drive_file · update_drive_file (rename/move/trash) · delete_drive_file · create_drive_folder. GOOGLE SHEETS (part of the Google Drive connection — export data to a spreadsheet the app creates, or read one the user picked; drive.file, no verification): create_sheet · append_to_sheet · read_sheet. GOOGLE DOCS (part of the Google Drive connection — export copy/brief/report as a doc, or read one the user picked; drive.file, no verification): create_doc · append_to_doc. GOOGLE SLIDES (part of the Google Drive connection — turn a swipefile collection into a real presentation, one slide per saved ad with the creative, brand, copy, run dates and platform; drive.file, no verification, no new scope): export_swipefile_deck — it CREATES a deck each time and cannot append to one the user already has, and a creative whose ad-library link has expired is reported rather than silently dropped. ONEDRIVE (full CRUD over the user’s Microsoft OneDrive): convert_onedrive_file (Microsoft converts a file server-side to PDF or JPG — ~130 formats including PowerPoint and Word decks, PSD, Illustrator, Sketch, 3D, video, iPhone HEIC and raw camera files; JPG needs both width and height) · save_to_onedrive · list_onedrive_files / get_onedrive_file · update_onedrive_file (rename/move) · delete_onedrive_file · create_onedrive_folder. INTERCOM (the brand’s own support inbox, nothing billed): list_intercom_conversations (filter by state, assignee, team, tag, channel, customer, dates, priority, unread, or app = the product the customer wrote from) · read_intercom_conversation · reply_to_intercom_conversation / add_intercom_note / manage_intercom_conversation (each a preview until confirm:true) · tag_intercom_conversation · list_intercom_tags · search_intercom_contacts · list_intercom_teammates · intercom_customer_language (customers’ own words for ad copy). VERCEL (the brand’s own sites, nothing billed): list_vercel_projects · list_vercel_deployments · list_vercel_domains · vercel_web_analytics (page views, visitors, top pages, referrers, countries) · deploy_vercel_page (a static landing page, confirm-gated; preview by default) · delete_vercel_deployment. Use these standalone — Hermoso is a full posting/ads/file-storage control surface, not only an ad generator.',
|
|
260
260
|
'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.',
|
|
261
261
|
].join('\n');
|
|
@@ -338,7 +338,7 @@ export const MCP_INSTRUCTIONS = [
|
|
|
338
338
|
'• 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.',
|
|
339
339
|
'• CREATE (finished ads): render_ad (Studio quality pipeline) or generate_image / generate_video / generate_avatar render on their own; plan_ad authors a board first when the ad wants one and render_ad takes it; get_brand (what we already know) / draft_brand (onboard one) / update_brand (patch a field) manage the saved brand, which the create tools hydrate by themselves; list_creators / save_creator / delete_creator (the reusable saved CAST — re-cast the same face instead of generating a new person every time; render_ad’s `creator` stars one of them in the ad); make_template_ad (native HTML formats); make_thumbnail (YouTube / Shorts / Instagram video thumbnails + covers — use it for any thumbnail or video-cover ask, never generate_image); clone_static / recast_motion / reframe_video / upscale_video / dub_video / change_voice / finish_video / fix_beat / hook_variants / stitch_video; plan_variations + score_ad.',
|
|
340
340
|
'• RAW MODEL PLAYGROUND: generate_image / generate_video (useBrand:false) for prompt-only renders, generate_voice for text-to-speech, generate_text for the writing models — against any of 30+ image / video / voice / writing model ids (credit costs in hermoso_capabilities), no ad framing.',
|
|
341
|
-
'• 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
|
|
341
|
+
'• ACCOUNT & WORKSPACES: hermoso_credits, billing_status, buy_credits (one-click top-up / first-purchase link), upgrade_plan / set_auto_reload (admin), list_jobs / get_job; list_brands / create_brand / use_brand / delete_brand (one account holds MANY profiles: brands, clients or creators, so an agency runs every client through here, each with its own brand details, memory, Library and connectors; create_brand → draft_brand onboards a new one, delete_brand is confirm-gated); get_settings / update_settings (the LANGUAGE every ad, script and plan is written in (chat replies follow the language the user writes in) — set it once and every render obeys it — plus app appearance and the weekly competitor-watch email); list_team / invite_member / remove_member / set_role.',
|
|
342
342
|
'• PUBLISH & MANAGE YOUR CHANNELS (the user’s connected accounts, over this MCP): Meta — post_to_meta (FB/IG/Threads), upload_file (post ANY external/local file), list_meta_ads + meta_insights (read campaigns/ad sets/ads + performance, broken down by age/gender/placement/country), preview_meta_ad (see the real ad per placement, 24h links), estimate_meta_reach (audience size before you spend), list_meta_audiences / create_meta_audience (retargeting + lookalikes), create_meta_campaign / create_meta_ad / upload_meta_asset (build), update_meta_object / delete_meta_object / set_meta_campaign_status (edit/delete/activate — spend + deletes confirm-gated), manage_meta_post (edit/delete a post); DM automations (a keyword comment, DM, story reply or ig.me link gets an automatic private reply) — save_dm_automation / test_dm_automation / list_dm_automations / delete_dm_automation on Instagram, Facebook (Page comments, ads, Messenger) and X (X billed in credits per event and send), and the Instagram-only save_instagram_dm_automation / test_instagram_dm_automation / list_instagram_dm_automations / delete_instagram_dm_automation; X — x_chat_settings / update_x_chat_settings (who may DM the brand on X); Microsoft Advertising (Bing Ads) — list_microsoft_ads_campaigns, microsoft_ads_report, microsoft_ads_geo_search, create_microsoft_ads_campaign / create_microsoft_ads_ad_group / create_microsoft_ads_ad / add_microsoft_ads_keywords (all created Paused), set_microsoft_ads_budget / set_microsoft_ads_status (spend confirm-gated); ChatGPT Ads (OpenAI Advertiser API) — list_openai_ads_campaigns, openai_ads_report, openai_ads_geo_search, create_openai_ads_campaign / create_openai_ads_ad_group / create_openai_ads_ad (all created PAUSED), update_openai_ads_object, set_openai_ads_budget / set_openai_ads_status (spend + archive confirm-gated). Connected by API key; ONE creative format (a text + image card, no video); Pinterest — list_pinterest_boards then post_to_pinterest (the user picks the board); Google Business Profile — list_business_locations, post_to_google_business, list_google_business_posts, delete_google_business_post, google_business_insights, get_business_location / update_business_location (read and CHANGE what the listing says — hours, phone, website, description, categories, name, address; the edit is live on Search and Maps, so the unconfirmed call writes nothing and shows the before-and-after), google_business_account (whose account it is on and whether that role can edit it); Google Drive (ONE connection covering Drive, Sheets and Docs) — save_to_drive, list_drive_files, get_drive_file, update_drive_file, delete_drive_file, create_drive_folder, plus create_sheet / append_to_sheet / read_sheet and create_doc / append_to_doc / read_doc (Hermoso-created files, plus any file the user hands over with the Google file picker in the app); Microsoft OneDrive — save_to_onedrive, list_onedrive_files, get_onedrive_file, update_onedrive_file, delete_onedrive_file, create_onedrive_folder; MANAGING THE CONNECTIONS — list_connectors, list_connector_accounts + set_connector_accounts (which Pages / ad accounts / company Pages this profile may post to and spend from — fails closed, an empty choice shares nothing), leave_connector (remove just YOUR OWN account from a connector several teammates have each joined — theirs keep working) · disconnect_connector (confirm-gated: reconnecting needs a browser). LINKING a NEW account is the one step that is not headless (an OAuth consent screen) — send the user to Workspace ▸ Connectors in the app.',
|
|
343
343
|
'YOUR ROSTER IS CORE-FIRST, AND NOTHING IS MISSING OR UNFINISHED. What you are LISTED is the core tools plus whatever this connection asked for or you have switched on; the rest of the product \u2014 hundreds of tools across research, creation, publishing, paid campaigns, analytics and channel administration \u2014 is held out of the LIST on SIZE ALONE. IT IS ALL CALLABLE RIGHT NOW. find_tools({query}) searches EVERY tool, listed or not, and each row carries its parameters, its credit cost and its recent health; call_tool({name, args}) then runs it through the same handler, the same account and the same permissions; a direct tools/call to a name you already know works too. This route needs no reload and works on every host, including claude.ai and ChatGPT, which fix their tool list when the connection is made. enable_tools({groups:[\u2026]}) additionally LISTS a group for hosts that re-list \u2014 free and instant: the MOMENT the user asks to build, budget, target, report on or change a campaign, enable_tools({groups:[\'ads\']}); for sessions, conversions, revenue by channel or how their site ranks, enable_tools({groups:[\'analytics\']}); to read, moderate, reply to, measure or delete something ALREADY on a channel, enable_tools({groups:[\'channel_admin\']}). IF THE TOOLS DO NOT APPEAR after that call \u2014 some clients cache their tool list for the whole conversation \u2014 do NOT tell the user the capability does not exist and do not keep retrying: just use call_tool, which does not depend on the roster changing at all. Reconnecting with `?tools=all` on the server URL lists everything at connect time. NEVER tell a user Hermoso cannot manage their campaigns or read their analytics, cannot read a comment, answer a DM or pull a channel\u2019s numbers, because you cannot see the tool \u2014 look it up with find_tools and run it with call_tool. The full set of group names is: core, research, create, channels, channel_admin, analytics, files, workspace, ads \u2014 or \'all\'.',
|
|
344
344
|
// HINTS (2026-09-17). The advice was always in the prose; this says the keyed copy exists so a client that
|
|
@@ -564,6 +564,9 @@ const wrap = (fn) => {
|
|
|
564
564
|
// time from the session's client, the same way a render decides whether to inline an image (hostRendersWidgets).
|
|
565
565
|
const _widget = hostRendersWidgets();
|
|
566
566
|
if (e?.videoChoice && typeof e.videoChoice === 'object') { msg = 'Error: ' + videoChoiceText(_tool, e.videoChoice, { widgetHost: _widget }); _hints.push(...videoChoiceHints(_tool, e.videoChoice, { widgetHost: _widget })); }
|
|
567
|
+
// FREE CREDITS WAITING (2026-10-25): the server's sentence already names the one fix (open the app once, signed in) and
|
|
568
|
+
// drops its top-up advice, so it passes through as written on every host, and neither buy_credits nor the pricing page is named.
|
|
569
|
+
else if (e?.welcomeClaim && e.welcomeClaim.url) _hints.push(welcomeClaimHint(e.welcomeClaim));
|
|
567
570
|
else if (_widget && /not enough credits|out of credits|needs (a paid plan|the Pro plan)/i.test(msg)) { msg = neutralCreditsText(msg); _hints.push(...creditsLowHints()); }
|
|
568
571
|
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) ? ' A monthly plan makes credits go twice as far: upgrade_plan moves the user to one, with no markup on models (a person pays on Stripe).' : ''}`;
|
|
569
572
|
// connector not connected → hand the human a ONE-CLICK connect link (OAuth needs a browser, so it can't happen
|
|
@@ -575,6 +578,10 @@ const wrap = (fn) => {
|
|
|
575
578
|
// so an agent relays it instead of retrying or telling the user to reconnect. Read from the STRUCTURED field.
|
|
576
579
|
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' });
|
|
577
580
|
if (Number(e?.status) === 401 && e?.connector) _hints.push({ do: `have the user connect "${e.connector}" (Settings \u25b8 Connectors, or the one-click link in this message)`, why: `${e.connector} is not connected in this workspace, so this tool can only answer 401 until it is` });
|
|
581
|
+
// A PUBLISH OUTCOME IS DATA (2026-10-25, fb_679fd3573ff0): "nothing was posted" and "could not confirm" both arrive
|
|
582
|
+
// as 502, so the hint follows the server's `remedy.posted`, never the status or the prose.
|
|
583
|
+
if (e?.remedy?.code === 'publish_not_sent') _hints.push({ do: 'call this tool again with the same arguments', why: 'nothing was posted: it failed before anything was sent to the platform, so a retry cannot double-post' });
|
|
584
|
+
else if (e?.remedy?.code === 'publish_unconfirmed' || e?.remedy?.code === 'publish_in_progress') _hints.push({ do: 'check the account for the post before anything else; do not retry with a new idempotency key', why: 'the publishing call did not answer cleanly, so the post may be live; allowDuplicate:true is the only way to post it again' });
|
|
578
585
|
}
|
|
579
586
|
// ── THE STRUCTURED ERROR MARKER (2026-08-26) ──────────────────────────────────────────────────────────
|
|
580
587
|
// `msg` above is a SENTENCE, built for a model to read. `/v1/tools` — the REST passthrough — has to answer
|
|
@@ -585,7 +592,9 @@ const wrap = (fn) => {
|
|
|
585
592
|
// `_meta` is MCP's own sanctioned extension point (spec: any result MAY carry it), so every MCP client
|
|
586
593
|
// sees an ordinary error result and ignores a key it does not recognise. `publishWrap` spreads the result,
|
|
587
594
|
// so its ambiguous-publish advice keeps the marker rather than dropping it.
|
|
588
|
-
|
|
595
|
+
// `remedy` (2026-10-25): the server's structured next step, whole, plus its `code` — Meta's security hold (code +
|
|
596
|
+
// steps[]) or a publish outcome (posted:false | 'unknown'). /v1 makes `code` its error code and returns `remedy`.
|
|
597
|
+
return withHints({ content: [{ type: 'text', text: msg }], isError: true, _meta: { 'hermoso.ai/error': { status: Number(e?.status) || 0, connector: e?.connector ? String(e.connector) : '', ...(e?.videoChoice?.options && typeof e.videoChoice.options === 'object' ? { options: e.videoChoice.options } : {}), ...(e?.remedy && typeof e.remedy === 'object' && typeof e.remedy.code === 'string' ? { code: e.remedy.code, remedy: e.remedy } : {}) } } }, _hints); // `options` = the video choice's three ways forward, structured, so /v1 answers its 402 with them
|
|
589
598
|
}
|
|
590
599
|
};
|
|
591
600
|
return outer;
|
|
@@ -605,6 +614,13 @@ const publishWrap = (fn) => {
|
|
|
605
614
|
const outer = async (args, extra) => {
|
|
606
615
|
const r = await inner(args, extra);
|
|
607
616
|
if (!r?.isError) return r;
|
|
617
|
+
// THE SERVER'S OWN VERDICT WINS (2026-10-25). A publish that failed before anything was sent answers "Nothing was
|
|
618
|
+
// posted … (timeout of 90000ms exceeded). It is safe to try again." — and the regex below matched its "timeout",
|
|
619
|
+
// so the same reply then said "the post may well be LIVE … DO NOT RETRY BLINDLY": two opposite verdicts in one
|
|
620
|
+
// answer, the ambiguity fb_679fd3573ff0 reported. When the server sent a publish `remedy`, its sentence already
|
|
621
|
+
// carries the right advice; the regex is only for a failure the server never answered (a dropped transport).
|
|
622
|
+
const _rc = r._meta?.['hermoso.ai/error']?.remedy?.code;
|
|
623
|
+
if (typeof _rc === 'string' && _rc.startsWith('publish_')) return r;
|
|
608
624
|
const text = r.content?.[0]?.text || '';
|
|
609
625
|
if (!PUBLISH_AMBIGUOUS_RE.test(text)) return r;
|
|
610
626
|
return { ...r, content: [{ type: 'text', text: `${text}\n\nDO NOT ASSUME THIS FAILED, AND DO NOT RETRY BLINDLY. Publishing routinely outlives a transport, so the post may well be LIVE. Hermoso protects you: call this tool AGAIN with the identical arguments${args?.idempotencyKey ? ` and the same idempotencyKey ("${args.idempotencyKey}")` : ''} — an identical publish is recognised and returns the ORIGINAL post instead of posting twice. Tell the user it is being confirmed, not that it failed. For video, prefer async:true (post_to_meta), which returns a job id you poll with get_job and cannot time out at all.` }] };
|
|
@@ -616,6 +632,24 @@ const publishWrap = (fn) => {
|
|
|
616
632
|
return outer;
|
|
617
633
|
};
|
|
618
634
|
|
|
635
|
+
// A CHANGE OF WORDS IN A DESIGN IS RE-MADE, NOT EDITED (2026-10-22). A template ad (made here, in the app or over the API)
|
|
636
|
+
// keeps its design on its Library row; "change $10 to $15" makes that design again with the words swapped, about 2 credits,
|
|
637
|
+
// never a video-model edit of every frame. The server decides, free (POST /api/template-ad/remake-text); edit_video asks
|
|
638
|
+
// it first unless the caller chose the model path (a preview, or an approved still). null = not a design: edit as before.
|
|
639
|
+
async function designRemakeFirst(video, prompt) {
|
|
640
|
+
let t = null;
|
|
641
|
+
try { t = await apiPost('/api/template-ad/remake-text', { video, instruction: prompt }); } catch (e) { if (Number(e?.status) !== 404) throw e; } // an older server without the route: the edit, as before
|
|
642
|
+
const ch = t?.change ? `"${t.change.from}" to "${t.change.to}"` : 'those words';
|
|
643
|
+
if (t?.route === 'remake') {
|
|
644
|
+
const r = await awaitRenderJob({ id: t.jobId });
|
|
645
|
+
if (r?.stillRendering) return ok(`This video was laid out from a design, so the same ${t.templateLabel} is being made again with ${ch} (about ${t.credits} credits) instead of a video edit [job ${t.jobId}]. Call get_job with that id until it is done.`, { ...r, remade: true });
|
|
646
|
+
return okVideo(`Made the same ${t.templateLabel} again with ${ch} everywhere it appeared (${t.hits} place${t.hits === 1 ? '' : 's'}); the original video is kept and was not edited: ${r.url} [job ${t.jobId}]${switchNote(r)}`, { ...r, remade: true });
|
|
647
|
+
}
|
|
648
|
+
if (t?.route === 'not_found') return ok(`Nothing changed or charged: "${t.change.from}" is not in this design's words. Its words are: ${(t.words || []).map((w) => `"${w}"`).join(', ')}. Ask which words to change and call again with them exactly as they appear.`, t);
|
|
649
|
+
if (t?.route === 'no_design') return ok(`Nothing changed or charged yet: this video was laid out from a design that was not kept with it, so it cannot be re-made word for word. Offer both ways: (1) make it again from the brand with make_template_ad (the same template, the words with ${ch}), about 2 credits; (2) edit the words inside this video with the video model: edit_video with previewFirstFrame:true shows the change on one still and quotes the clip before anything paid runs.`, t);
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
|
|
619
653
|
// run a job to completion, surfacing the served media URL. Under the HOSTED connector (Claude.ai/ChatGPT) the
|
|
620
654
|
// client kills long tool calls before a 1-3 min render finishes — so cap the in-call wait there and return a
|
|
621
655
|
// RESUMABLE handle instead of dying (the agent polls get_job, which now attaches the poster on done).
|
|
@@ -1910,8 +1944,14 @@ export const INBOX_SOURCES = [
|
|
|
1910
1944
|
// you cannot reply to a message id on either vendor. Meta wants the PAGE-SCOPED ID of the person (its
|
|
1911
1945
|
// /conversation route already computes it as `replyTo`) and Bluesky wants the conversation (`convoId`). Both
|
|
1912
1946
|
// routes already return exactly that field at the top level, so nothing new is computed here.
|
|
1913
|
-
|
|
1914
|
-
|
|
1947
|
+
//
|
|
1948
|
+
// `readAll` (2026-10-25): WITHOUT a conversationId these two no longer print "pass conversationId" and list
|
|
1949
|
+
// nothing. They read every waiting conversation in one call per surface (/api/meta/dm-inbox, already folded to
|
|
1950
|
+
// one row per thread, so it is NOT folded again here) — a customer's busy Instagram inbox read as zero items
|
|
1951
|
+
// because of that note plus the next defect. `readAllQuery` splits the two so no thread is listed twice:
|
|
1952
|
+
// meta_dm reads Messenger, instagram_dm reads Instagram (direct login account, or the Page-linked one).
|
|
1953
|
+
{ source: 'meta_dm', provider: 'meta', read: '/api/meta/conversation', reply: '/api/meta/message', kind: 'dm', label: 'Meta direct message', dmReplyId: 'replyTo', needs: { arg: 'conversationId', param: 'conversationId', from: 'list_meta_conversations' }, readAll: '/api/meta/dm-inbox', readAllQuery: { platform: 'MESSENGER' } },
|
|
1954
|
+
{ source: 'instagram_dm', provider: 'instagram', read: '/api/meta/conversation', reply: '/api/meta/message', kind: 'dm', label: 'Instagram direct message', dmReplyId: 'replyTo', needs: { arg: 'conversationId', param: 'conversationId', from: 'list_meta_conversations' }, readAll: '/api/meta/dm-inbox', readAllQuery: { platform: 'INSTAGRAM' }, query: { platform: 'INSTAGRAM' } }, // `query` rides on the per-thread read too: without it a direct (Instagram Login) thread id was read on the Page path and refused, measured 2026-10-25. A DIRECT (Instagram Login) account's inbox — same routes, resolved through igDmCtx by `account` (2026-09-02)
|
|
1915
1955
|
{ source: 'meta_webhook', provider: 'meta', read: '/api/meta/webhooks/events', reply: null, kind: 'pushed', label: 'Pushed Meta event' },
|
|
1916
1956
|
{ source: 'threads_mention', provider: 'threads', read: '/api/threads/mentions', reply: '/api/threads/reply', kind: 'mention', label: 'Threads mention' },
|
|
1917
1957
|
{ source: 'youtube', provider: 'youtube', read: '/api/youtube/comments', reply: '/api/youtube/reply-comment', kind: 'comment', label: 'YouTube comment', needs: { arg: 'videoId', param: 'videoId', from: 'list_youtube_videos' } },
|
|
@@ -2055,9 +2095,13 @@ export function normalizeInboxItem(source, row = {}) {
|
|
|
2055
2095
|
const nativeId = pick('id', 'commentId', 'name', 'reviewId', 'tweet_id', 'messageId', 'eventId');
|
|
2056
2096
|
// A REVIEW WITH A STAR RATING AND NO WORDS IS STILL AN ITEM. Google reviews are frequently rating-only, and
|
|
2057
2097
|
// dropping them would hide the thing a brand most wants to answer. So text OR a rating is enough to keep it.
|
|
2058
|
-
|
|
2098
|
+
// AND A DM WITH NO WORDS IS STILL AN ITEM (2026-10-25): a story mention, a shared reel, a photo or content Meta
|
|
2099
|
+
// withholds has no text, and this line used to drop every one of them — an Instagram inbox full of story
|
|
2100
|
+
// mentions read as "Nothing waiting." The read route's one-line `summary` stands in for the missing text.
|
|
2101
|
+
const text = pick('text', 'message', 'comment', 'body', 'content', 'reviewReply.comment', 'snippet.textDisplay') || pick('summary');
|
|
2059
2102
|
const rating = row.starRating ?? row.rating ?? null;
|
|
2060
2103
|
if (!nativeId || (!text && rating == null)) return null;
|
|
2104
|
+
const mk = src.kind === 'dm' && typeof row.kind === 'string' && typeof row.summary === 'string' ? row.kind : ''; // the Meta read shape only
|
|
2061
2105
|
return {
|
|
2062
2106
|
id: inboxId(source, nativeId),
|
|
2063
2107
|
source,
|
|
@@ -2078,6 +2122,15 @@ export function normalizeInboxItem(source, row = {}) {
|
|
|
2078
2122
|
|| row.inbound === false || row.fromMe === true,
|
|
2079
2123
|
canReply: !!src.reply,
|
|
2080
2124
|
...(row.messageId || row.eventId ? { messageId: String(row.messageId || row.eventId) } : {}),
|
|
2125
|
+
// WHAT THE DM IS (Meta's read routes, 2026-10-25): its kind, the sentence to read out, the media / story /
|
|
2126
|
+
// share links, and `unavailable` + why when Meta withheld the content. Only set when the route sent them.
|
|
2127
|
+
...(mk ? { messageKind: mk } : {}),
|
|
2128
|
+
...(src.kind === 'dm' && typeof row.summary === 'string' && row.summary.trim() ? { summary: row.summary.trim() } : {}),
|
|
2129
|
+
...(src.kind === 'dm' && row.unavailable === true ? { unavailable: true, unavailableReason: String(row.unavailableReason || '') } : {}),
|
|
2130
|
+
...(src.kind === 'dm' && Array.isArray(row.attachments) && row.attachments.length ? { attachments: row.attachments } : {}),
|
|
2131
|
+
...(src.kind === 'dm' && row.story && typeof row.story === 'object' ? { story: row.story } : {}),
|
|
2132
|
+
...(src.kind === 'dm' && row.share && typeof row.share === 'object' ? { share: row.share } : {}),
|
|
2133
|
+
...(src.kind === 'dm' && typeof row.conversationId === 'string' && row.conversationId ? { conversationId: row.conversationId } : {}),
|
|
2081
2134
|
};
|
|
2082
2135
|
}
|
|
2083
2136
|
|
|
@@ -2097,7 +2150,9 @@ export function inboxLine(it) {
|
|
|
2097
2150
|
it.label,
|
|
2098
2151
|
it.rating != null ? `${it.rating}★` : '',
|
|
2099
2152
|
`${it.author}:`,
|
|
2100
|
-
|
|
2153
|
+
// A DM that is not plain text (a story mention, a share, withheld content) reads as its summary, which already
|
|
2154
|
+
// carries the kind in words; plain text stays quoted as before.
|
|
2155
|
+
it.summary && it.messageKind && it.messageKind !== 'text' ? String(it.summary).slice(0, 220) : JSON.stringify(String(it.text || '').slice(0, 140)),
|
|
2101
2156
|
`[${it.id}]`,
|
|
2102
2157
|
];
|
|
2103
2158
|
return bits.filter(Boolean).join(' ');
|
|
@@ -2130,7 +2185,7 @@ export const TOOL_GROUPS = {
|
|
|
2130
2185
|
// list in prose has no check that can hold it true, so the fix is to stop making a claim that can rot: the tools
|
|
2131
2186
|
// themselves are the roster, and enabling the group is what reveals them.
|
|
2132
2187
|
ads: 'Paid campaign management on every connected ad platform — build, budget, target and report on campaigns, ad sets, ads, keywords and audiences. Enable this group to see which platforms this workspace can reach.',
|
|
2133
|
-
files: 'Google Drive, Sheets, Docs and
|
|
2188
|
+
files: 'Google Drive, Sheets, Docs, OneDrive and Notion.',
|
|
2134
2189
|
workspace:'Profiles, memory, skills, saved creators, connectors and team.',
|
|
2135
2190
|
};
|
|
2136
2191
|
export const TOOL_GROUP_NAMES = ['core', ...Object.keys(TOOL_GROUPS)];
|
|
@@ -2348,7 +2403,28 @@ export function parseToolScope(raw) {
|
|
|
2348
2403
|
// coupon, customer) create records and move nothing; tools/chatgpt-no-money-movement-check.mjs makes a NEW Stripe
|
|
2349
2404
|
// write fail until somebody classifies it.
|
|
2350
2405
|
export const WIDGET_MONEY_MOVEMENT_TOOLS = Object.freeze(['refund_stripe_charge', 'create_stripe_subscription', 'cancel_stripe_subscription', 'create_stripe_payment_link']);
|
|
2351
|
-
|
|
2406
|
+
// THE 2026-10-25 SCAN, the tools a widget host is not offered for a reason of their own (the sentence a direct call gets):
|
|
2407
|
+
// submit_tiktok_ads_verification — "appears to solicit a government-issued identifier": its INDIVIDUAL form takes the
|
|
2408
|
+
// government ID number (identityNo) and a legal name. Entered in TikTok Ads Manager instead.
|
|
2409
|
+
// merchant_issue_help + trigger_merchant_issue_action — "operations that are not individually exposed": the actions are
|
|
2410
|
+
// opaque flows Google defines per issue (actionContext / actionFlowId), so they cannot be split one per tool.
|
|
2411
|
+
// manage_analytics_measurement_protocol_secret — same finding; its list and create both print GA4 api_secret values into
|
|
2412
|
+
// the reply, and a secret does not belong in the conversation (the 2026-10-16 credentials-in-chat holds), so it is
|
|
2413
|
+
// withheld rather than split.
|
|
2414
|
+
// set_openai_ads_negative_keywords + create_reddit_ads_lead_form — "name inconsistent with its behavior": both vendors
|
|
2415
|
+
// retired the endpoint, so the tool can only refuse; no name would describe that, and a tool that never acts has no
|
|
2416
|
+
// place in the list.
|
|
2417
|
+
export const WIDGET_WITHHELD_WHY = Object.freeze({
|
|
2418
|
+
submit_tiktok_ads_verification: 'it sends a legal name and a government ID or business licence number, which are entered in TikTok Ads Manager instead',
|
|
2419
|
+
merchant_issue_help: 'Google defines the actions on each issue as opaque flows, so they are not offered as separate tools here; list_merchant_issues names the issue and Merchant Center fixes it',
|
|
2420
|
+
trigger_merchant_issue_action: 'Google defines the actions on each issue as opaque flows, so they are not offered as separate tools here; list_merchant_issues names the issue and Merchant Center fixes it',
|
|
2421
|
+
manage_analytics_measurement_protocol_secret: 'it returns GA4 Measurement Protocol secrets, which are managed in Google Analytics rather than in a conversation',
|
|
2422
|
+
set_openai_ads_negative_keywords: 'OpenAI no longer serves account-level negative keywords, so there is nothing it can set',
|
|
2423
|
+
create_reddit_ads_lead_form: 'Reddit retired onsite lead forms, so there is nothing it can create',
|
|
2424
|
+
});
|
|
2425
|
+
export const WITHHELD_FROM_WIDGET_HOSTS = new Set(['buy_credits', 'upgrade_plan', 'set_auto_reload', 'billing_status', 'delete_brand', 'connect_connector', 'x_post_insights', 'x_post_insights_historical', 'fetch_social_data', 'remix_static', ...WIDGET_MONEY_MOVEMENT_TOOLS, ...Object.keys(WIDGET_WITHHELD_WHY)]);
|
|
2426
|
+
// remix_static is clone_static under its old name (kept for callers that cached it); on ChatGPT two tools for one
|
|
2427
|
+
// action is a selection hazard OpenAI's metadata guide calls out, so only clone_static is offered there (2026-10-24).
|
|
2352
2428
|
// The withheld tools whose refusal is the generic-executor sentence rather than the commerce one.
|
|
2353
2429
|
const WIDGET_GENERIC_PROXIES = new Set(['fetch_social_data']);
|
|
2354
2430
|
const executorLike = (name) => EXECUTOR_TOOLS.has(name) || WIDGET_GENERIC_PROXIES.has(name);
|
|
@@ -2509,6 +2585,80 @@ export const WIDGET_HOST_SPLITS = Object.freeze({
|
|
|
2509
2585
|
splitOp('create_tiktok_tto_anchor', { action: 'create' }, ['ttoAccountId', 'categoryLabelId!', 'countryCode!', 'landingPageUrl!', 'anchorTitle!', 'anchorName!', 'thumbnailUrl!'], splitW(true, false), 'Create a TikTok One webpage anchor', 'Use this when the brand wants a webpage anchor for creator videos. PERMANENT: an anchor made here can never be deleted, so confirm with the user first. It shows only to viewers in countryCode, and the landing page must be a product or service detail page. Thumbnail exactly 210x375, under 2MB. Free.'),
|
|
2510
2586
|
splitOp('delete_tiktok_tto_anchor', { action: 'delete' }, ['ttoAccountId', 'anchorId!'], splitW(true, true), 'Delete a draft TikTok One webpage anchor', 'Use this when a DRAFT anchor should be removed. TikTok deletes only drafts, so an anchor already in review or live cannot be removed. Free.'),
|
|
2511
2587
|
],
|
|
2588
|
+
// THE 2026-10-25 SCAN held 15 more tools as "operations that are not individually exposed". Twelve are split here;
|
|
2589
|
+
// merchant_issue_help, trigger_merchant_issue_action and manage_analytics_measurement_protocol_secret are withheld
|
|
2590
|
+
// instead (WIDGET_WITHHELD_WHY says why). The ChatGPT op for TikTok catalog feeds leaves out the feed's username and
|
|
2591
|
+
// password, and the SFTP op takes an SSH public key only (no password comes back): credentials stay out of the chat.
|
|
2592
|
+
manage_merchant_notifications: [
|
|
2593
|
+
splitOp('list_merchant_notification_subscriptions', { action: 'list' }, ['merchantCenterId!'], SPLIT_RO, 'List Merchant Center notification subscriptions', 'Use this when you need the push-notification subscriptions on a Merchant Center account: each one’s id, event and the HTTPS endpoint Google posts to. Get merchantCenterId from list_merchant_accounts. Read-only, free.'),
|
|
2594
|
+
splitOp('create_merchant_notification_subscription', { action: 'create' }, ['merchantCenterId!', 'callBackUri!', 'registeredEvent', 'allManagedAccounts', 'targetAccount'], splitW(false, false), 'Subscribe an endpoint to Merchant Center notifications', 'Use this when the merchant runs an HTTPS endpoint that should receive a POST from Google each time a product’s status changes (PRODUCT_STATUS_CHANGE, the default) or an account service changes. callBackUri is the merchant’s own server; Hermoso does not receive these notifications. Free.', { callBackUri: 'the merchant’s own HTTPS endpoint Google will POST to; not a Hermoso URL and not localhost' }),
|
|
2595
|
+
splitOp('delete_merchant_notification_subscription', { action: 'delete' }, ['merchantCenterId!', 'subscriptionId!'], splitW(true, true), 'Delete a Merchant Center notification subscription', 'Use this when a notification subscription should stop, so Google no longer posts to that endpoint. Get subscriptionId from list_merchant_notification_subscriptions. Free.', { subscriptionId: 'from list_merchant_notification_subscriptions' }),
|
|
2596
|
+
],
|
|
2597
|
+
manage_merchant_conversion_source: [
|
|
2598
|
+
splitOp('list_merchant_conversion_sources', { action: 'list' }, ['merchantCenterId!', 'showDeleted'], SPLIT_RO, 'List Merchant Center conversion sources', 'Use this when you need to see where a Merchant Center account gets its conversion data: each source’s id, kind (Google tag destination or Google Analytics link), currency, attribution and state. showDeleted:true includes archived ones. Read-only, free.'),
|
|
2599
|
+
splitOp('create_merchant_conversion_source', { action: 'create' }, ['merchantCenterId!', 'kind', 'displayName', 'currencyCode', 'attributionModel', 'attributionLookbackWindowDays', 'conversionTypes', 'propertyId'], splitW(false, false), 'Add a Merchant Center conversion source', 'Use this when a Merchant Center account needs a conversion source. A Google tag destination needs displayName and currencyCode and returns a destination id (MC-...) the Google tag must send conversions to, so give that id to the user. A Google Analytics link needs propertyId and an admin on that GA4 property, and its property cannot be changed later. Lookback is 7, 30 or 40 days. Free.'),
|
|
2600
|
+
splitOp('update_merchant_conversion_source', { action: 'update' }, ['merchantCenterId!', 'conversionSourceId', 'displayName', 'currencyCode', 'attributionModel', 'attributionLookbackWindowDays'], splitW(true, true), 'Change a Merchant Center conversion source', 'Use this when a Google tag destination’s name, currency or attribution should change. It changes what conversion reporting counts from then on. Get conversionSourceId from list_merchant_conversion_sources. Free.', { conversionSourceId: 'from list_merchant_conversion_sources' }),
|
|
2601
|
+
splitOp('delete_merchant_conversion_source', { action: 'delete' }, ['merchantCenterId!', 'conversionSourceId', 'displayName', 'confirm'], splitW(true, true), 'Archive a Merchant Center conversion source', 'Use this when a conversion source should stop feeding Merchant Center. It is archived, and restore_merchant_conversion_source brings it back until the expiry Google reports. Needs confirm:true. Free.', { conversionSourceId: 'from list_merchant_conversion_sources', confirm: 'must be true to archive' }),
|
|
2602
|
+
splitOp('restore_merchant_conversion_source', { action: 'undelete' }, ['merchantCenterId!', 'conversionSourceId!'], splitW(false, true), 'Restore an archived Merchant Center conversion source', 'Use this when an archived conversion source should come back, before the expiry Google reports on it. Get conversionSourceId from list_merchant_conversion_sources with showDeleted:true. Free.', { conversionSourceId: 'from list_merchant_conversion_sources with showDeleted:true' }),
|
|
2603
|
+
],
|
|
2604
|
+
manage_analytics_channel_group: [
|
|
2605
|
+
splitOp('list_analytics_channel_groups', { action: 'list' }, ['property!'], SPLIT_RO, 'List GA4 channel groups', 'Use this when you need to see how GA4 buckets traffic into channels: every channel group with its channels in the order GA4 tests them, including Google’s Default channel group. Read it first when a campaign lands in the wrong channel or in Unassigned. Read-only, free.'),
|
|
2606
|
+
splitOp('create_analytics_channel_group', { action: 'create' }, ['property!', 'displayName!', 'description', 'channels!', 'primary'], splitW(true, false), 'Create a GA4 channel group', 'Use this when the user wants their own GA4 channel group. channels is an ordered list of {name, conditions}, and GA4 stops at the first rule that matches. Condition fields are eachScopeSource, eachScopeMedium, eachScopeCampaignName, eachScopeCampaignId, eachScopeSourcePlatform or eachScopeDefaultChannelGroup. primary:true makes it the group every report uses and unsets the previous one. Free.'),
|
|
2607
|
+
splitOp('update_analytics_channel_group', { action: 'update' }, ['property!', 'channelGroupId', 'displayName', 'description', 'channels', 'primary'], splitW(true, true), 'Change a GA4 channel group', 'Use this when a custom channel group’s rules, name or primary status should change. channels REPLACES every channel, so send the ones to keep. Google’s Default channel group cannot be edited. Get channelGroupId from list_analytics_channel_groups. Free.', { channelGroupId: 'from list_analytics_channel_groups' }),
|
|
2608
|
+
splitOp('delete_analytics_channel_group', { action: 'delete' }, ['property!', 'channelGroupId', 'displayName', 'confirm'], splitW(true, true), 'Delete a GA4 channel group', 'Use this when a custom channel group should be removed. Needs confirm:true. Get channelGroupId from list_analytics_channel_groups. Free.', { channelGroupId: 'from list_analytics_channel_groups', confirm: 'must be true to delete' }),
|
|
2609
|
+
],
|
|
2610
|
+
manage_analytics_calculated_metric: [
|
|
2611
|
+
splitOp('list_analytics_calculated_metrics', { action: 'list' }, ['property!'], SPLIT_RO, 'List GA4 calculated metrics', 'Use this when you need the calculated metrics on a GA4 property, with their formulas, units and whether a formula names a metric the property does not collect. Read-only, free.'),
|
|
2612
|
+
splitOp('create_analytics_calculated_metric', { action: 'create' }, ['property!', 'calculatedMetricId!', 'displayName!', 'formula!', 'metricUnit!', 'description'], splitW(false, false), 'Create a GA4 calculated metric', 'Use this when the user wants a derived metric such as cost per purchase or revenue per session, which analytics_report can then ask for as calcMetric:<id>. calculatedMetricId is permanent. The formula takes + - * / and parentheses over metric names and numbers. If the reply flags invalidMetricReference, say the formula names a metric this property does not collect. Free.'),
|
|
2613
|
+
splitOp('update_analytics_calculated_metric', { action: 'update' }, ['property!', 'calculatedMetricId!', 'displayName', 'formula', 'metricUnit', 'description'], splitW(true, true), 'Change a GA4 calculated metric', 'Use this when a calculated metric’s formula, name, unit or description should change. Its id cannot be renamed. Reports that use it change from then on. Free.'),
|
|
2614
|
+
splitOp('delete_analytics_calculated_metric', { action: 'delete' }, ['property!', 'calculatedMetricId!', 'confirm'], splitW(true, true), 'Delete a GA4 calculated metric', 'Use this when a calculated metric should be removed. Reports that ask for it stop working. Needs confirm:true. Free.', { confirm: 'must be true to delete' }),
|
|
2615
|
+
],
|
|
2616
|
+
manage_intercom_conversation: [
|
|
2617
|
+
splitOp('close_intercom_conversation', { action: 'close' }, ['conversationId!', 'message', 'confirm'], splitW(true, true), 'Close an Intercom conversation', 'Use this when a conversation in the brand’s Intercom inbox is resolved. message is optional and the CUSTOMER sees it. Call without confirm first to preview, then with confirm:true once the user agrees. The new state is read back. Free.'),
|
|
2618
|
+
splitOp('snooze_intercom_conversation', { action: 'snooze' }, ['conversationId!', 'snoozeUntil!', 'confirm'], splitW(false, true), 'Snooze an Intercom conversation', 'Use this when a conversation should leave the open inbox until a future time (snoozeUntil, an ISO date-time). Call without confirm first to preview, then with confirm:true. Free.'),
|
|
2619
|
+
splitOp('reopen_intercom_conversation', { action: 'open' }, ['conversationId!', 'confirm'], splitW(false, true), 'Reopen an Intercom conversation', 'Use this when a closed or snoozed conversation should be open again. Call without confirm first to preview, then with confirm:true. Free.'),
|
|
2620
|
+
splitOp('assign_intercom_conversation', { action: 'assign' }, ['conversationId!', 'assigneeId', 'teamId', 'assignToMe', 'unassign', 'message', 'confirm'], splitW(true, true), 'Assign an Intercom conversation', 'Use this when a conversation should go to a teammate (assigneeId), a team (teamId), the connected admin (assignToMe:true) or nobody (unassign:true); ids come from list_intercom_teammates. message is optional and the CUSTOMER sees it. Call without confirm first to preview, then with confirm:true. Free.'),
|
|
2621
|
+
],
|
|
2622
|
+
apply_microsoft_ads_customer_list: [
|
|
2623
|
+
splitOp('add_microsoft_ads_customer_list_emails', { action: 'Add' }, ['accountId', 'audienceId!', 'emails!', 'subType', 'acceptTerms'], splitW(false, true), 'Add emails to a Microsoft Customer Match list', 'Use this when customer emails should be added to a Microsoft Advertising Customer Match list. Pass plain addresses: Hermoso normalizes and SHA-256 hashes them and sends only the hashes. Show the user https://about.ads.microsoft.com/en-us/legal/customer-match-terms first and pass acceptTerms:true only after they agree. Up to 1000 per call. The list size can read 0 for up to 48 hours while it builds. 0 credits.'),
|
|
2624
|
+
splitOp('remove_microsoft_ads_customer_list_emails', { action: 'Remove' }, ['accountId', 'audienceId!', 'emails!', 'subType', 'acceptTerms'], splitW(true, true), 'Remove emails from a Microsoft Customer Match list', 'Use this when customer emails should be taken out of a Microsoft Advertising Customer Match list. The same hashing and acceptTerms rule as add_microsoft_ads_customer_list_emails applies. Up to 1000 per call. 0 credits.'),
|
|
2625
|
+
splitOp('replace_microsoft_ads_customer_list_emails', { action: 'Replace' }, ['accountId', 'audienceId!', 'emails!', 'subType', 'acceptTerms'], splitW(true, true), 'Replace a Microsoft Customer Match list', 'Use this when a Microsoft Advertising Customer Match list should hold ONLY the emails in this call: everyone else on it is removed. The same hashing and acceptTerms rule as add_microsoft_ads_customer_list_emails applies. Up to 1000 per call. 0 credits.'),
|
|
2626
|
+
],
|
|
2627
|
+
set_openai_ads_feed_sftp: [
|
|
2628
|
+
splitOp('set_openai_ads_feed_sftp_key', { action: 'create', authenticationMethod: 'ssh_key' }, ['feedId!', 'sshPublicKey!'], splitW(true, false), 'Set the SSH key for ChatGPT Ads feed SFTP delivery', 'Use this when a ChatGPT Ads product feed should accept SFTP uploads signed in with the user’s SSH PUBLIC key. It replaces any SFTP credentials the feed had, and the old ones stop working at once. The reply carries the connection URI. get_openai_ads_feed_sftp reads the state without changing it.', { sshPublicKey: 'the SSH PUBLIC key (never a private key)' }),
|
|
2629
|
+
splitOp('activate_openai_ads_feed_sftp', { action: 'activate' }, ['feedId!'], splitW(false, true), 'Turn on ChatGPT Ads feed SFTP delivery', 'Use this when SFTP delivery for a ChatGPT Ads product feed should start accepting files, without changing its credentials. Delivered files show up in list_openai_ads_feed_uploads.'),
|
|
2630
|
+
splitOp('pause_openai_ads_feed_sftp', { action: 'pause' }, ['feedId!'], splitW(false, true), 'Pause ChatGPT Ads feed SFTP delivery', 'Use this when SFTP delivery for a ChatGPT Ads product feed should stop accepting files for now, without changing its credentials. activate_openai_ads_feed_sftp turns it back on.'),
|
|
2631
|
+
],
|
|
2632
|
+
manage_tiktok_ads_test_lead: [
|
|
2633
|
+
splitOp('create_tiktok_ads_test_lead', { action: 'create' }, ['advertiserId', 'pageId', 'leadSource'], splitW(false, false), 'Create a TikTok test lead', 'Use this when the user wants to test a TikTok lead pipeline end to end without a real person filling in the form. It makes one dummy lead on an Instant Form (pageId, from list_tiktok_ads_lead_forms) that download_tiktok_ads_leads reads like a real one; it spends nothing and reaches nobody. TikTok allows one test lead per form, so delete the existing one first. Free.'),
|
|
2634
|
+
splitOp('get_tiktok_ads_test_lead', { action: 'get' }, ['advertiserId', 'pageId', 'leadSource'], SPLIT_RO, 'Read a TikTok test lead', 'Use this when you need the test lead that exists on a TikTok Instant Form, including its leadId. Read-only, free.'),
|
|
2635
|
+
splitOp('delete_tiktok_ads_test_lead', { action: 'delete' }, ['advertiserId', 'leadId!', 'leadSource'], splitW(true, true), 'Delete a TikTok test lead', 'Use this when the test lead on a TikTok form should be removed, for example before creating a new one. Get leadId from get_tiktok_ads_test_lead. Free.', { leadId: 'from get_tiktok_ads_test_lead' }),
|
|
2636
|
+
],
|
|
2637
|
+
manage_tiktok_ads_catalog_feed: [
|
|
2638
|
+
splitOp('list_tiktok_ads_catalog_feeds', { action: 'list' }, ['bcId', 'catalogId!', 'feedId'], SPLIT_RO, 'List a TikTok catalog’s product feeds', 'Use this when you need the scheduled product feeds on a TikTok catalog: name, feedId, schedule status, update mode and source URL. Read-only.', { feedId: 'only this feed' }),
|
|
2639
|
+
splitOp('create_tiktok_ads_catalog_feed', { action: 'create' }, ['bcId', 'catalogId!', 'feedName!', 'updateMode!', 'uri', 'intervalType', 'intervalCount', 'timezone', 'hour', 'minute', 'dayOfMonth'], splitW(true, false), 'Create a scheduled TikTok catalog product feed', 'Use this when a TikTok catalog should import products on a schedule from a feed the store publishes (CSV, TSV or XML). updateMode has no default: INCREMENTAL adds and updates products, OVERWRITE removes every product the feed omits. uri, intervalType and timezone go together. Products appear after the feed runs and TikTok reviews them; check list_tiktok_ads_catalog_products.', { feedName: 'a name for the feed' }),
|
|
2640
|
+
splitOp('delete_tiktok_ads_catalog_feed', { action: 'delete' }, ['bcId', 'catalogId!', 'feedId!', 'confirm'], splitW(true, true), 'Delete a TikTok catalog product feed', 'Use this when a scheduled product feed should stop importing. Products already imported stay in the catalog. Needs confirm:true. Get feedId from list_tiktok_ads_catalog_feeds.', { feedId: 'from list_tiktok_ads_catalog_feeds', confirm: 'must be true to delete' }),
|
|
2641
|
+
],
|
|
2642
|
+
manage_tiktok_ads_offline_event_set: [
|
|
2643
|
+
splitOp('create_tiktok_ads_offline_event_set', { action: 'create' }, ['advertiserId', 'name!', 'description', 'autoTracking'], splitW(false, false), 'Create a TikTok Offline Event set', 'Use this when the advertiser needs a container for in-store and other offline conversions. name is up to 40 characters and must be new on the account. autoTracking:true attaches every future campaign on the advertiser to this set (ten such sets at most), so leave it off unless the user asks. The created set is read back. Free.'),
|
|
2644
|
+
splitOp('update_tiktok_ads_offline_event_set', { action: 'update' }, ['advertiserId', 'eventSetId!', 'name', 'description', 'autoTracking'], splitW(true, true), 'Change a TikTok Offline Event set', 'Use this when an Offline Event set’s name, description or autoTracking should change. Get eventSetId from list_tiktok_ads_offline_event_sets. Free.'),
|
|
2645
|
+
splitOp('delete_tiktok_ads_offline_event_set', { action: 'delete' }, ['advertiserId', 'eventSetId!', 'confirm'], splitW(true, true), 'Delete a TikTok Offline Event set', 'Use this when an Offline Event set should be removed. Conversions already reported into it stop being available to reporting and optimisation, campaigns tracking it lose that attribution, and TikTok has no undelete. Needs confirm:true. Free.', { confirm: 'must be true to delete' }),
|
|
2646
|
+
],
|
|
2647
|
+
manage_linkedin_post: [
|
|
2648
|
+
splitOp('edit_linkedin_post', { action: 'edit' }, ['brand', 'postUrn!', 'text!'], splitW(true, true), 'Edit the text of a published LinkedIn post', 'Use this when the copy of a LinkedIn post already published on the personal profile or a company Page needs changing. Pass the postUrn returned when it was published. Only the text can change; the image or video of a published post cannot be replaced.', { text: 'the new post text' }),
|
|
2649
|
+
splitOp('delete_linkedin_post', { action: 'delete' }, ['brand', 'postUrn!', 'confirm'], splitW(true, true), 'Delete a published LinkedIn post', 'Use this when a published LinkedIn post must be removed. It is immediate and public, and needs confirm:true.', { confirm: 'must be true to delete' }),
|
|
2650
|
+
],
|
|
2651
|
+
manage_sheet_tabs: [
|
|
2652
|
+
splitOp('add_sheet_tab', { action: 'add' }, ['spreadsheetId', 'sheetUrl', 'title!'], splitW(false, false), 'Add a tab to a Google Sheet', 'Use this when a Google Spreadsheet needs a new tab. Pass spreadsheetId or sheetUrl and the tab title. The tab is read back before it is reported.'),
|
|
2653
|
+
splitOp('rename_sheet_tab', { action: 'rename' }, ['spreadsheetId', 'sheetUrl', 'tab!', 'newTitle!'], splitW(false, true), 'Rename a Google Sheet tab', 'Use this when a tab in a Google Spreadsheet should get a new name. Name the tab by its title or numeric sheetId (list_sheet_tabs gives both).'),
|
|
2654
|
+
splitOp('delete_sheet_tab', { action: 'delete' }, ['spreadsheetId', 'sheetUrl', 'tab!', 'confirm', 'confirmCells'], splitW(true, true), 'Delete a Google Sheet tab', 'Use this when a tab and everything on it should be removed. Call it without confirm first to get the filled-cell count, then with confirm:true and confirmCells. The last remaining tab cannot be deleted.'),
|
|
2655
|
+
],
|
|
2656
|
+
// update_pinterest_board has no action enum, but the scan found its NAME inconsistent with what it does (a privacy flip
|
|
2657
|
+
// that hides every Pin on the board, under "update"). So a widget host lists its two jobs under names that say them.
|
|
2658
|
+
update_pinterest_board: [
|
|
2659
|
+
splitOp('edit_pinterest_board_details', {}, ['boardId!', 'name', 'description'], splitW(true, true), 'Rename a Pinterest board or edit its description', 'Use this when a Pinterest board’s name or description should change. It overwrites the public text. Get boardId from list_pinterest_boards. 0 credits.', { name: 'the new board name' }),
|
|
2660
|
+
splitOp('set_pinterest_board_visibility', {}, ['boardId!', 'privacy!'], splitW(true, true), 'Make a Pinterest board public or secret', 'Use this when a Pinterest board should become SECRET (hidden, with every Pin on it, from everyone but this account) or PUBLIC again. Say what SECRET hides and get a yes first. It is reversible, and the reply reports how many Pins were hidden. 0 credits.', { privacy: 'PUBLIC or SECRET; SECRET hides every Pin on the board from everyone but this account' }),
|
|
2661
|
+
],
|
|
2512
2662
|
});
|
|
2513
2663
|
// op tool name → the multi-op tool it comes from.
|
|
2514
2664
|
export const WIDGET_SPLIT_OF = Object.freeze(Object.fromEntries(Object.entries(WIDGET_HOST_SPLITS).flatMap(([from, ops]) => ops.map((o) => [o.name, from]))));
|
|
@@ -2794,18 +2944,25 @@ const splitRef = (m, tool, word) => {
|
|
|
2794
2944
|
const hit = w && ops.find((o) => Object.values(o.set).some((v) => String(v).toLowerCase() === w));
|
|
2795
2945
|
return hit ? hit.name : ops.map((o) => o.name).join(' / ') + m.slice(tool.length);
|
|
2796
2946
|
};
|
|
2797
|
-
|
|
2947
|
+
// (2026-10-25) …and the two TikTok verification reads stop pointing at the withheld submit tool, and the Pinterest board
|
|
2948
|
+
// delete names the visibility operation. These run BEFORE the split rewrite, so a precise reference wins over the
|
|
2949
|
+
// generic "op / op" list.
|
|
2950
|
+
const WIDGET_PROXY_REFS = [[/\s*\/\s*fetch_social_data\b/g, ''], [/go through fetch_social_data with a handle/g, 'go through instagram_profile and search_instagram_hashtag'],
|
|
2951
|
+
[/, which submit_tiktok_ads_verification can reuse/g, ''], [/the code submit_tiktok_ads_verification needs/g, 'its code'],
|
|
2952
|
+
[/SECRET with update_pinterest_board\b/g, 'SECRET with set_pinterest_board_visibility']];
|
|
2953
|
+
const widgetProxyHit = (d) => WIDGET_PROXY_REFS.some(([re]) => { re.lastIndex = 0; const hit = re.test(d); re.lastIndex = 0; return hit; });
|
|
2798
2954
|
function renamedForWidgetHost(def) {
|
|
2799
2955
|
const d = def && typeof def.description === 'string' ? def.description : '';
|
|
2800
2956
|
const rename = !!d && WIDGET_RENAME_RE.test(d); WIDGET_RENAME_RE.lastIndex = 0;
|
|
2801
2957
|
const paste = !!d && WIDGET_KEY_PASTE_RE.test(d); WIDGET_KEY_PASTE_RE.lastIndex = 0;
|
|
2802
2958
|
const split = !!d && WIDGET_SPLIT_RE.test(d); WIDGET_SPLIT_RE.lastIndex = 0;
|
|
2803
|
-
const proxy = !!d && d
|
|
2959
|
+
const proxy = !!d && widgetProxyHit(d);
|
|
2804
2960
|
if (!rename && !paste && !split && !proxy) return def;
|
|
2805
2961
|
let hit = _widgetRenamed.get(def);
|
|
2806
2962
|
if (!hit) {
|
|
2807
|
-
let out = d
|
|
2963
|
+
let out = d;
|
|
2808
2964
|
for (const [re, to] of WIDGET_PROXY_REFS) out = out.replace(re, to);
|
|
2965
|
+
out = out.replace(WIDGET_RENAME_RE, (m) => WIDGET_HOST_RENAMES[m] || m).replace(WIDGET_KEY_PASTE_RE, '').replace(WIDGET_SPLIT_RE, splitRef);
|
|
2809
2966
|
hit = { ...def, description: out }; _widgetRenamed.set(def, hit);
|
|
2810
2967
|
}
|
|
2811
2968
|
return hit;
|
|
@@ -2925,6 +3082,7 @@ export const holdReasonText = (name, why, ctx = null) => {
|
|
|
2925
3082
|
}
|
|
2926
3083
|
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.`;
|
|
2927
3084
|
// ONE sentence, two reasons: OpenAI's generic-executor rule for EXECUTOR_TOOLS, its commerce policy for the rest.
|
|
3085
|
+
if (why === 'host_policy' && WIDGET_WITHHELD_WHY[name]) return `${name} is not offered on this host: ${WIDGET_WITHHELD_WHY[name]}.`;
|
|
2928
3086
|
if (why === 'host_policy') return `${name} is not offered on this host (${executorLike(name) ? 'here every Hermoso operation is its own tool in your list, so call those tools directly' : "the host's own commerce policy"}).${executorLike(name) ? '' : ' Use the Hermoso app or another client for it.'}`;
|
|
2929
3087
|
if (why === 'not_connected') {
|
|
2930
3088
|
// THE LINK BELONGS HERE TOO (2026-09-13, found live on a brand with no YouTube). A tool held back for a missing
|
|
@@ -4217,9 +4375,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4217
4375
|
+ "Direct), Instagram, Bluesky, X and Telegram. Use this for 'what do I need to reply to', 'any new comments', 'any new DMs', 'how are people responding'. Each "
|
|
4218
4376
|
+ "item carries a composite id you hand straight to reply_to_inbox_item. A channel that is not connected is "
|
|
4219
4377
|
+ "skipped silently; a channel that FAILS to read is named in `notes` rather than dropped, so a short list is "
|
|
4220
|
-
+ "never mistaken for a quiet week.
|
|
4221
|
-
+ "
|
|
4222
|
-
+ "
|
|
4378
|
+
+ "never mistaken for a quiet week. Each DM conversation folds to ONE item (the newest thing the other person said). "
|
|
4379
|
+
+ "Messenger and Instagram DMs need no argument; each has a messageKind (text, story_mention, story_reply, share, "
|
|
4380
|
+
+ "image, video...), a spoken summary, and unavailable:true when Meta withholds the content. Bluesky and X DMs need "
|
|
4381
|
+
+ "conversationId (list_bluesky_convos / list_x_dms). FREE ON EVERY CHANNEL EXCEPT X, which bills per API call — X mentions and X DMs cost credits per item returned, "
|
|
4223
4382
|
+ "and every other source only re-reads what the per-channel tools already read.",
|
|
4224
4383
|
inputSchema: {
|
|
4225
4384
|
sources: z.array(z.string()).optional().describe(`Limit to these sources: ${INBOX_SOURCE_NAMES.join(', ')}. Omit for every connected channel.`),
|
|
@@ -4229,7 +4388,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4229
4388
|
// and this schema did not carry it, so the note telling a caller to pass it named an argument the tool
|
|
4230
4389
|
// refused — and the handler quietly read `postId` for anything that was not `videoId`. The check now
|
|
4231
4390
|
// asserts INBOX_NEEDS_ARGS ⊆ these keys, so the two cannot drift again.
|
|
4232
|
-
conversationId: z.string().optional().describe('Restrict DMs to one conversation (from list_meta_conversations, list_bluesky_convos or list_x_dms).
|
|
4391
|
+
conversationId: z.string().optional().describe('Restrict DMs to one conversation (from list_meta_conversations, list_bluesky_convos or list_x_dms). Required for Bluesky and X DMs; on X reading one costs credits.'),
|
|
4233
4392
|
limit: z.number().optional().describe('Max items per source (default 25).'),
|
|
4234
4393
|
unansweredOnly: z.boolean().optional().describe('Only items with no reply from the brand yet.'),
|
|
4235
4394
|
},
|
|
@@ -4259,12 +4418,17 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4259
4418
|
// for. Saying so is very different from letting the vendor 400 and filing it as "could not be read": one
|
|
4260
4419
|
// tells the caller what to pass next, the other reads as a broken channel.
|
|
4261
4420
|
const given = argValues[src.needs.arg];
|
|
4262
|
-
|
|
4263
|
-
|
|
4421
|
+
// A SOURCE THAT CAN ALSO READ ACCOUNT-WIDE (`readAll`) DOES, rather than printing the note: the Meta and
|
|
4422
|
+
// Instagram DM inboxes list every waiting conversation in one call per surface (2026-10-25).
|
|
4423
|
+
if (!given && !src.readAll) { notes.push(`${src.label}s are read per ${src.kind === 'dm' ? 'conversation' : 'post'} — pass ${src.needs.arg} (from ${src.needs.from}) to include them.`); continue; }
|
|
4424
|
+
if (given) q[src.needs.param] = given;
|
|
4264
4425
|
}
|
|
4426
|
+
const whole = !!(src.readAll && src.needs && !argValues[src.needs.arg]);
|
|
4427
|
+
Object.assign(q, src.query || {}, whole ? (src.readAllQuery || {}) : {});
|
|
4265
4428
|
try {
|
|
4266
|
-
const d = await apiGet(src.read, q);
|
|
4429
|
+
const d = await apiGet(whole ? src.readAll : src.read, q);
|
|
4267
4430
|
const rows = Array.isArray(d) ? d : (d.comments || d.replies || d.mentions || d.reviews || d.messages || d.events || d.items || d.data || []);
|
|
4431
|
+
if (whole) { for (const r of rows) { const it = normalizeInboxItem(src.source, r); if (it) items.push(it); } continue; } // already one row per conversation
|
|
4268
4432
|
// A DM FOLDS TO ONE ITEM AND CARRIES THE ID YOU ANSWER TO — see foldConversationRows. Deriving this from
|
|
4269
4433
|
// `kind` rather than naming the two sources means a third DM channel inherits it by declaring its kind.
|
|
4270
4434
|
// FOLDED ONLY WHERE THE REPLY TARGET IS ON THE ENVELOPE. `dmReplyId` is precisely the declaration "this
|
|
@@ -4273,7 +4437,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4273
4437
|
// conversation, so folding them would collapse every chat into one — keying the fold on `kind` would do
|
|
4274
4438
|
// exactly that. The trigger is therefore the field the fold consumes, not the category.
|
|
4275
4439
|
const use = src.dmReplyId ? foldConversationRows(rows, d?.[src.dmReplyId]) : rows;
|
|
4276
|
-
for (const r of use) { const it = normalizeInboxItem(src.source, r); if (it) items.push(it); }
|
|
4440
|
+
for (const r of use) { const it = normalizeInboxItem(src.source, src.dmReplyId && typeof d?.conversationId === 'string' && !r.conversationId ? { ...r, conversationId: d.conversationId } : r); if (it) items.push(it); }
|
|
4277
4441
|
} catch (e) {
|
|
4278
4442
|
// NAMED, never swallowed. A connector that is simply not linked is not worth a line; anything else is.
|
|
4279
4443
|
const msg = String(e?.message || e);
|
|
@@ -4342,7 +4506,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4342
4506
|
server.registerTool('hermoso_capabilities', {
|
|
4343
4507
|
// The title a directory renders. It has to say CALL THIS FIRST without saying "call this first", because the
|
|
4344
4508
|
// listing shows it out of context, beside 680 siblings, to somebody who has never used Hermoso.
|
|
4345
|
-
title: '
|
|
4509
|
+
title: 'Hermoso models, options and credit costs',
|
|
4346
4510
|
description: 'Probe what this Hermoso account can do RIGHT NOW: available image/video model ids + their credit costs (estimates: a render is charged its actual cost), aspect ratios, video durations, the recipe ids, and the canEdit/canAvatar flags. Call it when you need a specific model id, a credit cost, or a capability you are not sure of. It is NOT a prerequisite for rendering: generate_image, generate_video and render_ad all run with `model` omitted and route to the server’s own default. Read-only, free.',
|
|
4347
4511
|
inputSchema: {}, outputSchema: {
|
|
4348
4512
|
image: z.boolean().optional().describe('TRUE when image generation is available on this account. A BOOLEAN, never a provider name — /api/generate/status deliberately reports availability only and never leaks the underlying provider, so there is no label here to report. The model ids live in options.image.models[].'),
|
|
@@ -4434,7 +4598,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4434
4598
|
// answer here too. Best effort: a reply about the balance never fails on the history read.
|
|
4435
4599
|
let recent = ''; try { const u = await apiGet('/api/billing/usage', { limit: 10 }); if (u?.items?.length) recent = `\nRecent charges (newest first):\n${u.items.map((r) => `• ${String(r.at).slice(0, 16).replace('T', ' ')} UTC · ${r.label} · ${r.credits} credits`).join('\n')}`; } catch {}
|
|
4436
4600
|
const _mr = d.memberRate === true ? ' This account\'s plan has no markup on models.' : d.planUpsell === true && !hostRendersWidgets() ? ' Monthly plans have no markup on models, so credits go twice as far (upgrade_plan); Free and top-up credits use standard prices.' : '';
|
|
4437
|
-
|
|
4601
|
+
const _wc = d.welcome?.claimable && Number(d.welcome.credits) > 0 && d.welcome.url ? ` This account has not received its ${d.welcome.credits} free credits yet: the user opens ${d.welcome.url} once in a browser, signed in with the same account, and they arrive in a few seconds after a quick check that a person is there (no card).` : ''; // the welcome waits behind a one-time check (2026-10-25)
|
|
4602
|
+
return ok(`Balance: ${bal} credits${d.sessionUsed != null ? ` · session used: ${d.sessionUsed}` : ''}.${_mr}${_wc} ${d.costModel || 'Credits are spent ONLY on AI models and Ad Spy research; posting, scheduling, campaign management, analytics and connectors are free (X is the one per-call exception).'} ${d.costEstimate || PRICE_ESTIMATE_LINE}${recent}`, d);
|
|
4438
4603
|
}));
|
|
4439
4604
|
|
|
4440
4605
|
// AGENT BILLING: out of credits → top up. With a saved card + billing-admin rights this is the SAME one-click
|
|
@@ -5703,13 +5868,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5703
5868
|
// no need for one edge case just for Facebook."
|
|
5704
5869
|
server.registerTool('schedule_post', {
|
|
5705
5870
|
title: 'Schedule a post for later',
|
|
5706
|
-
description: 'Use this when the user wants to schedule a social media post for later, on one channel or several at once. Scheduling and publishing cost no credits on any Hermoso plan (X alone bills a few credits per call). Queue a post for a future time on one or more connected channels at once: facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, bluesky, telegram — TEN channels, every one live.
|
|
5871
|
+
description: 'Use this when the user wants to schedule a social media post for later, on one channel or several at once. Scheduling and publishing cost no credits on any Hermoso plan (X alone bills a few credits per call). Queue a post for a future time on one or more connected channels at once: facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, bluesky, telegram — TEN channels, every one live. A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order — never schedule just its first slide. Name the time in `at`, or pass `useQueue:true` for the brand’s next free POSTING SLOT (what “just queue it” means). imageUrl/videoUrl take a Hermoso render URL or an upload_file URL. `captions` gives a channel its own wording; the rest use `message`. PINTEREST AND YOUTUBE SHOW A TITLE: `title` (max 100 chars), derived from the caption when omitted; YOUTUBE also takes `description`, `tags`, `thumbnailUrl`. Every PER-CHANNEL SETTING is a parameter below, carried straight to the real publisher — TikTok’s `brandedContent` / `yourBrand` disclosures (set them whenever the post is commercial), Google Business `topicType` / `event` / `offer` / `actionType`, an X `thread` / `poll`, Instagram `collaborators`, and the rest. Channels are attempted INDEPENDENTLY: one failing never blocks the others. SOME CHANNELS MUST BE TOLD WHICH ACCOUNT, and Hermoso never guesses: Pinterest needs `boardId` (list_pinterest_boards) or is refused; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages) or it goes to the person’s own profile; several Facebook Pages need `pageId` (list_meta_pages), several Google Business listings `locationId` (list_business_locations) — resolve those FIRST and let the user pick, or the post is refused when it fires. A scheduled post GOES LIVE PUBLICLY by default on every channel, never quietly downgraded. Only if the user asks, set `visibility` (or `visibilityByChannel`): ‘unlisted’ (YouTube) · ‘private’ (YouTube, or TikTok SELF_ONLY) · ‘draft’ (TikTok, or an unpublished Facebook Page post). A visibility a channel cannot do is REFUSED now, never posted weaker later.',
|
|
5707
5872
|
inputSchema: {
|
|
5708
5873
|
...HOOK_ATTR,
|
|
5709
5874
|
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram'])).describe('one or more channels to post to at that time'),
|
|
5710
|
-
at: z.string().optional().describe('when to post
|
|
5875
|
+
at: z.string().optional().describe('when to post: local wall clock, no offset (2026-10-30T10:00), read in `timezone` or the brand\u2019s zone ON THAT DATE so a series survives a clock change; or UTC (…Z) / epoch ms. Future, at most 365 days out. Give this OR useQueue, never both.'),
|
|
5711
5876
|
useQueue: z.boolean().optional().describe('instead of naming a minute, drop this into the brand’s POSTING QUEUE: Hermoso takes the earliest of its saved posting times that is still free (skipping any slot another queued post already holds). This is the natural answer to “just queue it” / “post it at my next opening”. Mutually exclusive with `at` — passing both is refused rather than one being silently preferred. If the profile has no posting times set, or every slot for the next 90 days is taken, it is refused by name and nothing is scheduled.'),
|
|
5712
|
-
timezone: z.string().optional().describe('IANA zone
|
|
5877
|
+
timezone: z.string().optional().describe('IANA zone (e.g. "Africa/Cairo") a wall-clock `at` or the queue is read in; overrides the brand’s saved zone for this one post. Without either, UTC.'),
|
|
5713
5878
|
message: z.string().optional().describe('the caption/text used for every channel unless overridden in captions'),
|
|
5714
5879
|
captions: z.record(z.string()).optional().describe('per-channel caption overrides, e.g. { "instagram": "…", "threads": "…" } — platforms want different lengths and hashtag conventions'),
|
|
5715
5880
|
accounts: z.record(z.union([z.array(z.string()), z.literal('all')])).optional().describe('WHICH accounts of a multi-account channel to post to, e.g. { "tiktok": ["@a", "@b"] } or { "tiktok": "all" } — one row per account at fire time, each with its own result. Omit for channels with one account (several and none named is refused by name).'),
|
|
@@ -5913,7 +6078,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5913
6078
|
brand: z.string().optional().describe('WHICH PROFILE this post is in — id or exact name from list_brands; needed when it is not the profile this connection is pinned to. A name that matches no profile, or two, is REFUSED.'),
|
|
5914
6079
|
...POST_INTENT,
|
|
5915
6080
|
id: z.string().describe('the scheduled post id from list_scheduled'),
|
|
5916
|
-
at: z.string().optional().describe('the new time
|
|
6081
|
+
at: z.string().optional().describe('the new time: local wall clock (2026-10-30T10:00, read in `timezone` or the brand\u2019s zone on that date), UTC (…Z) or epoch ms. Future, at most 365 days out.'),
|
|
6082
|
+
timezone: z.string().optional().describe('IANA zone for a wall-clock `at`'),
|
|
5917
6083
|
message: z.string().optional().describe('replace the caption used for every channel that has no override'),
|
|
5918
6084
|
captions: z.record(z.string()).optional().describe('replaces the WHOLE per-channel caption map — send every override you want to keep, not just the new one'),
|
|
5919
6085
|
optimizeCopy: z.boolean().optional().describe('fit the shared caption to each channel’s own rules when you schedule it (the fitted caption is stored on the scheduled post, so what you scheduled is what publishes) wherever no per-channel caption was written; a channel with its own caption is left exactly as written. Send false to switch it off on this item.'),
|
|
@@ -6056,9 +6222,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6056
6222
|
brand: z.string().optional().describe('WHICH PROFILE this post is in — id or exact name from list_brands; needed when it is not the profile this connection is pinned to. A name that matches no profile, or two, is REFUSED.'),
|
|
6057
6223
|
id: z.string().describe('the post to copy, from list_scheduled'),
|
|
6058
6224
|
...POST_INTENT, // the copy inherits the original's format and idea (and hook and subject); pass either to change it
|
|
6059
|
-
at: z.string().optional().describe('when the copy goes out
|
|
6225
|
+
at: z.string().optional().describe('when the copy goes out: local wall clock (2026-10-30T10:00), UTC or epoch ms (default: an hour from now)'),
|
|
6060
6226
|
useQueue: z.boolean().optional().describe('instead of naming a time, take the brand’s next free posting slot'),
|
|
6061
|
-
timezone: z.string().optional().describe('IANA zone for the queue
|
|
6227
|
+
timezone: z.string().optional().describe('IANA zone for a wall-clock `at` or the queue'),
|
|
6062
6228
|
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram'])).optional().describe('post the copy to these channels instead of the original’s'),
|
|
6063
6229
|
message: z.string().optional().describe('a different caption for the copy'),
|
|
6064
6230
|
captions: z.record(z.string()).optional().describe('per-channel caption overrides for the copy'),
|
|
@@ -6377,7 +6543,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6377
6543
|
}));
|
|
6378
6544
|
server.registerTool('x_mentions', {
|
|
6379
6545
|
title: 'Read who is mentioning you on X',
|
|
6380
|
-
description: 'Read the posts mentioning the connected X account — who is talking to the brand, in their own words, newest first. Use it to find what deserves a reply (reply with post_to_x + replyToId) and to mine real objections and customer language for ad copy. COSTS CREDITS PER MENTION RETURNED, plus one account lookup — keep maxResults small (default 10) and tell the user the cost before pulling a big page. Needs X connected.',
|
|
6546
|
+
description: 'Read the posts mentioning the connected X account — who is talking to the brand, in their own words, newest first. Use it to find what deserves a reply (reply with post_to_x + replyToId) and to mine real objections and customer language for ad copy. COSTS CREDITS PER MENTION RETURNED and per distinct author X returns with them, plus one account lookup — keep maxResults small (default 10) and tell the user the cost before pulling a big page. Needs X connected.',
|
|
6381
6547
|
inputSchema: {
|
|
6382
6548
|
maxResults: z.number().optional().describe('how many mentions to pull, 5-100 (default 10) — every one is billed'),
|
|
6383
6549
|
sinceId: z.string().optional().describe('only return mentions newer than this post id'),
|
|
@@ -6590,7 +6756,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6590
6756
|
boardSectionId: z.string().optional().describe('section within the board'),
|
|
6591
6757
|
},
|
|
6592
6758
|
outputSchema: { ok: z.boolean().optional(), pinId: z.string().optional(), title: z.string().optional(), description: z.string().optional(), link: z.string().nullable().optional(), altText: z.string().nullable().optional(), boardId: z.string().nullable().optional(), url: z.string().optional(), changed: z.array(z.string()).optional(), note: z.string().optional() },
|
|
6593
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
6759
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
6594
6760
|
}, wrap(async (a) => {
|
|
6595
6761
|
const d = await apiPost('/api/pinterest/pin/update', a);
|
|
6596
6762
|
return ok(d.note, d);
|
|
@@ -6634,14 +6800,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6634
6800
|
privacy: z.enum(['PUBLIC', 'SECRET']).optional().describe('SECRET hides every Pin on the board from everyone but this account'),
|
|
6635
6801
|
},
|
|
6636
6802
|
outputSchema: { ok: z.boolean().optional(), boardId: z.string().optional(), name: z.string().optional(), description: z.string().optional(), privacy: z.string().optional(), changed: z.array(z.string()).optional(), note: z.string().optional() },
|
|
6637
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
6803
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
6638
6804
|
}, wrap(async (a) => {
|
|
6639
6805
|
const d = await apiPost('/api/pinterest/board/update', a);
|
|
6640
6806
|
return ok(d.note, d);
|
|
6641
6807
|
}));
|
|
6642
6808
|
server.registerTool('delete_pinterest_board', {
|
|
6643
6809
|
title: 'Delete a Pinterest board',
|
|
6644
|
-
description: 'PERMANENTLY delete a board AND EVERY PIN ON IT. This is the heaviest thing that can be done to a Pinterest account and there is no undelete. Call it WITHOUT confirm first: nothing is deleted, and it answers with the board’s real name, how many Pins are on it, how many people FOLLOW it and how many collaborators lose access — all read back from Pinterest. Show the user that, then call again with confirm:true plus confirmName (its exact name) and confirmChildren (the Pin count it reported); those echoes exist because a caller who has not looked at the board cannot supply them, and confirming intent alone does not prove aim. IF THEY ONLY WANT IT OUT OF PUBLIC VIEW:
|
|
6810
|
+
description: 'PERMANENTLY delete a board AND EVERY PIN ON IT. This is the heaviest thing that can be done to a Pinterest account and there is no undelete. Call it WITHOUT confirm first: nothing is deleted, and it answers with the board’s real name, how many Pins are on it, how many people FOLLOW it and how many collaborators lose access — all read back from Pinterest. Show the user that, then call again with confirm:true plus confirmName (its exact name) and confirmChildren (the Pin count it reported); those echoes exist because a caller who has not looked at the board cannot supply them, and confirming intent alone does not prove aim. IF THEY ONLY WANT IT OUT OF PUBLIC VIEW: making the board SECRET with update_pinterest_board is reversible, but it needs a Pinterest permission (boards:write_secret) that an older connection may not hold. The unconfirmed refusal says which applies to this connection, read off the live scope set, so relay it verbatim rather than guessing. 0 credits. Needs Pinterest connected.',
|
|
6645
6811
|
inputSchema: {
|
|
6646
6812
|
boardId: z.string().describe('numeric board id from list_pinterest_boards'),
|
|
6647
6813
|
confirm: z.boolean().optional().describe('REQUIRED true — the board and its Pins are gone for good'),
|
|
@@ -7309,7 +7475,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7309
7475
|
}));
|
|
7310
7476
|
server.registerTool('search_x', {
|
|
7311
7477
|
title: 'Search X (Twitter)',
|
|
7312
|
-
description: 'Keyword search across the PUBLIC X index \u2014 the last 7 days. This is the X half of ad-spy research, alongside search_tiktok / search_instagram / search_reddit / search_threads / search_youtube. X\u2019s own search OPERATORS work and are the point: `from:nike`, `-is:retweet`, `has:media`, `has:links`, `lang:en`, `\"exact phrase\"`, `(a OR b)`. Every post comes back with impressions, likes, reposts, replies, quotes and bookmarks, so you can rank what actually landed rather than what merely exists. ONLY THE LAST 7 DAYS: the full archive needs a tier we do not hold, and an older window is refused by name rather than silently truncated. Costs a small number of credits (X bills per post read, and this bills the ACTUAL count returned, never the number asked for). Needs X connected.',
|
|
7478
|
+
description: 'Keyword search across the PUBLIC X index \u2014 the last 7 days. This is the X half of ad-spy research, alongside search_tiktok / search_instagram / search_reddit / search_threads / search_youtube. X\u2019s own search OPERATORS work and are the point: `from:nike`, `-is:retweet`, `has:media`, `has:links`, `lang:en`, `\"exact phrase\"`, `(a OR b)`. Every post comes back with impressions, likes, reposts, replies, quotes and bookmarks, so you can rank what actually landed rather than what merely exists. ONLY THE LAST 7 DAYS: the full archive needs a tier we do not hold, and an older window is refused by name rather than silently truncated. Costs a small number of credits (X bills per post read and per distinct author returned with them, and this bills the ACTUAL count returned, never the number asked for). Needs X connected.',
|
|
7313
7479
|
inputSchema: {
|
|
7314
7480
|
query: z.string().describe('the search, X operators included \u2014 e.g. `(protein OR creatine) -is:retweet has:media lang:en`'),
|
|
7315
7481
|
maxResults: z.number().optional().describe('10\u2013100, default 25'),
|
|
@@ -7551,7 +7717,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7551
7717
|
}));
|
|
7552
7718
|
server.registerTool('edit_telegram_message', {
|
|
7553
7719
|
title: 'Edit a Telegram post',
|
|
7554
|
-
description: 'Change the TEXT or CAPTION of a message already posted to a Telegram channel, group or chat \u2014 the fix for a typo that would otherwise cost the post AND its accumulated views, since deleting and reposting starts from zero. Pass `text` for a plain post or `caption` for one carrying media; Telegram refuses the wrong one and its error names the other.
|
|
7720
|
+
description: 'Change the TEXT or CAPTION of a message already posted to a Telegram channel, group or chat \u2014 the fix for a typo that would otherwise cost the post AND its accumulated views, since deleting and reposting starts from zero. Pass `text` for a plain post or `caption` for one carrying media; Telegram refuses the wrong one and its error names the other. It overwrites the published text, and the previous wording is not kept. Free, 0 credits. Needs Telegram connected.',
|
|
7555
7721
|
inputSchema: {
|
|
7556
7722
|
...MANAGE_BRAND,
|
|
7557
7723
|
chatId: z.string().describe('the channel\u2019s @username or numeric chat id'),
|
|
@@ -7560,7 +7726,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7560
7726
|
caption: z.string().optional().describe('the new caption, for a post carrying a photo/video/album'),
|
|
7561
7727
|
linkPreview: z.boolean().optional().describe('false to suppress the link preview'),
|
|
7562
7728
|
},
|
|
7563
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
7729
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
7564
7730
|
}, wrap(async (a) => {
|
|
7565
7731
|
const d = await apiPost('/api/telegram/edit', a);
|
|
7566
7732
|
return ok(`Edited message ${d.messageId} in ${d.chatId} (${d.method}).`, d);
|
|
@@ -7625,7 +7791,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7625
7791
|
}));
|
|
7626
7792
|
server.registerTool('search_linkedin_ad_library', {
|
|
7627
7793
|
title: 'Search LinkedIn\u2019s official Ad Library',
|
|
7628
|
-
description: 'Search the ads running on LinkedIn, from LinkedIn\u2019s OWN Ad Library, and get three things
|
|
7794
|
+
description: 'Search the ads running on LinkedIn, from LinkedIn\u2019s OWN Ad Library, and get three things LinkedIn publishes for each ad: the advertiser\u2019s actual TARGETING (location, language, audience, company, job, age, gender, interests, traits \u2014 both the segments they INCLUDED and the ones they EXCLUDED), estimated impressions as a range plus the percentage split BY COUNTRY, and, for ads LinkedIn REMOVED, the advertising policy that was violated and how it was caught. That last one is the cheapest way to learn what not to write. Coverage is global. COVERAGE IS BOUNDED and the reply always says so: nothing created before 2023-06-01, and an ad is dropped one year after its last impression, so an empty result usually means the window rather than the market. 25 ads per page. Needs LinkedIn connected \u2014 no extra permission, any LinkedIn connection works. 0 credits.',
|
|
7629
7795
|
inputSchema: {
|
|
7630
7796
|
keyword: z.string().optional().describe('searched in the ad\u2019s content; several words are a logical AND, not a phrase'),
|
|
7631
7797
|
advertiser: z.string().optional().describe('advertiser name, e.g. Notion'),
|
|
@@ -8141,7 +8307,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8141
8307
|
}));
|
|
8142
8308
|
server.registerTool('read_meta_conversation', {
|
|
8143
8309
|
title: 'Read one Messenger / Instagram DM thread',
|
|
8144
|
-
description: 'The messages in one DM thread, oldest to newest, each marked inbound or outbound — plus WHETHER A REPLY IS ALLOWED RIGHT NOW. Meta only permits a reply within 24 hours of the person acting, and the window is computed from their LAST MESSAGE rather than from the thread’s updated time (which includes our own replies and would report the window open for a day after WE spoke). Read this before drafting: "can I answer this?" is the question a person actually has, and finding out by being refused is the worst way to learn it. It also hands back `replyTo`, the id a reply goes to, so nobody has to guess which participant is the customer. ON INSTAGRAM Meta serves only the 20 most recent messages — a truncated thread is unreachable history, not a short conversation, and the reply says so. Read-only, 0 credits.',
|
|
8310
|
+
description: 'The messages in one DM thread, oldest to newest, each marked inbound or outbound — plus WHETHER A REPLY IS ALLOWED RIGHT NOW. Meta only permits a reply within 24 hours of the person acting, and the window is computed from their LAST MESSAGE rather than from the thread’s updated time (which includes our own replies and would report the window open for a day after WE spoke). Read this before drafting: "can I answer this?" is the question a person actually has, and finding out by being refused is the worst way to learn it. It also hands back `replyTo`, the id a reply goes to, so nobody has to guess which participant is the customer. EVERY MESSAGE SAYS WHAT IT IS: `kind` (text, story_mention, story_reply, share, image, video, audio, file, sticker, gif, template, unsupported, empty), a one-line `summary` to read out, and `attachments` / `story` / `share` / `inReplyTo` / `reactions`; content Meta withholds (unsupported, or a "use the latest app" placeholder) is `unavailable: true` with `unavailableReason`, never blank text. ON INSTAGRAM Meta serves only the 20 most recent messages — a truncated thread is unreachable history, not a short conversation, and the reply says so. Read-only, 0 credits.',
|
|
8145
8311
|
inputSchema: {
|
|
8146
8312
|
conversationId: z.string().describe('from list_meta_conversations'),
|
|
8147
8313
|
platform: z.enum(['MESSENGER', 'INSTAGRAM']).optional().describe('so the Instagram 20-message ceiling can be stated when it applies'),
|
|
@@ -8149,13 +8315,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8149
8315
|
account: z.string().optional().describe('which Instagram account — an @handle or id from list_connector_accounts("instagram"). The way a DIRECT (Instagram Login) account is reached; omit for the Page-linked one.'),
|
|
8150
8316
|
pageId: z.string().optional(),
|
|
8151
8317
|
},
|
|
8152
|
-
outputSchema: { conversationId: z.string().optional(), count: z.number().optional(), messages: z.array(z.any()).optional(), replyTo: z.string().optional(), windowState: z.string().optional(), hoursLeft: z.number().nullable().optional(), window: z.string().optional(), historyNote: z.string().optional() },
|
|
8318
|
+
outputSchema: { conversationId: z.string().optional(), count: z.number().optional(), messages: z.array(z.any()).optional(), kinds: z.record(z.string(), z.number()).optional(), unavailableCount: z.number().optional(), kindsNote: z.string().optional(), replyTo: z.string().optional(), windowState: z.string().optional(), hoursLeft: z.number().nullable().optional(), window: z.string().optional(), historyNote: z.string().optional() },
|
|
8153
8319
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
8154
8320
|
}, wrap(async (a) => {
|
|
8155
8321
|
const d = await apiGet('/api/meta/conversation', a);
|
|
8156
|
-
|
|
8322
|
+
// THE SUMMARY, NOT THE TEXT (2026-10-25): a story mention, a share or withheld content has no text, and
|
|
8323
|
+
// "(no text)" told a screen-reader user nothing about what had arrived. The kind rides in brackets.
|
|
8324
|
+
const lines = (d.messages || []).map(m => `${m.inbound ? '←' : '→'} ${m.from}${m.kind && m.kind !== 'text' ? ` [${m.kind}${m.unavailable ? ', unavailable' : ''}]` : ''}: ${String(m.summary || m.text || '').replace(/\s+/g, ' ').slice(0, 300)} [${String(m.at || '').slice(0, 16).replace('T', ' ')}]`);
|
|
8157
8325
|
const head = d.windowState === 'open' ? `You CAN reply (${d.hoursLeft}h left in Meta’s 24-hour window).` : d.windowState === 'closed' ? '⚠ You CANNOT reply — the 24-hour window is closed.' : '⚠ Could not tell whether the 24-hour window is open.';
|
|
8158
|
-
return ok(`${head}\n${lines.join('\n') || '(no messages)'}\n${d.replyTo ? `\nreplyTo: ${d.replyTo}` : ''}${d.window ? `\n\n${d.window}` : ''}${d.historyNote ? `\n\n${d.historyNote}` : ''}`, d);
|
|
8326
|
+
return ok(`${head}\n${lines.join('\n') || '(no messages)'}\n${d.kindsNote ? `\n${d.kindsNote}\n` : ''}${d.replyTo ? `\nreplyTo: ${d.replyTo}` : ''}${d.window ? `\n\n${d.window}` : ''}${d.historyNote ? `\n\n${d.historyNote}` : ''}`, d);
|
|
8159
8327
|
}));
|
|
8160
8328
|
server.registerTool('reply_to_meta_message', {
|
|
8161
8329
|
title: 'Reply to a Messenger / Instagram DM',
|
|
@@ -9117,7 +9285,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9117
9285
|
}));
|
|
9118
9286
|
server.registerTool('list_merchant_issues', {
|
|
9119
9287
|
title: 'List Merchant Center account issues',
|
|
9120
|
-
description: "Read the account-level issues Google reports on a Merchant Center — the answer to \"why is this product not showing?\"
|
|
9288
|
+
description: "Read the account-level issues Google reports on a Merchant Center — the answer to \"why is this product not showing?\". Needs merchantCenterId from list_merchant_accounts. Read-only and free.",
|
|
9121
9289
|
inputSchema: { merchantCenterId: z.string().describe('from list_merchant_accounts') },
|
|
9122
9290
|
outputSchema: { merchantCenterId: z.string().optional(), count: z.number().optional(), issues: z.array(z.any()).optional(), note: z.string().optional() },
|
|
9123
9291
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -9204,7 +9372,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9204
9372
|
confirm: z.boolean().optional().describe('must be true to actually fire it'),
|
|
9205
9373
|
},
|
|
9206
9374
|
outputSchema: { merchantCenterId: z.string().optional(), triggered: z.boolean().optional(), needsConfirm: z.boolean().optional(), actionFlowId: z.string().optional(), message: z.string().optional(), note: z.string().optional() },
|
|
9207
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
9375
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
9208
9376
|
}, wrap(async (a) => {
|
|
9209
9377
|
const d = await apiPost('/api/merchant/issue-action', a);
|
|
9210
9378
|
if (d.needsConfirm) return ok(d.message || 'Confirmation required.', d);
|
|
@@ -9300,7 +9468,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9300
9468
|
subscriptionId: z.string().optional().describe("for action:'delete', from action:'list'"),
|
|
9301
9469
|
},
|
|
9302
9470
|
outputSchema: { merchantCenterId: z.string().optional(), action: z.string().optional(), count: z.number().optional(), subscriptions: z.array(z.any()).optional(), subscription: z.any().optional(), deleted: z.boolean().optional(), verified: z.boolean().nullable().optional(), note: z.string().optional() },
|
|
9303
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
9471
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
9304
9472
|
}, wrap(async (a) => {
|
|
9305
9473
|
const d = await apiPost('/api/merchant/notifications', a);
|
|
9306
9474
|
const rows = (d.subscriptions || []).map(s => `${s.subscriptionId} — ${s.registeredEvent} → ${s.callBackUri}${s.allManagedAccounts ? ' (all managed accounts)' : ''}`);
|
|
@@ -9429,7 +9597,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9429
9597
|
attributes: z.record(z.any()).optional().describe('any other product attribute by its Merchant API name'),
|
|
9430
9598
|
},
|
|
9431
9599
|
outputSchema: { merchantCenterId: z.string().optional(), offerId: z.string().optional(), productInput: z.string().optional(), dataSource: z.string().optional(), updated: z.array(z.string()).optional(), attributes: z.record(z.any()).optional(), note: z.string().optional() },
|
|
9432
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
9600
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
9433
9601
|
}, wrap(async (a) => {
|
|
9434
9602
|
const d = await apiPost('/api/merchant/product/update', a);
|
|
9435
9603
|
return ok(d.note, d); // reports what Google now holds, not the patch that was sent
|
|
@@ -10432,7 +10600,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10432
10600
|
notes: z.string().optional(),
|
|
10433
10601
|
},
|
|
10434
10602
|
outputSchema: { tag: z.object({}).passthrough().optional(), container: z.object({}).passthrough().optional(), published: z.boolean().optional(), note: z.string().optional() },
|
|
10435
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
10603
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
10436
10604
|
}, wrap(async (a) => {
|
|
10437
10605
|
const d = await apiPost('/api/tag-manager/tag/update', a);
|
|
10438
10606
|
return ok(`Updated "${d.tag?.name}" [${d.tag?.type}]${d.tag?.paused ? ' (PAUSED)' : ''} in workspace ${d.tag?.workspace}. IT IS NOT LIVE YET. ${d.note}`, d);
|
|
@@ -10950,7 +11118,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10950
11118
|
}));
|
|
10951
11119
|
server.registerTool('posthog_query', {
|
|
10952
11120
|
title: 'Ask PostHog a question in HogQL',
|
|
10953
|
-
description: 'ASK POSTHOG A QUESTION IN HogQL — their SQL dialect over the `events`, `persons` and `sessions` tables.
|
|
11121
|
+
description: 'ASK POSTHOG A QUESTION IN HogQL — their SQL dialect over the `events`, `persons` and `sessions` tables. Use it for per-user funnels, retention and custom breakdowns, e.g. `SELECT properties.$current_url, count() FROM events WHERE event = \'$pageview\' AND timestamp > now() - INTERVAL 7 DAY GROUP BY 1 ORDER BY 2 DESC`. IT IS NOT AN EXPORTER AND MUST NEVER BE LOOPED — PostHog\'s own policy, verbatim: "Third-party connectors must use batch exports, not /query. Connectors built on /query are not supported and will be rate-limited or rejected." Ask ONE bounded question. Do not paginate it, do not schedule it, and do not call it repeatedly to assemble a whole table; if the user genuinely needs bulk data, tell them to set up a PostHog batch export. Every query is capped at 1000 rows, and OFFSET is refused outright because PostHog returns HTTP 400 for it on API keys — use keyset pagination on `timestamp` (events) or `id` (persons) if a second page is truly needed. Read-only, 0 credits.',
|
|
10954
11122
|
inputSchema: {
|
|
10955
11123
|
query: z.string().describe('a HogQL (ClickHouse-flavoured SQL) query — one bounded question, never a page of an export'),
|
|
10956
11124
|
limit: z.number().optional().describe('row cap, 1–1000 (default 1000). A LIMIT already in the query is honoured and clamped to the same ceiling'),
|
|
@@ -10982,7 +11150,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10982
11150
|
}));
|
|
10983
11151
|
server.registerTool('list_posthog_session_recordings', {
|
|
10984
11152
|
title: 'List PostHog session replays',
|
|
10985
|
-
description: 'SESSION REPLAYS
|
|
11153
|
+
description: 'SESSION REPLAYS. Lists recent recordings with who they belong to, how long they ran, how many clicks / keypresses / CONSOLE ERRORS each had, the URL they started on, and a link that opens the replay in PostHog. USE THE SIGNALS RATHER THAN JUST LISTING THEM: a session with console errors and a high keypress count on a checkout page is a bug report with a video attached, and that is the insight worth surfacing. THE RAW REPLAY IS NOT AVAILABLE OVER THE API — PostHog\'s words: "This endpoint does not provide the raw JSON of the replays. To get the raw JSON, you need to click Export as JSON in the replay options menu in-app." So describe and link; never promise a download and never claim to have watched one. Links need the user\'s own PostHog login. A publicly shareable link is minted in the PostHog UI and deliberately not by an agent, because it publishes a real person\'s session and PostHog themselves say they "make no guarantees about sensitive information contained in the recording". Read-only, 0 credits.',
|
|
10986
11154
|
inputSchema: { limit: z.number().optional().describe('1–100, default 20') },
|
|
10987
11155
|
outputSchema: { project: z.string().optional(), recordings: z.array(z.any()).optional(), count: z.number().optional(), note: z.string().optional() },
|
|
10988
11156
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -10992,7 +11160,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10992
11160
|
}));
|
|
10993
11161
|
server.registerTool('posthog_persons', {
|
|
10994
11162
|
title: 'Look up people in PostHog',
|
|
10995
|
-
description: 'Look up PEOPLE in PostHog by distinct id, email or a free-text search, with their properties.
|
|
11163
|
+
description: 'Look up PEOPLE in PostHog by distinct id, email or a free-text search, with their properties. Use it to resolve a specific customer, then use their distinct id inside posthog_query to see exactly what that one human did. Read-only, 0 credits.',
|
|
10996
11164
|
inputSchema: {
|
|
10997
11165
|
distinctId: z.string().optional().describe('PostHog distinct id'),
|
|
10998
11166
|
email: z.string().optional().describe('exact email match'),
|
|
@@ -11121,7 +11289,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11121
11289
|
}));
|
|
11122
11290
|
server.registerTool('amplitude_funnel', {
|
|
11123
11291
|
title: 'Amplitude funnel (conversion + drop-off)',
|
|
11124
|
-
description: 'AMPLITUDE FUNNELS
|
|
11292
|
+
description: 'AMPLITUDE FUNNELS: conversion and drop-off across ordered steps. Pass `events` as an ORDERED array of at least two steps. mode is ordered / unordered / sequential. conversionWindowSeconds is how long a user has to complete the funnel and DEFAULTS TO 2,592,000 SECONDS (30 DAYS) — ALWAYS state which window a conversion rate was measured over, because a 30-day window makes a funnel look dramatically healthier than a same-session one and the difference is invisible in the number itself. At most ONE group-by: segmentation allows two, funnels do not, and a second is refused by name rather than silently dropped. Returns step-by-step and cumulative conversion plus median and average transition times. Read-only, 0 credits.',
|
|
11125
11293
|
inputSchema: {
|
|
11126
11294
|
events: z.array(z.any()).describe('ordered steps — plain event names, or {eventType, filters:[…]} objects'),
|
|
11127
11295
|
mode: z.enum(['ordered', 'unordered', 'sequential']).optional().describe('default ordered'),
|
|
@@ -11171,7 +11339,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11171
11339
|
}));
|
|
11172
11340
|
server.registerTool('amplitude_user_activity', {
|
|
11173
11341
|
title: 'One person’s Amplitude activity stream',
|
|
11174
|
-
description: 'ONE PERSON\'S ACTIVITY STREAM in Amplitude
|
|
11342
|
+
description: 'ONE PERSON\'S ACTIVITY STREAM in Amplitude. Pass a user id, device id or user-id PREFIX and it SEARCHES; pass a numeric Amplitude ID and it returns that user\'s recent events with their properties. Two steps, because Amplitude splits them across two endpoints: search resolves a human-typed identifier to an Amplitude ID, and only that id reads an activity stream. NOTE these two endpoints sit on a DIFFERENT rate limit from the rest of Amplitude — flat counts (10 concurrent, 360 queries/hour) rather than the cost model — so they do not consume the segmentation budget and are not protected by it either. Read-only, 0 credits.',
|
|
11175
11343
|
inputSchema: {
|
|
11176
11344
|
user: z.string().describe('a numeric Amplitude ID to read activity, or a user id / device id / user-id prefix to search'),
|
|
11177
11345
|
search: z.boolean().optional().describe('force a search even when the value is numeric'),
|
|
@@ -11289,7 +11457,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11289
11457
|
}));
|
|
11290
11458
|
server.registerTool('mixpanel_profiles', {
|
|
11291
11459
|
title: 'Read the people in a Mixpanel cohort',
|
|
11292
|
-
description: "THE PEOPLE
|
|
11460
|
+
description: "THE PEOPLE in Mixpanel, with their profile properties. Pass `cohortId` (from list_mixpanel_cohorts) for \"who is in the Converted cohort and what do we know about them\", or `where` for an ad-hoc filter over profile properties, or `distinctId` for one person. PASS `outputProperties` ALMOST ALWAYS: it cuts the payload to the properties you actually need, and Mixpanel says it \"can drastically reduce the amount of data returned\" and \"can speed up queries significantly\". RETURNS ONE PAGE AND DOES NOT PAGINATE FOR YOU — it hands back page, pageSize, total and a sessionId, and Mixpanel REQUIRES that sessionId alongside any page above 0, so a second page is a deliberate second call. DO NOT LOOP IT: Mixpanel allows 60 queries per hour across its ENTIRE Query API and each page spends one of them. REPORT `total` (the whole cohort) SEPARATELY FROM `count` (this page) — quoting only the page size understates the audience, which is the number a targeting decision gets made on. Read-only, 0 credits.",
|
|
11293
11461
|
inputSchema: {
|
|
11294
11462
|
cohortId: z.string().optional().describe('a numeric cohort id from list_mixpanel_cohorts'),
|
|
11295
11463
|
where: z.string().optional().describe('a Mixpanel segmentation expression over profile properties, e.g. properties["$city"] == "London"'),
|
|
@@ -11546,14 +11714,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11546
11714
|
description: "Create a contact in the brand's HubSpot by email, or UPDATE the contact that already has that email — HubSpot's duplicate answer carries the existing id, so this is an UPSERT and never makes a twin. Optional firstname, lastname, phone, company, website, lifecyclestage, hs_lead_status, plus any other writable contact property in `properties` (by internal name). The contact is READ BACK after the write; the reply says created or updated. Free. Needs HubSpot connected.",
|
|
11547
11715
|
inputSchema: { email: z.string().describe('the contact email — the key HubSpot de-duplicates on'), firstname: z.string().optional(), lastname: z.string().optional(), phone: z.string().optional(), company: z.string().optional(), website: z.string().optional(), lifecyclestage: z.string().optional(), hs_lead_status: z.string().optional(), properties: z.record(z.any()).optional().describe('any other writable contact properties, by internal name') },
|
|
11548
11716
|
outputSchema: { ok: z.boolean().optional(), action: z.string().optional(), contact: z.any().optional(), note: z.string().optional() },
|
|
11549
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
11717
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
11550
11718
|
}, wrap(async (a) => { const d = await apiPost('/api/hubspot/contact', a); return ok(d.note, d); }));
|
|
11551
11719
|
server.registerTool('sync_leads_to_hubspot', {
|
|
11552
11720
|
title: 'Push lead-form leads into HubSpot',
|
|
11553
11721
|
description: "Push the leads collected by the brand's Meta and LinkedIn LEAD FORMS into HubSpot as contacts. Each lead is upserted by email with lead status NEW and the platform + form name written into the contact's Message property (a default HubSpot property, so no schema scope is needed). Synced lead ids are remembered per workspace, so a re-run, a retry or a second agent never creates twins. Reports created / updated / already-synced / no-email per platform; a platform that is not connected is REPORTED, not fatal. dryRun:true lists what would be synced and writes nothing. Free. Needs HubSpot connected, plus Meta and/or LinkedIn for the leads.",
|
|
11554
11722
|
inputSchema: { since: z.string().optional().describe('ISO date; default 7 days ago'), dryRun: z.boolean().optional().describe('list what would be synced and write nothing') },
|
|
11555
11723
|
outputSchema: { ok: z.boolean().optional(), dryRun: z.boolean().optional(), since: z.string().optional(), gathered: z.number().optional(), perPlatform: z.any().optional(), unavailable: z.any().optional(), results: z.array(z.any()).optional(), note: z.string().optional() },
|
|
11556
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
11724
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
11557
11725
|
}, wrap(async (a) => {
|
|
11558
11726
|
const d = await apiPost('/api/hubspot/sync-leads', a);
|
|
11559
11727
|
return ok(`${d.note}\n${(d.results || []).slice(0, 40).map(r => `• ${r.key}: ${r.action}${r.email ? ` ${r.email}` : ''}${r.contactId ? ` → ${r.contactId}` : ''}${r.error ? ` (${r.error})` : ''}`).join('\n')}`, d);
|
|
@@ -11770,7 +11938,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11770
11938
|
// are deliberately not offered.
|
|
11771
11939
|
server.registerTool('microsoft_ads_keyword_ideas', {
|
|
11772
11940
|
title: 'Microsoft Advertising keyword planner',
|
|
11773
|
-
description: 'Microsoft Advertising\u2019s KEYWORD PLANNER — real monthly search volume, competition, suggested bid and ad impression share, expanded from seed keywords, a landing-page URL to mine, or a category. Run it BEFORE choosing keywords for a Microsoft campaign
|
|
11941
|
+
description: 'Microsoft Advertising\u2019s KEYWORD PLANNER — real monthly search volume, competition, suggested bid and ad impression share, expanded from seed keywords, a landing-page URL to mine, or a category. Run it BEFORE choosing keywords for a Microsoft campaign. locationIds is REQUIRED and deliberately not defaulted: a search volume with no market attached is a number nobody can act on, and inventing a country would silently answer about the wrong market — use microsoft_ads_geo_search to resolve a country or city name to an id, free. SAY THIS WHEN REPORTING: Competition is Microsoft\u2019s Low/Medium/High bucket, NOT a percentage; MonthlySearchCounts is a per-month series rather than one number; SuggestedBid is in the account currency. An empty result means Microsoft found no ideas for those seeds, never that nobody searches for them. Read-only, 0 credits.',
|
|
11774
11942
|
inputSchema: {
|
|
11775
11943
|
accountId: z.string().optional(),
|
|
11776
11944
|
keywords: z.array(z.string()).optional().describe('seed terms to expand from'),
|
|
@@ -11836,7 +12004,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11836
12004
|
}));
|
|
11837
12005
|
server.registerTool('create_microsoft_ads_campaign', {
|
|
11838
12006
|
title: 'Build a Microsoft Advertising campaign (paused)',
|
|
11839
|
-
description: 'Build a campaign on a connected Microsoft Advertising (Bing Ads) account. ALWAYS created Paused — it spends NOTHING until you activate it with set_microsoft_ads_status(confirm:true). Microsoft’s object graph is campaign -> ad group -> responsive search ad -> keywords, so a campaign ON ITS OWN CANNOT SERVE AN IMPRESSION: pass adGroup{name, ad{headlines,descriptions,finalUrls}, keywords[]} and this builds the whole tree. Microsoft has NO atomic multi-object write
|
|
12007
|
+
description: 'Build a campaign on a connected Microsoft Advertising (Bing Ads) account. ALWAYS created Paused — it spends NOTHING until you activate it with set_microsoft_ads_status(confirm:true). Microsoft’s object graph is campaign -> ad group -> responsive search ad -> keywords, so a campaign ON ITS OWN CANNOT SERVE AN IMPRESSION: pass adGroup{name, ad{headlines,descriptions,finalUrls}, keywords[]} and this builds the whole tree. Microsoft has NO atomic multi-object write, so the levels are created in sequence and the campaign is DELETED again if anything below it is rejected — you never inherit a half-built campaign. Microsoft requires 3–15 headlines (≤30 chars) and 2–4 descriptions (≤90 chars); expanded text ads can no longer be created at all. dailyBudget is in the ACCOUNT’S currency, not necessarily USD. LOCATION TARGETING: pass locations[] (country / region / city names, ISO country codes, or numeric Microsoft location ids). A Microsoft campaign has NO geo targeting unless it is set, and Microsoft does not require any — so if you pass none, the campaign IS CREATED and serves WORLDWIDE (Microsoft’s own default), and the returned note says so loudly. That is safe at this stage because the campaign is Paused and spends nothing; it is NOT safe to activate without telling the user, so relay the warning. Nothing is created when a location you DID name cannot be resolved (call microsoft_ads_geo_search to disambiguate, then pass the id). Pass worldwide:true to record that everywhere was deliberate and suppress the nudge. The locations are written and READ BACK inside the same rollback as the rest of the tree, so a campaign is either targeted as asked or does not exist. Everything is READ BACK from Microsoft before you are told it exists; print the returned note verbatim, and if it says the campaign cannot serve yet, say that rather than calling it a finished ad.',
|
|
11840
12008
|
inputSchema: {
|
|
11841
12009
|
accountId: z.string().optional().describe('Microsoft ad account id — omit to use the single shared account'),
|
|
11842
12010
|
name: z.string().describe('campaign name, ≤128 characters'),
|
|
@@ -12046,7 +12214,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12046
12214
|
dryRun: z.boolean().optional().describe('validate against Microsoft and write nothing \u2014 Microsoft returns no ids for a dry run, so none is claimed'),
|
|
12047
12215
|
},
|
|
12048
12216
|
outputSchema: { ok: z.boolean().optional(), merchantId: z.string().optional(), dryRun: z.boolean().optional(), sent: z.number().optional(), stored: z.number().optional(), products: z.array(z.any()).optional(), failures: z.array(z.any()).optional(), note: z.string().optional() },
|
|
12049
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
12217
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
12050
12218
|
}, wrap(async (a) => {
|
|
12051
12219
|
const d = await apiPost('/api/microsoft-ads/merchant/product', a);
|
|
12052
12220
|
return ok(d.note, d);
|
|
@@ -12155,7 +12323,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12155
12323
|
acceptTerms: z.boolean().optional().describe('REQUIRED true \u2014 the user must agree to Microsoft\u2019s Customer Match terms first. Without it nothing is uploaded and nothing is hashed'),
|
|
12156
12324
|
},
|
|
12157
12325
|
outputSchema: { ok: z.boolean().optional(), accountId: z.string().optional(), audienceId: z.string().optional(), action: z.string().optional(), subType: z.string().optional(), sent: z.number().optional(), supplied: z.number().optional(), hashed: z.number().optional(), alreadyHashed: z.number().optional(), normalized: z.number().optional(), rejected: z.number().optional(), duplicates: z.number().optional(), note: z.string().optional() },
|
|
12158
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
12326
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
12159
12327
|
}, wrap(async (a) => {
|
|
12160
12328
|
const d = await apiPost('/api/microsoft-ads/customer-list/apply', a);
|
|
12161
12329
|
return ok(d.note, d);
|
|
@@ -13264,7 +13432,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13264
13432
|
delegations: z.array(z.record(z.any())).optional().describe('REPLACES the entire set. [{"resourceType":"CONTENT_PROVIDER","resourceId":"<CPID>"}].'),
|
|
13265
13433
|
},
|
|
13266
13434
|
outputSchema: { ok: z.boolean().optional(), level: z.string().optional(), id: z.string().optional(), verified: z.boolean().optional(), requested: z.any().optional(), read: z.any().optional(), summary: z.string().optional(), note: z.string().optional() },
|
|
13267
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
13435
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
13268
13436
|
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/ad-account/update', a); return ok(`Apple Ads ad account ${d.id} updated (verified: ${d.verified}):\n${d.summary}\n${d.note || ''}`, d); }));
|
|
13269
13437
|
// ── X: managing what you built — update, remove, and the reads both depend on (2026-08-05) ────────────────────
|
|
13270
13438
|
// Hermoso could build an X campaign and activate it and then change NOTHING about it, and could remove nothing at
|
|
@@ -13541,7 +13709,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13541
13709
|
description: 'Resume an existing ADD or REMOVE on a ChatGPT Ads custom audience from its own checkpoints with its original inputs, so nothing is uploaded again. Free. The reply is the status read back after the resume, never the request alone.',
|
|
13542
13710
|
inputSchema: { audienceId: z.string(), operationId: z.string() },
|
|
13543
13711
|
outputSchema: { operationId: z.string().optional(), audienceId: z.string().optional(), operation: z.string().optional(), status: z.string().optional(), accepted: z.string().optional(), readBack: z.boolean().optional(), note: z.string().optional() },
|
|
13544
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
13712
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
13545
13713
|
}, wrap(async (a) => { const d = await apiPost('/api/openai-ads/audience/operation/resume', a); return ok(d.note, d); }));
|
|
13546
13714
|
server.registerTool('get_openai_ads_audience_operation', {
|
|
13547
13715
|
title: 'Check a ChatGPT Ads audience membership operation',
|
|
@@ -13648,7 +13816,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13648
13816
|
idempotencyKey: z.string().optional().describe('request-level key that makes an uncertain retry safe. Reusing it with a DIFFERENT body is an error at OpenAI. To rerun a failed job, submit the same body with a NEW request-level key.'),
|
|
13649
13817
|
},
|
|
13650
13818
|
outputSchema: { jobId: z.string().optional(), status: z.string().optional(), operationCount: z.number().optional(), validateOnly: z.boolean().optional(), note: z.string().optional() },
|
|
13651
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
13819
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
13652
13820
|
}, wrap(async (a) => { const d = await apiPost('/api/openai-ads/bulk', a); return ok(d.note, d); }));
|
|
13653
13821
|
server.registerTool('openai_ads_bulk_job', {
|
|
13654
13822
|
title: 'Read a ChatGPT Ads bulk job',
|
|
@@ -13975,7 +14143,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13975
14143
|
server.registerTool('set_openai_ads_feed_sftp', {
|
|
13976
14144
|
title: 'Set up, activate or pause feed SFTP delivery', description: 'Manage SFTP bulk delivery for a ChatGPT Ads product feed (the alternative to pushing products through update_openai_ads_feed_products). action "create" (default): create or REPLACE the feed\'s SFTP credentials — authenticationMethod "password" (OpenAI returns the password ONCE in this reply; store it) or "ssh_key" (pass the public key as sshPublicKey). Replacing credentials invalidates the previous ones immediately, so re-run only when the user means to rotate. action "activate" / "pause": switch delivery on or off without touching credentials. The reply carries the connection URI and whether delivery is enabled; get_openai_ads_feed_sftp reads the same state without changing it. Files delivered over SFTP show up in list_openai_ads_feed_uploads with accepted / rejected / ads-eligible row counts.',
|
|
13977
14145
|
inputSchema: { feedId: z.string(), action: z.enum(['create', 'activate', 'pause']).optional(), authenticationMethod: z.enum(['password', 'ssh_key']).optional(), sshPublicKey: z.string().optional() },
|
|
13978
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
14146
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
13979
14147
|
}, wrap(async (a) => oaiNote(await apiPost('/api/openai-ads/feed/sftp', a))));
|
|
13980
14148
|
server.registerTool('get_openai_ads_feed_settings', {
|
|
13981
14149
|
title: 'Read a ChatGPT Ads feed’s settings', description: 'A product feed’s name, countries and whether its organic ChatGPT search listing respects each product’s is_eligible_search flag. Read-only, 0 credits.',
|
|
@@ -14418,7 +14586,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14418
14586
|
}));
|
|
14419
14587
|
server.registerTool('create_reddit_ads_ad_group', {
|
|
14420
14588
|
title: 'Create a Reddit ad group (targeting, budget, bidding, schedule)',
|
|
14421
|
-
description: 'Create an ad group under an existing Reddit campaign — this is the tier that holds the budget, the bid and ALL the targeting. ALWAYS created PAUSED; it spends nothing until set_reddit_ads_status(confirm:true). Reddit
|
|
14589
|
+
description: 'Create an ad group under an existing Reddit campaign — this is the tier that holds the budget, the bid and ALL the targeting. ALWAYS created PAUSED; it spends nothing until set_reddit_ads_status(confirm:true). Reddit refuses the create unless it has: a bidType, a bidStrategy, a startTime, a budget with its goalType, a bidAmount whenever the bid type is a paid rate, and a conversion pixel (resolved automatically when the ad account has exactly one). THE BID TYPE MUST FIT THE CAMPAIGN’S OBJECTIVE — a CLICKS campaign takes CPC and refuses CPM, a SALES campaign (Reddit\u2019s new name for conversions) asked for CPM; Reddit’s error says which. Money is ordinary amounts in the ad account’s currency (micro-currency is handled for you). Resolve community names and interest ids with search_reddit_ads_targeting first, and consider reddit_ads_forecast + reddit_ads_bid_suggestion before committing. Everything is READ BACK from Reddit before you are told it exists — print the returned note verbatim.',
|
|
14422
14590
|
inputSchema: {
|
|
14423
14591
|
adAccountId: z.string().optional(),
|
|
14424
14592
|
campaignId: z.string().describe('the campaign this ad group belongs to'),
|
|
@@ -15511,7 +15679,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15511
15679
|
// customer comparing us on Google (where we ship Performance Max) against TikTok found a hole on one side.
|
|
15512
15680
|
server.registerTool('create_tiktok_smart_campaign', {
|
|
15513
15681
|
title: 'Create a TikTok Smart+ campaign (forced paused)',
|
|
15514
|
-
description: 'Create an UPGRADED SMART+ campaign — TikTok’s automated campaign type, where TikTok picks the audience, placements and creative combinations for you. It is
|
|
15682
|
+
description: 'Create an UPGRADED SMART+ campaign — TikTok’s automated campaign type, where TikTok picks the audience, placements and creative combinations for you. It is what a customer means by "smart campaign", "automated campaign", "performance max" or "Smart+" on TikTok. CREATED PAUSED WITH NO OVERRIDE, exactly like the classic lane and for the same reason: TikTok creates ENABLED by default, so an omitted status would ship something that serves on the next auction. It spends nothing until set_tiktok_smart_status(confirm:true). SMART+ TAKES A SHORTER OBJECTIVE LIST THAN A CLASSIC CAMPAIGN — APP_PROMOTION, WEB_CONVERSIONS, LEAD_GENERATION and nothing else — and a value TikTok accepts on the classic endpoint can still be refused here, at the ad group, after the campaign exists; that is refused up front instead. CATALOG ADS: set catalogEnabled and catalogType (GENERIC for itemized NON-commerce inventories, added by TikTok on 2026-09-04; PRODUCT for a commerce catalog).',
|
|
15515
15683
|
inputSchema: {
|
|
15516
15684
|
name: z.string().describe('campaign name'),
|
|
15517
15685
|
objective: z.enum(['APP_PROMOTION', 'WEB_CONVERSIONS', 'LEAD_GENERATION']).describe('Smart+ supports these three ONLY'),
|
|
@@ -17255,7 +17423,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17255
17423
|
}));
|
|
17256
17424
|
server.registerTool('upload_snapchat_ads_creative', {
|
|
17257
17425
|
title: 'Upload a render to Snapchat as media + creative',
|
|
17258
|
-
description: 'PUT A FINISHED HERMOSO RENDER ONTO THE SNAPCHAT AD ACCOUNT so an ad can point at it —
|
|
17426
|
+
description: 'PUT A FINISHED HERMOSO RENDER ONTO THE SNAPCHAT AD ACCOUNT so an ad can point at it — create_snapchat_ads_ad needs the creativeId it returns. Pass the public https url of a render; SNAPCHAT HAS NO UPLOAD-FROM-URL, so Hermoso fetches the bytes and posts them as multipart. This does TWO things in one call — it uploads the MEDIA and then builds the CREATIVE that wraps it — because a Snapchat ad points at a CREATIVE and never at a media id, and stopping after the upload leaves an asset nothing can use. A PUBLIC PROFILE IS MANDATORY: Snapchat has required profile_properties on every ad creative since 2024-02-26, so profileId is REQUIRED and a call without one is refused BEFORE any bytes move (which is what stops an orphan media row being left on the ad account for a creative that could never be created). Get one from list_snapchat_ads_profiles, or read it in Snapchat Ads Manager. headline is required too. COPY LIMITS ARE SHORT AND ENFORCED: headline max 34 characters, brandName max 32, and over-long copy is REFUSED rather than truncated, because shipping words nobody wrote is worse than a refusal. To build a SECOND creative on media already uploaded, pass mediaId instead of url — nothing is downloaded or uploaded again. A file over 32MB is refused by name (Snapchat requires a chunked upload flow above that which Hermoso does not implement). Free.',
|
|
17259
17427
|
inputSchema: {
|
|
17260
17428
|
adAccountId: z.string().optional(),
|
|
17261
17429
|
url: z.string().optional().describe('public https url of the render — a Hermoso render URL already is one; for any other file run it through upload_file first. Omit only when reusing mediaId.'),
|
|
@@ -17386,7 +17554,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17386
17554
|
}));
|
|
17387
17555
|
server.registerTool('delete_snapchat_ads_object', {
|
|
17388
17556
|
title: 'Delete a Snapchat campaign, ad squad or ad',
|
|
17389
|
-
description: 'PERMANENTLY DELETE a Snapchat campaign, ad squad or ad. Snapchat
|
|
17557
|
+
description: 'PERMANENTLY DELETE a Snapchat campaign, ad squad or ad. Snapchat deletes at every tier, so this is irreversible and there is no undelete. Needs confirm:true, and the unconfirmed call changes nothing and names the objects READ FROM SNAPCHAT plus what deleting a parent takes with it (a campaign takes its ad squads and ads). TO STOP DELIVERY REVERSIBLY, use set_snapchat_ads_status with PAUSED instead — the refusal says so, because most people asking to "remove" an ad mean "stop it". The read-back is INVERTED for a delete: an id that still resolves afterwards is reported as NOT CONFIRMED, never as a success.',
|
|
17390
17558
|
inputSchema: {
|
|
17391
17559
|
adAccountId: z.string().optional(),
|
|
17392
17560
|
level: z.enum(['campaign', 'adsquad', 'ad']).describe('which tier these ids belong to'),
|
|
@@ -17764,7 +17932,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17764
17932
|
}, wrap(async (a) => { const d = await apiPost('/api/linkedin/ads-delete', a); return ok(d.note, d); }));
|
|
17765
17933
|
server.registerTool('create_linkedin_conversion_rule', {
|
|
17766
17934
|
title: 'Create a LinkedIn conversion rule',
|
|
17767
|
-
description: 'Create a LinkedIn CONVERSION RULE — the object LinkedIn attributes conversions to, and the prerequisite for send_linkedin_conversions. `type` is the behaviour being tracked (LEAD, PURCHASE, SIGN_UP, QUALIFIED_LEAD, KEY_PAGE_VIEW…). THE RULE IS BORN ASSOCIATED WITH NOTHING: until you attach campaigns with associate_linkedin_conversion_campaigns it attributes nothing, which also makes it the safe place to send test events — LinkedIn has no test mode on the wire
|
|
17935
|
+
description: 'Create a LinkedIn CONVERSION RULE — the object LinkedIn attributes conversions to, and the prerequisite for send_linkedin_conversions. `type` is the behaviour being tracked (LEAD, PURCHASE, SIGN_UP, QUALIFIED_LEAD, KEY_PAGE_VIEW…). THE RULE IS BORN ASSOCIATED WITH NOTHING: until you attach campaigns with associate_linkedin_conversion_campaigns it attributes nothing, which also makes it the safe place to send test events — LinkedIn has no test mode on the wire. Pass associateAllCampaigns:true to attach it to up to 200 ACTIVE campaigns instead; that spends nothing but it changes what those live campaigns optimise toward and what their reports count, so ask the user first. Attribution windows are 1, 7, 30 or 90 days (365 only for SUBMIT_APPLICATION, PURCHASE, ADD_TO_CART, QUALIFIED_LEAD and LEAD). Creating a rule cannot spend money — it is a definition. Free.',
|
|
17768
17936
|
inputSchema: {
|
|
17769
17937
|
adAccountId: z.string().optional(),
|
|
17770
17938
|
name: z.string().describe('shown in Campaign Manager and in every report'),
|
|
@@ -18012,7 +18180,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18012
18180
|
// so an import lands in the same list the app renders and syncs across devices.
|
|
18013
18181
|
server.registerTool('import_from_cloud', {
|
|
18014
18182
|
title: 'Import a Drive / OneDrive folder into the Library',
|
|
18015
|
-
description: "Pull the files in a Google Drive or OneDrive FOLDER into this profile's Library, so they can be used like anything rendered here — published, scheduled, cloned, used as a product photo or a reference. Hermoso downloads each file with the user's own connected account (a Drive/OneDrive file is not public
|
|
18183
|
+
description: "Pull the files in a Google Drive or OneDrive FOLDER into this profile's Library, so they can be used like anything rendered here — published, scheduled, cloned, used as a product photo or a reference. Hermoso downloads each file with the user's own connected account (a Drive/OneDrive file is not public) and stores a durable Hermoso url for each. Give `folderId` from list_drive_files / list_onedrive_files with onlyFolders — omit it for the root. GOOGLE DRIVE ONLY SHOWS WHAT THE USER HANDED OVER: our Drive scope is `drive.file`, so Hermoso can see the files and folders it created plus the ones the user picked with the Google picker in the app, and NEVER their whole Drive. Picking a FOLDER hands over the folder alone, not the files in it, so if a folder comes back empty the reply carries a link that opens the picker inside it, where the user selects every file at once (Cmd+A / Ctrl+A); give them that link. OneDrive has no such limit. SUBFOLDERS ARE NOT WALKED and Google-native docs (Docs/Sheets/Slides) have no file to download: both are reported back BY NAME rather than silently dropped, along with anything too large or unreadable, so you can tell the user exactly what did and did not come across.",
|
|
18016
18184
|
inputSchema: {
|
|
18017
18185
|
provider: z.enum(['drive', 'onedrive']).describe('which cloud — `drive` is Google Drive, `onedrive` is Microsoft OneDrive'),
|
|
18018
18186
|
folderId: z.string().optional().describe('the folder to import, from list_drive_files / list_onedrive_files (onlyFolders:true). Omit for the root of the drive.'),
|
|
@@ -18185,6 +18353,49 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18185
18353
|
return ok(d.summary, d);
|
|
18186
18354
|
}));
|
|
18187
18355
|
|
|
18356
|
+
// ---------- Notion (2026-10-25): the user's OWN Notion workspace, the same /api/notion routes the Studio tools call ----------
|
|
18357
|
+
// Find what they shared, save a report / teardown / swipefile collection / calendar as a NEW page, append, read, and
|
|
18358
|
+
// export the schedule to a database. Free (no model call), and nothing here deletes or overwrites Notion content.
|
|
18359
|
+
server.group('files');
|
|
18360
|
+
server.registerTool('list_notion_pages', {
|
|
18361
|
+
title: 'List Notion pages',
|
|
18362
|
+
description: "Find the Notion pages and databases the user shared with Hermoso, by title, newest edited first. Gives the page id that save_to_notion, append_to_notion_page and read_notion_page take, or the database id to save rows into. Read-only, free. Needs Notion connected (Settings > Connectors > Notion).",
|
|
18363
|
+
inputSchema: { query: z.string().optional().describe('words in the title'), type: z.enum(['page', 'database']).optional(), limit: z.number().optional().describe('1-100, default 25'), cursor: z.string().optional().describe('from the previous page') },
|
|
18364
|
+
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), results: z.array(z.any()).optional(), nextCursor: z.string().nullable().optional(), note: z.string().optional() },
|
|
18365
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
18366
|
+
}, wrap(async (a) => {
|
|
18367
|
+
const d = await apiGet('/api/notion/pages', a);
|
|
18368
|
+
return ok(d.count ? `${d.note}\n${(d.results || []).map((r) => `• ${r.kind === 'database' ? '[database] ' : ''}${r.title} (id ${r.kind === 'database' ? (r.databaseId || r.id) : r.id})${r.url ? ` ${r.url}` : ''}`).join('\n')}${d.nextCursor ? `\n(more: cursor=${d.nextCursor})` : ''}` : d.note, d);
|
|
18369
|
+
}));
|
|
18370
|
+
server.registerTool('save_to_notion', {
|
|
18371
|
+
title: 'Save to Notion',
|
|
18372
|
+
description: "Save to the user's Notion as a NEW page: a research report, competitor teardown or campaign report as `content` (markdown: headings, lists, links, and an image or video URL on its own line becomes the media), a swipefile collection (swipefileCollection: name or id; each ad with its creative, copy, run dates and link), the content calendar (includeSchedule:true), or a mix. Put it under parentPageId, as a row in databaseId, or omit both for a private page in their workspace. Long text is split across blocks, never cut. The answer is read back from Notion. Free; never overwrites anything.",
|
|
18373
|
+
inputSchema: { title: z.string().optional(), content: z.string().optional().describe('markdown body'), swipefileCollection: z.string().optional().describe('swipefile collection name or id'), includeSchedule: z.boolean().optional().describe('add the scheduled posts'), parentPageId: z.string().optional().describe('page id or link to save under'), databaseId: z.string().optional().describe('database id or link: saves as a new row'), dataSourceId: z.string().optional(), icon: z.string().optional().describe('one emoji') },
|
|
18374
|
+
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), url: z.string().optional(), title: z.string().optional(), blocks: z.number().optional(), parent: z.string().optional(), note: z.string().optional() },
|
|
18375
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
18376
|
+
}, wrap(async (a) => { const d = await apiPost('/api/notion/save', a); return ok(d.note, d); }));
|
|
18377
|
+
server.registerTool('append_to_notion_page', {
|
|
18378
|
+
title: 'Add to a Notion page',
|
|
18379
|
+
description: "Add to the END of an existing Notion page (pageId or its link, from list_notion_pages): markdown content, a swipefileCollection, or includeSchedule:true. Nothing already on the page changes. Free.",
|
|
18380
|
+
inputSchema: { pageId: z.string().describe('page id or link'), content: z.string().optional().describe('markdown to add'), swipefileCollection: z.string().optional(), includeSchedule: z.boolean().optional() },
|
|
18381
|
+
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), url: z.string().optional(), title: z.string().optional(), blocks: z.number().optional(), note: z.string().optional() },
|
|
18382
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
18383
|
+
}, wrap(async (a) => { const d = await apiPost('/api/notion/append', a); return ok(d.note, d); }));
|
|
18384
|
+
server.registerTool('read_notion_page', {
|
|
18385
|
+
title: 'Read a Notion page',
|
|
18386
|
+
description: "Read a Notion page the user shared with Hermoso, as markdown (pageId or a Notion link). A database id answers with its data sources instead. Read-only, free.",
|
|
18387
|
+
inputSchema: { pageId: z.string().describe('page id or Notion link') },
|
|
18388
|
+
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), kind: z.string().optional(), title: z.string().optional(), url: z.string().optional(), markdown: z.string().optional(), truncated: z.boolean().optional(), dataSources: z.array(z.any()).optional(), note: z.string().optional() },
|
|
18389
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
18390
|
+
}, wrap(async (a) => { const d = await apiGet('/api/notion/page', a); return ok(d.kind === 'page' ? `${d.note}\n\n${d.markdown || ''}` : d.note, d); }));
|
|
18391
|
+
server.registerTool('export_schedule_to_notion', {
|
|
18392
|
+
title: 'Export the schedule to Notion',
|
|
18393
|
+
description: "Export the posting schedule to a Notion database, one row per post: date, channels, caption, media link and status. Creates a new database (under parentPageId, or private in the workspace) or writes into an existing one (databaseId), matching its columns by name. historyDays adds posts already published. Up to 100 rows per run; the answer says if more were left. Free.",
|
|
18394
|
+
inputSchema: { parentPageId: z.string().optional(), databaseId: z.string().optional().describe('an existing database id or link'), dataSourceId: z.string().optional(), title: z.string().optional().describe('name for a new database'), historyDays: z.number().optional().describe('0-90 days of published posts to include') },
|
|
18395
|
+
outputSchema: { ok: z.boolean().optional(), created: z.boolean().optional(), databaseId: z.string().optional(), dataSourceId: z.string().optional(), url: z.string().optional(), rows: z.number().optional(), total: z.number().optional(), notWritten: z.number().optional(), note: z.string().optional() },
|
|
18396
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
18397
|
+
}, wrap(async (a) => { const d = await apiPost('/api/notion/schedule', a); return ok(d.note, d); }));
|
|
18398
|
+
|
|
18188
18399
|
// ---------- Google Docs: export copy / brief / report as a doc the app creates (drive.file scope) ----------
|
|
18189
18400
|
server.group('files');
|
|
18190
18401
|
server.registerTool('create_doc', {
|
|
@@ -18432,7 +18643,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18432
18643
|
height: z.number().optional().describe('REQUIRED for jpg — output height in pixels'),
|
|
18433
18644
|
},
|
|
18434
18645
|
outputSchema: { ok: z.boolean().optional(), fileId: z.string().optional(), sourceName: z.string().optional(), format: z.string().optional(), url: z.string().optional(), bytes: z.number().optional(), note: z.string().optional() },
|
|
18435
|
-
annotations: { readOnlyHint:
|
|
18646
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
18436
18647
|
}, wrap(async (a) => {
|
|
18437
18648
|
const d = await apiPost('/api/onedrive/convert', a);
|
|
18438
18649
|
return ok(d.note, d);
|
|
@@ -20181,7 +20392,7 @@ function memoryNoteVerdict(text) {
|
|
|
20181
20392
|
// so setting it once changes what every subsequent ad, plan and answer is written in.
|
|
20182
20393
|
server.registerTool('get_settings', {
|
|
20183
20394
|
title: 'Read app settings',
|
|
20184
|
-
description: 'Read this account\'s app settings — the LANGUAGE Hermoso writes ads
|
|
20395
|
+
description: 'Read this account\'s app settings — the LANGUAGE Hermoso writes ads and copy in (chat replies follow the language the user writes in), the app appearance (theme), and whether the weekly competitor-watch email is on. Same settings as the web app\'s Settings pane. Read-only, free.',
|
|
20185
20396
|
inputSchema: {},
|
|
20186
20397
|
outputSchema: { language: z.string().optional(), theme: z.string().optional(), notifications: z.any().optional(), privacy: z.any().optional() },
|
|
20187
20398
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
@@ -20191,9 +20402,9 @@ function memoryNoteVerdict(text) {
|
|
|
20191
20402
|
}));
|
|
20192
20403
|
server.registerTool('update_settings', {
|
|
20193
20404
|
title: 'Change app settings',
|
|
20194
|
-
description: 'Change this account\'s app settings. language = the language EVERY ad, script
|
|
20405
|
+
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.',
|
|
20195
20406
|
inputSchema: {
|
|
20196
|
-
language: z.string().optional().describe('language for generated ads
|
|
20407
|
+
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"'),
|
|
20197
20408
|
theme: z.enum(['dark', 'light']).optional().describe('app appearance'),
|
|
20198
20409
|
watchEmail: z.boolean().optional().describe('weekly competitor-watch email on/off'),
|
|
20199
20410
|
},
|
|
@@ -20885,79 +21096,102 @@ function memoryNoteVerdict(text) {
|
|
|
20885
21096
|
return { content: [{ type: 'text', text }, ...blocks], structuredContent: { ...d, ...(widget ? adSpyBlock('ads', rows.length, cards) : {}) } };
|
|
20886
21097
|
}));
|
|
20887
21098
|
|
|
20888
|
-
// ──
|
|
20889
|
-
//
|
|
20890
|
-
//
|
|
20891
|
-
// Spy ▸ Watching tab already calls (POST /api/watch/set, GET /api/watch/latest), so there is one set of rules.
|
|
21099
|
+
// ── MARKET RADAR (the competitor watch, 2026-08-18; modules 2026-10-23) — the STANDING research order, not a one-off
|
|
21100
|
+
// pull. Thin wrappers over the routes the web Ad Spy ▸ Watching tab calls (POST /api/watch/set, GET /api/watch/latest),
|
|
21101
|
+
// so there is one set of rules: lib/market-radar.mjs decides the modules, the quote and what counts as a winner.
|
|
20892
21102
|
const WATCH_PLATFORM_LABEL = { meta: 'Meta', google: 'Google', linkedin: 'LinkedIn' }; // the worker stores facebook results under 'meta'
|
|
20893
21103
|
const watchWhen = (ms) => (+ms > 0 ? new Date(+ms).toISOString().slice(0, 16).replace('T', ' ') + ' UTC' : 'never');
|
|
21104
|
+
const RADAR_KEYS = ['competitors', 'topics', 'modules', 'cadence', 'email', 'auto', 'seed', 'runNow', 'dryRun'];
|
|
21105
|
+
const radarOn = (m) => Object.entries(m || {}).filter(([, v]) => v).map(([k]) => k).join(', ') || 'none';
|
|
21106
|
+
const radarCost = (q) => (q && q.credits ? `A check holds up to ${q.credits} credits before it starts${q.later ? ` (about ${q.later} once each brand's Meta page is found)` : ''}, and is skipped with nothing spent when the balance is short.` : 'Nothing to check, so a check costs nothing.');
|
|
20894
21107
|
server.registerTool('set_competitor_watch', {
|
|
20895
|
-
title: 'Set the
|
|
20896
|
-
description: 'Set
|
|
20897
|
-
+ '
|
|
20898
|
-
+ '
|
|
20899
|
-
|
|
20900
|
-
+ 'runNow:true runs it once IMMEDIATELY (a background job — it spends now) and then keeps the weekly cadence; leave it off and the first check is a week out. '
|
|
20901
|
-
+ 'The country and the platform mix are NOT settable here — a re-set inherits whatever the pending run already carried (US / Meta for a watch that has never been configured otherwise). Read the findings back with list_watch_findings.',
|
|
20902
|
-
inputSchema: {
|
|
21108
|
+
title: 'Set the market radar',
|
|
21109
|
+
description: 'Set, change or stop a brand\'s MARKET RADAR (the standing competitor watch; Ad Spy > Watching in the web app; each brand has its own), run weekly or daily: newAds (what watched brands launched), winners (their ad passing 30 days running), trends + topPosts (formats and most-viewed posts for niche topics), ideas (3 to make). '
|
|
21110
|
+
+ 'Only what you pass changes. competitors / topics REPLACE their lists (max 8 brands, 5 topics); both empty stops it and clears findings. auto:true picks 3-5 competitors, 2-3 similar brands and 2-3 topics from the saved brand or `seed` (a few credits). dryRun:true quotes one check, saves nothing. '
|
|
21111
|
+
+ 'Saving is free; each check holds its quote first and is skipped when the balance is short. runNow is refused while a check runs or just after one finished. Read it with list_watch_findings.',
|
|
21112
|
+
inputSchema: { ...STORE_BRAND,
|
|
20903
21113
|
competitors: z.array(z.object({
|
|
20904
|
-
name: z.string()
|
|
20905
|
-
domain: z.string().optional().describe('
|
|
20906
|
-
|
|
20907
|
-
|
|
21114
|
+
name: z.string(),
|
|
21115
|
+
domain: z.string().optional().describe('e.g. ridge.com: Google is read by domain, and it finds the right Meta page'),
|
|
21116
|
+
kind: z.enum(['competitor', 'similar']).optional(),
|
|
21117
|
+
}).passthrough()).optional().describe('the COMPLETE brand list'),
|
|
21118
|
+
topics: z.array(z.string()).optional().describe('niche search phrases, e.g. "protein breakfast"'),
|
|
21119
|
+
modules: z.object({ newAds: z.boolean().optional(), winners: z.boolean().optional(), trends: z.boolean().optional(), topPosts: z.boolean().optional(), ideas: z.boolean().optional() }).optional().describe('on/off; omitted keep theirs'),
|
|
21120
|
+
cadence: z.enum(['weekly', 'daily']).optional(),
|
|
21121
|
+
email: z.boolean().optional().describe('the weekly email'),
|
|
21122
|
+
auto: z.boolean().optional(),
|
|
21123
|
+
seed: z.string().optional().describe('for auto: a website, brand or niche'),
|
|
21124
|
+
runNow: z.boolean().optional().describe('check now (spends now)'),
|
|
21125
|
+
force: z.boolean().optional().describe('with runNow: check again right after a check, allowed once what is watched changed'),
|
|
21126
|
+
dryRun: z.boolean().optional(),
|
|
20908
21127
|
},
|
|
20909
21128
|
outputSchema: {
|
|
20910
21129
|
watching: z.array(z.string()).optional().describe('the brand names now under watch (empty = the watch is off)'),
|
|
21130
|
+
competitors: z.array(z.any()).optional().describe('{name, domain, kind}'),
|
|
21131
|
+
topics: z.array(z.string()).optional(),
|
|
21132
|
+
modules: z.any().optional().describe('{newAds, winners, trends, topPosts, ideas} on/off'),
|
|
21133
|
+
cadence: z.string().optional(),
|
|
21134
|
+
email: z.boolean().optional(),
|
|
21135
|
+
quote: z.any().optional().describe('{credits, lines[{module, credits}], later?}: what one check holds'),
|
|
20911
21136
|
nextRunAt: z.number().optional().describe('epoch ms of the next scheduled check (0 = nothing scheduled)'),
|
|
21137
|
+
jobId: z.string().optional().describe('with runNow: the check\'s job (get_job follows it)'),
|
|
20912
21138
|
},
|
|
20913
21139
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
20914
|
-
}, wrap(async (a) => {
|
|
20915
|
-
// An
|
|
20916
|
-
|
|
20917
|
-
|
|
20918
|
-
const
|
|
20919
|
-
|
|
20920
|
-
|
|
20921
|
-
const
|
|
20922
|
-
|
|
20923
|
-
|
|
20924
|
-
|
|
20925
|
-
|
|
20926
|
-
//
|
|
20927
|
-
const noDomain =
|
|
20928
|
-
return ok(
|
|
20929
|
-
+ `
|
|
20930
|
-
|
|
20931
|
-
}));
|
|
21140
|
+
}, wrap(inBrand(async (a) => {
|
|
21141
|
+
// An EMPTY call is refused: defaulting it to anything would turn "I forgot the argument" into a silent change.
|
|
21142
|
+
if (!RADAR_KEYS.some(k => a[k] !== undefined)) return { content: [{ type: 'text', text: 'Pass what to change: competitors, topics, modules, cadence, email, auto (one-click setup), runNow or dryRun. competitors:[] and topics:[] together stop the radar.' }], isError: true };
|
|
21143
|
+
const body = {};
|
|
21144
|
+
for (const k of RADAR_KEYS) if (a[k] !== undefined) body[k] = a[k];
|
|
21145
|
+
if (a.force === true) body.force = true;
|
|
21146
|
+
if (Array.isArray(a.competitors)) body.competitors = a.competitors.filter(c => c && String(c.name || '').trim()).map(c => ({ name: String(c.name).trim(), domain: String(c.domain || '').trim(), ...(c.kind ? { kind: c.kind } : {}) }));
|
|
21147
|
+
const d = await apiPost('/api/watch/set', body);
|
|
21148
|
+
const on = d.watching || [], topics = d.topics || [];
|
|
21149
|
+
const setup = `Brands: ${on.join(', ') || '(none)'}. Topics: ${topics.join(', ') || '(none)'}. Modules on: ${radarOn(d.modules)}. Cadence: ${d.cadence || 'weekly'}. Weekly email: ${d.email === false ? 'off' : 'on'}.`;
|
|
21150
|
+
if (d.dryRun) return ok(`QUOTE ONLY, nothing saved. ${setup} ${radarCost(d.quote)}${a.auto ? ' Save it with set_competitor_watch({competitors, topics}) after editing.' : ''}`, d);
|
|
21151
|
+
if (!on.length && !topics.length) return ok('Market radar STOPPED: nothing is watched and the findings are cleared.', d);
|
|
21152
|
+
// The server trims and de-duplicates; report what it ACTUALLY stored, never what was asked for.
|
|
21153
|
+
const noDomain = (d.competitors || []).filter(c => !c.domain).map(c => c.name);
|
|
21154
|
+
return ok(`${setup} ${radarCost(d.quote)} Next check ${a.runNow === true ? `is running now${d.jobId ? ` (get_job ${d.jobId} follows it)` : ''}` : watchWhen(d.nextRunAt)}. Read what it finds with list_watch_findings.`
|
|
21155
|
+
+ `${noDomain.length ? ` No domain for ${noDomain.join(', ')}: Google is read by domain and is skipped for them.` : ''}`, d);
|
|
21156
|
+
})));
|
|
20932
21157
|
|
|
20933
21158
|
server.registerTool('list_watch_findings', {
|
|
20934
21159
|
_meta: openaiMeta(AD_SPY_URI, 'Reading the competitor watch…', 'Competitor watch read'),
|
|
20935
|
-
title: 'Read the
|
|
20936
|
-
description: 'Read what
|
|
20937
|
-
+ '
|
|
20938
|
-
|
|
20939
|
-
|
|
20940
|
-
|
|
20941
|
-
competitor: z.string().optional().describe('only findings for this watched brand (exact name as returned in `watching`) — omit for all of them'),
|
|
20942
|
-
limit: z.number().optional().describe('max findings to return (default 25, max 75 — the server keeps at most 75, and at most 15 per brand)'),
|
|
21160
|
+
title: 'Read the market radar',
|
|
21161
|
+
description: 'Read what a brand\'s MARKET RADAR (set_competitor_watch) has found: each watched brand\'s new ads, the winners (ads past 30 days running, with a ready brief to make your own version), the trending formats and top posts for its topics, and the ideas to make, plus its state (lastRun, nextRunAt, lastRunOk, paused) and the quote for its next check. '
|
|
21162
|
+
+ 'Findings marked `seed:true` are what a brand was already running at its first check, not new launches. Read-only and free; it never runs a check.',
|
|
21163
|
+
inputSchema: { ...STORE_BRAND,
|
|
21164
|
+
competitor: z.string().optional().describe('only this watched brand\'s ads (exact name as returned)'),
|
|
21165
|
+
limit: z.number().optional().describe('max ads (default 25, max 75)'),
|
|
20943
21166
|
},
|
|
20944
21167
|
outputSchema: {
|
|
20945
21168
|
watching: z.boolean().optional().describe('whether a watch is currently set'),
|
|
20946
|
-
competitors: z.array(z.any()).optional().describe('the brands under watch ({name, domain})'),
|
|
21169
|
+
competitors: z.array(z.any()).optional().describe('the brands under watch ({name, domain, kind})'),
|
|
21170
|
+
topics: z.array(z.string()).optional(),
|
|
21171
|
+
modules: z.any().optional(),
|
|
21172
|
+
cadence: z.string().optional(),
|
|
20947
21173
|
platforms: z.array(z.string()).optional().describe('the ad libraries this watch checks'),
|
|
20948
21174
|
lastRun: z.number().optional().describe('epoch ms of the last check (0 = never run)'),
|
|
20949
|
-
lastRunOk: z.boolean().optional().describe('false when the last check failed
|
|
21175
|
+
lastRunOk: z.boolean().optional().describe('false when the last check failed or was skipped — a stale board, not an empty market'),
|
|
20950
21176
|
lastRunNote: z.string().optional().describe('what went wrong on the last run, when something did'),
|
|
21177
|
+
paused: z.any().optional().describe('{need, balance} when the radar is paused for credits'),
|
|
20951
21178
|
nextRunAt: z.number().optional().describe('epoch ms of the next scheduled check (0 = nothing scheduled)'),
|
|
21179
|
+
quote: z.any().optional().describe('what the next check holds'),
|
|
20952
21180
|
findings: z.array(z.any()).optional().describe('the found ads ({competitor, platform, foundAt, seed, advertiser, body, media, ad})'),
|
|
20953
21181
|
total: z.number().optional().describe('how many findings the board holds before the filter/limit'),
|
|
21182
|
+
winners: z.array(z.any()).optional().describe('{competitor, platform, days, link, body, brief}'),
|
|
21183
|
+
trends: z.array(z.any()).optional().describe('{name, what, count, examples[]}'),
|
|
21184
|
+
topPosts: z.array(z.any()).optional().describe('{topic, platform, desc, author, link, plays}'),
|
|
21185
|
+
ideas: z.array(z.any()).optional().describe('{title, why, kind, brief}'),
|
|
21186
|
+
run: z.any().optional().describe('{jobId, status, startedAt, stage} while a check runs'),
|
|
21187
|
+
lastResult: z.any().optional().describe('the last check: counts, summary, email'),
|
|
20954
21188
|
},
|
|
20955
21189
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
20956
|
-
}, wrap(async (a) => {
|
|
21190
|
+
}, wrap(inBrand(async (a) => {
|
|
20957
21191
|
const d = await apiGet('/api/watch/latest');
|
|
20958
21192
|
// ABSENT IS NOT EMPTY ([[failed-read-is-not-empty]]): the route answers null when this workspace has never had a
|
|
20959
21193
|
// watch at all, which is a different sentence from "the watch ran and found nothing".
|
|
20960
|
-
if (!d) return ok('No
|
|
21194
|
+
if (!d) return ok('No market radar has ever been set for this brand. set_competitor_watch({auto:true}) sets one up.', { watching: false, findings: [], total: 0 });
|
|
20961
21195
|
const want = String(a.competitor || '').trim().toLowerCase();
|
|
20962
21196
|
const all = Array.isArray(d.newAds) ? d.newAds : [];
|
|
20963
21197
|
const rows = want ? all.filter(n => String(n.competitor || '').toLowerCase() === want) : all;
|
|
@@ -20977,9 +21211,16 @@ function memoryNoteVerdict(text) {
|
|
|
20977
21211
|
ad,
|
|
20978
21212
|
};
|
|
20979
21213
|
});
|
|
20980
|
-
const
|
|
20981
|
-
|
|
20982
|
-
|
|
21214
|
+
const adBody = (ad) => { const s = (ad || {}).snapshot || {}; return trunc((typeof s.body === 'string' ? s.body : s.body?.text) || ad?.headline || ad?.description || ''); };
|
|
21215
|
+
const winners = (d.winners || []).map(w => ({ competitor: w.competitor || '', platform: w.platform || '', days: w.days || 30, link: w.link || '', at: w.at || 0, body: adBody(w.ad),
|
|
21216
|
+
brief: `Make my own version of this ${w.competitor || 'competitor'} ad, which has been running ${w.days || 30}+ days${w.link ? ` (${w.link})` : ''}. Keep what makes it work, make it about my product and in my voice.` }));
|
|
21217
|
+
const trends = (d.trends || []).map(t => ({ name: t.name, what: t.what, count: t.count, at: t.at || 0, examples: (t.examples || []).map(p => ({ link: p.link, author: p.author, plays: p.plays })) }));
|
|
21218
|
+
const topPosts = (d.topPosts || []).map(p => ({ topic: p.topic, platform: p.platform, desc: trunc(p.desc), author: p.author, link: p.link, plays: p.plays, at: p.at || 0 }));
|
|
21219
|
+
const ideas = (d.ideas || []).map(i => ({ title: i.title, why: i.why, kind: i.kind, brief: i.brief, at: i.at || 0 }));
|
|
21220
|
+
const state = `Watching: ${names.join(', ') || '(nobody)'}${(d.topics || []).length ? ` · topics: ${d.topics.join(', ')}` : ''} · modules on: ${radarOn(d.modules)} · ${d.cadence || 'weekly'}`
|
|
21221
|
+
+ ` · last check ${watchWhen(d.lastRun)}${d.lastRunOk === false ? ` (DID NOT COMPLETE${d.lastRunNote ? `: ${d.lastRunNote}` : ''} — this board is stale, not a quiet market)` : ''}`
|
|
21222
|
+
+ ` · next check ${watchWhen(d.nextRunAt)}. ${radarCost(d.quote)}`
|
|
21223
|
+
+ `${d.run ? ` A check is running now (${d.run.stage || d.run.status}).` : ''}${d.lastResult?.summary ? ` Last check: ${d.lastResult.summary}` : ''}`;
|
|
20983
21224
|
// The watch board is an ad grid too — same card, so "what's new from our competitors?" SHOWS the creative.
|
|
20984
21225
|
// Each finding already carries its own platform label, and `ad` holds the still the card wants as its thumb.
|
|
20985
21226
|
const wcards = adSpyCards(findings.map((f) => ({
|
|
@@ -20988,16 +21229,22 @@ function memoryNoteVerdict(text) {
|
|
|
20988
21229
|
thumb: f.ad?.imageUrl || f.ad?.image || f.ad?.snapshot?.images?.[0]?.resized_image_url || f.ad?.snapshot?.cards?.[0]?.resized_image_url || f.ad?.snapshot?.videos?.[0]?.video_preview_image_url || '',
|
|
20989
21230
|
dates: [f.ad?.start_date_string, f.ad?.end_date_string].filter(Boolean).join(' → '),
|
|
20990
21231
|
})));
|
|
20991
|
-
const out = { watching: !!d.watching, competitors: d.competitors || [], platforms: d.platforms || [], lastRun: d.lastRun || 0, lastRunOk: d.lastRunOk !== false, lastRunNote: d.lastRunNote || '', nextRunAt: d.nextRunAt || 0, findings, total: rows.length, ...(hostRendersWidgets() ? adSpyBlock('new ads', rows.length, wcards) : {}) };
|
|
20992
|
-
if (!findings.length) return ok(`${state}\nNo findings recorded yet${want ? ` for "${a.competitor}"` : ''}${d.lastRun ? '' : ' — the first check has not run'}.`, out);
|
|
21232
|
+
const out = { watching: !!d.watching, competitors: d.competitors || [], topics: d.topics || [], modules: d.modules || {}, cadence: d.cadence || 'weekly', platforms: d.platforms || [], lastRun: d.lastRun || 0, lastRunOk: d.lastRunOk !== false, lastRunNote: d.lastRunNote || '', paused: d.paused || null, nextRunAt: d.nextRunAt || 0, quote: d.quote || null, run: d.run || null, lastResult: d.lastResult || null, findings, total: rows.length, winners, trends, topPosts, ideas, ...(hostRendersWidgets() ? adSpyBlock('new ads', rows.length, wcards) : {}) };
|
|
20993
21233
|
const line = (f) => ` • ${f.seed ? '[already running] ' : '[NEW] '}${f.competitor} · ${WATCH_PLATFORM_LABEL[f.platform] || f.platform} — ${f.advertiser}${f.body ? `: ${f.body}` : ''}${f.media ? ` ${f.media}` : ''}`;
|
|
20994
|
-
|
|
20995
|
-
}))
|
|
21234
|
+
const sections = [
|
|
21235
|
+
winners.length ? `Winners (running 30+ days, want your version?):\n` + winners.slice(0, 8).map(w => ` • ${w.competitor} · ${w.days} days${w.body ? `: ${w.body}` : ''}${w.link ? ` ${w.link}` : ''}`).join('\n') : '',
|
|
21236
|
+
trends.length ? `Trending formats:\n` + trends.slice(0, 5).map(t => ` • ${t.name} (${t.count} posts): ${t.what}`).join('\n') : '',
|
|
21237
|
+
topPosts.length ? `Top posts:\n` + topPosts.slice(0, 6).map(p => ` • ${p.plays || 0} views · ${p.author || ''}${p.desc ? `: ${p.desc}` : ''} ${p.link || ''}`).join('\n') : '',
|
|
21238
|
+
ideas.length ? `Ideas to make (each brief is ready for render_ad / plan_ad):\n` + ideas.slice(0, 3).map(i => ` • ${i.title}: ${i.brief}`).join('\n') : '',
|
|
21239
|
+
].filter(Boolean).join('\n');
|
|
21240
|
+
if (!findings.length) return ok(`${state}\nNo new ads recorded yet${want ? ` for "${a.competitor}"` : ''}${d.lastRun ? '' : ' — the first check has not run'}.${sections ? '\n' + sections : ''}`, out);
|
|
21241
|
+
return ok(`${state}\n${rows.length} ad finding(s)${want ? ` for "${a.competitor}"` : ''}${findings.length < rows.length ? ` — showing ${findings.length}` : ''}:\n` + findings.map(line).join('\n') + (sections ? '\n' + sections : ''), out);
|
|
21242
|
+
})));
|
|
20996
21243
|
|
|
20997
21244
|
server.registerTool('research_ads', {
|
|
20998
21245
|
_meta: openaiMeta(AD_SPY_URI, 'Researching ads…', 'Ad research ready'),
|
|
20999
21246
|
title: 'Research ads',
|
|
21000
|
-
description: 'Use this when the user
|
|
21247
|
+
description: 'Use this when the user asks an open-ended ad question across several brands or platforms, such as comparisons, which angles are working in a market, or ad examples for a category. Do not use for one named brand\u2019s current ads: pull_competitor_ads covers that. Open-ended ad research that needs JUDGMENT across platforms — comparisons, "what angle is working", "who else is doing this", anything where the right sources are not known up front. It is an agentic loop (several rounds of library pulls plus a written synthesis) and typically takes 30-60 seconds, so it is the WRONG tool for a question that names its own answer. For one named brand\u2019s live ads use pull_competitor_ads; for one keyword or one advertiser on Meta use search_meta_ads \u2014 both are a single call and return in a few seconds. Spends credits — an agentic loop, so a handful rather than the one-call cost of a targeted search.',
|
|
21001
21248
|
inputSchema: {
|
|
21002
21249
|
query: z.string().describe('what to research, e.g. "the longest-running protein-pancake ads on Meta"'),
|
|
21003
21250
|
brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name or profile object to tailor the research to; omit to use the workspace’s saved brand'),
|
|
@@ -21210,7 +21457,7 @@ function memoryNoteVerdict(text) {
|
|
|
21210
21457
|
queries: z.number().optional().describe('query variants per platform, 1–4 (default 3); each is a paid search call'),
|
|
21211
21458
|
minAvgViews: z.number().optional(),
|
|
21212
21459
|
minEngagement: z.number().optional().describe('interactions per view, 0–1 (0.05 = 5%)'),
|
|
21213
|
-
enrich: z.boolean().optional().describe('
|
|
21460
|
+
enrich: z.boolean().optional().describe('followers + public contact for the top 3 on TikTok and on Instagram (default true, 1 credit each)'),
|
|
21214
21461
|
marketplace: z.boolean().optional().describe('also search Instagram’s creator marketplace (Meta’s own creator directory, free) for the same niche; needs the Meta connector'),
|
|
21215
21462
|
},
|
|
21216
21463
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
|
|
@@ -21532,6 +21779,26 @@ function memoryNoteVerdict(text) {
|
|
|
21532
21779
|
return ok(`${assets.length} asset${assets.length === 1 ? '' : 's'} (newest first):\n` + assets.map((x) => ` ${x.id}. [${x.kind || '?'}${x.model ? ' · ' + x.model : ''}${x.ageHours != null ? ' · ' + x.ageHours + 'h ago' : ''}] ${x.url}`).join('\n'), { assets });
|
|
21533
21780
|
}));
|
|
21534
21781
|
|
|
21782
|
+
// DELETE FROM THE LIBRARY (2026-10-23). A customer was told it could not be done and sent to support. The server
|
|
21783
|
+
// resolves what to delete against THIS brand's Library only, deletes the stored file where it is ours, and reads
|
|
21784
|
+
// the Library back (POST /api/library/delete, lib/library-delete.mjs).
|
|
21785
|
+
server.registerTool('delete_library_item', {
|
|
21786
|
+
title: 'Delete from Library',
|
|
21787
|
+
description: "PERMANENTLY delete images/videos from this brand's Library: the entry and the stored file. Pass ids (L0… from list_library) or urls. Without confirm it lists what would be deleted; ask the user, then call again with confirm:true. Free.",
|
|
21788
|
+
inputSchema: {
|
|
21789
|
+
ids: z.array(z.string()).optional().describe('Library ids from list_library'),
|
|
21790
|
+
urls: z.array(z.string()).optional().describe('file links'),
|
|
21791
|
+
confirm: z.boolean().optional().describe('true deletes'),
|
|
21792
|
+
},
|
|
21793
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
21794
|
+
}, wrap(async ({ ids, urls, confirm }) => {
|
|
21795
|
+
const d = await apiPost('/api/library/delete', { ids: ids || [], urls: urls || [], confirm: confirm === true });
|
|
21796
|
+
const items = (d?.items || d?.removed || []).map((x) => ` ${x.id} [${x.kind || '?'}] ${abs(x.url)}`).join('\n');
|
|
21797
|
+
const kept = (d?.files || []).filter((f) => !f.fileDeleted).map((f) => ` ${f.id}: ${f.note}`).join('\n');
|
|
21798
|
+
const nf = (d?.notFound || []).length ? `\nNot in this brand's Library: ${d.notFound.join(', ')}` : '';
|
|
21799
|
+
return ok(`${d?.message || ''}${items ? '\n' + items : ''}${kept ? '\n' + kept : ''}${nf}`, { deleted: !!d?.deleted, needsConfirm: !!d?.needsConfirm });
|
|
21800
|
+
}));
|
|
21801
|
+
|
|
21535
21802
|
server.registerTool('fetch_asset', {
|
|
21536
21803
|
title: 'Fetch asset',
|
|
21537
21804
|
description: 'Resolve a generated asset reference (a /generated/… path or any URL) to a clickable absolute URL + a direct download URL.',
|
|
@@ -21643,7 +21910,7 @@ function memoryNoteVerdict(text) {
|
|
|
21643
21910
|
|
|
21644
21911
|
server.registerTool('reframe_video', {
|
|
21645
21912
|
title: 'Reframe video',
|
|
21646
|
-
description: "Reframe a video to a different aspect ratio (e.g. 16:9 master -> 9:16 vertical) with
|
|
21913
|
+
description: "Reframe a video to a different aspect ratio (e.g. 16:9 master -> 9:16 vertical): the whole original frame is kept and the new canvas around it is generated, with the sound and timing unchanged. Clips up to 300 seconds. Paid render; returns the served URL of the reframed video.",
|
|
21647
21914
|
inputSchema: { video: z.string().describe('the source video URL'), aspectRatio: z.enum(['9:16', '1:1', '16:9', '4:3', '3:4', '21:9', '9:21']).describe('the target aspect ratio') },
|
|
21648
21915
|
outputSchema: { ...JOB_OUT },
|
|
21649
21916
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
@@ -21740,17 +22007,19 @@ function memoryNoteVerdict(text) {
|
|
|
21740
22007
|
}, wrap(async ({ video, instruction, keepAudio, lighting, reference, literal, previewFirstFrame, previewStill, previewAt, elements, interactionId, engine, faceRoute }) => {
|
|
21741
22008
|
const prompt = String(instruction || '').trim();
|
|
21742
22009
|
if (!prompt) return { content: [{ type: 'text', text: 'Say what to change — edit_video needs an instruction.' }], isError: true };
|
|
22010
|
+
if (previewFirstFrame !== true && !previewStill) { const _rm = await designRemakeFirst(video, prompt); if (_rm) return _rm; } // words in a design: re-made
|
|
21743
22011
|
const _ref = reference ? await toRef(reference) : undefined;
|
|
22012
|
+
const els = (Array.isArray(elements) ? elements : []).filter(e => e && e.frontal).slice(0, 4);
|
|
22013
|
+
const _iid = String(interactionId || '').trim();
|
|
21744
22014
|
if (previewFirstFrame === true) {
|
|
21745
|
-
|
|
22015
|
+
// the clip is priced on the input the render will carry (engine, sound, elements, interaction), so the quote is its hold
|
|
22016
|
+
const d = await apiPost('/api/video/edit/preview', { video, instruction: prompt, engine: engine || 'auto', keepAudio: keepAudio !== false, ...(els.length ? { elements: els } : {}), ...(_iid ? { interactionId: _iid } : {}), ...(faceRoute === 'face_lane' ? { faceRoute } : {}), ...(lighting ? { lighting } : {}), ...(_ref ? { reference: _ref } : {}), ...(literal === true ? { literal: true } : {}), ...(Number.isFinite(previewAt) ? { previewAt } : {}) });
|
|
21746
22017
|
const still = d.still || d.image ? abs(d.still || d.image) : '';
|
|
21747
22018
|
const img = still ? await imageBlock(still) : null;
|
|
21748
22019
|
const c = d.clip || {};
|
|
21749
22020
|
const text = `${d.frameAt > 0 ? `Preview of the frame at ${d.frameAt}s` : 'First-frame preview'} (${d.stillCredits ?? d.creditsUsed ?? '?'} credits): ${still || 'no still came back'}\nThe clip is NOT rendered yet. On ${c.engine || 'the video editor'} it is held at up to ${c.holdCredits ?? '?'} credits and settles at the real price, about ${c.expectedCredits ?? '?'}${c.seconds ? ` for ${c.seconds}s` : ''}.\nTo render it: edit_video with the same video and instruction${still ? ` and previewStill: ${still}${d.frameAt > 0 ? `, previewAt: ${d.frameAt}` : ''}` : ''}.${d.editNote ? `\n${d.editNote}` : ''}`;
|
|
21750
22021
|
return { content: [{ type: 'text', text }, ...(img ? [img] : [])], structuredContent: { ...d, ...(still ? { still, image: still } : {}), ...(d.frame ? { frame: abs(d.frame) } : {}) } };
|
|
21751
22022
|
}
|
|
21752
|
-
const els = (Array.isArray(elements) ? elements : []).filter(e => e && e.frontal).slice(0, 4);
|
|
21753
|
-
const _iid = String(interactionId || '').trim();
|
|
21754
22023
|
const _still = previewStill ? await toRef(previewStill) : undefined;
|
|
21755
22024
|
const r = await renderJob('videoedit', { video, prompt, grammar: true, keepAudio: keepAudio !== false, ...(lighting ? { lighting } : {}), ...(_ref ? { reference: _ref } : {}), ...(_still ? { previewStill: _still, ...(previewAt > 0 ? { previewStillAt: previewAt } : {}) } : {}), ...(literal === true ? { literal: true } : {}), ...(els.length ? { elements: els } : {}), ...(_iid ? { interactionId: _iid } : {}), engine: engine || 'auto', ...(faceRoute === 'face_lane' ? { faceRoute } : {}) }, `Video edit · ${prompt.slice(0, 40)}`);
|
|
21756
22025
|
const p = renderPayload(r) || {};
|
|
@@ -22322,9 +22591,10 @@ function memoryNoteVerdict(text) {
|
|
|
22322
22591
|
// Brand tab; nothing is billed, so nothing is booked.
|
|
22323
22592
|
server.registerTool('fetch_app_screens', {
|
|
22324
22593
|
title: 'Fetch App Store screens',
|
|
22325
|
-
description: "Pull an APP brand's REAL App Store
|
|
22594
|
+
description: "Pull an APP brand's REAL store screenshots (Google Play or App Store) into the workspace brand, so a screen-hungry native format can use them. Use when the brand has 0–1 app screens on file and you want make_template_ad(template:'app-ui-tour'), or the user asks to 'pull my app's screenshots'. A store link or package id goes in storeUrl and is read exactly; else pass appName (defaults to the saved brand's name). FREE — a keyless store lookup. It needs a CONFIDENT match: an ambiguous or unknown app returns 0 screens and saves nothing, which you should relay plainly rather than retrying with guesses. On success the screens are saved to the brand (durable URLs) and are immediately usable.",
|
|
22326
22595
|
inputSchema: {
|
|
22327
22596
|
appName: z.string().optional().describe("the app's name to look up on the App Store — defaults to the saved brand's name"),
|
|
22597
|
+
storeUrl: z.string().optional().describe('a Google Play / App Store link or package id'),
|
|
22328
22598
|
brandId: z.string().optional().describe('a profile id/name from list_brands to save the screens onto; omit to use the active profile'),
|
|
22329
22599
|
},
|
|
22330
22600
|
outputSchema: {
|
|
@@ -22334,14 +22604,15 @@ function memoryNoteVerdict(text) {
|
|
|
22334
22604
|
saved: z.boolean().optional().describe('true when the screens were written onto the brand profile'),
|
|
22335
22605
|
},
|
|
22336
22606
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
22337
|
-
}, wrap(async ({ appName, brandId }) => {
|
|
22607
|
+
}, wrap(async ({ appName, storeUrl, brandId }) => {
|
|
22338
22608
|
const brand = await activeBrand(brandId);
|
|
22339
22609
|
const name = String(appName || brand?.name || '').trim();
|
|
22340
|
-
|
|
22341
|
-
|
|
22610
|
+
const store = String(storeUrl || '').trim();
|
|
22611
|
+
if (!name && !store) return { content: [{ type: 'text', text: "I need the app's name or store link — this workspace has no saved brand to fall back on. Pass appName or storeUrl." }], isError: true };
|
|
22612
|
+
const d = await apiGet(`/api/brand/app-screens?name=${encodeURIComponent(name)}&domain=${encodeURIComponent(brand?.domain || '')}${store ? `&store=${encodeURIComponent(store)}` : ''}`);
|
|
22342
22613
|
const found = d?.data || d || {};
|
|
22343
22614
|
const screens = (found.screens || []).map(s => abs(s && (s.image || s.url) ? (s.image || s.url) : s)).filter(Boolean);
|
|
22344
|
-
if (!screens.length) return ok(`No confident App Store match for “${name}”, so nothing was saved. Say that plainly — ask the user for the exact
|
|
22615
|
+
if (!screens.length) return ok(`${found.note || `No confident App Store match for “${name}”, so nothing was added.`} Nothing was saved. Say that plainly — ask the user for the app's store link or exact name, or pick a format that needs no app screens (value-prop, myth-vs-fact, chatgpt-chat).`, { screens: [], appName: '', saved: false });
|
|
22345
22616
|
// Typed single-field write (never a blind blob PUT): merge `appScreens` onto the saved brand and leave every other
|
|
22346
22617
|
// field exactly as it was — the same rule update_brand follows.
|
|
22347
22618
|
let saved = false;
|
|
@@ -22350,7 +22621,7 @@ function memoryNoteVerdict(text) {
|
|
|
22350
22621
|
await writeStore('heist.brand.v1', { ...b, appScreens: found });
|
|
22351
22622
|
saved = true;
|
|
22352
22623
|
} catch { saved = false; }
|
|
22353
|
-
return ok(`Pulled ${screens.length}
|
|
22624
|
+
return ok(`Pulled ${screens.length} screen(s) from ${found.store || 'the App Store'} for “${found.appName || name}”${saved ? ' and saved them to the brand' : ' (they could NOT be saved to the brand — pass them as reference URLs instead)'}.\n${screens.join('\n')}\n${saved ? "You can render the app UI tour now — make_template_ad(template:'app-ui-tour') with one punchy caption per screen (≤42 chars)." : ''}`, { screens, appName: found.appName || name, appStoreUrl: found.appStoreUrl || '', saved });
|
|
22354
22625
|
}));
|
|
22355
22626
|
// ══ POST PERFORMANCE (2026-08-04) ══════════════════════════════════════════════════════════════════════════════
|
|
22356
22627
|
// Every per-post measurement tool below already existed and every one needs a postId the caller must ALREADY hold.
|